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"
|
match_session: str = "next_open"
|
||||||
symbol: str
|
symbol: str
|
||||||
listing_days: int = 0
|
listing_days: int = 0
|
||||||
|
max_allocation: float | None = None # 软限额(spec §195),None=用 initial_capital
|
||||||
|
|
||||||
|
|
||||||
class PaperCreateRequest(BaseModel):
|
class PaperCreateRequest(BaseModel):
|
||||||
@@ -168,8 +169,10 @@ def _run_replay(db, aid, req: PaperCreateRequest):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
cta.set_strategy(strat)
|
cta.set_strategy(strat)
|
||||||
runners.append(StrategyRunner(s.name, strategy=strat, paper_cta_engine=cta,
|
runners.append(StrategyRunner(
|
||||||
symbol=s.symbol))
|
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,
|
pe = PaperEngine(account, runners, _DataSourceWrapper(data_cfg), acc_cfg,
|
||||||
db, aid, req.symbols, req.start, req.end, req.interval)
|
db, aid, req.symbols, req.start, req.end, req.interval)
|
||||||
pe.run()
|
pe.run()
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ class PaperEngine:
|
|||||||
reject_reason="insufficient_position_no_short")
|
reject_reason="insufficient_position_no_short")
|
||||||
return
|
return
|
||||||
if self.account.cash_enough(result):
|
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)
|
self.account.apply_trade(result)
|
||||||
runner.apply_trade(result)
|
runner.apply_trade(result)
|
||||||
save_trade(self.db_path, self.account_id,
|
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"):
|
if not hasattr(strat, "am"):
|
||||||
strat.am = ArrayManager(20)
|
strat.am = ArrayManager(20)
|
||||||
cta.set_strategy(strat)
|
cta.set_strategy(strat)
|
||||||
|
max_a = s.get("max_allocation")
|
||||||
runner = StrategyRunner(s["name"], strategy=strat, paper_cta_engine=cta,
|
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(
|
runner.positions = _restore_ledger(
|
||||||
load_positions(db_path, account_id, f"strategy:{s['name']}"))
|
load_positions(db_path, account_id, f"strategy:{s['name']}"))
|
||||||
runners.append(runner)
|
runners.append(runner)
|
||||||
|
|||||||
@@ -8,15 +8,27 @@ from .position_ledger import PositionLedger
|
|||||||
|
|
||||||
class StrategyRunner:
|
class StrategyRunner:
|
||||||
def __init__(self, strategy_id: str, strategy=None, paper_cta_engine=None,
|
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_id: str = strategy_id
|
||||||
self.strategy = strategy
|
self.strategy = strategy
|
||||||
self.paper_cta_engine = paper_cta_engine
|
self.paper_cta_engine = paper_cta_engine
|
||||||
self.symbol: str = symbol
|
self.symbol: str = symbol
|
||||||
|
self.max_allocation: float = max_allocation # 软限额(spec §195),默认不限
|
||||||
self.positions: dict[str, PositionLedger] = {}
|
self.positions: dict[str, PositionLedger] = {}
|
||||||
self.realized_pnl: float = 0.0
|
self.realized_pnl: float = 0.0
|
||||||
self.commission_paid: 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:
|
def _position(self, symbol: str) -> PositionLedger:
|
||||||
if symbol not in self.positions:
|
if symbol not in self.positions:
|
||||||
self.positions[symbol] = PositionLedger(symbol)
|
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