fix: correctly track balance in portfolio value calculation for max_drawdown
The bug was that running_balance was set to trade['amount'] which is the amount SPENT on a buy (not remaining balance), causing inflated portfolio values and incorrect max drawdown calculation. Now properly tracks: - After BUY: balance decreases by amount spent - After SELL: balance increases by amount received
This commit is contained in:
@@ -108,6 +108,34 @@ class BacktestEngine:
|
||||
|
||||
return self.results
|
||||
|
||||
async def run_with_klines(self, klines: List[Dict[str, Any]]):
|
||||
"""Test helper method that runs backtest with provided klines (bypasses API call)."""
|
||||
self.running = True
|
||||
self.status = "running"
|
||||
started_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
if not klines:
|
||||
self.status = "failed"
|
||||
self.results = {"error": "No kline data available"}
|
||||
return self.results
|
||||
|
||||
await self._process_klines(klines)
|
||||
self._calculate_metrics()
|
||||
self.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
self.status = "failed"
|
||||
self.results = {"error": str(e)}
|
||||
|
||||
ended_at = datetime.utcnow()
|
||||
self.results = self.results or {}
|
||||
self.results["started_at"] = started_at
|
||||
self.results["ended_at"] = ended_at
|
||||
self.results["duration_seconds"] = (ended_at - started_at).total_seconds()
|
||||
|
||||
return self.results
|
||||
|
||||
async def _process_klines(self, klines: List[Dict[str, Any]]):
|
||||
self.total_klines = len(klines)
|
||||
for i, kline in enumerate(klines):
|
||||
@@ -354,11 +382,11 @@ class BacktestEngine:
|
||||
for trade in self.trades:
|
||||
if trade["type"] == "buy":
|
||||
running_position = trade["quantity"]
|
||||
running_balance = trade["amount"]
|
||||
running_balance -= trade["amount"] # Subtract amount spent
|
||||
current_token = trade["token"]
|
||||
last_price = trade["price"]
|
||||
else:
|
||||
running_balance = trade["amount"]
|
||||
else: # sell
|
||||
running_balance += trade["amount"] # Add amount received
|
||||
running_position = 0
|
||||
last_price = trade["price"]
|
||||
|
||||
|
||||
189
src/backend/tests/test_backtest_engine.py
Normal file
189
src/backend/tests/test_backtest_engine.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Unit tests for BacktestEngine to verify stop loss functionality
|
||||
"""
|
||||
import pytest
|
||||
import asyncio
|
||||
from app.services.backtest.engine import BacktestEngine
|
||||
|
||||
|
||||
class TestStopLoss:
|
||||
"""Test stop loss functionality"""
|
||||
|
||||
def test_stop_loss_triggers_when_price_drops(self):
|
||||
"""Test that stop loss triggers when price drops to stop_loss_percent"""
|
||||
config = {
|
||||
"bot_id": "test-bot",
|
||||
"strategy_config": {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 10}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 100}
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss_percent": 5, # 5% stop loss
|
||||
"take_profit_percent": 10
|
||||
}
|
||||
},
|
||||
"ave_api_key": "test",
|
||||
"ave_api_plan": "free",
|
||||
"initial_balance": 10000.0,
|
||||
}
|
||||
|
||||
engine = BacktestEngine(config)
|
||||
|
||||
# Simulate klines: buy at $100, then price drops to $94 (6% drop - should trigger 5% stop loss)
|
||||
# Stop loss price = $100 * (1 - 0.05) = $95
|
||||
# Price at $94 is below stop loss, so it should trigger
|
||||
|
||||
klines = [
|
||||
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||
{"close": "99.0", "timestamp": 2000, "open": "99.0", "high": "99.0", "low": "99.0", "volume": "1000"},
|
||||
{"close": "98.0", "timestamp": 3000, "open": "98.0", "high": "98.0", "low": "98.0", "volume": "1000"},
|
||||
{"close": "97.0", "timestamp": 4000, "open": "97.0", "high": "97.0", "low": "97.0", "volume": "1000"},
|
||||
{"close": "96.0", "timestamp": 5000, "open": "96.0", "high": "96.0", "low": "96.0", "volume": "1000"},
|
||||
# Stop loss should trigger here at $95 or below
|
||||
{"close": "94.0", "timestamp": 6000, "open": "94.0", "high": "94.0", "low": "94.0", "volume": "1000"},
|
||||
]
|
||||
|
||||
result = asyncio.run(engine.run_with_klines(klines))
|
||||
|
||||
print(f"Trades: {engine.trades}")
|
||||
print(f"Position after: {engine.position}")
|
||||
print(f"Results: {result}")
|
||||
|
||||
# Should have executed a sell due to stop loss
|
||||
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
|
||||
assert len(sell_trades) > 0, "Should have executed a sell due to stop loss"
|
||||
assert sell_trades[0]["exit_reason"] == "stop_loss", f"Exit reason should be stop_loss, got {sell_trades[0].get('exit_reason')}"
|
||||
|
||||
def test_max_drawdown_with_multiple_buys(self):
|
||||
"""Test max drawdown when there are more buys than sells"""
|
||||
config = {
|
||||
"bot_id": "test-bot",
|
||||
"strategy_config": {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 10}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 50}
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss_percent": 5,
|
||||
"take_profit_percent": 5
|
||||
}
|
||||
},
|
||||
"ave_api_key": "test",
|
||||
"ave_api_plan": "free",
|
||||
"initial_balance": 10000.0,
|
||||
}
|
||||
|
||||
engine = BacktestEngine(config)
|
||||
|
||||
# Simulate:
|
||||
# 1. Buy at $100 (condition triggered at start)
|
||||
# 2. Price rises to $105 -> take profit sells
|
||||
# 3. Buy again at $105
|
||||
# 4. Price drops to $94 -> stop loss triggers at $99.75
|
||||
# 5. Buy at $94 (new position)
|
||||
# 6. Price continues to drop - no more sells
|
||||
|
||||
klines = [
|
||||
# First buy at $100
|
||||
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||
# Price rises, take profit at $105
|
||||
{"close": "105.0", "timestamp": 2000, "open": "105.0", "high": "105.0", "low": "105.0", "volume": "1000"},
|
||||
# Second buy at $105 (price dropped 10% from peak triggers buy)
|
||||
{"close": "105.0", "timestamp": 3000, "open": "105.0", "high": "105.0", "low": "105.0", "volume": "1000"},
|
||||
# Price drops, stop loss should trigger at $99.75 (5% from $105)
|
||||
{"close": "99.0", "timestamp": 4000, "open": "99.0", "high": "99.0", "low": "99.0", "volume": "1000"},
|
||||
# Third buy at $99 (after stop loss, price dropped 10% from $110)
|
||||
{"close": "99.0", "timestamp": 5000, "open": "99.0", "high": "99.0", "low": "99.0", "volume": "1000"},
|
||||
# Price continues to drop to $80 (no sell triggered since position is closed)
|
||||
{"close": "80.0", "timestamp": 6000, "open": "80.0", "high": "80.0", "low": "80.0", "volume": "1000"},
|
||||
]
|
||||
|
||||
result = asyncio.run(engine.run_with_klines(klines))
|
||||
|
||||
print(f"\n=== Max Drawdown Test ===")
|
||||
print(f"Trades: {engine.trades}")
|
||||
print(f"Number of sells: {len([t for t in engine.trades if t['type'] == 'sell'])}")
|
||||
print(f"Max drawdown: {result.get('max_drawdown')}")
|
||||
print(f"Stop loss percent configured: {engine.stop_loss_percent}")
|
||||
|
||||
# With 5% stop loss, max drawdown should be around 5% (plus some slippage)
|
||||
# NOT 82%!
|
||||
if result.get('max_drawdown', 0) > 10:
|
||||
print(f"ERROR: Max drawdown {result.get('max_drawdown')}% is too high with 5% stop loss!")
|
||||
|
||||
def test_multiple_buys_sells_sequence(self):
|
||||
"""Test with a sequence: buy, sell, buy, sell, buy (open position)"""
|
||||
config = {
|
||||
"bot_id": "test-bot",
|
||||
"strategy_config": {
|
||||
"conditions": [
|
||||
{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 10}
|
||||
],
|
||||
"actions": [
|
||||
{"type": "buy", "amount_percent": 50}
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss_percent": 5,
|
||||
"take_profit_percent": 10
|
||||
}
|
||||
},
|
||||
"ave_api_key": "test",
|
||||
"ave_api_plan": "free",
|
||||
"initial_balance": 10000.0,
|
||||
}
|
||||
|
||||
engine = BacktestEngine(config)
|
||||
|
||||
# Sequence:
|
||||
# 1. K1: $100 -> Buy (condition triggers because price_drop threshold met at start)
|
||||
# 2. K2: $110 -> Take profit sells (10% gain)
|
||||
# 3. K3: $100 -> Buy (10% drop triggers)
|
||||
# 4. K4: $90 -> Stop loss triggers (5% loss from $94.5 avg... wait no)
|
||||
# Stop loss from $100 with 5% = $95
|
||||
# $90 is below $95, so stop loss triggers
|
||||
# 5. K5: $85 -> Buy ($85 is 15% drop from $100)
|
||||
# 6. K6: $80 -> No sell (position open)
|
||||
|
||||
klines = [
|
||||
# Initial buy at $100
|
||||
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||
# Price goes up to $110 - take profit triggers (10% gain)
|
||||
{"close": "110.0", "timestamp": 2000, "open": "110.0", "high": "110.0", "low": "110.0", "volume": "1000"},
|
||||
# Price drops to $100 - buy triggers again
|
||||
{"close": "100.0", "timestamp": 3000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
|
||||
# Price drops to $94 - stop loss should trigger (5% from $100 = $95)
|
||||
{"close": "94.0", "timestamp": 4000, "open": "94.0", "high": "94.0", "low": "94.0", "volume": "1000"},
|
||||
# Price drops to $85 - buy triggers again (15% drop from $100)
|
||||
{"close": "85.0", "timestamp": 5000, "open": "85.0", "high": "85.0", "low": "85.0", "volume": "1000"},
|
||||
# Price drops to $80 - no sell, position still open
|
||||
{"close": "80.0", "timestamp": 6000, "open": "80.0", "high": "80.0", "low": "80.0", "volume": "1000"},
|
||||
]
|
||||
|
||||
result = asyncio.run(engine.run_with_klines(klines))
|
||||
|
||||
print(f"\n=== Multiple Buys/Sells Test ===")
|
||||
print(f"Trades: {engine.trades}")
|
||||
print(f"Buy trades: {len([t for t in engine.trades if t['type'] == 'buy'])}")
|
||||
print(f"Sell trades: {len([t for t in engine.trades if t['type'] == 'sell'])}")
|
||||
print(f"Open position: {engine.position}")
|
||||
print(f"Max drawdown: {result.get('max_drawdown')}")
|
||||
print(f"Total return: {result.get('total_return')}")
|
||||
|
||||
# Should have 2 sell trades (take profit and stop loss)
|
||||
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
|
||||
print(f"Sell exit reasons: {[t.get('exit_reason') for t in sell_trades]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test = TestStopLoss()
|
||||
print("=== Test 1: Stop Loss Triggers ===")
|
||||
test.test_stop_loss_triggers_when_price_drops()
|
||||
print("\n=== Test 2: Max Drawdown with Multiple Buys ===")
|
||||
test.test_max_drawdown_with_multiple_buys()
|
||||
print("\n=== Test 3: Multiple Buys/Sells Sequence ===")
|
||||
test.test_multiple_buys_sells_sequence()
|
||||
Reference in New Issue
Block a user