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新), 无回归.
This commit is contained in:
2026-07-09 22:05:35 +08:00
parent 0810259911
commit 193064c953
5 changed files with 165 additions and 4 deletions
+5 -2
View File
@@ -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()
+12
View File
@@ -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,
+3 -1
View File
@@ -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)
+13 -1
View File
@@ -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)
+132
View File
@@ -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 股 @~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