Files
sanguo_vnpy_v2/tests/trader/test_soft_limit.py
T
claude_dev 193064c953 feat(trader): 软限额max_allocation(分期项)—每策略资金额度消除顺序依赖
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新), 无回归.
2026-07-09 22:05:35 +08:00

133 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""软限额(每策略 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 股 @~10turnover≈1000),max_allocation=2500
# d1 fill→pos100d2 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()
# Assert2 成交 + 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()
# Assert3 成交 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_allocation200 股×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:直接驱动 _matchSELL 路径)
pe._match(sell_order, runner, bars, {"600000": 10.0}, "2024-01-01")
# AssertSELL 成交,不受 max_allocation 限制
trades = list_trades(db, aid)
assert len(trades) == 1
assert not trades[0]["rejected"]
assert runner.positions["600000"].volume == 100