Skip to content

Commit b13f9e4

Browse files
committed
test: add evaluator integration tests for #384 using local test data
1 parent 258d653 commit b13f9e4

1 file changed

Lines changed: 309 additions & 0 deletions

File tree

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import os
2+
from datetime import datetime, timezone
3+
4+
import polars as pl
5+
6+
from investing_algorithm_framework import (
7+
PortfolioConfiguration,
8+
MarketCredential,
9+
OrderStatus,
10+
TradeStatus,
11+
BacktestDateRange,
12+
)
13+
from investing_algorithm_framework.domain import INDEX_DATETIME
14+
from investing_algorithm_framework.services import (
15+
BacktestTradeOrderEvaluator,
16+
OrderBacktestService,
17+
)
18+
from tests.resources import TestBase
19+
20+
# Path to OHLCV CSV used by these tests (BTC-EUR, 15m, Binance)
21+
OHLCV_CSV = os.path.join(
22+
os.path.dirname(os.path.dirname(__file__)),
23+
"resources", "test_data", "ohlcv",
24+
"OHLCV_BTC-EUR_BINANCE_15m_2023-12-14-21-45_2023-12-25-00-00.csv",
25+
)
26+
27+
28+
class TestBacktestTradeOrderEvaluatorStopLoss(TestBase):
29+
"""
30+
Integration tests for BacktestTradeOrderEvaluator.evaluate()
31+
verifying that newly filled orders don't crash stop-loss / take-profit
32+
evaluation when last_reported_price is None.
33+
34+
Regression tests for issue #384.
35+
"""
36+
37+
market_credentials = [
38+
MarketCredential(
39+
market="binance",
40+
api_key="api_key",
41+
secret_key="secret_key",
42+
)
43+
]
44+
portfolio_configurations = [
45+
PortfolioConfiguration(
46+
market="binance",
47+
trading_symbol="EUR",
48+
)
49+
]
50+
external_balances = {"EUR": 1000000}
51+
52+
def setUp(self):
53+
super().setUp()
54+
55+
# Override order service with backtest variant
56+
self.app.container.order_service.override(
57+
OrderBacktestService(
58+
trade_service=self.app.container.trade_service(),
59+
order_repository=self.app.container.order_repository(),
60+
position_service=self.app.container.position_service(),
61+
portfolio_repository=(
62+
self.app.container.portfolio_repository()
63+
),
64+
portfolio_configuration_service=(
65+
self.app.container.portfolio_configuration_service()
66+
),
67+
portfolio_snapshot_service=(
68+
self.app.container.portfolio_snapshot_service()
69+
),
70+
configuration_service=(
71+
self.app.container.configuration_service()
72+
),
73+
)
74+
)
75+
76+
backtest_date_range = BacktestDateRange(
77+
start_date=datetime(2023, 12, 14),
78+
end_date=datetime(2023, 12, 25),
79+
)
80+
self.app.initialize_backtest_config(backtest_date_range)
81+
82+
# Set INDEX_DATETIME before the CSV data starts (first row
83+
# is 2023-12-14 21:45:00) so that orders are created with
84+
# updated_at earlier than the OHLCV rows.
85+
configuration_service = self.app.container.configuration_service()
86+
configuration_service.add_value(
87+
INDEX_DATETIME,
88+
datetime(2023, 12, 14, 21, 0, 0, tzinfo=timezone.utc),
89+
)
90+
91+
# Load OHLCV data from local test CSV (no downloads).
92+
# Convert Datetime to UTC-aware to match the order model's
93+
# DateTime(timezone=True) column.
94+
self.ohlcv_df = pl.read_csv(OHLCV_CSV)
95+
self.ohlcv_df = self.ohlcv_df.with_columns(
96+
pl.col("Datetime")
97+
.str.to_datetime()
98+
.dt.replace_time_zone("UTC")
99+
)
100+
101+
# ------------------------------------------------------------------
102+
# helpers
103+
# ------------------------------------------------------------------
104+
105+
def _create_evaluator(self):
106+
return BacktestTradeOrderEvaluator(
107+
trade_service=self.app.container.trade_service(),
108+
order_service=self.app.container.order_service(),
109+
trade_stop_loss_service=(
110+
self.app.container.trade_stop_loss_service()
111+
),
112+
trade_take_profit_service=(
113+
self.app.container.trade_take_profit_service()
114+
),
115+
configuration_service=(
116+
self.app.container.configuration_service()
117+
),
118+
)
119+
120+
def _create_filled_buy_order(self, target_symbol, price, amount):
121+
"""Create a BUY order and immediately fill it, producing an
122+
OPEN trade."""
123+
order_service = self.app.container.order_service()
124+
order = order_service.create({
125+
"target_symbol": target_symbol,
126+
"trading_symbol": "EUR",
127+
"amount": amount,
128+
"order_side": "BUY",
129+
"price": price,
130+
"order_type": "LIMIT",
131+
"portfolio_id": 1,
132+
"status": "CREATED",
133+
})
134+
order_service.update(order.id, {
135+
"status": OrderStatus.CLOSED.value,
136+
"filled": amount,
137+
"remaining": 0,
138+
})
139+
return order
140+
141+
def _create_pending_buy_order(self, target_symbol, price, amount):
142+
"""Create a BUY order that stays OPEN (unfilled). The
143+
OrderBacktestService.execute_order sets status=OPEN, filled=0."""
144+
order_service = self.app.container.order_service()
145+
return order_service.create({
146+
"target_symbol": target_symbol,
147+
"trading_symbol": "EUR",
148+
"amount": amount,
149+
"order_side": "BUY",
150+
"price": price,
151+
"order_type": "LIMIT",
152+
"portfolio_id": 1,
153+
"status": "CREATED",
154+
})
155+
156+
# ------------------------------------------------------------------
157+
# tests
158+
# ------------------------------------------------------------------
159+
160+
def test_evaluate_no_crash_when_order_fills_with_stop_loss(self):
161+
"""
162+
Issue #384 — regression test.
163+
164+
Scenario
165+
--------
166+
1. Trade A is already OPEN (previously filled) with a stop-loss.
167+
2. Order B is OPEN (pending) with a corresponding CREATED trade
168+
that also has a stop-loss.
169+
3. evaluate() fills Order B, promoting Trade B to OPEN.
170+
4. The structural fix re-queries ALL open trades after filling
171+
orders, so Trade B also gets its price updated.
172+
5. _check_stop_losses() evaluates ALL OPEN trades without error.
173+
"""
174+
trade_service = self.app.container.trade_service()
175+
order_service = self.app.container.order_service()
176+
177+
# ---- Trade A: already filled → OPEN --------------------------
178+
order_a = self._create_filled_buy_order("BTC", 39000, 0.1)
179+
trade_a = trade_service.find({"order_id": order_a.id})
180+
self.assertEqual(TradeStatus.OPEN.value, trade_a.status)
181+
trade_service.add_stop_loss(
182+
trade_a, percentage=10, trailing=False, sell_percentage=50
183+
)
184+
185+
# ---- Order B: pending → will fill during evaluate() ----------
186+
# Price 39200: the CSV Low values start at 39052 which is ≤ 39200
187+
order_b = self._create_pending_buy_order("BTC", 39200, 0.05)
188+
trade_b = trade_service.find({"order_id": order_b.id})
189+
self.assertIsNotNone(trade_b)
190+
trade_service.add_stop_loss(
191+
trade_b, percentage=10, trailing=False, sell_percentage=50
192+
)
193+
194+
# ---- Prepare evaluate() inputs ------------------------------
195+
open_trades = trade_service.get_all(
196+
{"status": TradeStatus.OPEN.value}
197+
)
198+
open_orders = order_service.get_all(
199+
{"status": OrderStatus.OPEN.value}
200+
)
201+
self.assertEqual(1, len(open_trades)) # only Trade A
202+
self.assertEqual(1, len(open_orders)) # only Order B
203+
204+
# ---- Run evaluate — this must NOT raise TypeError ------------
205+
evaluator = self._create_evaluator()
206+
evaluator.evaluate(
207+
open_trades=open_trades,
208+
open_orders=open_orders,
209+
ohlcv_data={"BTC/EUR": self.ohlcv_df},
210+
)
211+
212+
# ---- Verify Order B is now filled ----------------------------
213+
order_b_updated = order_service.get(order_b.id)
214+
self.assertEqual(OrderStatus.CLOSED.value, order_b_updated.status)
215+
216+
# ---- Verify Trade B is now OPEN ------------------------------
217+
trade_b_updated = trade_service.find({"order_id": order_b.id})
218+
self.assertEqual(TradeStatus.OPEN.value, trade_b_updated.status)
219+
220+
# The structural fix (#384) re-queries open trades after order
221+
# fills, so Trade B's price is updated — not left as None.
222+
self.assertIsNotNone(trade_b_updated.last_reported_price)
223+
224+
# ---- Verify Trade A also got its price updated ---------------
225+
trade_a_updated = trade_service.find({"order_id": order_a.id})
226+
self.assertIsNotNone(trade_a_updated.last_reported_price)
227+
228+
def test_evaluate_no_crash_when_order_fills_with_take_profit(self):
229+
"""
230+
Same scenario as above but with take-profit instead of stop-loss.
231+
"""
232+
trade_service = self.app.container.trade_service()
233+
order_service = self.app.container.order_service()
234+
235+
# Trade A: already filled → OPEN
236+
order_a = self._create_filled_buy_order("BTC", 39000, 0.1)
237+
trade_a = trade_service.find({"order_id": order_a.id})
238+
trade_service.add_take_profit(
239+
trade_a, percentage=10, trailing=False, sell_percentage=50
240+
)
241+
242+
# Order B: pending → will fill during evaluate()
243+
order_b = self._create_pending_buy_order("BTC", 39200, 0.05)
244+
trade_b = trade_service.find({"order_id": order_b.id})
245+
trade_service.add_take_profit(
246+
trade_b, percentage=10, trailing=False, sell_percentage=50
247+
)
248+
249+
open_trades = trade_service.get_all(
250+
{"status": TradeStatus.OPEN.value}
251+
)
252+
open_orders = order_service.get_all(
253+
{"status": OrderStatus.OPEN.value}
254+
)
255+
256+
evaluator = self._create_evaluator()
257+
# Must NOT raise TypeError
258+
evaluator.evaluate(
259+
open_trades=open_trades,
260+
open_orders=open_orders,
261+
ohlcv_data={"BTC/EUR": self.ohlcv_df},
262+
)
263+
264+
order_b_updated = order_service.get(order_b.id)
265+
self.assertEqual(OrderStatus.CLOSED.value, order_b_updated.status)
266+
267+
trade_b_updated = trade_service.find({"order_id": order_b.id})
268+
self.assertEqual(TradeStatus.OPEN.value, trade_b_updated.status)
269+
# Structural fix: newly opened trade also gets its price updated
270+
self.assertIsNotNone(trade_b_updated.last_reported_price)
271+
272+
def test_evaluate_only_pending_order_no_existing_open_trades(self):
273+
"""
274+
Edge case: no existing OPEN trades, only a pending order.
275+
evaluate() should fill the order without errors. Because
276+
open_trades is empty, _check_stop_losses/_check_take_profits
277+
are skipped entirely (they live inside the
278+
``if len(open_trades) > 0`` block).
279+
"""
280+
order_service = self.app.container.order_service()
281+
trade_service = self.app.container.trade_service()
282+
283+
order_b = self._create_pending_buy_order("BTC", 39200, 0.05)
284+
trade_b = trade_service.find({"order_id": order_b.id})
285+
trade_service.add_stop_loss(
286+
trade_b, percentage=10, trailing=False, sell_percentage=50
287+
)
288+
289+
open_trades = trade_service.get_all(
290+
{"status": TradeStatus.OPEN.value}
291+
)
292+
open_orders = order_service.get_all(
293+
{"status": OrderStatus.OPEN.value}
294+
)
295+
self.assertEqual(0, len(open_trades))
296+
self.assertEqual(1, len(open_orders))
297+
298+
evaluator = self._create_evaluator()
299+
evaluator.evaluate(
300+
open_trades=open_trades,
301+
open_orders=open_orders,
302+
ohlcv_data={"BTC/EUR": self.ohlcv_df},
303+
)
304+
305+
order_b_updated = order_service.get(order_b.id)
306+
self.assertEqual(OrderStatus.CLOSED.value, order_b_updated.status)
307+
308+
trade_b_updated = trade_service.find({"order_id": order_b.id})
309+
self.assertEqual(TradeStatus.OPEN.value, trade_b_updated.status)

0 commit comments

Comments
 (0)