"""软限额(每策略 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