summaryrefslogtreecommitdiff
path: root/tests/worker/test_rules_regression.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/worker/test_rules_regression.py')
-rw-r--r--tests/worker/test_rules_regression.py201
1 files changed, 201 insertions, 0 deletions
diff --git a/tests/worker/test_rules_regression.py b/tests/worker/test_rules_regression.py
new file mode 100644
index 0000000..d59ca0a
--- /dev/null
+++ b/tests/worker/test_rules_regression.py
@@ -0,0 +1,201 @@
+"""Regression tests: broker rules, benchmark normalization, drawdown zero."""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from fixtures_synth import synthetic_daily # noqa: E402
+from worker.backtest import run_backtest # noqa: E402
+
+
+def build_manifest(tmp_path, specs):
+ d = tmp_path / "data"
+ d.mkdir(exist_ok=True)
+ objects = []
+ for sym, code, asset, base in specs:
+ df = synthetic_daily(sym, base=base, extra_fields=False) # code param unused
+ slug = sym.lower().replace("#", "_")
+ path = f"{slug}.csv"
+ df.to_csv(d / path, index=False)
+ objects.append({"instrument": {"symbol": sym, "market": "cn", "asset_type": asset},
+ "path": path, "row_count": len(df)})
+ manifest = {"hash": "fix-hash", "_synthetic": True, "objects": objects, "warnings": []}
+ return d, manifest
+
+
+def make_request(tmp_path, code, specs, benchmark=None, params=None):
+ d, manifest = build_manifest(tmp_path, specs)
+ return {
+ "_synthetic": True,
+ "code": code,
+ "config": {"capital": 100000.0, "commission": 0.0003, "slippage": 0.001,
+ "benchmark_symbol": benchmark, "parameters": params or {}},
+ "dataset_manifest": manifest,
+ "data_root": str(d),
+ }
+
+
+BUY_STOCK_ONLY = '''
+import backtrader as bt
+class Strategy(bt.Strategy):
+ def __init__(self): self.done = False
+ def next(self):
+ if not self.done and len(self) > 2:
+ self.buy(data=self.getdatabyname("SH#600000"), size=250)
+ self.done = True
+'''
+
+BUY_INDEX = '''
+import backtrader as bt
+class Strategy(bt.Strategy):
+ def __init__(self): self.done = False
+ def next(self):
+ if not self.done and len(self) > 2:
+ self.buy(data=self.getdatabyname("SH000300"), size=100)
+ self.done = True
+'''
+
+BUY_BM = '''
+import backtrader as bt
+class Strategy(bt.Strategy):
+ def __init__(self): self.done = False
+ def next(self):
+ if not self.done and len(self) > 2:
+ self.buy(data=self.getdatabyname("SH#600000"), size=100)
+ self.done = True
+'''
+
+SELL_OPEN = '''
+import backtrader as bt
+class Strategy(bt.Strategy):
+ def __init__(self): self.state = 0
+ def next(self):
+ if self.state == 0 and len(self) > 2:
+ self.buy(data=self.getdatabyname("SH#600000"), size=200)
+ self.state = 1
+ elif self.state == 1:
+ self.sell(data=self.getdatabyname("SH#600000"), size=150)
+ self.state = 2
+'''
+
+SHORT = '''
+import backtrader as bt
+class Strategy(bt.Strategy):
+ def __init__(self): self.state = 0
+ def next(self):
+ if self.state == 0 and len(self) > 2:
+ self.buy(data=self.getdatabyname("SH#600000"), size=100)
+ self.state = 1
+ elif self.state == 1:
+ self.sell(data=self.getdatabyname("SH#600000"), size=400)
+ self.state = 2
+'''
+
+BUY250_TEST = '''
+import backtrader as bt
+class Strategy(bt.Strategy):
+ def __init__(self): self.done = False
+ def next(self):
+ if not self.done and len(self) > 2:
+ self.buy(data=self.getdatabyname("SH#600000"), size=250)
+ self.done = True
+'''
+
+
+def test_zero_drawdown_is_zero_not_null(tmp_path):
+ req = make_request(tmp_path, SELL_OPEN, [("SH#600000", "sh_600000", "stock", 10.0)])
+ res = run_backtest(req)
+ assert res["status"] == "succeeded"
+ m = res["metrics"]
+ req2 = make_request(tmp_path / "flat" if False else tmp_path,
+ 'import backtrader as bt\nclass Strategy(bt.Strategy):\n pass\n',
+ [("SH#600000", "sh_600000", "stock", 10.0)])
+ res2 = run_backtest(req2)
+ assert res2["metrics"]["max_drawdown"] == 0.0
+ assert res2["metrics"]["total_return"] == 0.0
+ assert res2["metrics"]["trade_count"] == 0
+ # initial capital and per-bar positions recorded
+ assert res2["initial_cash"] == 100000.0
+ assert all("positions" in e for e in res2["equity"])
+
+
+def test_benchmark_normalized_to_initial_capital(tmp_path):
+ # stock 10 -> wobbles; benchmark BJ feed base differs so raw price != equity scale
+ req = make_request(
+ tmp_path, SELL_OPEN,
+ [("SH#600000", "sh_600000", "stock", 10.0),
+ ("SH000300", "sh000300", "index", 3800.0)],
+ benchmark="SH000300")
+ res = run_backtest(req)
+ assert all(e["benchmark"] is not None for e in res["equity"])
+ # raw price never mixed into the equity scale
+ assert res["equity"][0]["benchmark"] == res["initial_cash"]
+ assert res["equity"][-1]["closes"]["SH000300"] > 3000 # raw close available separately
+ assert any("normalized to initial capital" in w for w in res["warnings"])
+ assert res["orders"]
+
+
+def test_index_order_rejected_broker_level(tmp_path):
+ specs = [("SH#600000", "sh_600000", "stock", 10.0),
+ ("SH000300", "sh000300", "index", 3800.0)]
+ req = make_request(tmp_path, BUY_INDEX, specs)
+ res = run_backtest(req)
+ assert res["status"] == "succeeded"
+ index_fills = [t for t in res["trades"] if t["symbol"] == "SH000300"]
+ assert not index_fills
+ assert not [t for t in res["trades"] if t["symbol"] == "SH#600000"] # strategy only bought index
+ user_orders = [o for o in res["orders"] if o.get("date")] # real order notifications
+ rejected = [o for o in user_orders if o["symbol"] == "SH000300"]
+ assert rejected and rejected[-1]["order_state"] == "Rejected"
+ rejects = [o for o in res["orders"] if o.get("order_state") == "rejected"]
+ assert rejects and "nontradable" in rejects[0]["reject_reason"]
+ assert any("nontradable" in w for w in res["warnings"])
+
+
+def test_index_only_as_benchmark_accepted(tmp_path):
+ specs = [("SH#600000", "sh_600000", "stock", 10.0),
+ ("SH000300", "sh000300", "index", 3800.0)]
+ req = make_request(tmp_path, BUY_BM, specs, benchmark="SH000300")
+ res = run_backtest(req)
+ assert res["status"] == "succeeded"
+ assert res["trades"][0]["symbol"] == "SH#600000"
+ assert res["equity"][-1]["benchmark"] is not None
+
+
+def test_lot_size_rounded_down_to_100(tmp_path):
+ req = make_request(tmp_path, BUY250_TEST, [("SH#600000", "sh_600000", "stock", 10.0)])
+ res = run_backtest(req)
+ assert res["status"] == "succeeded"
+ assert res["trades"], "rejection would leave no fill; rounding should keep 200"
+ q = res["trades"][0]["quantity"]
+ assert q == 200, f"expected 250 -> 200 (2 lots), got {q}"
+
+
+def test_no_naked_short_rejected(tmp_path):
+ req = make_request(tmp_path, SHORT, [("SH#600000", "sh_600000", "stock", 10.0)])
+ res = run_backtest(req)
+ short_fills = [t for t in res["trades"] if t["side"] == "sell" and t["quantity"] > 100]
+ # position was 100 (bought) but sell attempted 400 -> trimmed or rejected, not shorted
+ pos_last = res["equity"][-1]["positions"]["SH#600000"]
+ assert pos_last >= 0
+ assert any("no naked short" in w or "would exceed" in w for w in res["warnings"]) or \
+ not short_fills or short_fills[0]["quantity"] <= 100
+
+
+def test_t1_samebar_buy_sell_guarded(tmp_path):
+ # sell submitted on the same bar as buy (position still 0 at submit) must be trimmed
+ req = make_request(tmp_path, SELL_OPEN, [("SH#600000", "sh_600000", "stock", 10.0)])
+ res = run_backtest(req)
+ assert res["status"] == "succeeded"
+ positions = [e["positions"]["SH#600000"] for e in res["equity"]]
+ assert min(positions) >= 0 # never negative (no short anywhere in series)
+
+
+def test_cash_plus_position_reconciles_equity(tmp_path):
+ req = make_request(tmp_path, BUY_STOCK_ONLY, [("SH#600000", "sh_600000", "stock", 10.0)])
+ res = run_backtest(req)
+ last = res["equity"][-1]
+ held = {s: p for s, p in last["positions"].items() if p}
+ expected = last["cash"] + sum(p * last["closes"][s] for s, p in held.items())
+ assert abs(last["equity"] - expected) < 1e-6