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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user