193064c953
spec §195: 多策略并发下单"先到后到"不可复现 → 每策略独立max_allocation - StrategyRunner: max_allocation字段(默认inf) + used_allocation(持仓市值) - engine._match: BUY cash_enough后查 used+成交额>max_allocation → 拒单max_allocation_exceeded - live_orchestrator: runner传max_allocation(默认initial_capital) - routes_paper: StrategyCfg加max_allocation(API→DB→live_step数据流) - test_soft_limit: 3测试(累计超限拒单/默认不限/SELL不受限) 116 passed(113旧+3新), 无回归.
133 lines
5.2 KiB
Python
133 lines
5.2 KiB
Python
"""软限额(每策略 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
|