feat(paper): 模拟盘重构 — 多账户对比与策略自动执行

- 对比榜单 14 列可排序, 盘中实时估值; 行展开完整账户操作台
- 创建即绑策略自动执行: 入场信号买入 · 出场信号全平, 支持最大持仓
  (对齐回测口径)、仓位、订单类型、冷却; 批量创建同享
- 账户删除 (两步强确认)、冻结; 回合明细; 基础配置 (新账户默认参数)
- 策略联想下拉重设计: 全量候选、两行式选项、portal 防裁剪
This commit is contained in:
shy3130
2026-10-14 15:11:54 +08:00
parent ac7d8c1c8f
commit b14f3679d5
8 changed files with 1249 additions and 175 deletions
+45
View File
@@ -120,6 +120,15 @@ def create_account(request: Request, body: AccountModel):
return {"account": acc}
@router.delete("/account")
def delete_account(request: Request, force: bool = False, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
"""删除账户及其全部数据 (不可恢复)。仍有持仓或待成交订单时须 force=true。"""
result, err = paper.delete_account(_data_dir(request), _acc(request, account), force=force)
if err:
raise HTTPException(status_code=400 if "force" in (err or "") else 404, detail=err)
return {"ok": True, "deleted": _acc(request, account), **(result or {})}
@router.get("/overview")
def overview(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
"""总览: 现金 + 持仓估值 + 账户状态。
@@ -211,6 +220,12 @@ def get_stats(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
return paper.stats(_data_dir(request), _acc(request, account))
@router.get("/rounds")
def list_rounds(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
"""FIFO 配对回合明细 (开平日期/数量/盈亏/持有天数), 供账户面板流水展示。"""
return {"rounds": paper.round_trips(_data_dir(request), _acc(request, account))}
@router.get("/compare")
def compare_accounts(request: Request):
"""横向对比全部账户: 概览 + 回合统计 + 定版净值 (供对比表与净值叠加图)。
@@ -222,6 +237,22 @@ def compare_accounts(request: Request):
from app.strategy import paper_auto
data_dir = _data_dir(request)
# 盘中实时估算: 当日 enriched 快照就绪时取一次 raw_close 映射, 之后每账户
# 纯内存 overview (与 /overview 实时口径一致); 失败降级定版口径
live_snapshot: dict[str, float] | None = None
qs = getattr(request.app.state, "quote_service", None)
if qs is not None:
try:
enriched, enriched_date = qs.get_enriched_today()
from app.market_time import cn_today
if not enriched.is_empty() and enriched_date == cn_today():
live_snapshot = dict(
zip(enriched["symbol"].to_list(), enriched["raw_close"].to_list(), strict=False)
)
except Exception as e: # noqa: BLE001 — 快照来源不定 (行情服务/数据缺失), 软失败降级定版口径
logger.warning("paper compare 实时快照不可用, 用定版口径: %s", e)
rows = []
for acc_id in paper.list_account_ids(data_dir):
acc = paper.get_account(data_dir, acc_id)
@@ -237,7 +268,18 @@ def compare_accounts(request: Request):
if len(nav_rows) >= 2 and float(nav_rows[-2]["nav"] or 0) > 0:
day_change_pct = round((float(nav_rows[-1]["nav"]) / float(nav_rows[-2]["nav"]) - 1) * 100, 2)
rules = paper_auto.load_auto_rules(data_dir, acc_id)
# 盘中估算字段 (加法扩展): live_* 为快照价口径, 旧前端忽略不影响
live_total = live_pnl_pct = None
if live_snapshot is not None and acc.get("status") == "active":
live_ov = paper.overview(data_dir, live_snapshot, account_id=acc_id)
if live_ov.get("initialized"):
live_total = live_ov.get("total")
if initial > 0:
live_pnl_pct = round((live_ov.get("total_pnl") or 0) / initial * 100, 2)
rows.append({
"live_total": live_total,
"live_pnl_pct": live_pnl_pct,
"estimating": live_total is not None,
"account": acc_id,
"name": acc.get("name") or acc_id,
"status": acc.get("status"),
@@ -315,6 +357,7 @@ class AutoRuleModel(BaseModel):
size_value: float
order_type: str = "next_open"
cooldown_days: int = 5
max_positions: int = 0 # 最大同时持仓标的数, 0 不限 (对齐回测口径)
enabled: bool = True
@@ -361,6 +404,7 @@ class ArenaSourceModel(BaseModel):
size_value: float = 10.0
order_type: str = "next_open"
cooldown_days: int = 5
max_positions: int = 0 # 最大同时持仓标的数, 0 不限 (对齐回测口径)
class ArenaBatchModel(BaseModel):
@@ -401,6 +445,7 @@ def arena_batch_create(request: Request, body: ArenaBatchModel):
"size_value": src.size_value,
"order_type": src.order_type,
"cooldown_days": src.cooldown_days,
"max_positions": src.max_positions,
"enabled": True,
}
paper_auto.validate_rule(rule) # 全部通过前不落盘 (fail-closed)
+28
View File
@@ -259,6 +259,34 @@ def update_settings(data_dir: Path, account_id: str = DEFAULT_ACCOUNT_ID, **fiel
return acc
def delete_account(data_dir: Path, account_id: str = DEFAULT_ACCOUNT_ID, *, force: bool = False) -> tuple[dict | None, str | None]:
"""删除账户及其全部数据 (台账/持仓/净值/规则, 不可恢复)。
fail-closed: 账户仍有持仓标的或待成交订单时拒绝删除, 调用方显式 force=True
才强删。目录删除限定在 accounts/{account_id} (id 校验只允许字母数字下划线
短横线, 无路径穿越面)。返回 (统计, None) 或 (None, 拒绝原因)。
"""
with PAPER_LOCK:
acc = get_account(data_dir, account_id)
if acc is None:
return None, "账户不存在"
positions = load_positions(data_dir, account_id)
holdings = sum(1 for p in positions.values() if p["qty"] > 0)
pending = sum(1 for o in load_orders(data_dir, account_id) if o["status"] == "pending")
if (holdings or pending) and not force:
detail = " 与 ".join(
s for s in (
f"{holdings} 个持仓标的" if holdings else "",
f"{pending} 张待成交订单" if pending else "",
) if s
)
return None, f"账户仍有 {detail}, 确认删除需 force=true"
root = accounts_base(data_dir) / validate_account_id(account_id)
if root.exists():
shutil.rmtree(root)
return {"holdings": holdings, "pending_orders": pending}, None
# ── 费用 (纯函数) ───────────────────────────────────────
def apply_slippage(price: float, side: str, slippage_bps: float) -> float:
"""买入向上滑、卖出向下滑 (对成交价不利方向)。"""
+76 -5
View File
@@ -50,8 +50,10 @@ def validate_rule(rule: dict) -> None:
raise ValueError(f"match_kind 非法: {rule.get('match_kind')!r} (应为 strategy / rule)")
if not (rule.get("match_id") or "").strip():
raise ValueError("match_id 不能为空")
if rule.get("side") not in ("buy", "sell"):
if rule.get("side") not in ("buy", "sell", "auto"):
raise ValueError(f"side 非法: {rule.get('side')!r}")
if rule.get("side") == "auto" and rule.get("match_kind") != "strategy":
raise ValueError("side=auto (跟信号方向) 仅支持匹配策略 — 监控规则事件无方向语义")
if rule.get("size_mode") not in ("fixed_amount", "pct_equity"):
raise ValueError(f"size_mode 非法: {rule.get('size_mode')!r}")
value = rule.get("size_value")
@@ -64,16 +66,25 @@ def validate_rule(rule: dict) -> None:
cooldown = rule.get("cooldown_days", 0)
if isinstance(cooldown, bool) or not isinstance(cooldown, int) or cooldown < 0:
raise ValueError("cooldown_days 必须是非负整数")
max_positions = rule.get("max_positions", 0)
if isinstance(max_positions, bool) or not isinstance(max_positions, int) or max_positions < 0:
raise ValueError("max_positions 必须是非负整数 (0 = 不限制)")
def normalize_rule(rule: dict) -> dict:
d = dict(rule)
d["name"] = (d.get("name") or "").strip()
d["match_id"] = (d.get("match_id") or "").strip()
d.setdefault("side", "buy")
# side=auto: 跟随策略信号方向 (入场/进池 → 买入, 出场/移出 → 卖出)。
# 存量规则缺 side 时按 buy 兼容 (原语义), 新建默认 auto。
if d.get("match_kind") == "strategy":
d.setdefault("side", "auto")
else:
d.setdefault("side", "buy")
d.setdefault("size_mode", "fixed_amount")
d.setdefault("order_type", "next_open")
d.setdefault("cooldown_days", 5)
d.setdefault("max_positions", 0) # 0 = 不限制 (对齐回测 max_positions 语义)
d.setdefault("enabled", True)
d.setdefault("created_at", _now_iso())
return d
@@ -160,11 +171,49 @@ def _sizing_qty(data_dir: Path, rule: dict, ref_price: float, account_id: str) -
return paper.qty_from_amount(amount, ref_price)
def _position_slots_used(data_dir: Path, account_id: str) -> set[str]:
"""已占用的持仓槽: 当前持仓标的 + 待成交买单标的 (去重)。
模拟盘 next_open 单未成交也占槽, 防同日多信号重复开仓超限。"""
used: set[str] = set()
for symbol, p in paper.load_positions(data_dir, account_id).items():
if p["qty"] > 0:
used.add(symbol)
for o in paper.load_orders(data_dir, account_id):
if o.get("status") == "pending" and o["side"] == "buy":
used.add(o["symbol"])
return used
_AUTO_BUY_TYPES = {"buy_signal", "pool_entry"}
def _resolve_side(rule: dict, ev: dict) -> str | None:
"""规则方向 → 本次事件实际方向。side=auto (跟策略信号): 入场/进池买,
出场/移出卖; 其余按规则固定方向。返回 None 表示该事件不适用 (如 auto
收到无方向语义的事件类型)。"""
side = rule.get("side", "buy")
if side != "auto":
return side
etype = str(ev.get("type") or "")
if etype in _AUTO_BUY_TYPES:
return "buy"
if etype in ("sell_signal", "pool_exit"):
return "sell"
return None
def _held_qty(data_dir: Path, symbol: str, account_id: str) -> int:
p = paper.load_positions(data_dir, account_id).get(symbol)
return int(p["qty"]) if p else 0
def on_rule_events(data_dir: Path, events: list[dict], account_id: str = paper.DEFAULT_ACCOUNT_ID) -> list[dict]:
"""监控事件 → 自动下单。返回本次创建的订单列表 (被拒订单只记日志)。
触发条件: 事件带 symbol 与价格、有规则匹配、未冷却; 下单复用 paper.create_order
(冻结/资金/T+1/涨跌停等校验自动生效), ref_price 用事件价格 (当前快照价)。
买入信号受 max_positions 约束 (对齐回测口径): 已持仓 + 待成交买单的标的数
达到上限后, 新标的开仓被跳过; 已持仓标的加仓与卖出信号不受限。
"""
created: list[dict] = []
if not events:
@@ -173,6 +222,7 @@ def on_rule_events(data_dir: Path, events: list[dict], account_id: str = paper.D
if not rules:
return created
with paper.PAPER_LOCK:
# 同一批事件内复用快照, 由 create_order 后的持仓变化反映 (循环内重取)
for ev in events:
symbol = (ev.get("symbol") or "").strip()
price = ev.get("price")
@@ -181,14 +231,35 @@ def on_rule_events(data_dir: Path, events: list[dict], account_id: str = paper.D
for rule in rules:
if not _matches(rule, ev):
continue
side = _resolve_side(rule, ev)
if side is None:
continue
if _in_cooldown(data_dir, rule, symbol, int(rule.get("cooldown_days", 0)), account_id):
continue
qty = _sizing_qty(data_dir, rule, float(price), account_id)
if side == "buy":
max_positions = int(rule.get("max_positions", 0))
if max_positions > 0:
used = _position_slots_used(data_dir, account_id)
if len(used) >= max_positions and symbol not in used:
logger.info(
"paper auto %s: %s 跳过开仓 — 已达最大持仓 %d (%s)",
rule["name"], symbol, max_positions, ", ".join(sorted(used)) or "空",
)
continue
qty = _sizing_qty(data_dir, rule, float(price), account_id)
else:
# auto 的出场卖出 = 全平该标的 (策略出场语义); 固定 sell 规则仍按 sizing。
if rule.get("side") == "auto":
qty = _held_qty(data_dir, symbol, account_id)
if qty <= 0:
continue # 未持仓, 出场信号无意义
else:
qty = _sizing_qty(data_dir, rule, float(price), account_id)
if qty <= 0:
logger.info("paper auto %s: %s 金额不足以一手 (价 %s)", rule["name"], symbol, price)
continue
order, err = paper.create_order(
data_dir, symbol, rule["side"],
data_dir, symbol, side,
account_id=account_id,
qty=qty,
order_type=rule["order_type"],
@@ -199,7 +270,7 @@ def on_rule_events(data_dir: Path, events: list[dict], account_id: str = paper.D
logger.info("paper auto %s: %s 下单被拒: %s", rule["name"], symbol, err)
continue
created.append(order)
logger.info("paper auto %s: %s 触发 %s %d 股 (%s)", rule["name"], symbol, rule["side"], qty, order["id"])
logger.info("paper auto %s: %s 触发 %s %d 股 (%s)", rule["name"], symbol, side, qty, order["id"])
return created
+147
View File
@@ -0,0 +1,147 @@
"""模拟盘账户删除 / 回合明细 / 对比盘中估算 契约测试。
删除是破坏性操作 (per CONTRIBUTING §8): 必须覆盖 fail-closed 拒绝、force 强删、
不存在账户、路径穿越防护; 强删后数据目录必须整体消失。
"""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import polars as pl
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.paper import router
from app.market_time import cn_today
@pytest.fixture
def client(tmp_path: Path) -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.repo = SimpleNamespace(
store=SimpleNamespace(data_dir=tmp_path),
resolve_asset_type=lambda s: "stock",
)
return TestClient(app)
def _mk_account(client: TestClient, acc: str = "del_me", cash: float = 1000000) -> None:
r = client.post("/api/paper/account", json={"initial_cash": cash, "account_id": acc})
assert r.status_code == 200
def test_delete_empty_account(client: TestClient, tmp_path: Path):
_mk_account(client)
r = client.delete("/api/paper/account?account=del_me")
assert r.status_code == 200 and r.json()["ok"] is True
assert not (tmp_path / "paper" / "accounts" / "del_me").exists()
ids = {a["id"] for a in client.get("/api/paper/accounts").json()["accounts"]}
assert "del_me" not in ids
def test_delete_missing_account_404(client: TestClient):
r = client.delete("/api/paper/account?account=nope")
assert r.status_code == 404
def test_delete_rejects_invalid_id(client: TestClient):
assert client.delete("/api/paper/account?account=../escape").status_code == 400
def test_delete_with_pending_order_requires_force(client: TestClient, tmp_path: Path):
_mk_account(client)
assert client.post(
"/api/paper/orders?account=del_me",
json={"symbol": "600519.SH", "side": "buy", "qty": 100, "ref_price": 1500.0},
).status_code == 200
r = client.delete("/api/paper/account?account=del_me")
assert r.status_code == 400 and "force" in r.json()["detail"]
assert (tmp_path / "paper" / "accounts" / "del_me" / "account.json").exists() # 拒绝时不动数据
r = client.delete("/api/paper/account?account=del_me&force=true")
assert r.status_code == 200
assert not (tmp_path / "paper" / "accounts" / "del_me").exists()
def test_delete_with_holding_requires_force(client: TestClient, tmp_path: Path):
"""持仓存在时同样 fail-closed; 直接写台账构造持仓 (绕过撮合, 聚焦删除语义)。"""
_mk_account(client)
fills = tmp_path / "paper" / "accounts" / "del_me" / "fills.jsonl"
fills.parent.mkdir(parents=True, exist_ok=True)
fill = {
"seq": 1, "ts": "2026-10-14T10:00:00+08:00", "date": "2026-10-13",
"order_id": "o1", "symbol": "600519.SH", "asset_type": "stock",
"side": "buy", "qty": 100, "price": 1500.0, "fee": 5.0, "kind": "fill",
}
fills.write_text(json.dumps(fill, ensure_ascii=False) + "\n", encoding="utf-8")
# 物化持仓 (rebuild 由台账重建)
assert client.post("/api/paper/rebuild?account=del_me").status_code == 200
r = client.delete("/api/paper/account?account=del_me")
assert r.status_code == 400 and "持仓" in r.json()["detail"]
assert client.delete("/api/paper/account?account=del_me&force=true").status_code == 200
def test_rounds_endpoint_empty_and_filled(client: TestClient, tmp_path: Path):
_mk_account(client)
assert client.get("/api/paper/rounds?account=del_me").json() == {"rounds": []}
# 一买一卖 → 一个完整回合
fills = tmp_path / "paper" / "accounts" / "del_me" / "fills.jsonl"
fills.parent.mkdir(parents=True, exist_ok=True)
rows = [
{"seq": 1, "ts": "2026-10-10T10:00:00+08:00", "date": "2026-10-10", "order_id": "o1",
"symbol": "600519.SH", "asset_type": "stock", "side": "buy", "qty": 100,
"price": 100.0, "fee": 5.0, "kind": "fill"},
{"seq": 2, "ts": "2026-10-13T10:00:00+08:00", "date": "2026-10-13", "order_id": "o2",
"symbol": "600519.SH", "asset_type": "stock", "side": "sell", "qty": 100,
"price": 110.0, "fee": 6.0, "kind": "fill"},
]
fills.write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n", encoding="utf-8")
rounds = client.get("/api/paper/rounds?account=del_me").json()["rounds"]
assert len(rounds) == 1
rt = rounds[0]
assert rt["symbol"] == "600519.SH"
assert rt["open_date"] == "2026-10-10" and rt["close_date"] == "2026-10-13"
assert rt["qty"] == 100
# 盈亏 = 卖出净额 (110*100-6) - 买入成本 (100*100+5) = 10994 - 10005 = 989
assert rt["pnl"] == 989.0
assert rt["holding_days"] == 3
def test_compare_without_quote_service_uses_settled(client: TestClient):
"""无 quote_service (或快照失败) 时 live 字段为空, 定版口径不受影响。"""
_mk_account(client, "cmp_a")
rows = client.get("/api/paper/compare").json()["accounts"]
assert len(rows) == 1
assert rows[0]["live_total"] is None and rows[0]["estimating"] is False
def test_compare_with_live_snapshot(client: TestClient, tmp_path: Path):
"""当日快照就绪 → live_total 按快照价估算 (盘中口径), 定版字段并存。"""
_mk_account(client, "cmp_a")
fills = tmp_path / "paper" / "accounts" / "cmp_a" / "fills.jsonl"
fills.parent.mkdir(parents=True, exist_ok=True)
fills.write_text(json.dumps({
"seq": 1, "ts": "2026-10-13T10:00:00+08:00", "date": "2026-10-13", "order_id": "o1",
"symbol": "600519.SH", "asset_type": "stock", "side": "buy", "qty": 100,
"price": 100.0, "fee": 5.0, "kind": "fill",
}, ensure_ascii=False) + "\n", encoding="utf-8")
client.post("/api/paper/rebuild?account=cmp_a")
# mock quote_service: 当日 enriched 快照, 现价 120 → 估算总资产 = 现金 + 100*120
enriched = pl.DataFrame({"symbol": ["600519.SH"], "raw_close": [120.0]})
client.app.state.quote_service = SimpleNamespace(get_enriched_today=lambda: (enriched, cn_today()))
rows = client.get("/api/paper/compare").json()["accounts"]
assert len(rows) == 1
row = rows[0]
assert row["estimating"] is True
# 现金 1000000 - 10005 = 989995; 市值 100*120 = 12000 → live_total 1001995
assert row["live_total"] == pytest.approx(1001995.0, abs=0.01)
assert row["live_pnl_pct"] == pytest.approx(0.1995, abs=0.001)
@@ -0,0 +1,174 @@
"""自动跟单 max_positions (最大持仓数) 语义测试 — 对齐回测引擎口径。
约束只在买入开新仓时生效:
- 已占槽 (当前持仓 + 待成交买单) 达上限后, 新标的买入信号被跳过
- 已持仓标的加仓不受限; 卖出信号不受限
- max_positions = 0 (默认) 不限制
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from pathlib import Path
import pytest
from app.market_time import CN_TZ
from app.strategy import paper, paper_auto
SYM = "600519.SH"
SYM2 = "000001.SZ"
DAY = date(2026, 9, 24)
@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
return tmp_path
@pytest.fixture(autouse=True)
def _market_session(monkeypatch):
"""钉在连续竞价时段: 即时单按事件价直接成交。"""
now = datetime.combine(DAY, time(10, 0), tzinfo=CN_TZ)
monkeypatch.setattr(paper, "cn_now", lambda: now)
monkeypatch.setattr(paper, "cn_today", lambda: now.date())
def _rule(**over) -> dict:
base = {
"name": "跟策略", "match_kind": "strategy", "match_id": "strat_1",
"side": "buy", "size_mode": "fixed_amount", "size_value": 10000,
"order_type": "market", "cooldown_days": 0,
}
base.update(over)
return base
def _ev(symbol: str, price: float = 10.0) -> dict:
return {"source": "strategy", "strategy_id": "strat_1", "symbol": symbol, "price": price}
def test_max_positions_blocks_new_symbol(data_dir: Path):
"""上限 1: 第一标的信号开仓 (pending 占槽) 后, 第二标的的买入信号被跳过。"""
paper.create_account(data_dir, 1_000_000)
paper_auto.create_auto_rule(data_dir, _rule(max_positions=1))
created = paper_auto.on_rule_events(data_dir, [_ev(SYM), _ev(SYM2, 12.0)])
assert len(created) == 1 and created[0]["symbol"] == SYM
# 钩子撮合后: 只持有 SYM
paper.evaluate_intraday(data_dir, {SYM: 10.0, SYM2: 12.0})
positions = paper.load_positions(data_dir)
assert positions[SYM]["qty"] > 0
assert SYM2 not in positions or positions[SYM2]["qty"] == 0
def test_max_positions_allows_rebuy_held_symbol(data_dir: Path):
"""上限 1: 已持仓标的再加仓不受限 (回测语义: 加仓不占新槽)。"""
paper.create_account(data_dir, 1_000_000)
paper_auto.create_auto_rule(data_dir, _rule(max_positions=1))
assert len(paper_auto.on_rule_events(data_dir, [_ev(SYM)])) == 1
paper.evaluate_intraday(data_dir, {SYM: 10.0}) # 成交建仓 (1万/10元 = 1000股)
assert paper.load_positions(data_dir)[SYM]["qty"] == 1000
assert len(paper_auto.on_rule_events(data_dir, [_ev(SYM)])) == 1 # 加仓通过
paper.evaluate_intraday(data_dir, {SYM: 10.0})
assert paper.load_positions(data_dir)[SYM]["qty"] == 2000
def test_max_positions_pending_buy_occupies_slot(data_dir: Path):
"""待成交买单占槽: next_open 单未成交时, 新标的信号同样被跳过。"""
paper.create_account(data_dir, 1_000_000)
paper_auto.create_auto_rule(data_dir, _rule(max_positions=1, order_type="next_open"))
created = paper_auto.on_rule_events(data_dir, [_ev(SYM), _ev(SYM2, 12.0)])
# 同批: 第一单 pending 占槽 → 第二标的跳过
assert len(created) == 1 and created[0]["order_type"] == "next_open"
assert paper.get_order(data_dir, created[0]["id"])["status"] == "pending"
def test_max_positions_zero_means_unlimited(data_dir: Path):
"""0 (默认) 不限制: 多标的照常开仓。"""
paper.create_account(data_dir, 1_000_000)
paper_auto.create_auto_rule(data_dir, _rule(max_positions=0))
assert len(paper_auto.on_rule_events(data_dir, [_ev(SYM), _ev(SYM2, 12.0)])) == 2
def test_max_positions_does_not_limit_sell(data_dir: Path, monkeypatch):
"""卖出方向不受 max_positions 约束 (T+1: 次一交易日才可卖)。"""
paper.create_account(data_dir, 1_000_000)
# 先建仓两标的各 1000 股 (即时单 + 钩子撮合; 卖出按金额 1万/价10 = 1000 股)
for sym in (SYM, SYM2):
order, err = paper.create_order(data_dir, sym, "buy", qty=1000, ref_price=10.0)
assert err is None
paper.evaluate_intraday(data_dir, {SYM: 10.0, SYM2: 10.0})
# 推进到次一交易日, 绕开 T+1 可卖限制
next_day = datetime.combine(DAY + timedelta(days=1), time(10, 0), tzinfo=CN_TZ)
monkeypatch.setattr(paper, "cn_now", lambda: next_day)
monkeypatch.setattr(paper, "cn_today", lambda: next_day.date())
paper_auto.create_auto_rule(data_dir, _rule(side="sell", max_positions=1))
created = paper_auto.on_rule_events(data_dir, [_ev(SYM), _ev(SYM2, 10.0)])
assert len(created) == 2 and all(o["side"] == "sell" for o in created)
def test_rule_validation_rejects_bad_max_positions(tmp_path: Path):
with pytest.raises(ValueError, match="max_positions"):
paper_auto.create_auto_rule(tmp_path, _rule(max_positions=-1))
with pytest.raises(ValueError, match="max_positions"):
paper_auto.create_auto_rule(tmp_path, _rule(max_positions=1.5))
# 旧规则文件缺字段 → normalize 补 0 (不限), 不破坏存量
rule = paper_auto.normalize_rule(_rule())
assert rule["max_positions"] == 0
def _ev_typed(etype: str, symbol: str, price: float = 10.0) -> dict:
return {"source": "strategy", "strategy_id": "strat_1", "type": etype, "symbol": symbol, "price": price}
def test_auto_side_follows_signal_direction(data_dir: Path, monkeypatch):
"""side=auto: 入场信号买入, 出场信号全平 — 一条规则表达完整策略周期。"""
paper.create_account(data_dir, 1_000_000)
paper_auto.create_auto_rule(data_dir, _rule(side="auto"))
# 入场信号 → 买入
created = paper_auto.on_rule_events(data_dir, [_ev_typed("buy_signal", SYM)])
assert len(created) == 1 and created[0]["side"] == "buy"
paper.evaluate_intraday(data_dir, {SYM: 10.0})
assert paper.load_positions(data_dir)[SYM]["qty"] == 1000
# 次日出场信号 → 全平 (T+1)
next_day = datetime.combine(DAY + timedelta(days=1), time(10, 0), tzinfo=CN_TZ)
monkeypatch.setattr(paper, "cn_now", lambda: next_day)
monkeypatch.setattr(paper, "cn_today", lambda: next_day.date())
created = paper_auto.on_rule_events(data_dir, [_ev_typed("sell_signal", SYM)])
assert len(created) == 1 and created[0]["side"] == "sell" and created[0]["qty"] == 1000
# 未持仓时出场信号 → 跳过
paper.evaluate_intraday(data_dir, {SYM: 10.0})
assert paper_auto.on_rule_events(data_dir, [_ev_typed("pool_exit", SYM)]) == []
def test_auto_side_pool_entry_exit(data_dir: Path, monkeypatch):
"""auto 同样认进池/移出池事件 (结果池口径的策略)。"""
paper.create_account(data_dir, 1_000_000)
paper_auto.create_auto_rule(data_dir, _rule(side="auto"))
assert len(paper_auto.on_rule_events(data_dir, [_ev_typed("pool_entry", SYM)])) == 1
paper.evaluate_intraday(data_dir, {SYM: 10.0})
next_day = datetime.combine(DAY + timedelta(days=1), time(10, 0), tzinfo=CN_TZ)
monkeypatch.setattr(paper, "cn_now", lambda: next_day)
monkeypatch.setattr(paper, "cn_today", lambda: next_day.date())
created = paper_auto.on_rule_events(data_dir, [_ev_typed("pool_exit", SYM)])
assert len(created) == 1 and created[0]["qty"] == 1000
def test_auto_side_rejects_non_strategy_match(tmp_path: Path):
"""auto 仅限匹配策略 (监控规则事件无方向语义)。"""
with pytest.raises(ValueError, match="auto"):
paper_auto.create_auto_rule(tmp_path, _rule(side="auto", match_kind="rule"))
# normalize: strategy 缺 side → auto; rule 缺 side → buy (存量兼容)
no_side = _rule(); no_side.pop("side")
assert paper_auto.normalize_rule(no_side)["side"] == "auto"
no_side_r = _rule(match_kind="rule"); no_side_r.pop("side")
assert paper_auto.normalize_rule(no_side_r)["side"] == "buy"
File diff suppressed because it is too large Load Diff
+29 -2
View File
@@ -1131,6 +1131,21 @@ export interface PaperCompareRow {
day_change_pct?: number | null
auto_rules?: Array<{ name: string; match_kind: string; match_id: string; side: 'buy' | 'sell'; enabled: boolean }>
auto_enabled?: number
/** 盘中实时估算 (后端增量, 当日快照就绪才有): live_* 为快照价口径 */
live_total?: number | null
live_pnl_pct?: number | null
estimating?: boolean
}
/** FIFO 配对回合 (GET /api/paper/rounds): 开仓成本含买入费, 平仓净额扣卖出费 */
export interface PaperRound {
symbol: string
open_date: string
close_date: string
qty: number
pnl: number
pnl_pct: number
holding_days: number
}
/** 对比批量创建的单个来源 (POST /api/paper/arena/batch_create) */
@@ -1138,11 +1153,13 @@ export interface PaperArenaSource {
name?: string
match_kind: 'strategy' | 'rule'
match_id: string
side?: 'buy' | 'sell'
side?: 'buy' | 'sell' | 'auto'
size_mode?: 'fixed_amount' | 'pct_equity'
size_value?: number
order_type?: 'market' | 'next_open' | 'close'
cooldown_days?: number
/** 最大同时持仓标的数 (0 = 不限); 对齐回测 max_positions 口径 */
max_positions?: number
}
export interface PaperHolding {
@@ -1204,11 +1221,14 @@ export interface PaperAutoRule {
name: string
match_kind: 'strategy' | 'rule'
match_id: string
side: 'buy' | 'sell'
/** auto = 跟随策略信号方向 (入场/进池买入, 出场/移出全平); 仅 match_kind=strategy 支持 */
side: 'buy' | 'sell' | 'auto'
size_mode: 'fixed_amount' | 'pct_equity'
size_value: number
order_type: 'market' | 'next_open' | 'close'
cooldown_days: number
/** 最大同时持仓标的数 (仅约束买入开新仓, 0 = 不限); 对齐回测 max_positions 口径 */
max_positions?: number
enabled: boolean
created_at: string
}
@@ -3977,12 +3997,19 @@ export const api = {
paperStats: (account?: string) =>
request<{ rounds: number; win_rate: number; profit_loss_ratio: number | null; avg_holding_days: number; realized_pnl: number; max_drawdown: number | null }>(accUrl('/api/paper/stats', account)),
paperRounds: (account?: string) =>
request<{ rounds: PaperRound[] }>(accUrl('/api/paper/rounds', account)),
paperCompare: () =>
request<{ accounts: PaperCompareRow[] }>('/api/paper/compare'),
paperFreeze: (frozen: boolean, account?: string) =>
request<{ account: PaperAccount }>(accUrl('/api/paper/freeze?frozen=' + frozen, account), { method: 'POST' }),
/** 删除账户及其全部数据 (不可恢复); 有持仓/待成交订单时后端拒绝, force=true 强删 */
paperDeleteAccount: (force: boolean, account?: string) =>
request<{ ok: boolean; deleted: string; holdings?: number; pending_orders?: number }>(accUrl(`/api/paper/account?force=${force}`, account), { method: 'DELETE' }),
paperAutoRules: (account?: string) =>
request<{ rules: PaperAutoRule[] }>(accUrl('/api/paper/auto_rules', account)),
+1
View File
@@ -116,6 +116,7 @@ export const QK = {
paperTrades: (acc: string) => ['paper', 'trades', acc] as const,
paperNav: (acc: string) => ['paper', 'nav', acc] as const,
paperStats: (acc: string) => ['paper', 'stats', acc] as const,
paperRounds: (acc: string) => ['paper', 'rounds', acc] as const,
paperCompare: ['paper', 'compare'] as const,
paperAutoRules: (acc: string) => ['paper', 'auto-rules', acc] as const,
alerts: (source?: string) => ['alerts', source ?? ''] as const,