diff --git a/sanguo_api/routes_paper.py b/sanguo_api/routes_paper.py index 86145f1..6a9f810 100644 --- a/sanguo_api/routes_paper.py +++ b/sanguo_api/routes_paper.py @@ -34,6 +34,7 @@ class StrategyCfg(BaseModel): match_session: str = "next_open" symbol: str listing_days: int = 0 + max_allocation: float | None = None # 软限额(spec §195),None=用 initial_capital class PaperCreateRequest(BaseModel): @@ -168,8 +169,10 @@ def _run_replay(db, aid, req: PaperCreateRequest): except Exception: pass cta.set_strategy(strat) - runners.append(StrategyRunner(s.name, strategy=strat, paper_cta_engine=cta, - symbol=s.symbol)) + runners.append(StrategyRunner( + s.name, strategy=strat, paper_cta_engine=cta, symbol=s.symbol, + max_allocation=(s.max_allocation if s.max_allocation is not None + else req.initial_capital))) pe = PaperEngine(account, runners, _DataSourceWrapper(data_cfg), acc_cfg, db, aid, req.symbols, req.start, req.end, req.interval) pe.run() diff --git a/sanguo_trader/engine.py b/sanguo_trader/engine.py index fcf05e4..86df55e 100644 --- a/sanguo_trader/engine.py +++ b/sanguo_trader/engine.py @@ -120,6 +120,18 @@ class PaperEngine: reject_reason="insufficient_position_no_short") return if self.account.cash_enough(result): + # 软限额:BUY 检查该策略已用额度(消除多策略撮合顺序依赖,spec §195) + if result.side == OrderSide.BUY: + closes = {sym: b.close_price for sym, b in bars.items()} + if (runner.used_allocation(closes) + result.price * result.volume + > runner.max_allocation): + save_trade(self.db_path, self.account_id, + {"strategy_id": order.strategy_id, + "symbol": order.symbol, + "bar_date": str(bar_date)}, + rejected=True, + reject_reason="max_allocation_exceeded") + return self.account.apply_trade(result) runner.apply_trade(result) save_trade(self.db_path, self.account_id, diff --git a/sanguo_trader/live_orchestrator.py b/sanguo_trader/live_orchestrator.py index dab2436..e87d308 100644 --- a/sanguo_trader/live_orchestrator.py +++ b/sanguo_trader/live_orchestrator.py @@ -80,8 +80,10 @@ def live_step(db_path: str, account_id: int, data_source, cfg, today: str | None if not hasattr(strat, "am"): strat.am = ArrayManager(20) cta.set_strategy(strat) + max_a = s.get("max_allocation") runner = StrategyRunner(s["name"], strategy=strat, paper_cta_engine=cta, - symbol=s["symbol"]) + symbol=s["symbol"], + max_allocation=max_a if max_a is not None else initial) runner.positions = _restore_ledger( load_positions(db_path, account_id, f"strategy:{s['name']}")) runners.append(runner) diff --git a/sanguo_trader/strategy_runner.py b/sanguo_trader/strategy_runner.py index 906c6ad..a56ba72 100644 --- a/sanguo_trader/strategy_runner.py +++ b/sanguo_trader/strategy_runner.py @@ -8,15 +8,27 @@ from .position_ledger import PositionLedger class StrategyRunner: def __init__(self, strategy_id: str, strategy=None, paper_cta_engine=None, - symbol: str = "") -> None: + symbol: str = "", max_allocation: float = float("inf")) -> None: self.strategy_id: str = strategy_id self.strategy = strategy self.paper_cta_engine = paper_cta_engine self.symbol: str = symbol + self.max_allocation: float = max_allocation # 软限额(spec §195),默认不限 self.positions: dict[str, PositionLedger] = {} self.realized_pnl: float = 0.0 self.commission_paid: float = 0.0 + def used_allocation(self, closes: dict[str, float] | None = None) -> float: + """该策略已占用资金 = 持仓市值(spec §195 软限额检查用)。 + + closes 为当日 raw 收盘价 {symbol: price};缺省股票用 avg_price 兜底。 + """ + closes = closes or {} + return sum( + p.volume * closes.get(sym, p.avg_price) + for sym, p in self.positions.items() if p.volume > 0 + ) + def _position(self, symbol: str) -> PositionLedger: if symbol not in self.positions: self.positions[symbol] = PositionLedger(symbol) diff --git a/tests/trader/test_soft_limit.py b/tests/trader/test_soft_limit.py new file mode 100644 index 0000000..fb57c9f --- /dev/null +++ b/tests/trader/test_soft_limit.py @@ -0,0 +1,132 @@ +"""软限额(每策略 max_allocation)测试(spec §195)。 + +消除多策略并发撮合的顺序依赖:BUY 检查该策略已用额度,超限拒单。 +""" +from types import SimpleNamespace + +from sanguo_trader.account import Account +from sanguo_trader.cta_adapter import PaperCtaEngine +from sanguo_trader.engine import PaperEngine +from sanguo_trader.models import ( + AccountConfig, MatchSession, OrderSide, PaperOrder, +) +from sanguo_trader.persistence import init_db, list_trades, save_account +from sanguo_trader.position_ledger import PositionLedger +from sanguo_trader.strategy_runner import StrategyRunner + + +def _bar(date, o, h, l, c): + return SimpleNamespace(open_price=o, high_price=h, low_price=l, + close_price=c, datetime=date) + + +class _FakeDataSource: + def __init__(self, sections): + self.sections = sections + + def iter_bars(self, symbols, start, end, interval, adjust="qfq", cfg=None): + del symbols, start, end, interval, adjust, cfg + for date, bars in self.sections: + yield date, bars + + +class _BuyEveryBar: + """每根 bar 发一张 BUY 单(volume 可配,用于累加测试)。""" + + def __init__(self, engine, vt_symbol, volume=100): + self.cta_engine = engine + self.vt_symbol = vt_symbol + self.volume = volume + + def on_bar(self, bar): + self.cta_engine.send_order(self, "LONG", "OPEN", bar.close_price, self.volume) + + +def _build(tmp_path, sections, max_allocation=None, match_session=MatchSession.CURRENT_CLOSE): + """构造单策略 engine。max_allocation=None 表示不限(默认)。""" + db = str(tmp_path / "soft.db") + init_db(db) + aid = save_account(db, {"name": "t", "initial_capital": 1_000_000}) + cfg = AccountConfig(initial_capital=1_000_000) + account = Account(1_000_000) + cta = PaperCtaEngine("s1", match_session=match_session) + strat = _BuyEveryBar(cta, "600000.SSE") + cta.set_strategy(strat) + kwargs = {"max_allocation": max_allocation} if max_allocation is not None else {} + runner = StrategyRunner("s1", strategy=strat, paper_cta_engine=cta, + symbol="600000", **kwargs) + pe = PaperEngine(account, [runner], _FakeDataSource(sections), cfg, db, aid, + symbols=["600000"], start="2024-01-01", end="2024-12-31") + return pe, db, aid, account, runner + + +def test_buy_accumulation_beyond_max_allocation_rejected(tmp_path): + # Arrange:每 bar 买 100 股 @~10(turnover≈1000),max_allocation=2500 + # d1 fill→pos100;d2 fill→pos200(used2000+1000=3000>2500 看的是 d3) + # d3: used=200×10=2000,+1000=3000 > 2500 → 拒单 + sections = [ + ("2024-01-01", {"600000": _bar("2024-01-01", 10.0, 10.5, 9.5, 10.0)}), + ("2024-01-02", {"600000": _bar("2024-01-02", 10.0, 10.5, 9.5, 10.0)}), + ("2024-01-03", {"600000": _bar("2024-01-03", 10.0, 10.5, 9.5, 10.0)}), + ] + pe, db, aid, *_ = _build(tmp_path, sections, max_allocation=2500) + + # Act + pe.run() + + # Assert:2 成交 + 1 拒单(max_allocation_exceeded) + trades = list_trades(db, aid) + fills = [t for t in trades if not t["rejected"]] + rejects = [t for t in trades if t["rejected"]] + assert len(fills) == 2 + assert len(rejects) == 1 + assert rejects[0]["reject_reason"] == "max_allocation_exceeded" + + +def test_default_max_allocation_unlimited(tmp_path): + # Arrange:未设 max_allocation(默认不限)→ 同样 3 bar 全部成交 + sections = [ + ("2024-01-01", {"600000": _bar("2024-01-01", 10.0, 10.5, 9.5, 10.0)}), + ("2024-01-02", {"600000": _bar("2024-01-02", 10.0, 10.5, 9.5, 10.0)}), + ("2024-01-03", {"600000": _bar("2024-01-03", 10.0, 10.5, 9.5, 10.0)}), + ] + pe, db, aid, *_ = _build(tmp_path, sections) # 不传 max_allocation + + # Act + pe.run() + + # Assert:3 成交 0 拒单 + trades = list_trades(db, aid) + fills = [t for t in trades if not t["rejected"]] + rejects = [t for t in trades if t["rejected"]] + assert len(fills) == 3 + assert len(rejects) == 0 + + +def test_sell_not_limited_by_max_allocation(tmp_path): + # Arrange:预置持仓已超 max_allocation(200 股×10=2000 > 1000),发 SELL + db = str(tmp_path / "sell.db") + init_db(db) + aid = save_account(db, {"name": "t", "initial_capital": 1_000_000}) + cfg = AccountConfig(initial_capital=1_000_000) + account = Account(1_000_000) + runner = StrategyRunner("s1", symbol="600000", max_allocation=1000) + for r in (account, runner): + pos = PositionLedger("600000") + pos.volume = 200 + pos.avg_price = 10.0 + r.positions["600000"] = pos + pe = PaperEngine(account, [runner], None, cfg, db, aid, + ["600000"], "x", "y") + sell_order = PaperOrder("s1", "600000", OrderSide.SELL, 10.0, 100, + match_session=MatchSession.CURRENT_CLOSE) + bars = {"600000": _bar("2024-01-01", 10.0, 10.5, 9.5, 10.0)} + + # Act:直接驱动 _match(SELL 路径) + pe._match(sell_order, runner, bars, {"600000": 10.0}, "2024-01-01") + + # Assert:SELL 成交,不受 max_allocation 限制 + trades = list_trades(db, aid) + assert len(trades) == 1 + assert not trades[0]["rejected"] + assert runner.positions["600000"].volume == 100