mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat(paper): 模拟盘重构 — 多账户对比与策略自动执行
- 对比榜单 14 列可排序, 盘中实时估值; 行展开完整账户操作台 - 创建即绑策略自动执行: 入场信号买入 · 出场信号全平, 支持最大持仓 (对齐回测口径)、仓位、订单类型、冷却; 批量创建同享 - 账户删除 (两步强确认)、冻结; 回合明细; 基础配置 (新账户默认参数) - 策略联想下拉重设计: 全量候选、两行式选项、portal 防裁剪
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
"""买入向上滑、卖出向下滑 (对成交价不利方向)。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user