Skip to content

Commit ba324e1

Browse files
committed
fix(backtest): ignore untradable fill remainders
1 parent 3b0040e commit ba324e1

2 files changed

Lines changed: 78 additions & 6 deletions

File tree

‎backend_api_python/app/services/strategy_v2/runtime.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,12 @@ def execute(
537537
continue
538538
delta = feasible_delta
539539
target_qty = current.amount + delta
540+
remaining_quantity = max(0.0, abs(requested_delta) - abs(delta))
541+
has_tradable_remainder = (
542+
remaining_quantity + 1e-12 >= lot_size
543+
and remaining_quantity * open_price + 1e-12 >= 0.01
544+
)
545+
execution_status = "partial" if has_tradable_remainder else "filled"
540546
notional = abs(delta * fill_price)
541547
fee = notional * self.commission
542548
projected_cash = self.portfolio.available_cash - delta * fill_price - fee
@@ -584,15 +590,17 @@ def execute(
584590
"signal_time": str(order.signal_time) if order.signal_time is not None else str(pd.Timestamp(timestamp)),
585591
"fill_reference": "bar_open",
586592
"reference_price": open_price,
587-
"status": "partial" if abs(delta) + 1e-12 < abs(requested_delta) else "filled",
593+
"status": execution_status,
588594
"requested_quantity": abs(requested_delta),
589595
}
590596
self.executions.append(execution)
591-
reason = constraint_reason or (
592-
"insufficient_liquidity"
593-
if liquidity_cap is not None and abs(requested_delta) > liquidity_cap
594-
else "filled"
595-
)
597+
reason = "filled"
598+
if execution_status == "partial":
599+
reason = constraint_reason or (
600+
"insufficient_liquidity"
601+
if liquidity_cap is not None and abs(requested_delta) > liquidity_cap
602+
else "partial_fill"
603+
)
596604
self.order_ledger.append(self._order_event(
597605
order_id,
598606
order,

‎backend_api_python/tests/test_strategy_v2_runtime.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,67 @@ def handle_data(context, data):
368368
item["statusReason"] == "target_already_met"
369369
for item in result["orderLedger"]
370370
)
371+
372+
373+
def test_crypto_lot_rounding_is_filled_without_a_tail_retry():
374+
frame = _frame([58_700, 58_700, 58_700])
375+
code = """
376+
def initialize(context):
377+
g.symbol = "Crypto:BTC/USDT@swap"
378+
g.sent = False
379+
context.set_universe([g.symbol])
380+
context.subscribe(frequency="1m")
381+
382+
def handle_data(context, data):
383+
if not g.sent:
384+
order_target_percent(g.symbol, 0.95, reason="entry")
385+
g.sent = True
386+
"""
387+
result = StrategyV2BacktestRunner(
388+
code=code,
389+
frames={"Crypto:BTC/USDT@swap": frame},
390+
initial_capital=10_000,
391+
commission=0.0005,
392+
slippage=0.0005,
393+
).run()
394+
395+
assert result["totalExecutions"] == 1
396+
assert result["rawTrades"][0]["status"] == "filled"
397+
assert result["attribution"]["orderStatus"] == {
398+
"filled": 1,
399+
"partial": 0,
400+
"deferred": 0,
401+
"rejected": 0,
402+
}
403+
assert not any(
404+
item["statusReason"] == "target_already_met"
405+
for item in result["orderLedger"]
406+
)
407+
408+
409+
def test_crypto_target_reversals_do_not_retry_untradable_tail_quantities():
410+
frame = _frame([58_700] * 6)
411+
code = """
412+
def initialize(context):
413+
g.symbol = "Crypto:BTC/USDT@swap"
414+
g.step = 0
415+
context.set_universe([g.symbol])
416+
context.subscribe(frequency="1m")
417+
418+
def handle_data(context, data):
419+
target = 0.95 if g.step % 2 == 0 else -0.95
420+
order_target_percent(g.symbol, target, reason="regime_change")
421+
g.step += 1
422+
"""
423+
result = StrategyV2BacktestRunner(
424+
code=code,
425+
frames={"Crypto:BTC/USDT@swap": frame},
426+
initial_capital=10_000,
427+
commission=0.0005,
428+
slippage=0.0005,
429+
).run()
430+
431+
assert result["totalExecutions"] == 5
432+
assert {item["status"] for item in result["rawTrades"]} == {"filled"}
433+
assert result["attribution"]["orderStatus"]["partial"] == 0
434+
assert result["attribution"]["orderStatus"]["rejected"] == 0

0 commit comments

Comments
 (0)