diff options
Diffstat (limited to 'tests/worker/test_rules_regression.py')
| -rw-r--r-- | tests/worker/test_rules_regression.py | 201 |
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 |
