mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-29 07:42:55 +08:00
_schedule_due only looked at the target day of the current week or month. When that day had no bar and the next bar opened a new period (Good Friday with weekday=5, a month-end on a weekend with monthday=31, or a Saturday target on a stock market), the run was silently dropped. Inside a period the missed day was already caught up on the next bar; apply the same rule to the previous period's target day.
1567 lines
51 KiB
Python
1567 lines
51 KiB
Python
import math
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from app.services.instrument_rules import InstrumentRules
|
|
from app.services.strategy_v2 import StrategyV2BacktestRunner, StrategyV2LiveSession
|
|
from app.services.strategy_v2.data import MultiAssetDataPortal
|
|
from app.services.strategy_v2.models import ScheduleSpec
|
|
from app.services.strategy_v2.runtime import MultiAssetSimulationBroker, OrderIntent, Position
|
|
|
|
|
|
def _frame(prices):
|
|
index = pd.date_range("2026-01-01", periods=len(prices), freq="D")
|
|
return pd.DataFrame({
|
|
"open": prices,
|
|
"high": [price * 1.01 for price in prices],
|
|
"low": [price * 0.99 for price in prices],
|
|
"close": prices,
|
|
"volume": [100000] * len(prices),
|
|
}, index=index)
|
|
|
|
|
|
def _rules(key: str, *, amount_step: float, min_notional: float = 0.0, min_amount: float = 0.0):
|
|
market_type = "swap" if key.lower().endswith("@swap") else "spot"
|
|
return {
|
|
key: InstrumentRules(
|
|
key=key,
|
|
exchange_id="binance",
|
|
market_type=market_type,
|
|
symbol=key.split(":", 1)[-1].split("@", 1)[0],
|
|
amount_step=amount_step,
|
|
min_amount=min_amount,
|
|
min_notional=min_notional,
|
|
price_tick=0.01,
|
|
captured_at="2026-01-01T00:00:00Z",
|
|
)
|
|
}
|
|
|
|
|
|
def test_data_portal_caches_timestamps_and_slices_point_in_time_history():
|
|
portal = MultiAssetDataPortal({"USStock:AAPL": _frame(range(1000))})
|
|
cached_timestamps = portal.timestamps
|
|
|
|
portal.set_clock(cached_timestamps[500], include_current=False)
|
|
previous = portal.visible_frame("AAPL", count=2)
|
|
portal.set_clock(cached_timestamps[500], include_current=True)
|
|
current = portal.visible_frame("AAPL", count=2)
|
|
|
|
assert portal.timestamps is cached_timestamps
|
|
assert list(previous.index) == list(cached_timestamps[498:500])
|
|
assert list(current.index) == list(cached_timestamps[499:501])
|
|
|
|
|
|
def test_multi_asset_strategy_controls_symbols_and_rebalances_without_ui_market_fields():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL", "USStock:MSFT"])
|
|
context.subscribe(frequency="1d")
|
|
run_daily(rebalance, time="09:35")
|
|
|
|
def rebalance(context, data):
|
|
order_target_percent("AAPL", 0.5)
|
|
order_target_percent("MSFT", 0.5)
|
|
"""
|
|
runner = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={
|
|
"USStock:AAPL": _frame([100, 101, 102]),
|
|
"USStock:MSFT": _frame([200, 202, 204]),
|
|
},
|
|
initial_capital=10000,
|
|
commission=0,
|
|
slippage=0,
|
|
)
|
|
|
|
result = runner.run()
|
|
|
|
assert result["engine"]["version"] == "quantdinger-strategy-api-v2"
|
|
assert result["manifest"]["strategyType"] == "portfolio"
|
|
assert {trade["symbol"] for trade in result["rawTrades"]} == {"USStock:AAPL", "USStock:MSFT"}
|
|
assert result["totalExecutions"] >= 2
|
|
assert result["finalEquity"] > 10000
|
|
|
|
|
|
def test_backtest_range_accepts_timezone_aware_utc_boundaries():
|
|
runner = StrategyV2BacktestRunner(
|
|
code="""
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
pass
|
|
""",
|
|
frames={"USStock:AAPL": _frame([100, 101, 102])},
|
|
initial_capital=10000,
|
|
)
|
|
|
|
result = runner.run(
|
|
start_date=pd.Timestamp("2026-01-02", tz="UTC"),
|
|
end_date=pd.Timestamp("2026-01-03", tz="UTC"),
|
|
)
|
|
|
|
assert len(result["equityCurve"]) == 2
|
|
|
|
|
|
def test_result_distinguishes_total_return_from_peak_to_trough_drawdown():
|
|
runner = StrategyV2BacktestRunner(
|
|
code="""
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
pass
|
|
""",
|
|
frames={"USStock:AAPL": _frame([100, 101, 102])},
|
|
initial_capital=100,
|
|
commission=0,
|
|
slippage=0,
|
|
)
|
|
runner.broker.equity_curve = [
|
|
{"time": "2026-01-01", "value": 100.0},
|
|
{"time": "2026-01-02", "value": 135.6793190007},
|
|
{"time": "2026-01-03", "value": 94.5187761357},
|
|
]
|
|
runner.broker.portfolio.total_value = 94.5187761357
|
|
|
|
result = runner._result()
|
|
|
|
assert result["totalReturn"] == pytest.approx(-5.4812238643)
|
|
assert result["maxDrawdown"] == pytest.approx(-30.3366372769)
|
|
assert result["maxDrawdownPeakEquity"] == pytest.approx(135.6793190007)
|
|
assert result["maxDrawdownTroughEquity"] == pytest.approx(94.5187761357)
|
|
assert result["maxDrawdownPeakTime"] == "2026-01-02"
|
|
assert result["maxDrawdownTroughTime"] == "2026-01-03"
|
|
assert result["equityCurve"][-1]["drawdown"] == pytest.approx(-30.3366372769)
|
|
|
|
|
|
def test_drawdown_uses_initial_capital_before_the_first_equity_sample():
|
|
runner = StrategyV2BacktestRunner(
|
|
code="""
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
pass
|
|
""",
|
|
frames={"USStock:AAPL": _frame([100, 101, 102])},
|
|
initial_capital=100,
|
|
commission=0,
|
|
slippage=0,
|
|
)
|
|
runner.broker.equity_curve = [
|
|
{"time": "2026-01-01", "value": 99.0},
|
|
{"time": "2026-01-02", "value": 101.0},
|
|
{"time": "2026-01-03", "value": 100.0},
|
|
]
|
|
runner.broker.portfolio.total_value = 100.0
|
|
|
|
result = runner._result()
|
|
|
|
assert result["maxDrawdown"] == pytest.approx(-1.0)
|
|
assert result["maxDrawdownPeakEquity"] == pytest.approx(100.0)
|
|
assert result["maxDrawdownTroughEquity"] == pytest.approx(99.0)
|
|
assert result["maxDrawdownPeakTime"] == "2026-01-01"
|
|
assert result["maxDrawdownTroughTime"] == "2026-01-01"
|
|
assert result["equityCurve"][0]["drawdown"] == pytest.approx(-1.0)
|
|
|
|
|
|
def test_history_is_point_in_time_and_close_signal_fills_next_open():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
bars = get_history(10, security_list="AAPL")
|
|
if len(bars) == 1:
|
|
order_target_percent("AAPL", 1.0)
|
|
"""
|
|
runner = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"USStock:AAPL": _frame([100, 110, 121])},
|
|
initial_capital=10000,
|
|
commission=0,
|
|
slippage=0,
|
|
)
|
|
|
|
result = runner.run()
|
|
|
|
assert len(result["rawTrades"]) == 1
|
|
assert result["rawTrades"][0]["time"].startswith("2026-01-02")
|
|
assert result["rawTrades"][0]["price"] == 110
|
|
|
|
|
|
def test_target_percent_open_sizing_ignores_same_bar_future_ohlc_values():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
bars = get_history(10, "1d", "close", "USStock:AAPL")
|
|
if len(bars) == 1:
|
|
order_target_percent("USStock:AAPL", 1.0, reason="initial_target")
|
|
elif len(bars) == 2:
|
|
order_target_percent("USStock:AAPL", 0.5, reason="rebalance_target")
|
|
"""
|
|
index = pd.date_range("2026-01-01", periods=3, freq="D")
|
|
|
|
def run(close: float, high: float, low: float):
|
|
frame = pd.DataFrame({
|
|
"open": [100.0, 100.0, 100.0],
|
|
"high": [101.0, 101.0, high],
|
|
"low": [99.0, 99.0, low],
|
|
"close": [100.0, 100.0, close],
|
|
"volume": [1_000_000.0] * 3,
|
|
}, index=index)
|
|
return StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"USStock:AAPL": frame},
|
|
initial_capital=10_000,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
|
|
low_close = run(80.0, 150.0, 50.0)
|
|
high_close = run(120.0, 150.0, 50.0)
|
|
changed_range = run(80.0, 500.0, 1.0)
|
|
|
|
for result in (low_close, high_close, changed_range):
|
|
rebalance = result["executions"][1]
|
|
assert result["engine"]["preFillValuationPolicy"] == (
|
|
"explicit_fill_or_current_open_then_last_completed_close-v1"
|
|
)
|
|
assert rebalance["price"] == pytest.approx(100.0)
|
|
assert rebalance["side"] == "sell"
|
|
assert rebalance["quantity"] == pytest.approx(50.0)
|
|
assert result["rebalanceRecords"][1]["equityBefore"] == pytest.approx(10_000.0)
|
|
|
|
assert low_close["orderLedger"] == high_close["orderLedger"] == changed_range["orderLedger"]
|
|
assert low_close["rebalanceRecords"] == high_close["rebalanceRecords"] == changed_range["rebalanceRecords"]
|
|
|
|
|
|
def test_buy_limit_remains_resting_and_uses_favorable_gap_open():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
g.sent = False
|
|
|
|
def handle_data(context, data):
|
|
if not g.sent:
|
|
order_value("AAPL", 95.0, order_type="limit", limit_price=95.0)
|
|
g.sent = True
|
|
"""
|
|
index = pd.date_range("2026-01-01", periods=3, freq="D")
|
|
frame = pd.DataFrame({
|
|
"open": [100.0, 100.0, 94.0],
|
|
"high": [101.0, 101.0, 96.0],
|
|
"low": [99.0, 99.0, 93.0],
|
|
"close": [100.0, 100.0, 95.0],
|
|
"volume": [100000.0] * 3,
|
|
}, index=index)
|
|
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"USStock:AAPL": frame},
|
|
initial_capital=1000,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
|
|
assert result["totalExecutions"] == 1
|
|
assert result["executions"][0]["price"] == pytest.approx(94.0)
|
|
assert result["executions"][0]["fill_reference"] == "gap_open"
|
|
assert result["audit"]["passed"] is True
|
|
assert any(
|
|
row["status"] == "deferred" and row["statusReason"] == "limit_not_reached"
|
|
for row in result["orderLedger"]
|
|
)
|
|
|
|
|
|
def test_resting_limit_compacts_poll_events_without_changing_fill_or_status():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
g.sent = False
|
|
|
|
def handle_data(context, data):
|
|
if not g.sent:
|
|
order_value(
|
|
"AAPL",
|
|
95.0,
|
|
order_type="limit",
|
|
limit_price=95.0,
|
|
client_order_id="resting-buy",
|
|
)
|
|
g.sent = True
|
|
"""
|
|
periods = 20
|
|
index = pd.date_range("2026-01-01", periods=periods, freq="D")
|
|
frame = pd.DataFrame({
|
|
"open": [100.0] * (periods - 1) + [94.0],
|
|
"high": [101.0] * (periods - 1) + [96.0],
|
|
"low": [99.0] * (periods - 1) + [93.0],
|
|
"close": [100.0] * (periods - 1) + [95.0],
|
|
"volume": [100000.0] * periods,
|
|
}, index=index)
|
|
runner = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"USStock:AAPL": frame},
|
|
initial_capital=1000,
|
|
commission=0,
|
|
slippage=0,
|
|
)
|
|
|
|
result = runner.run()
|
|
|
|
resting = [
|
|
item
|
|
for item in result["orderLedger"]
|
|
if item.get("statusReason") == "limit_not_reached"
|
|
]
|
|
assert len(resting) == 1
|
|
assert resting[0]["occurrenceCount"] == periods - 2
|
|
assert resting[0]["firstEventTime"] < resting[0]["lastEventTime"]
|
|
assert result["orderLedgerStats"] == {
|
|
"storedEvents": 2,
|
|
"eventOccurrences": periods - 1,
|
|
"compactedOccurrences": periods - 3,
|
|
}
|
|
assert result["totalExecutions"] == 1
|
|
assert result["executions"][0]["price"] == pytest.approx(94.0)
|
|
status = runner.context.get_order_status("resting-buy")
|
|
assert status["status"] == "filled"
|
|
# Limit-order sizing is fixed at the submitted limit price; a favorable
|
|
# gap improves cash usage without changing the requested base quantity.
|
|
assert status["filled_quantity"] == pytest.approx(1.0)
|
|
assert runner._order_status_cursor == len(runner.broker.order_ledger)
|
|
|
|
|
|
def test_buy_limit_fills_at_limit_when_touched_inside_bar():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
g.sent = False
|
|
|
|
def handle_data(context, data):
|
|
if not g.sent:
|
|
order_value("AAPL", 95.0, order_type="limit", limit_price=95.0)
|
|
g.sent = True
|
|
"""
|
|
index = pd.date_range("2026-01-01", periods=2, freq="D")
|
|
frame = pd.DataFrame({
|
|
"open": [100.0, 100.0],
|
|
"high": [101.0, 101.0],
|
|
"low": [99.0, 94.0],
|
|
"close": [100.0, 96.0],
|
|
"volume": [100000.0] * 2,
|
|
}, index=index)
|
|
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"USStock:AAPL": frame},
|
|
initial_capital=1000,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
|
|
assert result["executions"][0]["price"] == pytest.approx(95.0)
|
|
assert result["executions"][0]["fill_reference"] == "limit"
|
|
assert result["audit"]["passed"] is True
|
|
|
|
|
|
def test_partial_incremental_limit_retries_only_the_remaining_notional():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["Crypto:BTC/USDT@spot"])
|
|
context.subscribe(frequency="1d")
|
|
g.sent = False
|
|
|
|
def handle_data(context, data):
|
|
if not g.sent:
|
|
order_value(
|
|
"Crypto:BTC/USDT@spot",
|
|
100.0,
|
|
order_type="limit",
|
|
limit_price=100.0,
|
|
)
|
|
g.sent = True
|
|
"""
|
|
index = pd.date_range("2026-01-01", periods=15, freq="D")
|
|
frame = pd.DataFrame({
|
|
"open": [100.0] * 15,
|
|
"high": [101.0] * 15,
|
|
"low": [99.0] * 15,
|
|
"close": [100.0] * 15,
|
|
# The simulator allows at most 10% of bar volume per fill.
|
|
"volume": [1.0] * 15,
|
|
}, index=index)
|
|
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@spot": frame},
|
|
initial_capital=1000,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
|
|
position = result["positions"]["Crypto:BTC/USDT@spot"]
|
|
assert position["amount"] == pytest.approx(1.0)
|
|
assert sum(row["notional"] for row in result["executions"]) == pytest.approx(100.0)
|
|
assert result["audit"]["passed"] is True
|
|
|
|
|
|
def test_full_target_percent_reserves_commission_instead_of_rejecting_order():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
order_target_percent("AAPL", 1.0)
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"USStock:AAPL": _frame([100, 101, 102])},
|
|
initial_capital=10000,
|
|
commission=0.0005,
|
|
slippage=0.0005,
|
|
).run()
|
|
|
|
assert result["totalTrades"] == 0
|
|
assert result["totalExecutions"] == 1
|
|
assert result["positions"]["USStock:AAPL"]["amount"] > 0
|
|
|
|
|
|
def test_swap_margin_budget_expands_target_percent_by_leverage():
|
|
broker = MultiAssetSimulationBroker(
|
|
initial_capital=10_000,
|
|
leverage=5,
|
|
commission=0,
|
|
slippage=0,
|
|
)
|
|
order = OrderIntent(symbol="Crypto:BTC/USDT@okx:swap", kind="target_percent", value=0.25)
|
|
|
|
target = broker._target_quantity(
|
|
order,
|
|
Position(order.symbol),
|
|
price=100,
|
|
equity=10_000,
|
|
)
|
|
|
|
assert target == 125.0
|
|
|
|
|
|
def test_explicit_backtest_quantity_is_not_scaled_by_leverage():
|
|
broker = MultiAssetSimulationBroker(initial_capital=10_000, leverage=5)
|
|
order = OrderIntent(symbol="Crypto:BTC/USDT@okx:swap", kind="target_quantity", value=2.5)
|
|
|
|
target = broker._target_quantity(
|
|
order,
|
|
Position(order.symbol),
|
|
price=100,
|
|
equity=10_000,
|
|
)
|
|
|
|
assert target == 2.5
|
|
|
|
|
|
@pytest.mark.parametrize("leverage", [5, 20])
|
|
@pytest.mark.parametrize("commission,slippage", [(0, 0), (0.0005, 0.0005)])
|
|
def test_leveraged_backtest_force_closes_and_stops_after_insolvency(leverage, commission, slippage):
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["Crypto:BTC/USDT@swap"])
|
|
context.subscribe(frequency="1d")
|
|
context.allow_leverage(max_leverage=20)
|
|
|
|
def handle_data(context, data):
|
|
if get_position("Crypto:BTC/USDT@swap").amount == 0:
|
|
order_target_percent("Crypto:BTC/USDT@swap", 0.95, reason="open_long")
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@swap": _frame([100, 100, 70, 60, 50, 40])},
|
|
initial_capital=10_000,
|
|
leverage_enabled=True,
|
|
leverage=leverage,
|
|
commission=commission,
|
|
slippage=slippage,
|
|
).run()
|
|
|
|
assert result["liquidated"] is True
|
|
assert result["finalEquity"] == pytest.approx(0.0)
|
|
assert result["totalReturn"] == pytest.approx(-100.0)
|
|
assert result["annualizedReturn"] == pytest.approx(-100.0)
|
|
assert result["maxDrawdown"] == pytest.approx(-100.0)
|
|
assert result["maxDrawdownTroughEquity"] == pytest.approx(0.0)
|
|
assert result["maxDrawdownTroughTime"] == result["liquidationEvents"][0]["time"]
|
|
assert min(point["drawdown"] for point in result["equityCurve"]) == pytest.approx(-100.0)
|
|
liquidation_time = result["liquidationEvents"][0]["time"]
|
|
assert all(point["value"] == 0 and point["drawdown"] == -100
|
|
for point in result["equityCurve"] if point["time"] >= liquidation_time)
|
|
assert result["totalExecutions"] == 2
|
|
assert result["totalTrades"] == 1
|
|
assert result["closedTrades"][0]["close_reason"] == "margin_liquidation"
|
|
assert result["orderLedger"][-1]["statusReason"] == "margin_liquidation"
|
|
assert result["audit"]["passed"] is True
|
|
assert all(float(point["value"]) >= 0 for point in result["equityCurve"])
|
|
|
|
|
|
def test_leverage_does_not_change_regime_signal_count_while_account_is_solvent():
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@swap"
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1d")
|
|
context.allow_leverage(max_leverage=20)
|
|
|
|
def handle_data(context, data):
|
|
bars = get_history(6, "1d", "close", g.symbol)
|
|
if len(bars) < 5:
|
|
return
|
|
close = bars["close"]
|
|
fast = float(close.tail(2).mean())
|
|
slow = float(close.tail(5).mean())
|
|
amount = float(get_position(g.symbol).amount or 0.0)
|
|
target = 0.95 if fast > slow else -0.95
|
|
if (target > 0 and amount <= 0) or (target < 0 and amount >= 0):
|
|
order_target_percent(g.symbol, target, reason="regime_change")
|
|
"""
|
|
prices = [100 + 2 * math.sin(index / 4) for index in range(120)]
|
|
|
|
unleveraged = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@swap": _frame(prices)},
|
|
initial_capital=10_000,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
leveraged = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@swap": _frame(prices)},
|
|
initial_capital=10_000,
|
|
leverage_enabled=True,
|
|
leverage=5,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
|
|
assert unleveraged["liquidated"] is False
|
|
assert leveraged["liquidated"] is False
|
|
assert leveraged["totalExecutions"] == unleveraged["totalExecutions"]
|
|
assert leveraged["totalTrades"] == unleveraged["totalTrades"]
|
|
|
|
|
|
def test_runtime_rejects_leverage_not_declared_by_strategy():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["Crypto:BTC/USDT@okx:swap"])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
pass
|
|
"""
|
|
try:
|
|
StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@okx:swap": _frame([100, 101])},
|
|
initial_capital=10000,
|
|
leverage_enabled=True,
|
|
leverage=2,
|
|
)
|
|
except ValueError as exc:
|
|
assert str(exc) == "strategyV2.leverageNotAllowed"
|
|
else:
|
|
raise AssertionError("Expected leverage policy rejection")
|
|
|
|
|
|
def test_runtime_helpers_and_logger_are_supported():
|
|
code = """
|
|
def initialize(context):
|
|
g.sec_code = "600519.XSHG"
|
|
context.set_universe([g.sec_code])
|
|
context.subscribe(frequency="1d")
|
|
log.info(context.current_dt)
|
|
run_daily(daily_event, time="14:50")
|
|
|
|
def daily_event(context):
|
|
if not is_trade():
|
|
return
|
|
bars = get_history(2, "1d", "close", g.sec_code, fq="pre", include=True)
|
|
position = get_position(g.sec_code)
|
|
log.info("position=%s" % position.amount)
|
|
if len(bars) >= 1 and position.amount == 0:
|
|
order_target_value(g.sec_code, context.portfolio.available_cash)
|
|
"""
|
|
runner = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"CNStock:600519.SH": _frame([100, 101, 102])},
|
|
initial_capital=10000,
|
|
commission=0,
|
|
slippage=0,
|
|
)
|
|
|
|
result = runner.run()
|
|
|
|
assert result["totalTrades"] == 0
|
|
assert result["totalExecutions"] == 1
|
|
assert result["sampleCount"] == len(result["equityCurve"])
|
|
assert any("position=0.0" in item for item in result["logs"])
|
|
position = next(iter(result["positions"].values()))
|
|
assert position["amount"] > 0
|
|
|
|
|
|
def test_backtest_separates_executions_from_closed_trades_and_realized_metrics():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
g.calls = 0
|
|
|
|
def handle_data(context, data):
|
|
g.calls += 1
|
|
if g.calls == 1:
|
|
order_target_percent("AAPL", 0.5, reason="entry")
|
|
elif g.calls == 2:
|
|
order_target_percent("AAPL", 0.0, reason="exit")
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"USStock:AAPL": _frame([100, 110, 120, 130])},
|
|
initial_capital=10000,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
|
|
assert result["totalExecutions"] == 2
|
|
assert result["totalTrades"] == 1
|
|
assert result["rawTrades"][0]["type"] == "open_long"
|
|
assert result["rawTrades"][1]["type"] == "close_long"
|
|
assert result["rawTrades"][0]["time"].endswith("Z")
|
|
assert result["rawTrades"][0]["signal_time"].endswith("Z")
|
|
assert result["closedTrades"][0]["entry_time"].endswith("Z")
|
|
assert result["closedTrades"][0]["exit_time"].endswith("Z")
|
|
assert all(point["time"].endswith("Z") for point in result["equityCurve"])
|
|
assert result["closedTrades"][0]["entry_price"] == 110
|
|
assert result["closedTrades"][0]["exit_price"] == 120
|
|
assert result["closedTrades"][0]["profit"] > 0
|
|
assert result["winRate"] == 100.0
|
|
assert result["profitFactor"] > 0
|
|
assert result["avgTrade"] > 0
|
|
|
|
|
|
def test_live_session_processes_each_closed_bar_once_and_preserves_state():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
g.calls = 0
|
|
|
|
def handle_data(context, data):
|
|
g.calls += 1
|
|
if g.calls == 1:
|
|
order_target_percent("AAPL", 0.5)
|
|
"""
|
|
session = StrategyV2LiveSession(
|
|
code=code,
|
|
frames={"USStock:AAPL": _frame([100, 101])},
|
|
initial_capital=10000,
|
|
)
|
|
|
|
first_orders, _, first_timestamp = session.process({"USStock:AAPL": _frame([100, 101])})
|
|
duplicate_orders, _, duplicate_timestamp = session.process({"USStock:AAPL": _frame([100, 101])})
|
|
next_orders, _, next_timestamp = session.process({"USStock:AAPL": _frame([100, 101, 102])})
|
|
|
|
assert len(first_orders) == 1
|
|
assert first_orders[0].kind == "target_percent"
|
|
assert duplicate_orders == []
|
|
assert next_orders == []
|
|
assert first_timestamp == duplicate_timestamp
|
|
assert next_timestamp > first_timestamp
|
|
|
|
|
|
def test_live_daily_schedule_uses_wall_clock_without_startup_catch_up():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
run_daily(rebalance, time="09:35")
|
|
|
|
def rebalance(context, data):
|
|
order_target_percent("AAPL", 0.5)
|
|
"""
|
|
frames = {"USStock:AAPL": _frame([100, 101])}
|
|
session = StrategyV2LiveSession(
|
|
code=code,
|
|
frames=frames,
|
|
initial_capital=10000,
|
|
schedule_timezone="Asia/Shanghai",
|
|
)
|
|
|
|
startup_orders, _, _ = session.process(
|
|
frames,
|
|
schedule_time="2026-07-18 22:57:42+08:00",
|
|
)
|
|
early_orders, _, _ = session.process(
|
|
frames,
|
|
schedule_time="2026-07-19 09:34:59+08:00",
|
|
)
|
|
due_orders, _, _ = session.process(
|
|
frames,
|
|
schedule_time="2026-07-19 09:35:00+08:00",
|
|
)
|
|
duplicate_orders, _, _ = session.process(
|
|
frames,
|
|
schedule_time="2026-07-19 09:36:00+08:00",
|
|
)
|
|
|
|
assert startup_orders == []
|
|
assert early_orders == []
|
|
assert len(due_orders) == 1
|
|
assert due_orders[0].signal_time == pd.Timestamp("2026-07-19 09:35:00+08:00")
|
|
assert duplicate_orders == []
|
|
|
|
|
|
def test_live_daily_schedule_fires_without_a_new_daily_bar():
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
run_daily(rebalance, time="09:35")
|
|
|
|
def rebalance(context, data):
|
|
order("AAPL", 1)
|
|
"""
|
|
frames = {"USStock:AAPL": _frame([100])}
|
|
session = StrategyV2LiveSession(
|
|
code=code,
|
|
frames=frames,
|
|
initial_capital=10000,
|
|
schedule_timezone="Asia/Shanghai",
|
|
)
|
|
|
|
session.process(frames, schedule_time="2026-07-19 09:34:00+08:00")
|
|
orders, _, timestamp = session.process(
|
|
frames,
|
|
schedule_time="2026-07-19 09:35:00+08:00",
|
|
)
|
|
|
|
assert len(orders) == 1
|
|
assert timestamp == frames["USStock:AAPL"].index[-1]
|
|
|
|
|
|
def test_get_fundamentals_resolves_public_api_field_aliases():
|
|
frame = _frame([100, 101])
|
|
frame["pe_ratio"] = [20.0, 21.0]
|
|
frame["return_on_equity"] = [0.10, 0.12]
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
values = get_fundamentals(["PE", "ROE"], "AAPL")
|
|
if not values.empty:
|
|
log.info("pe=%s,roe=%s" % (values.iloc[0]["PE"], values.iloc[0]["ROE"]))
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"USStock:AAPL": frame},
|
|
initial_capital=10000,
|
|
).run()
|
|
|
|
assert any("pe=21.0,roe=0.12" in item for item in result["logs"])
|
|
|
|
|
|
def test_scheduler_honors_weekday_monthday_and_intraday_time():
|
|
weekly = ScheduleSpec("weekly", "rebalance", weekday=3, time="09:35")
|
|
monthly = ScheduleSpec("monthly", "rebalance", monthday=15, time="09:35")
|
|
daily = ScheduleSpec("daily", "rebalance", time="09:35")
|
|
|
|
assert not StrategyV2BacktestRunner._schedule_due(
|
|
weekly, pd.Timestamp("2026-01-06"), pd.Timestamp("2026-01-05"), "1d"
|
|
)
|
|
assert StrategyV2BacktestRunner._schedule_due(
|
|
weekly, pd.Timestamp("2026-01-07"), pd.Timestamp("2026-01-06"), "1d"
|
|
)
|
|
assert StrategyV2BacktestRunner._schedule_due(
|
|
monthly, pd.Timestamp("2026-01-16"), pd.Timestamp("2026-01-14"), "1d"
|
|
)
|
|
assert not StrategyV2BacktestRunner._schedule_due(
|
|
daily, pd.Timestamp("2026-01-05 09:30"), pd.Timestamp("2026-01-05 09:25"), "5m"
|
|
)
|
|
assert StrategyV2BacktestRunner._schedule_due(
|
|
daily, pd.Timestamp("2026-01-05 09:35"), pd.Timestamp("2026-01-05 09:30"), "5m"
|
|
)
|
|
|
|
|
|
def test_scheduler_catches_up_target_day_missed_at_period_boundary():
|
|
good_friday_weekly = ScheduleSpec("weekly", "rebalance", weekday=5)
|
|
month_end = ScheduleSpec("monthly", "rebalance", monthday=31)
|
|
weekend_weekly = ScheduleSpec("weekly", "rebalance", weekday=6)
|
|
|
|
# 2026-04-03 is Good Friday (no US session): the week must still run once.
|
|
assert StrategyV2BacktestRunner._schedule_due(
|
|
good_friday_weekly, pd.Timestamp("2026-04-06"), pd.Timestamp("2026-04-02"), "1d"
|
|
)
|
|
assert not StrategyV2BacktestRunner._schedule_due(
|
|
good_friday_weekly, pd.Timestamp("2026-04-07"), pd.Timestamp("2026-04-06"), "1d"
|
|
)
|
|
# 2026-01-31 is a Saturday: the January month-end run happens on 2026-02-02.
|
|
assert StrategyV2BacktestRunner._schedule_due(
|
|
month_end, pd.Timestamp("2026-02-02"), pd.Timestamp("2026-01-30"), "1d"
|
|
)
|
|
assert not StrategyV2BacktestRunner._schedule_due(
|
|
month_end, pd.Timestamp("2026-02-03"), pd.Timestamp("2026-02-02"), "1d"
|
|
)
|
|
# A Saturday target on a weekday-only market runs on the following Monday.
|
|
assert StrategyV2BacktestRunner._schedule_due(
|
|
weekend_weekly, pd.Timestamp("2026-01-12"), pd.Timestamp("2026-01-09"), "1d"
|
|
)
|
|
assert not StrategyV2BacktestRunner._schedule_due(
|
|
weekend_weekly, pd.Timestamp("2026-01-13"), pd.Timestamp("2026-01-12"), "1d"
|
|
)
|
|
# Intraday: a Monday bar before the scheduled time does not re-run last week.
|
|
intraday = ScheduleSpec("weekly", "rebalance", weekday=1, time="09:35")
|
|
assert not StrategyV2BacktestRunner._schedule_due(
|
|
intraday, pd.Timestamp("2026-01-12 09:30"), pd.Timestamp("2026-01-09 16:00"), "5m"
|
|
)
|
|
assert not StrategyV2BacktestRunner._schedule_due(
|
|
good_friday_weekly, pd.Timestamp("2026-04-06"), None, "1d"
|
|
)
|
|
# Live clocks are zone-aware: stepping back a week must not cross into
|
|
# the week before when the gap spans a DST change (2026-03-08 in New York).
|
|
live = ScheduleSpec("weekly", "rebalance", weekday=6, time="09:35")
|
|
assert StrategyV2BacktestRunner._schedule_due(
|
|
live,
|
|
pd.Timestamp("2026-03-09 00:30", tz="America/New_York"),
|
|
pd.Timestamp("2026-03-06 16:00", tz="America/New_York"),
|
|
"1m",
|
|
)
|
|
|
|
|
|
def test_rejected_and_deferred_orders_are_visible_in_audit_ledger():
|
|
frame = _frame([100, 101, 102])
|
|
frame["is_suspended"] = [False, True, False]
|
|
code = """
|
|
def initialize(context):
|
|
context.set_universe(["USStock:AAPL"])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
order_target_value("AAPL", 50)
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"USStock:AAPL": frame},
|
|
initial_capital=10000,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
|
|
statuses = {(item["status"], item["statusReason"]) for item in result["orderLedger"]}
|
|
assert ("deferred", "suspended") in statuses
|
|
assert ("rejected", "minimum_trade_unit") in statuses
|
|
assert result["attribution"]["orderStatus"]["deferred"] >= 1
|
|
assert result["holdingSnapshots"]
|
|
|
|
|
|
def test_deferred_target_order_never_reverses_its_original_direction():
|
|
frame = _frame([100, 100, 1000, 1000])
|
|
frame["volume"] = [1, 1, 1, 1]
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT"
|
|
g.sent = False
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
if not g.sent:
|
|
order_target_percent(g.symbol, 0.5, reason="entry")
|
|
g.sent = True
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT": frame},
|
|
initial_capital=100,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
|
|
assert result["totalExecutions"] == 1
|
|
assert result["totalTrades"] == 0
|
|
assert result["rawTrades"][0]["side"] == "buy"
|
|
assert any(
|
|
item["statusReason"] == "target_already_met"
|
|
for item in result["orderLedger"]
|
|
)
|
|
|
|
|
|
def test_crypto_lot_rounding_is_filled_without_a_tail_retry():
|
|
frame = _frame([58_700, 58_700, 58_700])
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@swap"
|
|
g.sent = False
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
if not g.sent:
|
|
order_target_percent(g.symbol, 0.95, reason="entry")
|
|
g.sent = True
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@swap": frame},
|
|
initial_capital=10_000,
|
|
commission=0.0005,
|
|
slippage=0.0005,
|
|
).run()
|
|
|
|
assert result["totalExecutions"] == 1
|
|
assert result["rawTrades"][0]["status"] == "filled"
|
|
assert result["attribution"]["orderStatus"] == {
|
|
"filled": 1,
|
|
"partial": 0,
|
|
"deferred": 0,
|
|
"rejected": 0,
|
|
}
|
|
assert not any(
|
|
item["statusReason"] == "target_already_met"
|
|
for item in result["orderLedger"]
|
|
)
|
|
|
|
|
|
def test_crypto_target_reversals_do_not_retry_untradable_tail_quantities():
|
|
frame = _frame([58_700] * 6)
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@swap"
|
|
g.step = 0
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
target = 0.95 if g.step % 2 == 0 else -0.95
|
|
order_target_percent(g.symbol, target, reason="regime_change")
|
|
g.step += 1
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@swap": frame},
|
|
initial_capital=10_000,
|
|
commission=0.0005,
|
|
slippage=0.0005,
|
|
).run()
|
|
|
|
assert result["totalExecutions"] == 5
|
|
assert {item["status"] for item in result["rawTrades"]} == {"filled"}
|
|
assert result["attribution"]["orderStatus"]["partial"] == 0
|
|
assert result["attribution"]["orderStatus"]["rejected"] == 0
|
|
|
|
|
|
def test_position_cap_never_reverses_an_incremental_order_direction():
|
|
broker = MultiAssetSimulationBroker(
|
|
initial_capital=1000,
|
|
commission=0.0005,
|
|
slippage=0.0005,
|
|
)
|
|
position = Position(
|
|
"Crypto:BTC/USDT@spot",
|
|
amount=10,
|
|
avg_cost=100,
|
|
last_price=100,
|
|
)
|
|
broker.portfolio.positions[position.symbol] = position
|
|
broker.portfolio.available_cash = 0.0001
|
|
broker.portfolio.total_value = 1000.0001
|
|
|
|
feasible, reason = broker._feasible_delta(
|
|
delta=1,
|
|
current=position,
|
|
fill_price=100.05,
|
|
equity=1000.0001,
|
|
lot_size=1e-8,
|
|
position_key=position.symbol,
|
|
)
|
|
|
|
assert feasible == 0
|
|
assert reason == "position_limit"
|
|
|
|
|
|
def test_closed_trade_breaks_out_open_and_close_commission():
|
|
frame = _frame([100, 100, 110, 110, 110])
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@swap"
|
|
g.step = 0
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
if g.step == 0:
|
|
order_target_value(g.symbol, 1000, reason="entry")
|
|
elif g.step == 1:
|
|
order_target_value(g.symbol, 0, reason="exit")
|
|
g.step += 1
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code, frames={"Crypto:BTC/USDT@swap": frame}, initial_capital=10_000,
|
|
commission=0.001, slippage=0,
|
|
).run()
|
|
|
|
trade = result["closedTrades"][0]
|
|
assert trade["entry_commission"] > 0
|
|
assert trade["exit_commission"] > 0
|
|
assert trade["commission"] == pytest.approx(
|
|
trade["entry_commission"] + trade["exit_commission"]
|
|
)
|
|
assert trade["profit"] == pytest.approx(trade["gross_profit"] - trade["commission"])
|
|
|
|
|
|
def test_grid_exit_uses_matched_cell_entry_without_hiding_account_realized_pnl():
|
|
symbol = "Crypto:XAUT/USDT@swap"
|
|
index = pd.date_range("2026-01-01", periods=4, freq="min")
|
|
frame = pd.DataFrame({
|
|
"open": [80.0, 90.0, 110.0, 105.0],
|
|
"high": [80.0, 90.0, 110.0, 105.0],
|
|
"low": [80.0, 90.0, 110.0, 105.0],
|
|
"close": [80.0, 90.0, 110.0, 105.0],
|
|
"volume": [100_000.0] * 4,
|
|
}, index=index)
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:XAUT/USDT@swap"
|
|
g.low_sent = False
|
|
g.high_sent = False
|
|
g.exit_sent = False
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
context.set_metadata(direction_mode="neutral")
|
|
|
|
def handle_data(context, data):
|
|
if not g.low_sent:
|
|
order(
|
|
g.symbol, -1, position_side="short", order_type="limit",
|
|
limit_price=90, reason="short_entry",
|
|
client_order_id="grid-0-short-entry-1",
|
|
)
|
|
g.low_sent = True
|
|
elif get_order_status("grid-0-short-entry-1")["status"] == "filled" and not g.high_sent:
|
|
order(
|
|
g.symbol, -1, position_side="short", order_type="limit",
|
|
limit_price=110, reason="short_entry",
|
|
client_order_id="grid-1-short-entry-1",
|
|
)
|
|
g.high_sent = True
|
|
elif get_order_status("grid-1-short-entry-1")["status"] == "filled" and not g.exit_sent:
|
|
order(
|
|
g.symbol, 1, position_side="short", order_type="limit",
|
|
limit_price=105, reason="short_exit",
|
|
client_order_id="grid-1-short-exit-1",
|
|
)
|
|
g.exit_sent = True
|
|
"""
|
|
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={symbol: frame},
|
|
initial_capital=1000,
|
|
commission=0.001,
|
|
slippage=0,
|
|
).run()
|
|
|
|
trade = result["closedTrades"][0]
|
|
assert trade["profit_basis"] == "grid_cell"
|
|
assert trade["entry_price"] == pytest.approx(110)
|
|
assert trade["matched_entry_price"] == pytest.approx(110)
|
|
assert trade["exit_price"] == pytest.approx(105)
|
|
assert trade["gross_profit"] == pytest.approx(5)
|
|
assert trade["entry_commission"] == pytest.approx(0.11)
|
|
assert trade["exit_commission"] == pytest.approx(0.105)
|
|
assert trade["profit"] == pytest.approx(4.785)
|
|
assert trade["grid_matched_profit"] == pytest.approx(4.785)
|
|
assert trade["account_avg_entry_price"] == pytest.approx(100)
|
|
assert trade["account_realized_profit"] == pytest.approx(-5.205)
|
|
assert result["winRate"] == pytest.approx(100)
|
|
assert result["gridMatchedProfit"] == pytest.approx(4.785)
|
|
assert result["accountRealizedProfit"] == pytest.approx(-5.205)
|
|
assert result["tradeProfitBasis"] == "grid_cell_when_available"
|
|
assert result["attribution"]["symbols"][0]["realizedProfit"] == pytest.approx(-5.205)
|
|
assert [row["client_order_id"] for row in result["executions"]] == [
|
|
"grid-0-short-entry-1",
|
|
"grid-1-short-entry-1",
|
|
"grid-1-short-exit-1",
|
|
]
|
|
|
|
|
|
def test_live_session_snapshot_round_trips_strategy_timestamps_and_order_statuses():
|
|
frame = _frame([100, 101])
|
|
code = """
|
|
PERSIST_RUNTIME_STATE = True
|
|
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@spot"
|
|
g.last_order_at = None
|
|
g.sent = False
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
if not g.sent:
|
|
g.last_order_at = context.current_dt
|
|
g.reference = order_value(
|
|
g.symbol,
|
|
10,
|
|
client_order_id="state-test-order",
|
|
)
|
|
g.sent = True
|
|
"""
|
|
first = StrategyV2LiveSession(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@spot": frame},
|
|
initial_capital=100,
|
|
)
|
|
intents, _, _ = first.process(
|
|
{"Crypto:BTC/USDT@spot": frame},
|
|
schedule_time=frame.index[-1],
|
|
)
|
|
reference = intents[0].client_order_id
|
|
first.context.update_order_statuses({
|
|
reference: {
|
|
"client_order_id": reference,
|
|
"status": "submitted",
|
|
}
|
|
})
|
|
|
|
restored = StrategyV2LiveSession(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@spot": frame},
|
|
initial_capital=100,
|
|
)
|
|
restored.restore_session_snapshot(first.session_snapshot())
|
|
|
|
assert isinstance(restored.program.state.last_order_at, pd.Timestamp)
|
|
assert restored.program.state.last_order_at == frame.index[-1]
|
|
assert restored.program.state.reference == reference
|
|
assert restored.context.get_order_status(reference)["status"] == "submitted"
|
|
duplicate, _, _ = restored.process(
|
|
{"Crypto:BTC/USDT@spot": frame},
|
|
schedule_time=frame.index[-1],
|
|
)
|
|
assert duplicate == []
|
|
|
|
|
|
def test_custom_strategy_keeps_legacy_order_return_and_does_not_persist_g_by_default():
|
|
frame = _frame([100, 101])
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@spot"
|
|
g.counter = 0
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1d")
|
|
|
|
def handle_data(context, data):
|
|
g.counter += 1
|
|
g.legacy_result = order_value(g.symbol, 5)
|
|
g.explicit_reference = order_value(
|
|
g.symbol,
|
|
5,
|
|
client_order_id="custom-explicit-order",
|
|
)
|
|
"""
|
|
session = StrategyV2LiveSession(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@spot": frame},
|
|
initial_capital=100,
|
|
)
|
|
intents, _, _ = session.process(
|
|
{"Crypto:BTC/USDT@spot": frame},
|
|
schedule_time=frame.index[-1],
|
|
)
|
|
|
|
assert len(intents) == 2
|
|
assert session.program.state.legacy_result is None
|
|
assert session.program.state.explicit_reference == "custom-explicit-order"
|
|
snapshot = session.session_snapshot()
|
|
assert set(snapshot) == {"version", "protection"}
|
|
|
|
restored = StrategyV2LiveSession(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@spot": frame},
|
|
initial_capital=100,
|
|
)
|
|
restored.restore_session_snapshot({
|
|
**snapshot,
|
|
"strategyState": {"counter": 99},
|
|
})
|
|
assert restored.program.state.counter == 0
|
|
|
|
|
|
def test_crypto_integer_lot_size_no_dust_on_close():
|
|
"""
|
|
Test that when using real exchange lot_size (fractional for BTC, integer for low-priced perps),
|
|
closing a position does not leave sub-lot dust that blocks re-entry.
|
|
|
|
This reproduces the issue from #219 where 1e-8 hardcoded lot_size caused
|
|
dust to remain after partial fills due to liquidity caps.
|
|
"""
|
|
# Simulate a crypto perp with realistic BTC lot_size (0.001 BTC)
|
|
# Price: ~50,000 USDT, volume allows only 0.1 BTC per bar due to 10% liquidity cap
|
|
prices = [50000, 51000, 52000, 53000, 54000]
|
|
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
|
|
frame = pd.DataFrame({
|
|
"open": prices,
|
|
"high": [p * 1.001 for p in prices],
|
|
"low": [p * 0.999 for p in prices],
|
|
"close": prices,
|
|
"volume": [0.1] * len(prices), # Low volume so liquidity cap = 0.1 BTC
|
|
}, index=index)
|
|
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@swap"
|
|
g.step = 0
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
if g.step == 0:
|
|
order_target_value(g.symbol, 5000, reason="entry") # ~0.1 BTC
|
|
elif g.step == 1:
|
|
order_target_value(g.symbol, 0, reason="exit") # Full close
|
|
g.step += 1
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@swap": frame},
|
|
initial_capital=10_000,
|
|
commission=0.0005,
|
|
slippage=0.0005,
|
|
instrument_rules=_rules(
|
|
"Crypto:BTC/USDT@swap", amount_step=0.001, min_notional=5.0
|
|
),
|
|
).run()
|
|
|
|
# Should have 2 executions (entry + exit)
|
|
assert result["totalExecutions"] == 2
|
|
assert result["totalTrades"] == 1
|
|
|
|
# Position should be fully closed (no dust remaining)
|
|
trade = result["closedTrades"][0]
|
|
assert trade["profit"] != 0 # Trade actually happened
|
|
|
|
# No rejected orders due to minimum_trade_unit dust
|
|
rejected_reasons = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"]
|
|
assert "minimum_trade_unit" not in rejected_reasons, f"Dust caused minimum_trade_unit rejection: {rejected_reasons}"
|
|
|
|
# Position should be cleanly closed
|
|
assert len(result["executions"]) == 2
|
|
assert result["executions"][0]["side"] == "buy"
|
|
assert result["executions"][1]["side"] == "sell"
|
|
|
|
|
|
def test_crypto_min_notional_rejection():
|
|
"""
|
|
Test that orders below MIN_NOTIONAL are rejected.
|
|
"""
|
|
prices = [100, 100, 100]
|
|
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
|
|
frame = pd.DataFrame({
|
|
"open": prices,
|
|
"high": prices,
|
|
"low": prices,
|
|
"close": prices,
|
|
"volume": [10000] * len(prices),
|
|
}, index=index)
|
|
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@swap"
|
|
g.sent = False
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
if not g.sent:
|
|
order_target_value(g.symbol, 50, reason="entry") # Below min notional of 100
|
|
g.sent = True
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@swap": frame},
|
|
initial_capital=10_000,
|
|
commission=0.0005,
|
|
slippage=0.0005,
|
|
instrument_rules=_rules(
|
|
"Crypto:BTC/USDT@swap", amount_step=0.01, min_notional=100.0
|
|
),
|
|
).run()
|
|
|
|
# Order should be rejected due to min_notional
|
|
rejected_reasons = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"]
|
|
assert "min_notional" in rejected_reasons, f"Expected min_notional rejection, got: {rejected_reasons}"
|
|
|
|
|
|
def test_crypto_min_notional_does_not_block_full_close():
|
|
prices = [10, 10, 4, 4]
|
|
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
|
|
frame = pd.DataFrame({
|
|
"open": prices,
|
|
"high": prices,
|
|
"low": prices,
|
|
"close": prices,
|
|
"volume": [1000] * len(prices),
|
|
}, index=index)
|
|
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:TUT/USDT@swap"
|
|
g.step = 0
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
if g.step == 0:
|
|
order(g.symbol, 1, reason="entry")
|
|
elif g.step == 1:
|
|
order_target_value(g.symbol, 0, reason="exit")
|
|
g.step += 1
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:TUT/USDT@swap": frame},
|
|
initial_capital=100,
|
|
commission=0.0,
|
|
slippage=0.0,
|
|
instrument_rules=_rules(
|
|
"Crypto:TUT/USDT@swap", amount_step=1.0, min_notional=5.0
|
|
),
|
|
).run()
|
|
|
|
assert result["totalExecutions"] == 2
|
|
assert result["totalTrades"] == 1
|
|
assert not result["positions"]
|
|
assert result["orderLedger"][-1]["status"] == "filled"
|
|
|
|
|
|
def test_crypto_spot_close_still_obeys_min_notional():
|
|
prices = [10, 10, 4, 4]
|
|
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
|
|
frame = pd.DataFrame({
|
|
"open": prices,
|
|
"high": prices,
|
|
"low": prices,
|
|
"close": prices,
|
|
"volume": [1000] * len(prices),
|
|
}, index=index)
|
|
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:TUT/USDT@spot"
|
|
g.step = 0
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
if g.step == 0:
|
|
order(g.symbol, 1, reason="entry")
|
|
elif g.step == 1:
|
|
order_target_value(g.symbol, 0, reason="exit")
|
|
g.step += 1
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:TUT/USDT@spot": frame},
|
|
initial_capital=100,
|
|
commission=0.0,
|
|
slippage=0.0,
|
|
instrument_rules=_rules(
|
|
"Crypto:TUT/USDT@spot", amount_step=1.0, min_notional=5.0
|
|
),
|
|
).run()
|
|
|
|
assert result["totalExecutions"] == 1
|
|
assert result["positions"]
|
|
assert result["orderLedger"][-1]["statusReason"] == "min_notional"
|
|
|
|
|
|
def test_crypto_min_notional_uses_cash_capped_fill_quantity():
|
|
prices = [10, 10, 10]
|
|
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
|
|
frame = pd.DataFrame({
|
|
"open": prices,
|
|
"high": prices,
|
|
"low": prices,
|
|
"close": prices,
|
|
"volume": [1000] * len(prices),
|
|
}, index=index)
|
|
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:TUT/USDT@swap"
|
|
g.sent = False
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
if not g.sent:
|
|
order(g.symbol, 10, reason="entry")
|
|
g.sent = True
|
|
"""
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:TUT/USDT@swap": frame},
|
|
initial_capital=20,
|
|
commission=0.0,
|
|
slippage=0.0,
|
|
instrument_rules=_rules(
|
|
"Crypto:TUT/USDT@swap", amount_step=1.0, min_notional=50.0
|
|
),
|
|
).run()
|
|
|
|
assert result["totalExecutions"] == 0
|
|
assert not result["positions"]
|
|
assert result["orderLedger"][0]["statusReason"] == "min_notional"
|
|
|
|
|
|
def test_crypto_target_zero_reconciles_a_real_sub_lot_residual():
|
|
symbol = "Crypto:BTC/USDT@swap"
|
|
frame = _frame([100, 100])
|
|
portal = MultiAssetDataPortal({symbol: frame})
|
|
broker = MultiAssetSimulationBroker(
|
|
initial_capital=10_000,
|
|
commission=0.0,
|
|
slippage=0.0,
|
|
instrument_rules=_rules(symbol, amount_step=0.1, min_notional=1.0),
|
|
)
|
|
broker.portfolio.positions[symbol] = Position(
|
|
symbol=symbol,
|
|
amount=0.25,
|
|
avg_cost=100,
|
|
last_price=100,
|
|
)
|
|
|
|
timestamp = frame.index[0]
|
|
portal.set_clock(timestamp, include_current=True)
|
|
broker.execute([OrderIntent(symbol, "target_quantity", 0.0)], portal, timestamp)
|
|
|
|
assert broker.executions[-1]["quantity"] == pytest.approx(0.25)
|
|
assert not broker.portfolio.positions
|
|
|
|
|
|
def test_backtest_results_independent_of_initial_capital():
|
|
"""
|
|
Test that backtest execution results are independent of initial capital
|
|
(the core issue from #219: larger positions hit liquidity cap more often,
|
|
leaving more dust with hardcoded 1e-8 lot_size).
|
|
"""
|
|
prices = [50000, 51000, 52000, 53000]
|
|
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
|
|
frame = pd.DataFrame({
|
|
"open": prices,
|
|
"high": [p * 1.001 for p in prices],
|
|
"low": [p * 0.999 for p in prices],
|
|
"close": prices,
|
|
"volume": [0.2] * len(prices), # Very low volume -> tight liquidity cap (0.02 BTC per bar)
|
|
}, index=index)
|
|
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@swap"
|
|
g.step = 0
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
if g.step == 0:
|
|
order_target_value(g.symbol, 100000, reason="entry") # 2 BTC
|
|
elif g.step == 1:
|
|
order_target_value(g.symbol, 0, reason="exit") # Full close
|
|
g.step += 1
|
|
"""
|
|
# Run with different initial capitals
|
|
result_small = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@swap": frame},
|
|
initial_capital=50_000, # Can only afford ~1 BTC
|
|
commission=0.0005,
|
|
slippage=0.0005,
|
|
instrument_rules=_rules(
|
|
"Crypto:BTC/USDT@swap", amount_step=0.001, min_notional=5.0
|
|
),
|
|
).run()
|
|
|
|
result_large = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@swap": frame},
|
|
initial_capital=500_000, # Can afford 10 BTC
|
|
commission=0.0005,
|
|
slippage=0.0005,
|
|
instrument_rules=_rules(
|
|
"Crypto:BTC/USDT@swap", amount_step=0.001, min_notional=5.0
|
|
),
|
|
).run()
|
|
|
|
# Both should complete the trade (no dust blocking)
|
|
assert result_small["totalTrades"] == 1, f"Small capital: {result_small['totalTrades']} trades"
|
|
assert result_large["totalTrades"] == 1, f"Large capital: {result_large['totalTrades']} trades"
|
|
|
|
# Both should have no minimum_trade_unit rejections
|
|
for result in [result_small, result_large]:
|
|
rejected = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"]
|
|
assert "minimum_trade_unit" not in rejected, f"Dust rejection: {rejected}"
|
|
|
|
|
|
def test_strategy_can_cancel_a_resting_limit_before_a_later_bar_crosses_it():
|
|
index = pd.date_range("2026-01-01", periods=4, freq="1min")
|
|
frame = pd.DataFrame({
|
|
"open": [100, 100, 90, 90],
|
|
"high": [101, 101, 91, 91],
|
|
"low": [99, 99, 89, 89],
|
|
"close": [100, 100, 90, 90],
|
|
"volume": [1000] * 4,
|
|
}, index=index)
|
|
code = """
|
|
def initialize(context):
|
|
g.symbol = "Crypto:BTC/USDT@spot"
|
|
g.reference = ""
|
|
g.cancelled = False
|
|
context.set_universe([g.symbol])
|
|
context.subscribe(frequency="1m")
|
|
|
|
def handle_data(context, data):
|
|
if not g.reference:
|
|
g.reference = order_value(
|
|
g.symbol,
|
|
50,
|
|
order_type="limit",
|
|
limit_price=95,
|
|
client_order_id="cancel-me",
|
|
)
|
|
return
|
|
if not g.cancelled and get_order_status(g.reference).get("status") == "deferred":
|
|
g.cancelled = cancel_order(g.reference)
|
|
"""
|
|
|
|
result = StrategyV2BacktestRunner(
|
|
code=code,
|
|
frames={"Crypto:BTC/USDT@spot": frame},
|
|
initial_capital=100,
|
|
commission=0,
|
|
slippage=0,
|
|
).run()
|
|
|
|
assert result["totalExecutions"] == 0
|