feat(paper): 模拟盘虚拟账户 — 模拟撮合/自动跟单/多账户/涨跌停排队/统计告警

- 撮合域: append-only 成交台账唯一事实源, T+1/涨跌停/停牌顺延/除权调整,
  佣金印花税滑点与回测引擎同口径
- 下单: 即时/次日开盘/当日收盘, 盘中快照撮合 + 盘后结算幂等;
  涨跌停排队次日重试可选 (账户级开关, 顺延上限 3 日)
- 自动跟单: 监控事件触发按规则下单 (跟策略/跟规则, 固定金额/权益%,
  同标的冷却去重), 下单复用撮合域同一把锁与校验
- 多账户: data/paper/accounts/{id}/ 完全隔离, 旧单账户布局自动迁移,
  API ?account= 贯穿全部端点, 盘中钩子与盘后结算逐账户执行
- 统计: FIFO 回合/胜率/盈亏比/均持天数/最大回撤; 成交落告警记录
- 前端: 模拟盘页 (净值对比沪深300/持仓/订单/成交台账/下单/规则面板/
  账户切换), 买入红卖出绿 A 股配色
- 测试: 域模块 33 项 + API 契约 8 项
This commit is contained in:
shy3130
2026-09-24 17:19:09 +08:00
parent ecc418bd94
commit dbb2e6dd14
13 changed files with 3339 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
"""虚拟账户(模拟盘) API — 薄胶水: 校验/映射在 paper 域模块, 这里只做 HTTP 壳。
多账户: 所有端点接受 ?account=<id> (缺省 "default"); 帐户 ID 校验在域模块。
路由挂载在 app/main.py; 域模块自带进程内写锁, API 层不再加第二把锁
(镜像 lots API 的跨请求互斥语义, 但锁下沉到域)。
"""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel
from app.strategy import paper
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/paper", tags=["paper"])
def _data_dir(request: Request) -> Path:
return request.app.state.repo.store.data_dir
def _acc(request: Request, account: str) -> str:
try:
return paper.validate_account_id(account)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
class AccountModel(BaseModel):
initial_cash: float
account_id: str = paper.DEFAULT_ACCOUNT_ID
name: str | None = None
commission_pct: float = paper.DEFAULT_COMMISSION_PCT
stamp_tax_pct: float = paper.DEFAULT_STAMP_TAX_PCT
slippage_bps: float = paper.DEFAULT_SLIPPAGE_BPS
queue_limit_orders: bool = False
class OrderModel(BaseModel):
symbol: str
side: str # buy / sell
qty: int | None = None
amount: float | None = None
order_type: str = "market" # market / next_open / close
ref_price: float | None = None # amount 模式折算 & 买入资金预检
class SettingsModel(BaseModel):
queue_limit_orders: bool | None = None
def _resolve_asset_type(request: Request, symbol: str) -> str:
repo = getattr(request.app.state, "repo", None)
try:
return repo.resolve_asset_type(symbol) if repo is not None else "stock"
except Exception:
logging.getLogger(__name__).warning(
"resolve_asset_type failed for %s, falling back to stock", symbol, exc_info=True
)
return "stock"
def _last_close(data_dir: Path, symbol: str, asset_type: str) -> float | None:
"""最近收盘价 (amount 折算参考价)。读最新分区日 raw close。"""
base = paper._daily_path(data_dir, asset_type)
if not base.exists():
return None
try:
import polars as pl
df = (
pl.scan_parquet((base / "**" / "*.parquet").as_posix())
.filter(pl.col("symbol") == symbol)
.sort("date")
.select(pl.col("close").last())
.collect()
)
except Exception:
return None
if df.is_empty() or df["close"][0] is None:
return None
return float(df["close"][0])
@router.get("/accounts")
def list_accounts(request: Request):
"""账户列表 (带最新净值摘要, 供前端切换器)。"""
return {"accounts": paper.list_accounts(_data_dir(request))}
@router.get("/account")
def get_account(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
acc = paper.get_account(_data_dir(request), _acc(request, account))
return {"account": acc}
@router.post("/account")
def create_account(request: Request, body: AccountModel):
try:
acc = paper.create_account(
_data_dir(request),
body.initial_cash,
account_id=_acc(request, body.account_id),
name=body.name,
commission_pct=body.commission_pct,
stamp_tax_pct=body.stamp_tax_pct,
slippage_bps=body.slippage_bps,
queue_limit_orders=body.queue_limit_orders,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"account": acc}
@router.get("/overview")
def overview(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
"""总览: 现金 + 持仓估值 + 账户状态。
盘中(交易时段且实时快照就绪)用当日实时 raw_close 估算 (estimating=true),
其余时刻用最近日线收盘价 (与收盘定版口径一致)。
"""
data_dir = _data_dir(request)
acc_id = _acc(request, account)
acc = paper.get_account(data_dir, acc_id)
if acc is None:
return {"initialized": False, "account_id": acc_id}
ov = paper.overview(data_dir, account_id=acc_id)
ov["fees"] = {k: acc[k] for k in ("commission_pct", "stamp_tax_pct", "slippage_bps")}
# 盘中实时估算: 从行情服务的当日 enriched 快照取 raw_close (软失败, 降级日线)
qs = getattr(request.app.state, "quote_service", None)
if qs is not None and acc.get("status") == "active":
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() and ov.get("holdings"):
snapshot = dict(zip(enriched["symbol"].to_list(), enriched["raw_close"].to_list(), strict=False))
live = paper.overview(data_dir, snapshot, account_id=acc_id)
live["fees"] = ov["fees"]
return live
except Exception as e:
logger.warning("paper overview 实时估算失败, 降级日线口径: %s", e)
return ov
@router.post("/orders")
def create_order(request: Request, body: OrderModel, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
data_dir = _data_dir(request)
acc_id = _acc(request, account)
asset_type = _resolve_asset_type(request, body.symbol)
ref_price = body.ref_price
if ref_price is None:
ref_price = _last_close(data_dir, body.symbol, asset_type)
order, err = paper.create_order(
data_dir, body.symbol, body.side,
account_id=acc_id,
qty=body.qty, amount=body.amount,
order_type=body.order_type, asset_type=asset_type, ref_price=ref_price,
)
if err:
raise HTTPException(status_code=400, detail=err)
return {"order": order}
@router.get("/orders")
def list_orders(request: Request, status: str | None = None, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
orders = paper.load_orders(_data_dir(request), _acc(request, account))
if status:
orders = [o for o in orders if o["status"] == status]
orders.sort(key=lambda o: o["created_at"], reverse=True)
return {"orders": orders}
@router.delete("/orders/{order_id}")
def cancel_order(request: Request, order_id: str, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
order, err = paper.cancel_order(_data_dir(request), order_id, _acc(request, account))
if err:
raise HTTPException(status_code=400, detail=err)
return {"order": order}
@router.get("/trades")
def list_trades(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
fills = paper.load_fills(_data_dir(request), _acc(request, account))
fills.sort(key=lambda f: f.get("seq", 0), reverse=True)
return {"fills": fills}
@router.get("/positions")
def list_positions(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
data_dir = _data_dir(request)
ov = paper.overview(data_dir, account_id=_acc(request, account))
return {"holdings": ov.get("holdings", []), "initialized": ov.get("initialized", False)}
@router.get("/nav")
def list_nav(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
return {"nav": paper.load_nav(_data_dir(request), _acc(request, account))}
@router.get("/stats")
def get_stats(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
return paper.stats(_data_dir(request), _acc(request, account))
@router.post("/rebuild")
def rebuild(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
"""由成交台账重建物化持仓与现金 (修复兜底)。"""
positions = paper.rebuild_positions(_data_dir(request), _acc(request, account))
return {"symbols": len(positions)}
@router.post("/freeze")
def freeze(request: Request, frozen: bool = True, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
"""冻结/解冻账户: 冻结后拒绝新订单, 持仓只读。"""
data_dir = _data_dir(request)
acc_id = _acc(request, account)
acc = paper.get_account(data_dir, acc_id)
if acc is None:
raise HTTPException(status_code=400, detail="尚未创建模拟账户")
acc["status"] = "frozen" if frozen else "active"
paper.save_account(data_dir, acc, acc_id)
return {"account": acc}
@router.post("/settings")
def update_settings(request: Request, body: SettingsModel, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
"""更新账户设置 (涨跌停排队等开关)。"""
try:
acc = paper.update_settings(_data_dir(request), _acc(request, account), **body.model_dump())
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"account": acc}
# ===== 自动跟单规则 (V2) =====
class AutoRuleModel(BaseModel):
name: str
match_kind: str # strategy / rule
match_id: str
side: str = "buy"
size_mode: str = "fixed_amount" # fixed_amount / pct_equity
size_value: float
order_type: str = "next_open"
cooldown_days: int = 5
enabled: bool = True
@router.get("/auto_rules")
def list_auto_rules(request: Request, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
from app.strategy import paper_auto
return {"rules": paper_auto.load_auto_rules(_data_dir(request), _acc(request, account))}
@router.post("/auto_rules")
def create_auto_rule(request: Request, body: AutoRuleModel, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
from app.strategy import paper_auto
try:
rule = paper_auto.create_auto_rule(_data_dir(request), body.model_dump(), _acc(request, account))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"rule": rule}
@router.post("/auto_rules/{rule_id}/enabled")
def set_auto_rule_enabled(request: Request, rule_id: str, enabled: bool, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
from app.strategy import paper_auto
rule = paper_auto.set_enabled(_data_dir(request), rule_id, enabled, _acc(request, account))
if rule is None:
raise HTTPException(status_code=404, detail=f"规则不存在: {rule_id}")
return {"rule": rule}
@router.delete("/auto_rules/{rule_id}")
def delete_auto_rule(request: Request, rule_id: str, account: str = Query(paper.DEFAULT_ACCOUNT_ID)):
from app.strategy import paper_auto
if not paper_auto.delete_auto_rule(_data_dir(request), rule_id, _acc(request, account)):
raise HTTPException(status_code=404, detail=f"规则不存在: {rule_id}")
return {"ok": True}
+27
View File
@@ -724,6 +724,32 @@ def run_now(
stage_errors.append(f"compute_mainline: {e}")
skipped.append("mainline")
# Step 2.8: 模拟盘结算: 顺延单按当日开盘/收盘撮合 → 除权调整 → 定版净值。
# 幂等: 重跑同日不会重复成交/二次除权 (订单状态与 corp_action 台账守卫)。
# 必须在日K/除权同步之后, 才能读到当日 raw OHLC 与因子。核心账务不受
# 「市场环境」等可选开关控制, 恒运行 (开销可忽略); 软失败不阻断主管道。
paper_summary: dict = {}
try:
emit("paper_settle", 94, "模拟盘结算…")
from app.strategy import paper as paper_trading
# 逐账户结算 (账户间订单/台账隔离), 汇总合并供日志与结果展示
totals = {"filled": 0, "expired": 0, "corp_actions": 0, "nav": None, "accounts": []}
for acc_id in paper_trading.list_account_ids(repo.store.data_dir):
s = paper_trading.settle_day(repo.store.data_dir, today.isoformat(), account_id=acc_id)
totals["filled"] += s.get("filled", 0)
totals["expired"] += s.get("expired", 0)
totals["corp_actions"] += s.get("corp_actions", 0)
if s.get("nav") is not None:
totals["nav"] = s["nav"]
totals["accounts"].append({"account": acc_id, **{k: s.get(k) for k in ("filled", "expired", "corp_actions")}})
paper_summary = totals
if paper_summary.get("filled") or paper_summary.get("corp_actions"):
logger.info("paper_settle: %s", paper_summary)
emit("paper_settle", 94, "模拟盘结算完成")
except Exception as e:
logger.warning("paper_settle failed (soft): %s", e)
stage_errors.append(f"paper_settle: {e}")
# Step 3: 刷新视图
emit("refresh_views", 95, "刷新 DuckDB 视图…")
_refresh_views(repo)
@@ -744,6 +770,7 @@ def run_now(
"minute_rows": written_minute,
"regime_days": regime_days,
"mainline_rows": mainline_rows,
"paper_settle": paper_summary,
"lagging_symbols": len(lagging_symbols),
"enriched_total_days": enriched_total_days,
"integrity_repair_from": repair_start.isoformat() if repair_start else None,
+2
View File
@@ -29,6 +29,7 @@ from app.api import (
mining,
monitor_rules,
overview,
paper,
pipeline,
regime,
rps,
@@ -478,6 +479,7 @@ app.include_router(mining.router)
app.include_router(intraday.router)
app.include_router(indices.router)
app.include_router(overview.router)
app.include_router(paper.router)
app.include_router(abnormal.router)
app.include_router(regime.router)
app.include_router(analysis.router)
+21
View File
@@ -1318,6 +1318,27 @@ class QuoteService:
if rule_events:
self._maybe_send_webhook(rule_events, engine)
# 模拟盘钩子: 与监控评估同频 —— ① 用同一份实时快照撮合 pending 即时单;
# ② rule_events 喂给自动跟单规则触发自动下单。逐账户执行 (账户间规则与订单隔离)。
# 即时撮合仅股票 (ETF 即时单在下单时已转次日开盘); 独立 try ——
# 模拟盘任何异常只留痕, 不得影响监控告警链路。
if stock_ready and self._app_state is not None:
try:
from app.strategy import paper as paper_trading
from app.strategy import paper_auto
data_dir = self._app_state.repo.store.data_dir
snapshot = dict(zip(
enriched_today["symbol"].to_list(),
enriched_today["raw_close"].to_list(),
strict=False,
))
for acc_id in paper_trading.list_account_ids(data_dir):
paper_trading.evaluate_intraday(data_dir, snapshot, account_id=acc_id)
if rule_events:
paper_auto.on_rule_events(data_dir, rule_events, account_id=acc_id)
except Exception as e:
logger.warning("模拟盘钩子失败 (不影响监控): %s", e)
except Exception as e: # noqa: BLE001
logger.warning("监控评估失败: %s", e)
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
"""自动跟单规则域 — 监控事件触发模拟盘自动下单 (V2)。
规则匹配: 事件 source=="strategy" 且 strategy_id 相等 (跟策略), 或 rule_id 相等
(跟任意监控规则, 含信号/价格规则)。仓位: 固定金额 或 账户权益百分比。
冷却: 同规则同 symbol 最近一次自动下单后 N 个交易日内不再触发 (按订单
created_at 日期判断, 无独立状态文件 — 可由订单列表重放推导)。
规则按账户存放 (accounts/{account_id}/auto_rules/), 账户间隔离;
on_rule_events 由钩子对每个账户各调一次。
触发链路: quote_service._evaluate_monitors 产出的 rule_events →
paper_auto.on_rule_events → paper.create_order(source=f"auto:{rule_id}")。
所有下单走 paper 域的同一把锁与校验, 账户冻结/资金/涨跌停等约束自动生效。
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import date as _date
from datetime import datetime
from pathlib import Path
from app.market_time import cn_now
from app.services.fs_utils import atomic_write_text
from app.strategy import paper
logger = logging.getLogger(__name__)
def _dir(data_dir: Path, account_id: str) -> Path:
d = paper._root(data_dir, account_id) / "auto_rules"
d.mkdir(parents=True, exist_ok=True)
return d
def _now_iso() -> str:
return cn_now().isoformat()
def _new_id() -> str:
return f"arule_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:6]}"
def validate_rule(rule: dict) -> None:
"""校验规则字段, 非法抛 ValueError (中文信息)。"""
if (rule.get("name") or "").strip() == "":
raise ValueError("规则名称不能为空")
if rule.get("match_kind") not in ("strategy", "rule"):
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"):
raise ValueError(f"side 非法: {rule.get('side')!r}")
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")
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
raise ValueError("size_value 必须是正数")
if rule.get("size_mode") == "pct_equity" and value > 100:
raise ValueError("pct_equity 的 size_value 不能超过 100 (%)")
if rule.get("order_type") not in ("market", "next_open", "close"):
raise ValueError(f"order_type 非法: {rule.get('order_type')!r}")
cooldown = rule.get("cooldown_days", 0)
if isinstance(cooldown, bool) or not isinstance(cooldown, int) or cooldown < 0:
raise ValueError("cooldown_days 必须是非负整数")
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")
d.setdefault("size_mode", "fixed_amount")
d.setdefault("order_type", "next_open")
d.setdefault("cooldown_days", 5)
d.setdefault("enabled", True)
d.setdefault("created_at", _now_iso())
return d
def load_auto_rules(data_dir: Path, account_id: str = paper.DEFAULT_ACCOUNT_ID, enabled_only: bool = False) -> list[dict]:
out: list[dict] = []
for f in sorted(_dir(data_dir, account_id).glob("arule_*.json")):
try:
r = normalize_rule(json.loads(f.read_text(encoding="utf-8")))
if enabled_only and not r.get("enabled"):
continue
out.append(r)
except Exception as e:
logger.warning("paper auto rule load failed %s: %s", f.name, e)
return out
def save_auto_rule(data_dir: Path, rule: dict, account_id: str = paper.DEFAULT_ACCOUNT_ID) -> dict:
validate_rule(rule)
atomic_write_text(_dir(data_dir, account_id) / f"{rule['id']}.json", json.dumps(rule, ensure_ascii=False, indent=2))
return rule
def create_auto_rule(data_dir: Path, rule: dict, account_id: str = paper.DEFAULT_ACCOUNT_ID) -> dict:
rule = normalize_rule({**rule, "id": _new_id()})
validate_rule(rule)
return save_auto_rule(data_dir, rule, account_id)
def delete_auto_rule(data_dir: Path, rule_id: str, account_id: str = paper.DEFAULT_ACCOUNT_ID) -> bool:
p = _dir(data_dir, account_id) / f"{rule_id}.json"
if p.exists():
p.unlink()
return True
return False
def set_enabled(data_dir: Path, rule_id: str, enabled: bool, account_id: str = paper.DEFAULT_ACCOUNT_ID) -> dict | None:
p = _dir(data_dir, account_id) / f"{rule_id}.json"
if not p.exists():
return None
rule = normalize_rule(json.loads(p.read_text(encoding="utf-8")))
rule["enabled"] = enabled
return save_auto_rule(data_dir, rule, account_id)
def _matches(rule: dict, ev: dict) -> bool:
if rule["match_kind"] == "strategy":
return ev.get("source") == "strategy" and ev.get("strategy_id") == rule["match_id"]
return ev.get("rule_id") == rule["match_id"]
def _in_cooldown(data_dir: Path, rule: dict, symbol: str, cooldown_days: int, account_id: str) -> bool:
"""同规则同 symbol 最近一次自动下单是否仍在冷却期 (按日历日, 含当日)。"""
if cooldown_days <= 0:
return False
prefix = f"auto:{rule['id']}"
today = cn_now().date()
for order in paper.load_orders(data_dir, account_id):
if order.get("source") != prefix or order.get("symbol") != symbol:
continue
try:
created = _date.fromisoformat(order["created_at"][:10])
except (KeyError, ValueError):
continue
if (today - created).days < cooldown_days:
return True
return False
def _sizing_qty(data_dir: Path, rule: dict, ref_price: float, account_id: str) -> int:
if ref_price <= 0:
return 0
if rule["size_mode"] == "fixed_amount":
amount = float(rule["size_value"])
else: # pct_equity: 按账户总权益 (现金 + 最新定版持仓市值)
acc = paper.get_account(data_dir, account_id)
if acc is None:
return 0
nav_rows = paper.load_nav(data_dir, account_id)
equity = nav_rows[-1]["nav"] if nav_rows else float(acc["cash"])
amount = equity * float(rule["size_value"]) / 100.0
return paper.qty_from_amount(amount, ref_price)
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 用事件价格 (当前快照价)。
"""
created: list[dict] = []
if not events:
return created
rules = load_auto_rules(data_dir, account_id, enabled_only=True)
if not rules:
return created
with paper.PAPER_LOCK:
for ev in events:
symbol = (ev.get("symbol") or "").strip()
price = ev.get("price")
if not symbol or price is None or price <= 0:
continue
for rule in rules:
if not _matches(rule, ev):
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 qty <= 0:
logger.info("paper auto %s: %s 金额不足以一手 (价 %s)", rule["name"], symbol, price)
continue
order, err = paper.create_order(
data_dir, symbol, rule["side"],
account_id=account_id,
qty=qty,
order_type=rule["order_type"],
ref_price=float(price),
source=f"auto:{rule['id']}",
)
if err:
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"])
return created
+127
View File
@@ -0,0 +1,127 @@
"""虚拟账户 API 契约测试 — 成功/无数据/错误响应 (per CONTRIBUTING API 契约矩阵)。
镜像 test_alerts_query_bounds.py: 只挂本路由的裸 FastAPI app, 不经过主应用
全局中间件 (访问密码门是部署层关注点, 不属于本契约)。
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.paper import router
@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: "etf" if s.split(".")[0].startswith(("51", "56", "58", "15")) else "stock",
)
return TestClient(app)
def test_overview_before_init(client: TestClient):
r = client.get("/api/paper/overview")
assert r.status_code == 200
assert r.json() == {"initialized": False, "account_id": "default"}
def test_multi_account_isolation_and_settings(client: TestClient):
"""双账户隔离: 订单/概览互不可见; settings 端点切换涨跌停排队。"""
client.post("/api/paper/account", json={"initial_cash": 1000000}) # default
r = client.post("/api/paper/account", json={"initial_cash": 500000, "account_id": "acc_a", "name": "策略A"})
assert r.status_code == 200 and r.json()["account"]["name"] == "策略A"
# 账户列表
ids = {a["id"] for a in client.get("/api/paper/accounts").json()["accounts"]}
assert ids == {"default", "acc_a"}
# 订单隔离: 同一笔下单只出现在对应账户
assert client.post("/api/paper/orders?account=acc_a", json={"symbol": "600519.SH", "side": "buy", "qty": 100, "ref_price": 1500.0}).status_code == 200
assert len(client.get("/api/paper/orders?account=acc_a").json()["orders"]) == 1
assert len(client.get("/api/paper/orders").json()["orders"]) == 0
# 概览隔离: acc_a 有 pending 单不影响 default 的现金展示
assert client.get("/api/paper/overview?account=acc_a").json()["initial_cash"] == 500000
assert client.get("/api/paper/overview").json()["initial_cash"] == 1000000
# settings: 开启涨跌停排队 → 账户字段更新
r = client.post("/api/paper/settings?account=acc_a", json={"queue_limit_orders": True})
assert r.status_code == 200 and r.json()["account"]["queue_limit_orders"] is True
assert client.get("/api/paper/overview?account=acc_a").json()["queue_limit_orders"] is True
assert client.get("/api/paper/overview").json()["queue_limit_orders"] is False
# 非法账户 id → 400 (路径穿越防护)
assert client.get("/api/paper/overview?account=../x").status_code == 400
def test_account_create_idempotent(client: TestClient):
r1 = client.post("/api/paper/account", json={"initial_cash": 500000})
assert r1.status_code == 200
r2 = client.post("/api/paper/account", json={"initial_cash": 900000})
assert r2.status_code == 200
assert r2.json()["account"]["cash"] == 500000 # 幂等: 不覆盖
def test_account_rejects_non_positive_cash(client: TestClient):
assert client.post("/api/paper/account", json={"initial_cash": 0}).status_code == 400
assert client.post("/api/paper/account", json={"initial_cash": -1}).status_code == 400
def test_order_create_list_cancel_flow(client: TestClient):
client.post("/api/paper/account", json={"initial_cash": 1000000})
r = client.post("/api/paper/orders", json={"symbol": "600519.SH", "side": "buy", "qty": 100, "ref_price": 1500.0})
assert r.status_code == 200
order = r.json()["order"]
assert order["status"] == "pending"
# 非百股整数倍 → 400
assert client.post("/api/paper/orders", json={"symbol": "600519.SH", "side": "buy", "qty": 150}).status_code == 400
# 超资金 → 400
assert client.post("/api/paper/orders", json={"symbol": "600519.SH", "side": "buy", "qty": 100000, "ref_price": 1500.0}).status_code == 400
# 卖出无持仓 → 400
assert client.post("/api/paper/orders", json={"symbol": "600519.SH", "side": "sell", "qty": 100}).status_code == 400
# 列表 + 撤单
assert len(client.get("/api/paper/orders?status=pending").json()["orders"]) == 1
r = client.delete(f"/api/paper/orders/{order['id']}")
assert r.status_code == 200 and r.json()["order"]["status"] == "cancelled"
# 已撤不可再撤
assert client.delete(f"/api/paper/orders/{order['id']}").status_code == 400
def test_order_amount_mode_auto_converts(client: TestClient):
client.post("/api/paper/account", json={"initial_cash": 1000000})
r = client.post("/api/paper/orders", json={"symbol": "600519.SH", "side": "buy", "amount": 200000, "ref_price": 1500.0})
assert r.status_code == 200
assert r.json()["order"]["qty"] == 100 # 20万 / 1500 = 133 股 → 向下取整百 = 100
# 无参考价的金额单 → 400
assert client.post("/api/paper/orders", json={"symbol": "600519.SH", "side": "buy", "amount": 100}).status_code == 400
def test_freeze_blocks_new_orders(client: TestClient):
client.post("/api/paper/account", json={"initial_cash": 100000})
assert client.post("/api/paper/freeze?frozen=true").status_code == 200
r = client.post("/api/paper/orders", json={"symbol": "600519.SH", "side": "buy", "qty": 100, "ref_price": 10.0})
assert r.status_code == 400 and "冻结" in r.json()["detail"]
# 解冻恢复
assert client.post("/api/paper/freeze?frozen=false").status_code == 200
assert client.post("/api/paper/orders", json={"symbol": "600519.SH", "side": "buy", "qty": 100, "ref_price": 10.0}).status_code == 200
def test_freeze_before_init_rejected(client: TestClient):
assert client.post("/api/paper/freeze?frozen=true").status_code == 400
def test_trades_nav_stats_empty(client: TestClient):
client.post("/api/paper/account", json={"initial_cash": 100000})
assert client.get("/api/paper/trades").json() == {"fills": []}
assert client.get("/api/paper/nav").json() == {"nav": []}
stats = client.get("/api/paper/stats").json()
assert stats["rounds"] == 0 and stats["win_rate"] == 0.0
+623
View File
@@ -0,0 +1,623 @@
"""虚拟账户(模拟盘)域模块测试 — 撮合口径 / 账务一致性 / 管道幂等。
设计文档: docs/paper-trading-plan.md。价格全程不复权 raw 价;
台账 append-only, 持仓/现金由重放推导, 修复以追加记录表达。
"""
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
import pytest
from app.strategy import paper
from app.tickflow.repository import DataStore, KlineRepository
SYM = "600519.SH"
def _cap_account(tmp_path, cash: float = 1_000_000.0) -> dict:
return paper.create_account(tmp_path, cash)
def _write_daily(tmp_path, rows: list[tuple[date, float, float]]) -> None:
"""写 kline_daily 分区: [(day, open, close)] (不复权 raw 价)。"""
repo = KlineRepository(DataStore(tmp_path))
df = pl.DataFrame(
{
"symbol": [SYM] * len(rows),
"date": [r[0] for r in rows],
"open": [r[1] for r in rows],
"high": [max(r[1], r[2]) for r in rows],
"low": [min(r[1], r[2]) for r in rows],
"close": [r[2] for r in rows],
"volume": [10000.0] * len(rows),
"amount": [r[2] * 10000.0 for r in rows],
}
)
repo.append_daily(df)
def _write_factor(tmp_path, day: date, factor: float, asset_type: str = "stock") -> None:
sub = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
out = tmp_path / sub / "all.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame(
{"symbol": [SYM], "trade_date": [day], "ex_factor": [factor]},
schema={"symbol": pl.String, "trade_date": pl.Date, "ex_factor": pl.Float64},
).write_parquet(out)
# ── 纯函数口径 ──────────────────────────────────────────
def test_fees_min_commission_and_stamp_tax_sell_only():
# 佣金最低 5 元: 100 股 x 10 元 x 万2.5 = 0.25 → 取 5
assert paper.buy_fee(100, 10.0, 0.00025) == 5.0
# 大额按比例: 10000 股 x 10 元 = 10万 x 万2.5 = 25
assert paper.buy_fee(10000, 10.0, 0.00025) == 25.0
# 印花税仅卖出: 25 + 10万 x 千1 = 125
assert paper.sell_fee(10000, 10.0, 0.00025, 0.001) == 25.0 + 100.0
assert paper.buy_fee(10000, 10.0, 0.001) == 100.0
def test_slippage_direction_adverse():
assert paper.apply_slippage(10.0, "buy", 5) > 10.0
assert paper.apply_slippage(10.0, "sell", 5) < 10.0
assert paper.apply_slippage(10.0, "buy", 5) == pytest.approx(10.005)
def test_limit_prices_by_board():
assert paper.limit_pct("600519.SH", "stock") == 0.10
assert paper.limit_pct("000001.SZ", "stock") == 0.10
assert paper.limit_pct("300750.SZ", "stock") == 0.20
assert paper.limit_pct("688981.SH", "stock") == 0.20
assert paper.limit_pct("832000.BJ", "stock") == 0.30
assert paper.limit_pct("510300.SH", "etf") == 0.10
up, down = paper.limit_prices(10.0, "600519.SH", "stock")
assert (up, down) == (11.0, 9.0)
up3, down3 = paper.limit_prices(1.0, "510300.SH", "etf")
assert (up3, down3) == (1.1, 0.9) # 3 位小数 round 不改变该值
def test_qty_from_amount_floors_to_lot():
assert paper.qty_from_amount(9_999.0, 100.0) == 0 # 不足一手
assert paper.qty_from_amount(10_500.0, 100.0) == 100
assert paper.qty_from_amount(99_000.0, 100.0) == 900
# ── 下单校验 ────────────────────────────────────────────
def test_create_order_requires_account(tmp_path):
with pytest.raises(ValueError, match="尚未创建"):
paper.create_order(tmp_path, SYM, "buy", qty=100)
def test_create_order_rejects_bad_qty_and_insufficient_cash(tmp_path):
_cap_account(tmp_path, 100_000.0)
_, err = paper.create_order(tmp_path, SYM, "buy", qty=150) # 非百股整数倍
assert "100 的整数倍" in err
_, err = paper.create_order(tmp_path, SYM, "buy", qty=2000, ref_price=100.0)
assert "资金不足" in err
def test_create_order_etf_market_converts_to_next_open(tmp_path):
_cap_account(tmp_path)
order, err = paper.create_order(
tmp_path, "510300.SH", "buy", qty=1000, order_type="market", asset_type="etf", ref_price=4.0,
)
assert err is None
assert order["order_type"] == "next_open"
def test_sell_requires_holding_and_t1(tmp_path, monkeypatch):
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_cap_account(tmp_path)
_, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
assert "不能卖出" in err
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
buy_order, err = paper.create_order(tmp_path, SYM, "buy", qty=100, ref_price=10.0)
assert err is None
assert buy_order["status"] == "pending"
assert paper.evaluate_intraday(tmp_path, {SYM: 10.0}) == 1
# T+1: 当日买入当日不可卖
_, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
assert "可卖数量不足" in err
# 次日可卖
monkeypatch.setattr(paper, "cn_today", lambda: day + timedelta(days=1))
paper._materialize(tmp_path)
sell, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
assert err is None and sell["status"] == "pending"
def test_cancel_pending_only(tmp_path, monkeypatch):
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_cap_account(tmp_path)
order, _ = paper.create_order(tmp_path, SYM, "buy", qty=100, ref_price=10.0)
cancelled, err = paper.cancel_order(tmp_path, order["id"])
assert err is None and cancelled["status"] == "cancelled"
_, err = paper.cancel_order(tmp_path, order["id"])
assert "不可撤销" in err
# ── 撮合流程 ────────────────────────────────────────────
def test_immediate_fill_cash_and_positions_consistent(tmp_path, monkeypatch):
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
_cap_account(tmp_path)
order, err = paper.create_order(tmp_path, SYM, "buy", qty=1000, ref_price=10.0)
assert err is None
fills = paper.evaluate_intraday(tmp_path, {SYM: 10.0, "000002.SZ": 5.0})
assert fills == 1
filled = paper.get_order(tmp_path, order["id"])
# 滑点向上: 10 * 1.0005
assert filled["fill_price"] == pytest.approx(10.005)
fee = paper.buy_fee(1000, 10.005, paper.DEFAULT_COMMISSION_PCT)
assert filled["fees"] == pytest.approx(fee)
positions, cash = paper.replay_positions(tmp_path)
assert cash == pytest.approx(1_000_000 - 1000 * 10.005 - fee)
assert positions[SYM]["qty"] == 1000
assert positions[SYM]["avg_cost"] == pytest.approx((1000 * 10.005 + fee) / 1000)
# 台账重放 == 物化持仓 (账务一致性)
mat = paper.load_positions(tmp_path)[SYM]
assert mat["qty"] == positions[SYM]["qty"]
assert mat["avg_cost"] == pytest.approx(positions[SYM]["avg_cost"])
def test_limit_up_buy_rejected(tmp_path, monkeypatch):
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
_cap_account(tmp_path)
order, _ = paper.create_order(tmp_path, SYM, "buy", qty=100, ref_price=10.0)
# 主板涨停 11.0; 快照 11 + 滑点 ≥ 涨停 → 拒单
assert paper.evaluate_intraday(tmp_path, {SYM: 11.0}) == 0
got = paper.get_order(tmp_path, order["id"])
assert got["status"] == "expired"
assert "涨停" in got["reason"]
def test_limit_down_sell_rejected(tmp_path, monkeypatch):
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
_cap_account(tmp_path)
_, err = paper.create_order(tmp_path, SYM, "buy", qty=100, ref_price=10.0)
assert err is None
paper.evaluate_intraday(tmp_path, {SYM: 10.0})
monkeypatch.setattr(paper, "cn_today", lambda: day + timedelta(days=1))
paper._materialize(tmp_path)
sell, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
assert err is None
# 跌停 9.0; 快照 9 减滑点后不高于跌停价 -> 拒单
assert paper.evaluate_intraday(tmp_path, {SYM: 9.0}) == 0
got = paper.get_order(tmp_path, sell["id"])
assert got["status"] == "expired" and "跌停" in got["reason"]
def test_settle_next_open_close_and_postpone_expire(tmp_path):
day = date(2026, 9, 24)
_cap_account(tmp_path)
_write_daily(tmp_path, [
(day - timedelta(days=1), 10.0, 10.0),
(day, 10.2, 10.8), # 涨跌幅内 (±10%): 开 10.2 收 10.8
])
no_open, _ = paper.create_order(tmp_path, SYM, "buy", qty=100, order_type="next_open", ref_price=10.0)
close_ord, _ = paper.create_order(tmp_path, SYM, "buy", qty=100, order_type="close", ref_price=10.0)
summary = paper.settle_day(tmp_path, day.isoformat())
assert summary["filled"] == 2
assert paper.get_order(tmp_path, no_open["id"])["fill_price"] == pytest.approx(10.2 * 1.0005)
assert paper.get_order(tmp_path, close_ord["id"])["fill_price"] == pytest.approx(10.8 * 1.0005)
# 停牌顺延 → 连续无行情 4 日过期 (MAX_POSTPONE_DAYS=3)
stuck, _ = paper.create_order(tmp_path, SYM, "buy", qty=100, order_type="next_open", ref_price=11.0)
for i in range(4):
s = paper.settle_day(tmp_path, (day + timedelta(days=i + 1)).isoformat())
got = paper.get_order(tmp_path, stuck["id"])
assert got["status"] == "expired" and "自动过期" in got["reason"]
assert s["expired"] >= 1
def test_pending_sell_occupies_available_quota(tmp_path, monkeypatch):
"""超卖防护: pending 卖出单占用可卖额度, 第二张超额卖出单在下单时被拒。"""
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
_cap_account(tmp_path)
_, err = paper.create_order(tmp_path, SYM, "buy", qty=200, ref_price=10.0)
assert err is None
assert paper.evaluate_intraday(tmp_path, {SYM: 10.0}) == 1
# 次日: 可卖 200
monkeypatch.setattr(paper, "cn_today", lambda: day + timedelta(days=1))
paper._materialize(tmp_path)
s1, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
assert err is None and s1["status"] == "pending"
# 已 pending 100, 再挂 200 超出可卖 200 → 拒 (基础可卖校验或占用检查, 双闸任一)
_, err = paper.create_order(tmp_path, SYM, "sell", qty=200)
assert err is not None and ("可卖" in err or "占用" in err)
# 撤单后额度释放, 可以再挂
paper.cancel_order(tmp_path, s1["id"])
s2, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
assert err is None and s2["status"] == "pending"
def test_pending_buy_occupies_cash_at_fill(tmp_path, monkeypatch):
"""资金占用兜底: 两张 pending 买入, 第一张成交后现金不够第二张 → 第二张拒单留痕。"""
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_write_daily(tmp_path, [
(day - timedelta(days=1), 10.0, 10.0),
(day, 10.0, 10.0), # 当日有行情, close 单才会在 settle 撮合
])
_cap_account(tmp_path, 10_000.0) # 只够一张 500 股 x 10 元 + 费用
a, _ = paper.create_order(tmp_path, SYM, "buy", qty=500, order_type="close", ref_price=10.0)
b, err = paper.create_order(tmp_path, SYM, "buy", qty=500, order_type="close", ref_price=10.0)
assert err is None # 预检按单张各自通过, 由撮合侧 (资金/占用双闸) 兜底
paper.settle_day(tmp_path, day.isoformat())
assert paper.get_order(tmp_path, a["id"])["status"] == "filled"
got_b = paper.get_order(tmp_path, b["id"])
# b 必须被拒且留痕: 现金已被 a 消耗, 基础资金校验或占用检查任一拦下都算防线生效
assert got_b["status"] == "expired"
assert got_b["reason"] is not None
# 台账只有一张成交, 现金不透支
fills = [f for f in paper.load_fills(tmp_path) if f.get("kind", "fill") == "fill"]
assert len(fills) == 1
_, cash = paper.replay_positions(tmp_path)
assert cash >= 0
def test_position_symbol_cap(tmp_path, monkeypatch):
"""持仓标的数上限: 第 51 只新开仓买入被拒 (加仓已有持仓不受限)。"""
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
_cap_account(tmp_path, 100_000_000.0)
# 人为塞满 50 只持仓 (直接写台账, 绕过 100 股 x 价格的资金约束)
for i in range(50):
paper._append_fill(tmp_path, {
"seq": i, "ts": "", "date": (day - timedelta(days=1)).isoformat(),
"order_id": f"seed{i}", "symbol": f"{600000 + i:06d}.SH",
"asset_type": "stock", "side": "buy", "qty": 100, "price": 1.0, "fee": 1.0,
"kind": "fill",
})
paper._materialize(tmp_path)
# 新开仓第 51 只 → 拒
_, err = paper.create_order(tmp_path, "601999.SH", "buy", qty=100, ref_price=10.0)
assert "上限" in err
# 加仓已有持仓 → 放行
ok, err = paper.create_order(tmp_path, "600000.SH", "buy", qty=100, ref_price=10.0)
assert err is None and ok["status"] == "pending"
def test_settle_idempotent_no_double_fill(tmp_path):
day = date(2026, 9, 24)
_cap_account(tmp_path)
_write_daily(tmp_path, [(day, 10.0, 10.5)])
paper.create_order(tmp_path, SYM, "buy", qty=100, order_type="next_open", ref_price=10.0)
paper.settle_day(tmp_path, day.isoformat())
paper.settle_day(tmp_path, day.isoformat()) # 重跑同日
fills = [f for f in paper.load_fills(tmp_path) if f.get("kind", "fill") == "fill"]
assert len(fills) == 1
_, cash = paper.replay_positions(tmp_path)
assert cash == pytest.approx(1_000_000 - fills[0]["qty"] * fills[0]["price"] - fills[0]["fee"])
def test_corporate_action_adjusts_position_and_idempotent(tmp_path):
day = date(2026, 9, 24)
_cap_account(tmp_path)
_write_daily(tmp_path, [
(day - timedelta(days=1), 10.0, 10.0),
(day, 8.0, 8.0), # 除权后价格
])
order, _ = paper.create_order(tmp_path, SYM, "buy", qty=1000, order_type="close", ref_price=10.0)
paper.settle_day(tmp_path, (day - timedelta(days=1)).isoformat())
assert paper.load_positions(tmp_path)[SYM]["qty"] == 1000
_write_factor(tmp_path, day, 1.25)
paper.settle_day(tmp_path, day.isoformat())
pos = paper.load_positions(tmp_path)[SYM]
assert pos["qty"] == pytest.approx(1250)
# 成本被因子摊薄: 总成本不变 (买入费已摊入), 数量乘 1.25 后 avg_cost 除以 1.25
order_after = paper.get_order(tmp_path, order["id"])
total_cost = 1000 * order_after["fill_price"] + order_after["fees"]
replayed = paper.replay_positions(tmp_path)[0][SYM]
assert replayed["avg_cost"] * 1250 == pytest.approx(total_cost, rel=1e-4)
# 重跑同日: 不二次乘因子
paper.settle_day(tmp_path, day.isoformat())
assert paper.load_positions(tmp_path)[SYM]["qty"] == pytest.approx(1250)
corp = [f for f in paper.load_fills(tmp_path) if f.get("kind") == "corp_action"]
assert len(corp) == 1
def test_nav_and_overview_math(tmp_path, monkeypatch):
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_cap_account(tmp_path, 100_000.0)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
_, err = paper.create_order(tmp_path, SYM, "buy", qty=1000, ref_price=10.0)
assert err is None
paper.evaluate_intraday(tmp_path, {SYM: 10.0})
fill = next(f for f in paper.load_fills(tmp_path) if f.get("kind", "fill") == "fill")
# 盘中估算: 价格 11 → 市值 11000
ov = paper.overview(tmp_path, {SYM: 11.0})
assert ov["estimating"] is True
assert ov["market_value"] == pytest.approx(11000.0)
assert ov["cash"] == pytest.approx(100_000 - fill["qty"] * fill["price"] - fill["fee"])
assert ov["total"] == pytest.approx(ov["cash"] + ov["market_value"])
# 定版净值: 收盘价写 nav/daily.jsonl, 同日重写幂等
nav = paper.daily_nav(tmp_path, day.isoformat(), {SYM: 10.5})
paper._write_nav_line(tmp_path, day.isoformat(), nav)
paper._write_nav_line(tmp_path, day.isoformat(), nav)
lines = paper.load_nav(tmp_path)
assert len(lines) == 1 and lines[0]["nav"] == pytest.approx(nav["nav"])
def test_round_trips_fifo_stats(tmp_path):
day = date(2026, 9, 1)
_cap_account(tmp_path)
_write_daily(tmp_path, [(day, 10.0, 10.0), (day + timedelta(days=1), 12.0, 12.0)])
# 手工构造台账 (纯统计口径测试, 绕过撮合): 两次买入一次卖出
for f in (
{"seq": 1, "ts": "", "date": day.isoformat(), "order_id": "o1", "symbol": SYM,
"asset_type": "stock", "side": "buy", "qty": 100, "price": 10.0, "fee": 5.0, "kind": "fill"},
{"seq": 2, "ts": "", "date": day.isoformat(), "order_id": "o2", "symbol": SYM,
"asset_type": "stock", "side": "buy", "qty": 100, "price": 11.0, "fee": 5.0, "kind": "fill"},
{"seq": 3, "ts": "", "date": (day + timedelta(days=1)).isoformat(), "order_id": "o3", "symbol": SYM,
"asset_type": "stock", "side": "sell", "qty": 150, "price": 12.0, "fee": 5.0 + 18.0, "kind": "fill"},
):
paper._append_fill(tmp_path, f)
rounds = paper.round_trips(tmp_path)
assert len(rounds) == 2
# FIFO: 先平 10 元批次 100 股, 再平 11 元批次 50 股
assert rounds[0]["qty"] == 100 and rounds[0]["open_date"] == day.isoformat()
assert rounds[0]["pnl"] == pytest.approx(12.0 * 100 - (10.0 * 100 + 5.0) - (5.0 + 18.0) * 100 / 150, rel=1e-3)
assert rounds[1]["qty"] == 50
st = paper.stats(tmp_path)
assert st["rounds"] == 2 and st["win_rate"] == 100.0
# ── 自动跟单 (V2) ───────────────────────────────────────
def _auto_rule(**overrides) -> dict:
base = {
"name": "跟单测试",
"match_kind": "strategy",
"match_id": "strat_1",
"side": "buy",
"size_mode": "fixed_amount",
"size_value": 5000.0,
"order_type": "next_open",
"cooldown_days": 5,
"enabled": True,
}
base.update(overrides)
return base
def test_auto_rule_crud_and_validation(tmp_path):
from app.strategy import paper_auto
with pytest.raises(ValueError, match="不能为空"):
paper_auto.create_auto_rule(tmp_path, _auto_rule(name=""))
with pytest.raises(ValueError, match="match_kind"):
paper_auto.create_auto_rule(tmp_path, _auto_rule(match_kind="xxx"))
with pytest.raises(ValueError, match="不能超过 100"):
paper_auto.create_auto_rule(tmp_path, _auto_rule(size_mode="pct_equity", size_value=150))
rule = paper_auto.create_auto_rule(tmp_path, _auto_rule())
assert rule["id"].startswith("arule_")
assert len(paper_auto.load_auto_rules(tmp_path)) == 1
paper_auto.set_enabled(tmp_path, rule["id"], False)
assert paper_auto.load_auto_rules(tmp_path, enabled_only=True) == []
assert paper_auto.delete_auto_rule(tmp_path, rule["id"]) is True
assert paper_auto.delete_auto_rule(tmp_path, rule["id"]) is False
def test_auto_trigger_matches_strategy_event(tmp_path, monkeypatch):
from app.strategy import paper_auto
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
_cap_account(tmp_path)
paper_auto.create_auto_rule(tmp_path, _auto_rule())
ev = {"source": "strategy", "strategy_id": "strat_1", "rule_id": "r1",
"symbol": SYM, "price": 10.0}
orders = paper_auto.on_rule_events(tmp_path, [ev])
assert len(orders) == 1
assert orders[0]["qty"] == 500 # 5000 元 / 10 元 → 500 股
assert orders[0]["source"].startswith("auto:")
assert orders[0]["order_type"] == "next_open"
# 冷却期内同 symbol 不再触发
assert paper_auto.on_rule_events(tmp_path, [ev]) == []
# 冷却外 (6 天后) 恢复触发
paper_auto._now_iso = lambda: "" # no-op 防误用提示
rule_id = orders[0]["source"].split(":", 1)[1]
paper_auto.delete_auto_rule(tmp_path, rule_id) if False else None
# 直接改订单 created_at 模拟 6 天前
orders_loaded = paper.load_orders(tmp_path)
for o in orders_loaded:
o["created_at"] = "2026-09-18T10:00:00+08:00"
paper.save_order(tmp_path, o)
orders2 = paper_auto.on_rule_events(tmp_path, [ev])
assert len(orders2) == 1
def test_auto_trigger_non_matching_events_ignored(tmp_path):
from app.strategy import paper_auto
_cap_account(tmp_path)
paper_auto.create_auto_rule(tmp_path, _auto_rule())
# strategy_id 不匹配
ev1 = {"source": "strategy", "strategy_id": "other", "symbol": SYM, "price": 10.0}
# 无 symbol (批量事件)
ev2 = {"source": "strategy", "strategy_id": "strat_1", "symbol": "", "price": 10.0}
# 无价格
ev3 = {"source": "strategy", "strategy_id": "strat_1", "symbol": SYM}
assert paper_auto.on_rule_events(tmp_path, [ev1, ev2, ev3]) == []
def test_auto_rule_disabled_not_triggered(tmp_path):
from app.strategy import paper_auto
_cap_account(tmp_path)
rule = paper_auto.create_auto_rule(tmp_path, _auto_rule(enabled=False))
ev = {"source": "strategy", "strategy_id": "strat_1", "symbol": SYM, "price": 10.0}
assert paper_auto.on_rule_events(tmp_path, [ev]) == []
paper_auto.set_enabled(tmp_path, rule["id"], True)
assert len(paper_auto.on_rule_events(tmp_path, [ev])) == 1
def test_auto_frozen_account_rejects(tmp_path, monkeypatch):
from app.strategy import paper_auto
acc = _cap_account(tmp_path)
acc["status"] = "frozen"
paper.save_account(tmp_path, acc)
paper_auto.create_auto_rule(tmp_path, _auto_rule())
ev = {"source": "strategy", "strategy_id": "strat_1", "symbol": SYM, "price": 10.0}
assert paper_auto.on_rule_events(tmp_path, [ev]) == []
def test_max_drawdown_pure():
assert paper.max_drawdown([]) is None
assert paper.max_drawdown([100]) == 0.0
assert paper.max_drawdown([100, 110, 99, 105]) == pytest.approx(0.1)
assert paper.max_drawdown([100, 120, 60, 90]) == pytest.approx(0.5)
assert paper.max_drawdown([100, 100, 100]) == 0.0
# ── 涨跌停排队 (V2: queue_limit_orders) ─────────────────
def test_limit_queue_default_off_rejects_immediately(tmp_path, monkeypatch):
"""默认关闭: 触及涨停直接过期 (与 V1 行为一致, 由 test_limit_up_buy_rejected 锁定)。"""
acc = _cap_account(tmp_path)
assert acc["queue_limit_orders"] is False
def test_limit_queue_retries_next_open_then_expires(tmp_path, monkeypatch):
"""排队开启: 涨停买不进 → 转 next_open 次日重试, 连续涨停计顺延, 超限过期。"""
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
paper.create_account(tmp_path, 1_000_000, queue_limit_orders=True)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
order, _ = paper.create_order(tmp_path, SYM, "buy", qty=100, ref_price=10.0)
# day1 盘中触及涨停 11.0 → 不再过期, 转 next_open 排队
assert paper.evaluate_intraday(tmp_path, {SYM: 11.0}) == 0
got = paper.get_order(tmp_path, order["id"])
assert got["status"] == "pending" and got["order_type"] == "next_open"
assert got["postponed"] == 1 and "排队" in got["reason"]
# day2..day4 开盘连续一字涨停 (open == 涨停价) → 顺延到 3, 第 4 次超限过期
_write_daily(tmp_path, [
(day, 12.10, 12.10), # vs 前收 11.0 涨停 12.10
(day + timedelta(days=1), 13.31, 13.31), # vs 12.10 涨停 13.31
(day + timedelta(days=2), 14.64, 14.64), # vs 13.31 涨停 14.64 → 第 4 次
])
for i in range(3):
paper.settle_day(tmp_path, (day + timedelta(days=i)).isoformat())
got = paper.get_order(tmp_path, order["id"])
assert got["status"] == "expired" and "排队" in got["reason"]
assert got["postponed"] == paper.MAX_POSTPONE_DAYS
def test_limit_queue_fills_when_open_below_limit(tmp_path, monkeypatch):
"""排队开启后次日开盘回落 → 按 next_open 正常成交。"""
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
paper.create_account(tmp_path, 1_000_000, queue_limit_orders=True)
_write_daily(tmp_path, [
(day - timedelta(days=1), 10.0, 10.0),
(day, 10.5, 10.9), # 次日开盘 10.5 未涨停
])
order, _ = paper.create_order(tmp_path, SYM, "buy", qty=100, ref_price=10.0)
assert paper.evaluate_intraday(tmp_path, {SYM: 11.0}) == 0 # 涨停排队
assert paper.get_order(tmp_path, order["id"])["status"] == "pending"
summary = paper.settle_day(tmp_path, day.isoformat())
assert summary["filled"] == 1
got = paper.get_order(tmp_path, order["id"])
assert got["status"] == "filled" and got["fill_price"] == pytest.approx(paper.apply_slippage(10.5, "buy", 5.0))
# ── 多账户 (V2) ─────────────────────────────────────────
def test_multi_account_isolation(tmp_path, monkeypatch):
"""两账户完全隔离: 订单/持仓/现金/统计互不可见。"""
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
paper.create_account(tmp_path, 1_000_000, account_id="default", name="主账户")
paper.create_account(tmp_path, 500_000, account_id="acc_a", name="策略A")
# 同一标的分别在两个账户各买一笔
assert paper.evaluate_intraday(tmp_path, {SYM: 10.0}, account_id="default") == 0 # 无订单
o1, err = paper.create_order(tmp_path, SYM, "buy", qty=100, account_id="default")
o2, err = paper.create_order(tmp_path, SYM, "buy", qty=200, account_id="acc_a")
assert err is None
assert paper.evaluate_intraday(tmp_path, {SYM: 10.0}, account_id="default") == 1
assert paper.evaluate_intraday(tmp_path, {SYM: 10.0}, account_id="acc_a") == 1
# 订单互不可见
assert [o["id"] for o in paper.load_orders(tmp_path, "default")] == [o1["id"]]
assert [o["id"] for o in paper.load_orders(tmp_path, "acc_a")] == [o2["id"]]
# 持仓互不可见
assert paper.load_positions(tmp_path, "default")[SYM]["qty"] == 100
assert paper.load_positions(tmp_path, "acc_a")[SYM]["qty"] == 200
# 现金互不可见 (含费用扣减不同)
ov1 = paper.overview(tmp_path, account_id="default")
ov2 = paper.overview(tmp_path, account_id="acc_a")
assert ov1["cash"] > ov2["cash"]
assert ov1["account_name"] == "主账户" and ov2["account_name"] == "策略A"
# 账户列表与遍历
assert paper.list_account_ids(tmp_path) == ["default", "acc_a"]
infos = {a["id"]: a for a in paper.list_accounts(tmp_path)}
assert set(infos) == {"default", "acc_a"} and infos["acc_a"]["name"] == "策略A"
# settings 只影响目标账户
paper.update_settings(tmp_path, "acc_a", queue_limit_orders=True)
assert paper.get_account(tmp_path, "default")["queue_limit_orders"] is False
assert paper.get_account(tmp_path, "acc_a")["queue_limit_orders"] is True
# 净值隔离
paper.settle_day(tmp_path, day.isoformat(), account_id="default")
paper.settle_day(tmp_path, day.isoformat(), account_id="acc_a")
nav1 = paper.load_nav(tmp_path, "default")
nav2 = paper.load_nav(tmp_path, "acc_a")
assert len(nav1) == 1 and len(nav2) == 1 and nav1[0]["nav"] != nav2[0]["nav"]
def test_account_id_validation_blocks_traversal(tmp_path):
import pytest as _pytest
for bad in ("../evil", "a/b", "", "x" * 33, "a b"):
with _pytest.raises(ValueError):
paper.get_account(tmp_path, bad)
with _pytest.raises(ValueError):
paper.create_account(tmp_path, 100.0, account_id=bad)
def test_legacy_single_account_layout_migrates(tmp_path, monkeypatch):
"""旧版 data/paper/* 单账户布局首次访问自动迁移到 accounts/default/。"""
monkeypatch.setattr(paper, "_MIGRATION_DONE", False)
legacy = tmp_path / "paper"
legacy.mkdir()
(legacy / "account.json").write_text(
__import__("json").dumps({
"id": "default", "initial_cash": 800000.0, "cash": 750000.0,
"commission_pct": 0.00025, "stamp_tax_pct": 0.001, "slippage_bps": 5.0,
"status": "active", "created_at": "2026-09-01T10:00:00",
}, ensure_ascii=False),
encoding="utf-8",
)
(legacy / "orders").mkdir()
(legacy / "orders" / "order_x.json").write_text("{}", encoding="utf-8")
acc = paper.get_account(tmp_path)
assert acc is not None and acc["cash"] == 750000.0
# 物理迁移到位
assert (tmp_path / "paper" / "accounts" / "default" / "account.json").exists()
assert (tmp_path / "paper" / "accounts" / "default" / "orders" / "order_x.json").exists()
assert not (legacy / "account.json").exists()
# 幂等: 重复访问不报错
assert paper.get_account(tmp_path)["cash"] == 750000.0
+2
View File
@@ -39,6 +39,7 @@ import {
Gauge,
Sparkles,
Layers2,
Wallet,
Layers3,
Zap,
Landmark,
@@ -98,6 +99,7 @@ const nav = [
{ to: '/regime', label: '市场环境', icon: Gauge },
{ to: '/abnormal', label: '异动监控', icon: Siren },
{ to: '/lots', label: '持仓提醒', icon: Layers2 },
{ to: '/paper', label: '模拟盘', icon: Wallet },
{ to: '/signals', label: '信号库', icon: Zap },
{ to: '/review', label: '复盘', icon: BookOpenCheck },
{ to: '/indices', label: '指数', icon: BarChart3 },
+178
View File
@@ -1031,6 +1031,121 @@ export interface Lot {
created_at?: string
}
// ===== Paper (虚拟账户/模拟盘) =====
/** 多账户: 追加 ?account= 查询参数 (缺省账户由后端 default 兜底)。 */
function accUrl(base: string, account?: string): string {
if (!account) return base
return `${base}${base.includes('?') ? '&' : '?'}account=${encodeURIComponent(account)}`
}
export interface PaperAccount {
id: string
name?: string
initial_cash: number
cash: number
commission_pct: number
stamp_tax_pct: number
slippage_bps: number
queue_limit_orders?: boolean
status: 'active' | 'frozen'
created_at: string
}
export interface PaperAccountSummary {
id: string
name: string
status: 'active' | 'frozen'
initial_cash?: number
cash?: number
latest_nav?: number | null
created_at?: string
}
export interface PaperHolding {
symbol: string
asset_type: string
qty: number
avg_cost: number
last_price: number
market_value: number
pnl: number
pnl_pct: number
available_qty: number
}
export interface PaperNavItem {
date: string
cash: number
mv: number
nav: number
benchmark_close?: number
}
export interface PaperOverview {
initialized: boolean
account_id?: string
account_name?: string
status?: 'active' | 'frozen'
queue_limit_orders?: boolean
cash?: number
market_value?: number
total?: number
total_pnl?: number
initial_cash?: number
estimating?: boolean
holdings?: PaperHolding[]
fees?: { commission_pct: number; stamp_tax_pct: number; slippage_bps: number }
}
export interface PaperOrder {
id: string
symbol: string
asset_type: string
side: 'buy' | 'sell'
qty: number
order_type: 'market' | 'next_open' | 'close'
status: 'pending' | 'filled' | 'cancelled' | 'expired'
ref_price?: number | null
postponed: number
source: string
created_at: string
filled_at?: string | null
fill_price?: number | null
fees?: number | null
reason?: string | null
}
export interface PaperAutoRule {
id: string
name: string
match_kind: 'strategy' | 'rule'
match_id: string
side: 'buy' | 'sell'
size_mode: 'fixed_amount' | 'pct_equity'
size_value: number
order_type: 'market' | 'next_open' | 'close'
cooldown_days: number
enabled: boolean
created_at: string
}
export interface PaperFill {
seq: number
ts: string
date: string
order_id: string | null
symbol: string
asset_type: string
side: 'buy' | 'sell' | 'corp_action'
qty?: number
price?: number
fee?: number
kind?: 'fill' | 'corp_action'
factor?: number
qty_before?: number
cost_before?: number
}
export interface VDBasicFilter {
price_min?: number | null // 股价下限 (元)
price_max?: number | null // 股价上限 (元)
@@ -3576,6 +3691,69 @@ export const api = {
lotDelete: (id: string) =>
request<{ ok: boolean }>(`/api/lots/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// ===== Paper (虚拟账户/模拟盘: 虚拟资金 + 真实行情价格模拟撮合, 多账户) =====
paperAccounts: () =>
request<{ accounts: PaperAccountSummary[] }>('/api/paper/accounts'),
paperOverview: (account?: string) =>
request<PaperOverview>(accUrl('/api/paper/overview', account)),
paperCreateAccount: (body: { initial_cash: number; account_id?: string; name?: string; commission_pct?: number; stamp_tax_pct?: number; slippage_bps?: number; queue_limit_orders?: boolean }) =>
request<{ account: PaperAccount }>('/api/paper/account', {
method: 'POST',
body: JSON.stringify(body),
}),
paperSettings: (body: { queue_limit_orders?: boolean }, account?: string) =>
request<{ account: PaperAccount }>(accUrl('/api/paper/settings', account), {
method: 'POST',
body: JSON.stringify(body),
}),
paperOrderCreate: (body: { symbol: string; side: 'buy' | 'sell'; qty?: number; amount?: number; order_type: 'market' | 'next_open' | 'close'; ref_price?: number }, account?: string) =>
request<{ order: PaperOrder }>(accUrl('/api/paper/orders', account), {
method: 'POST',
body: JSON.stringify(body),
}),
paperOrders: (status?: string, account?: string) => {
const p = new URLSearchParams()
if (status) p.set('status', status)
if (account) p.set('account', account)
const q = p.toString()
return request<{ orders: PaperOrder[] }>(`/api/paper/orders${q ? `?${q}` : ''}`)
},
paperOrderCancel: (id: string, account?: string) =>
request<{ order: PaperOrder }>(accUrl(`/api/paper/orders/${encodeURIComponent(id)}`, account), { method: 'DELETE' }),
paperTrades: (account?: string) =>
request<{ fills: PaperFill[] }>(accUrl('/api/paper/trades', account)),
paperNav: (account?: string) =>
request<{ nav: Array<{ date: string; cash: number; mv: number; nav: number }> }>(accUrl('/api/paper/nav', account)),
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)),
paperFreeze: (frozen: boolean, account?: string) =>
request<{ account: PaperAccount }>(accUrl('/api/paper/freeze?frozen=' + frozen, account), { method: 'POST' }),
paperAutoRules: (account?: string) =>
request<{ rules: PaperAutoRule[] }>(accUrl('/api/paper/auto_rules', account)),
paperAutoRuleCreate: (body: Omit<PaperAutoRule, 'id' | 'created_at'>, account?: string) =>
request<{ rule: PaperAutoRule }>(accUrl('/api/paper/auto_rules', account), {
method: 'POST',
body: JSON.stringify(body),
}),
paperAutoRuleSetEnabled: (id: string, enabled: boolean, account?: string) =>
request<{ rule: PaperAutoRule }>(accUrl(`/api/paper/auto_rules/${encodeURIComponent(id)}/enabled?enabled=${enabled}`, account), { method: 'POST' }),
paperAutoRuleDelete: (id: string, account?: string) =>
request<{ ok: boolean }>(accUrl(`/api/paper/auto_rules/${encodeURIComponent(id)}`, account), { method: 'DELETE' }),
/** 模拟触发 ladder 封单监控 (Dev 调试, 不落盘不推送) */
monitorRuleTestLadder: () =>
request<{
+10
View File
@@ -104,6 +104,16 @@ export const QK = {
monitorRuleOptions: ['monitor-rule-options'] as const,
lots: ['lots'] as const,
lotsKline: (symbols: string) => ['lots-kline', symbols] as const,
// 模拟盘 (多账户: 键按账户隔离; paperAll 作账户无关失效前缀)
paperAll: ['paper'] as const,
paperAccounts: ['paper', 'accounts'] as const,
paperOverview: (acc: string) => ['paper', 'overview', acc] as const,
paperOrders: (acc: string) => ['paper', 'orders', acc] as const,
paperTrades: (acc: string) => ['paper', 'trades', acc] as const,
paperNav: (acc: string) => ['paper', 'nav', acc] as const,
paperStats: (acc: string) => ['paper', 'stats', acc] as const,
paperAutoRules: (acc: string) => ['paper', 'auto-rules', acc] as const,
alerts: (source?: string) => ['alerts', source ?? ''] as const,
// AI 大盘复盘
+775
View File
@@ -0,0 +1,775 @@
/**
* 模拟盘 (虚拟账户) — 虚拟资金 + 真实行情价格的模拟撮合, 多账户隔离。
*
* 口径提示: 持仓现价用最近日线收盘价 (收盘定版一致, 盘中估算见设计方案
* docs/paper-trading-plan.md)。费用/滑点参数与回测引擎同名同默认值。
* 多账户: 所有查询按账户隔离 (queryKey 前缀 'paper'), 切换即换一套数据。
*/
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import * as echarts from 'echarts'
import { CircleDollarSign, Plus, X } from 'lucide-react'
import { api, type PaperFill, type PaperOrder } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import { fmtPct, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
const ACC_STORAGE_KEY = 'paper.account'
const ORDER_TYPE_LABEL: Record<string, string> = {
market: '即时',
next_open: '次日开盘',
close: '当日收盘',
}
const STATUS_LABEL: Record<string, string> = {
pending: '待成交',
filled: '已成交',
cancelled: '已撤单',
expired: '已过期',
}
function fmtMoney(v: number | null | undefined, digits = 2) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })
}
/** 账户 id: 字母数字开头短横线/下划线, 与后端校验一致 */
function genAccountId() {
return `acc_${Date.now().toString(36)}${Math.floor(Math.random() * 36).toString(36)}`
}
/** 归一化到首日=100 (基准对比); 空洞(缺行情日)保留 null 不连线 */
function normalizeBase(values: Array<number | null | undefined>): Array<number | null> {
const first = values.find((v): v is number => v != null && v > 0)
if (!first) return values.map(() => null)
return values.map(v => (v != null && v > 0 ? Number(((v / first) * 100).toFixed(2)) : null))
}
/** 净值曲线 (echarts line, 跟随主题色): 账户归一化 vs 沪深300 归一化 (首日=100) */
function NavChart({ nav }: { nav: Array<{ date: string; nav: number; benchmark_close?: number }> }) {
const elRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<echarts.ECharts | null>(null)
useEffect(() => {
if (!elRef.current) return
chartRef.current = echarts.init(elRef.current, undefined, { renderer: 'canvas' })
const onResize = () => chartRef.current?.resize()
window.addEventListener('resize', onResize)
// 跟随亮暗主题切换重设坐标轴配色
const observer = new MutationObserver(() => {
const darkNow = document.documentElement.classList.contains('dark')
const axisColor = darkNow ? '#8E8E96' : '#52525B'
const splitColor = darkNow ? '#353539' : '#E4E4E7'
chartRef.current?.setOption({
xAxis: { axisLine: { lineStyle: { color: splitColor } }, axisLabel: { color: axisColor } },
yAxis: { axisLabel: { color: axisColor }, splitLine: { lineStyle: { color: splitColor } } },
})
})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => {
window.removeEventListener('resize', onResize)
observer.disconnect()
chartRef.current?.dispose()
chartRef.current = null
}
}, [])
useEffect(() => {
const chart = chartRef.current
if (!chart) return
const dark = document.documentElement.classList.contains('dark')
const axisColor = dark ? '#8E8E96' : '#52525B'
const splitColor = dark ? '#353539' : '#E4E4E7'
chart.setOption({
grid: { left: 56, right: 16, top: 16, bottom: 28 },
tooltip: {
trigger: 'axis',
valueFormatter: (v: number) => fmtMoney(v),
},
xAxis: {
type: 'category',
data: nav.map(n => n.date),
axisLine: { lineStyle: { color: splitColor } },
axisLabel: { color: axisColor, fontSize: 10 },
},
yAxis: {
type: 'value',
scale: true,
axisLabel: { color: axisColor, fontSize: 10, formatter: (v: number) => fmtMoney(v, 0) },
splitLine: { lineStyle: { color: splitColor } },
},
series: [
{
name: '虚拟账户',
type: 'line',
data: normalizeBase(nav.map(n => n.nav)),
showSymbol: false,
lineStyle: { color: '#8B5CF6', width: 2 },
areaStyle: { color: 'rgba(139, 92, 246, 0.08)' },
},
...(nav.some(n => n.benchmark_close) ? [{
name: '沪深300',
type: 'line' as const,
data: normalizeBase(nav.map(n => n.benchmark_close)),
showSymbol: false,
lineStyle: { color: dark ? '#8E8E96' : '#52525B', width: 1.5, type: 'dashed' as const },
}] : []),
],
legend: {
top: 0,
right: 0,
textStyle: { color: axisColor, fontSize: 10 },
itemWidth: 14,
},
})
}, [nav])
return <div ref={elRef} className="h-48 w-full" />
}
function StatCard({ label, value, sub, valueClass }: { label: string; value: string; sub?: string; valueClass?: string }) {
return (
<div className="rounded-card border border-border bg-surface px-4 py-3">
<div className="text-[11px] text-muted">{label}</div>
<div className={cn('mt-1 font-mono text-xl font-semibold tabular', valueClass)}>{value}</div>
{sub && <div className="mt-0.5 text-[10px] text-muted">{sub}</div>}
</div>
)
}
/** 初始化向导: 选中账户不存在时展示 (account_id 已由外层定好) */
function SetupCard({ accId, onDone }: { accId: string; onDone: (createdId: string) => void }) {
const [cash, setCash] = useState('1000000')
const [name, setName] = useState('')
const m = useMutation({
mutationFn: () =>
api.paperCreateAccount({
initial_cash: Number(cash),
account_id: accId,
name: name.trim() || undefined,
}),
onSuccess: r => onDone(r.account.id),
})
const valid = Number(cash) > 0
return (
<div className="mx-auto mt-16 max-w-md rounded-card border border-border bg-surface p-6">
<div className="flex items-center gap-2">
<CircleDollarSign className="h-5 w-5 text-accent" />
<h2 className="text-base font-semibold">创建虚拟账户</h2>
</div>
<p className="mt-2 text-xs leading-relaxed text-muted">
虚拟资金 + 真实行情价格模拟撮合, 不涉及任何真实资金。费用口径与回测一致
(佣金万2.5/最低5元、印花税卖出千1、滑点万分之5), 可在创建后调整。
</p>
<label className="mt-4 block text-xs text-secondary">账户名称 (可选)</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="mt-1 w-full rounded-btn border border-border bg-base px-3 py-2 text-sm outline-none focus:border-accent/50"
placeholder={accId === 'default' ? '默认账户' : `账户 ${accId}`}
/>
<label className="mt-3 block text-xs text-secondary">初始虚拟资金 (元)</label>
<input
type="number"
value={cash}
onChange={e => setCash(e.target.value)}
className="mt-1 w-full rounded-btn border border-border bg-base px-3 py-2 font-mono text-sm outline-none focus:border-accent/50"
placeholder="1000000"
/>
<button
onClick={() => valid && m.mutate()}
disabled={!valid || m.isPending}
className="mt-4 w-full rounded-btn bg-accent py-2 text-sm font-medium text-white transition-opacity disabled:opacity-50"
>
{m.isPending ? '创建中…' : '创建账户'}
</button>
{m.isError && <div className="mt-2 text-xs text-danger">{String((m.error as Error).message)}</div>}
</div>
)
}
/** 下单面板 */
function OrderForm({ acc, onDone }: { acc: string; onDone: () => void }) {
const [symbol, setSymbol] = useState('')
const [side, setSide] = useState<'buy' | 'sell'>('buy')
const [qty, setQty] = useState('100')
const [amount, setAmount] = useState('10000')
const [qtyMode, setQtyMode] = useState<'qty' | 'amount'>('qty')
const [orderType, setOrderType] = useState<'market' | 'next_open' | 'close'>('market')
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
const m = useMutation({
mutationFn: () =>
api.paperOrderCreate({
symbol: symbol.trim().toUpperCase(),
side,
...(qtyMode === 'qty' ? { qty: Number(qty) } : { amount: Number(amount) }),
order_type: orderType,
}, acc),
onSuccess: r => {
setMsg({ ok: true, text: `已提交 ${ORDER_TYPE_LABEL[r.order.order_type]}单 ${r.order.side === 'buy' ? '买入' : '卖出'} ${r.order.symbol} x ${r.order.qty}` })
onDone()
},
onError: e => setMsg({ ok: false, text: String((e as Error).message) }),
})
const canSubmit = symbol.trim().length >= 6
&& (qtyMode === 'qty' ? Number(qty) > 0 && Number(qty) % 100 === 0 : Number(amount) > 0)
return (
<div className="rounded-card border border-border bg-surface p-4">
<div className="text-sm font-medium">下单</div>
<div className="mt-3 space-y-3">
<div>
<label className="text-[11px] text-muted">代码 (如 600519.SH)</label>
<input
value={symbol}
onChange={e => setSymbol(e.target.value)}
placeholder="600519.SH"
className="mt-1 w-full rounded-btn border border-border bg-base px-3 py-1.5 font-mono text-sm uppercase outline-none focus:border-accent/50"
/>
</div>
<div className="flex gap-2">
{(['buy', 'sell'] as const).map(s => (
<button
key={s}
onClick={() => setSide(s)}
className={cn(
'flex-1 rounded-btn border py-1.5 text-xs font-medium transition-all duration-150 ease-smooth',
side === s
? s === 'buy'
? 'border-bull/50 bg-bull/10 text-bull shadow-[0_0_0_1px_rgba(240,68,56,0.08)]'
: 'border-bear/50 bg-bear/10 text-bear shadow-[0_0_0_1px_rgba(18,183,106,0.08)]'
: 'border-border text-muted hover:border-border hover:bg-elevated/60 hover:text-secondary',
)}
>
{s === 'buy' ? '买入' : '卖出'}
</button>
))}
</div>
<div>
<div className="flex items-center justify-between">
<label className="text-[11px] text-muted">{qtyMode === 'qty' ? '数量 (股, 100 的整数倍)' : '金额 (元, 按最近收盘价折算)'}</label>
<div className="flex gap-1 text-[10px]">
{(['qty', 'amount'] as const).map(mo => (
<button
key={mo}
onClick={() => setQtyMode(mo)}
className={cn('rounded px-1.5 py-px transition-colors', qtyMode === mo ? 'bg-elevated text-foreground' : 'text-muted hover:text-secondary')}
>
{mo === 'qty' ? '按数量' : '按金额'}
</button>
))}
</div>
</div>
<input
type="number"
value={qtyMode === 'qty' ? qty : amount}
onChange={e => (qtyMode === 'qty' ? setQty(e.target.value) : setAmount(e.target.value))}
className="mt-1 w-full rounded-btn border border-border bg-base px-3 py-1.5 font-mono text-sm outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="text-[11px] text-muted">订单类型</label>
<div className="mt-1 flex gap-1.5">
{(Object.keys(ORDER_TYPE_LABEL) as Array<'market' | 'next_open' | 'close'>).map(t => (
<button
key={t}
onClick={() => setOrderType(t)}
title={t === 'market' ? '盘中按最新快照价成交; ETF 即时单自动转次日开盘' : undefined}
className={cn(
'flex-1 rounded-btn border py-1 text-[11px] transition-colors',
orderType === t ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border text-muted hover:text-secondary',
)}
>
{ORDER_TYPE_LABEL[t]}
</button>
))}
</div>
</div>
<button
onClick={() => canSubmit && m.mutate()}
disabled={!canSubmit || m.isPending}
className={cn(
'w-full rounded-btn py-2 text-sm font-medium text-white transition-all duration-150 ease-smooth disabled:opacity-40 disabled:cursor-not-allowed',
side === 'buy' ? 'bg-bull hover:bg-bull/85' : 'bg-bear hover:bg-bear/85',
)}
>
{m.isPending ? '提交中…' : `${side === 'buy' ? '买入' : '卖出'} ${symbol.trim().toUpperCase() || ''}`}
</button>
{msg && (
<div className={cn(
'rounded-btn border px-2 py-1.5 text-[11px]',
msg.ok
? side === 'buy' ? 'border-bull/20 bg-bull/[0.06] text-bull' : 'border-bear/20 bg-bear/[0.06] text-bear'
: 'border-danger/20 bg-danger/[0.06] text-danger',
)}>
{msg.text}
</div>
)}
</div>
</div>
)
}
/** 自动跟单规则列表 + 新建表单 (V2): 监控事件 → 自动下单 (按账户隔离) */
function AutoRulesPanel({ acc }: { acc: string }) {
const qc = useQueryClient()
const rulesQ = useQuery({ queryKey: QK.paperAutoRules(acc), queryFn: () => api.paperAutoRules(acc) })
const rules = rulesQ.data?.rules ?? []
const [creating, setCreating] = useState(false)
const invalidate = () => qc.invalidateQueries({ queryKey: QK.paperAutoRules(acc) })
const toggleM = useMutation({
mutationFn: (args: { id: string; enabled: boolean }) => api.paperAutoRuleSetEnabled(args.id, args.enabled, acc),
onSuccess: invalidate,
})
const deleteM = useMutation({
mutationFn: (id: string) => api.paperAutoRuleDelete(id, acc),
onSuccess: invalidate,
})
if (creating) {
return <AutoRuleForm acc={acc} onDone={() => { setCreating(false); invalidate() }} onCancel={() => setCreating(false)} />
}
return (
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">自动跟单规则</div>
<button onClick={() => setCreating(true)} className="flex items-center gap-1 rounded-btn bg-accent/10 px-2.5 py-1 text-xs text-accent transition-colors hover:bg-accent/20">
<Plus className="h-3 w-3" /> 新建规则
</button>
</div>
<div className="mt-1 text-[11px] leading-relaxed text-muted">
监控规则/策略触发时自动按规则下单 (默认次日开盘成交), 同标的 {`冷却 N 天`}内不重复触发
</div>
{rules.length === 0 ? (
<div className="py-8 text-center text-xs text-muted">暂无规则 — 新建一条, 让策略信号自动进入模拟盘</div>
) : (
<div className="mt-2 space-y-1">
{rules.map(r => (
<div key={r.id} className="flex items-center gap-2 rounded-btn px-2 py-1.5 text-xs transition-colors hover:bg-elevated/40">
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', r.enabled ? 'bg-bear' : 'bg-muted')} />
<span className="w-28 shrink-0 truncate font-medium">{r.name}</span>
<span className="w-20 shrink-0 text-muted">{r.match_kind === 'strategy' ? '跟策略' : '跟规则'}</span>
<span className="w-32 shrink-0 truncate font-mono text-[11px] text-muted" title={r.match_id}>{r.match_id}</span>
<span className={cn('w-8 shrink-0 font-medium', r.side === 'buy' ? 'text-bull' : 'text-bear')}>{r.side === 'buy' ? '买' : '卖'}</span>
<span className="w-24 shrink-0 font-mono text-[11px] text-muted">
{r.size_mode === 'fixed_amount' ? fmtMoney(r.size_value, 0) : `${r.size_value}% 权益`}
</span>
<span className="w-16 shrink-0 text-[11px] text-muted">{ORDER_TYPE_LABEL[r.order_type]} · 冷却{r.cooldown_days}天</span>
<button
onClick={() => toggleM.mutate({ id: r.id, enabled: !r.enabled })}
className={cn('ml-auto shrink-0 rounded-btn px-2 py-0.5 text-[10px] transition-colors',
r.enabled ? 'bg-bear/10 text-bear hover:bg-bear/20' : 'bg-elevated text-muted hover:text-secondary')}
>
{r.enabled ? '停用' : '启用'}
</button>
<button onClick={() => deleteM.mutate(r.id)} className="shrink-0 rounded p-0.5 text-muted hover:text-danger" title="删除规则">
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)
}
function AutoRuleForm({ acc, onDone, onCancel }: { acc: string; onDone: () => void; onCancel: () => void }) {
const [name, setName] = useState('')
const [matchKind, setMatchKind] = useState<'strategy' | 'rule'>('strategy')
const [matchId, setMatchId] = useState('')
const [side, setSide] = useState<'buy' | 'sell'>('buy')
const [sizeMode, setSizeMode] = useState<'fixed_amount' | 'pct_equity'>('fixed_amount')
const [sizeValue, setSizeValue] = useState('10000')
const [orderType, setOrderType] = useState<'market' | 'next_open' | 'close'>('next_open')
const [cooldown, setCooldown] = useState('5')
const [msg, setMsg] = useState<string | null>(null)
const m = useMutation({
mutationFn: () =>
api.paperAutoRuleCreate({
name: name.trim(),
match_kind: matchKind,
match_id: matchId.trim(),
side,
size_mode: sizeMode,
size_value: Number(sizeValue),
order_type: orderType,
cooldown_days: Number(cooldown),
enabled: true,
}, acc),
onSuccess: onDone,
onError: e => setMsg(String((e as Error).message)),
})
const valid = name.trim() && matchId.trim() && Number(sizeValue) > 0
return (
<div className="rounded-card border border-border bg-surface p-4">
<div className="text-sm font-medium">新建自动跟单规则</div>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="text-[11px] text-muted">规则名称</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="如: 跟单前高突破"
className="mt-1 w-full rounded-btn border border-border bg-base px-3 py-1.5 text-sm outline-none focus:border-accent/50" />
</div>
<div>
<label className="text-[11px] text-muted">跟什么</label>
<div className="mt-1 flex gap-1.5">
{(['strategy', 'rule'] as const).map(k => (
<button key={k} onClick={() => setMatchKind(k)}
className={cn('flex-1 rounded-btn border py-1 text-[11px] transition-colors',
matchKind === k ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border text-muted hover:text-secondary')}>
{k === 'strategy' ? '跟策略' : '跟监控规则'}
</button>
))}
</div>
</div>
<div className="md:col-span-2">
<label className="text-[11px] text-muted">{matchKind === 'strategy' ? '策略 ID (策略页可见)' : '监控规则 ID'}</label>
<input value={matchId} onChange={e => setMatchId(e.target.value)} placeholder={matchKind === 'strategy' ? 'strategy id' : 'rule id'}
className="mt-1 w-full rounded-btn border border-border bg-base px-3 py-1.5 font-mono text-sm outline-none focus:border-accent/50" />
</div>
<div>
<label className="text-[11px] text-muted">方向</label>
<div className="mt-1 flex gap-1.5">
{(['buy', 'sell'] as const).map(sd => (
<button key={sd} onClick={() => setSide(sd)}
className={cn('flex-1 rounded-btn border py-1 text-[11px] font-medium transition-colors',
side === sd ? (sd === 'buy' ? 'border-bull/50 bg-bull/10 text-bull' : 'border-bear/50 bg-bear/10 text-bear') : 'border-border text-muted')}>
{sd === 'buy' ? '买入' : '卖出'}
</button>
))}
</div>
</div>
<div>
<label className="text-[11px] text-muted">仓位</label>
<div className="mt-1 flex gap-1.5">
<button onClick={() => setSizeMode('fixed_amount')}
className={cn('flex-1 rounded-btn border py-1 text-[11px] transition-colors', sizeMode === 'fixed_amount' ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border text-muted')}>固定金额</button>
<button onClick={() => setSizeMode('pct_equity')}
className={cn('flex-1 rounded-btn border py-1 text-[11px] transition-colors', sizeMode === 'pct_equity' ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border text-muted')}>权益 %</button>
</div>
</div>
<div>
<label className="text-[11px] text-muted">{sizeMode === 'fixed_amount' ? '每笔金额 (元)' : '每笔占权益 %'}</label>
<input type="number" value={sizeValue} onChange={e => setSizeValue(e.target.value)}
className="mt-1 w-full rounded-btn border border-border bg-base px-3 py-1.5 font-mono text-sm outline-none focus:border-accent/50" />
</div>
<div>
<label className="text-[11px] text-muted">订单类型</label>
<div className="mt-1 flex gap-1.5">
{(['next_open', 'close', 'market'] as const).map(t => (
<button key={t} onClick={() => setOrderType(t)}
className={cn('flex-1 rounded-btn border py-1 text-[11px] transition-colors',
orderType === t ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border text-muted hover:text-secondary')}>
{ORDER_TYPE_LABEL[t]}
</button>
))}
</div>
</div>
<div>
<label className="text-[11px] text-muted">冷却天数 (同标的)</label>
<input type="number" value={cooldown} onChange={e => setCooldown(e.target.value)}
className="mt-1 w-full rounded-btn border border-border bg-base px-3 py-1.5 font-mono text-sm outline-none focus:border-accent/50" />
</div>
</div>
<div className="mt-3 flex gap-2">
<button onClick={() => valid && m.mutate()} disabled={!valid || m.isPending}
className="flex-1 rounded-btn bg-accent py-2 text-sm font-medium text-white transition-opacity disabled:opacity-40">
{m.isPending ? '创建中…' : '创建规则'}
</button>
<button onClick={onCancel} className="rounded-btn border border-border px-4 py-2 text-sm text-secondary transition-colors hover:text-foreground">取消</button>
</div>
{msg && <div className="mt-2 rounded-btn bg-danger/10 px-2 py-1.5 text-[11px] text-danger">{msg}</div>}
</div>
)
}
export function Paper() {
const qc = useQueryClient()
const [tab, setTab] = useState<'orders' | 'trades'>('orders')
const [accId, setAccIdState] = useState(() => localStorage.getItem(ACC_STORAGE_KEY) || 'default')
const [creatingNew, setCreatingNew] = useState(false)
const setAccId = (id: string) => {
localStorage.setItem(ACC_STORAGE_KEY, id)
setAccIdState(id)
}
const accountsQ = useQuery({ queryKey: QK.paperAccounts, queryFn: api.paperAccounts })
const overviewQ = useQuery({ queryKey: QK.paperOverview(accId), queryFn: () => api.paperOverview(accId) })
const ordersQ = useQuery({ queryKey: QK.paperOrders(accId), queryFn: () => api.paperOrders(undefined, accId) })
const tradesQ = useQuery({ queryKey: QK.paperTrades(accId), queryFn: () => api.paperTrades(accId) })
const navQ = useQuery({ queryKey: QK.paperNav(accId), queryFn: () => api.paperNav(accId) })
const statsQ = useQuery({ queryKey: QK.paperStats(accId), queryFn: () => api.paperStats(accId) })
// 'paper' 前缀兜底失效: 覆盖全部账户的全部查询 (订单变动可能影响净值/统计)
const invalidateAll = () => qc.invalidateQueries({ queryKey: QK.paperAll })
const cancelM = useMutation({
mutationFn: (id: string) => api.paperOrderCancel(id, accId),
onSuccess: invalidateAll,
})
const queueM = useMutation({
mutationFn: (on: boolean) => api.paperSettings({ queue_limit_orders: on }, accId),
onSuccess: invalidateAll,
})
if (overviewQ.isLoading) {
return <div className="p-5 text-sm text-muted">加载中…</div>
}
const ov = overviewQ.data
if (creatingNew || !ov?.initialized) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<PageHeader title="模拟盘" subtitle="虚拟账户 · 用假钱验证你的策略" />
<div className="flex-1 overflow-y-auto">
<SetupCard
accId={accId}
onDone={createdId => {
setAccId(createdId)
setCreatingNew(false)
invalidateAll()
}}
/>
</div>
</div>
)
}
const holdings = ov.holdings ?? []
const orders = (ordersQ.data?.orders ?? []).filter(o => o.status === 'pending' || o.status === 'expired' || o.status === 'cancelled').slice(0, 20)
const trades: PaperFill[] = (tradesQ.data?.fills ?? []).slice(0, 30)
const nav = navQ.data?.nav ?? []
const stats = statsQ.data
const pnlPct = ov.initial_cash && ov.initial_cash > 0 ? ((ov.total_pnl ?? 0) / ov.initial_cash) * 100 : 0
const accounts = accountsQ.data?.accounts ?? []
return (
<div className="flex min-h-0 flex-1 flex-col">
<PageHeader
title="模拟盘"
subtitle="虚拟账户 · 用假钱验证你的策略"
titleExtra={
<>
{ov.status === 'frozen' && <span className="rounded-full bg-warning/15 px-2 py-0.5 text-[10px] text-warning">已冻结</span>}
{ov.queue_limit_orders && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] text-accent" title="触及涨跌停不直接拒单, 转次日开盘重试 (最多顺延 3 日)">
涨跌停排队
</span>
)}
</>
}
right={
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<select
value={accId}
onChange={e => setAccId(e.target.value)}
className="max-w-36 rounded-btn border border-border bg-surface px-2 py-1 text-xs outline-none focus:border-accent/50"
title="切换虚拟账户 (数据相互隔离)"
>
{(accounts.some(a => a.id === accId) ? accounts : [...accounts, { id: accId, name: accId }]).map(a => (
<option key={a.id} value={a.id}>{a.name || a.id}</option>
))}
</select>
<button
onClick={() => setAccId(genAccountId())}
className="flex items-center gap-0.5 rounded-btn border border-border px-1.5 py-1 text-[11px] text-muted transition-colors hover:border-accent/40 hover:text-accent"
title="新建虚拟账户"
>
<Plus className="h-3 w-3" />
</button>
</div>
<span className="text-[11px] text-muted" title={`佣金 ${((ov.fees?.commission_pct ?? 0) * 10000).toFixed(1)}‱ (最低5元) · 印花税 ${((ov.fees?.stamp_tax_pct ?? 0) * 1000).toFixed(1)}‰ 仅卖出 · 滑点 ${ov.fees?.slippage_bps ?? 0}bps`}>
佣金 {((ov.fees?.commission_pct ?? 0) * 10000).toFixed(1)}‱ · 印花税 {((ov.fees?.stamp_tax_pct ?? 0) * 1000).toFixed(1)}‰ · 滑点 {ov.fees?.slippage_bps ?? 0}bps
</span>
<button
onClick={() => api.paperFreeze(ov.status !== 'frozen', accId).then(invalidateAll)}
className="rounded-btn border border-border px-2 py-0.5 text-[11px] text-muted transition-colors hover:border-warning/40 hover:text-warning"
>
{ov.status === 'frozen' ? '解冻账户' : '冻结账户'}
</button>
</div>
}
/>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{/* 总览卡片 */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="总资产 (虚拟)" value={fmtMoney(ov.total)} />
<StatCard label="现金" value={fmtMoney(ov.cash)} />
<StatCard label="持仓市值" value={fmtMoney(ov.market_value)} />
<StatCard
label="累计盈亏"
value={`${(ov.total_pnl ?? 0) >= 0 ? '+' : ''}${fmtMoney(ov.total_pnl)} (${fmtPct(pnlPct / 100)})`}
valueClass={priceColorClass((ov.total_pnl ?? 0) / (ov.initial_cash || 1))}
/>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[1fr_320px]">
{/* 左列: 净值 + 持仓 + 流水 */}
<div className="min-w-0 space-y-4">
<div className="rounded-card border border-border bg-surface p-4">
<div className="text-sm font-medium">净值曲线 <span className="ml-1 text-[10px] text-muted">按交易日收盘定版</span></div>
{nav.length > 0 ? <NavChart nav={nav} /> : <div className="py-10 text-center text-xs text-muted">暂无定版净值 — 交易日盘后管道自动结算</div>}
</div>
<div className="rounded-card border border-border bg-surface p-4">
<div className="text-sm font-medium">当前持仓</div>
{holdings.length === 0 ? (
<div className="py-8 text-center text-xs text-muted">暂无持仓 — 右侧下单或等自动跟单触发</div>
) : (
<table className="mt-2 w-full text-xs">
<thead className="sticky top-0 bg-surface">
<tr className="text-left text-[10px] text-muted">
<th className="py-1.5 font-normal">代码</th>
<th className="py-1.5 text-right font-normal">数量</th>
<th className="py-1.5 text-right font-normal">可卖(T+1)</th>
<th className="py-1.5 text-right font-normal">成本</th>
<th className="py-1.5 text-right font-normal">现价</th>
<th className="py-1.5 text-right font-normal">市值</th>
<th className="py-1.5 text-right font-normal">盈亏</th>
</tr>
</thead>
<tbody className="font-mono">
{holdings.map(h => (
<tr key={h.symbol} className="border-t border-border/50 transition-colors hover:bg-elevated/40">
<td className="py-1.5 font-sans">{h.symbol}</td>
<td className="py-1.5 text-right">{h.qty}</td>
<td className="py-1.5 text-right text-muted">{h.available_qty}</td>
<td className="py-1.5 text-right">{fmtMoney(h.avg_cost, 3)}</td>
<td className="py-1.5 text-right">{fmtMoney(h.last_price, 3)}</td>
<td className="py-1.5 text-right">{fmtMoney(h.market_value, 0)}</td>
<td className={cn('py-1.5 text-right', priceColorClass(h.pnl))}>
{(h.pnl >= 0 ? '+' : '') + fmtMoney(h.pnl, 0)} ({fmtPct(h.pnl_pct / 100)})
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* 订单 / 成交流水 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center gap-2">
{(['orders', 'trades'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={cn('rounded-btn px-2.5 py-1 text-xs transition-colors', tab === t ? 'bg-elevated text-foreground' : 'text-muted hover:text-secondary')}
>
{t === 'orders' ? '订单' : '成交台账'}
</button>
))}
{stats && (
<span className="ml-auto text-[11px] text-muted" title="回合为 FIFO 配对的完整买卖; 回撤按定版净值序列">
回合 {stats.rounds} · 胜率 {stats.win_rate}% · 盈亏比 {stats.profit_loss_ratio ?? '--'} · 均持 {stats.avg_holding_days}天 · 回撤{' '}
{stats.max_drawdown != null ? `-${stats.max_drawdown}%` : '--'} ·{' '}
<span className={cn('font-mono', priceColorClass(stats.realized_pnl))}>
已实现 {stats.realized_pnl >= 0 ? '+' : ''}{fmtMoney(stats.realized_pnl, 0)}
</span>
</span>
)}
</div>
{tab === 'orders' ? (
<div className="mt-2 space-y-1">
{orders.length === 0 && <div className="py-6 text-center text-xs text-muted">暂无订单</div>}
{orders.map((o: PaperOrder) => (
<div key={o.id} className="flex items-center gap-2 rounded-btn px-2 py-1.5 text-xs hover:bg-elevated/50">
<span className={cn('w-8 shrink-0 font-medium', o.side === 'buy' ? 'text-bull' : 'text-bear')}>
{o.side === 'buy' ? '买入' : '卖出'}
</span>
<span className="w-24 shrink-0 font-mono">{o.symbol}</span>
<span className="w-16 shrink-0 font-mono text-right">{o.qty}</span>
<span className="w-16 shrink-0 text-muted">{ORDER_TYPE_LABEL[o.order_type]}</span>
<span className={cn(
'w-16 shrink-0 rounded-full px-1.5 py-px text-center text-[10px] leading-4',
o.status === 'filled' ? 'bg-bear/10 text-bear'
: o.status === 'expired' ? 'bg-warning/10 text-warning'
: o.status === 'cancelled' ? 'bg-elevated text-muted'
: 'bg-accent/10 text-accent',
)}>
{STATUS_LABEL[o.status]}
</span>
{o.status === 'pending' && o.postponed > 0 && (
<span className="shrink-0 rounded-full bg-accent/10 px-1.5 py-px text-[10px] leading-4 text-accent" title={`涨跌停排队重试中, 已顺延 ${o.postponed}/${3} 日`}>
排队 {o.postponed}/3
</span>
)}
<span className="min-w-0 flex-1 truncate text-[11px] text-muted" title={o.reason ?? undefined}>
{o.status === 'filled' && o.fill_price != null ? `@ ${fmtMoney(o.fill_price, 3)}` : o.reason ?? ''}
</span>
{o.status === 'pending' && (
<button onClick={() => cancelM.mutate(o.id)} className="shrink-0 rounded p-0.5 text-muted hover:text-foreground" title="撤单">
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
) : (
<div className="mt-2 space-y-1">
{trades.length === 0 && <div className="py-6 text-center text-xs text-muted">暂无成交</div>}
{trades.map((f: PaperFill) => (
<div key={`${f.seq}-${f.order_id ?? 'corp'}`} className="flex items-center gap-2 rounded-btn px-2 py-1.5 font-mono text-xs hover:bg-elevated/50">
<span className="w-14 shrink-0 text-muted">{f.date}</span>
{f.kind === 'corp_action' ? (
<span className="text-accent">除权 ×{f.factor} ({f.symbol}: {f.qty_before} → )</span>
) : (
<>
<span className={cn('w-8 shrink-0 font-sans font-medium', f.side === 'buy' ? 'text-bull' : 'text-bear')}>
{f.side === 'buy' ? '买入' : '卖出'}
</span>
<span className="w-24 shrink-0">{f.symbol}</span>
<span className="w-16 shrink-0 text-right">{f.qty}</span>
<span className="w-20 shrink-0 text-right">@ {fmtMoney(f.price, 3)}</span>
<span className="w-16 shrink-0 text-right text-muted">费 {fmtMoney(f.fee)}</span>
</>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* 右列: 下单 + 自动跟单 */}
<div className="space-y-4">
<OrderForm acc={accId} onDone={invalidateAll} />
<AutoRulesPanel acc={accId} />
<div className="rounded-card border border-border/60 bg-base/40 p-3 text-[11px] leading-relaxed text-muted">
<div className="font-medium text-secondary">撮合口径</div>
<div className="mt-1">· 即时单: 交易时段按最新快照价 + 滑点成交</div>
<div>· 次日开盘 / 当日收盘单: 盘后管道按真实开盘/收盘价成交</div>
<div>· T+1: 当日买入次一交易日可卖</div>
<div>· 停牌顺延 3 日自动过期; 除权按因子调整数量与成本, 台账留痕</div>
<div className="mt-2 flex items-center justify-between border-t border-border/40 pt-2">
<span title="开启后, 触及涨停的买入/触及跌停的卖出不再直接过期, 转次日开盘重试 (最多顺延 3 日)">
涨跌停排队次日重试
</span>
<button
onClick={() => queueM.mutate(!ov.queue_limit_orders)}
disabled={queueM.isPending}
className={cn(
'relative h-4 w-8 shrink-0 rounded-full transition-colors',
ov.queue_limit_orders ? 'bg-accent' : 'bg-border',
)}
aria-label="涨跌停排队次日重试开关"
>
<span className={cn(
'absolute top-0.5 h-3 w-3 rounded-full bg-white shadow transition-all',
ov.queue_limit_orders ? 'left-[18px]' : 'left-0.5',
)} />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
+2
View File
@@ -23,6 +23,7 @@ const Financials = lazy(() => import('./pages/Financials').then(m => ({ default:
const Data = lazy(() => import('./pages/Data').then(m => ({ default: m.Data })))
const Monitor = lazy(() => import('./pages/Monitor').then(m => ({ default: m.Monitor })))
const Lots = lazy(() => import('./pages/Lots').then(m => ({ default: m.Lots })))
const Paper = lazy(() => import('./pages/Paper').then(m => ({ default: m.Paper })))
const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })))
const AnalysisDetail = lazy(() => import('./pages/AnalysisDetail').then(m => ({ default: m.AnalysisDetail })))
const ConceptAnalysis = lazy(() => import('./pages/ConceptAnalysis').then(m => ({ default: m.ConceptAnalysis })))
@@ -139,6 +140,7 @@ export const router = createBrowserRouter([
{ path: 'data', element: <Data /> },
{ path: 'monitor', element: <Monitor /> },
{ path: 'lots', element: <Lots /> },
{ path: 'paper', element: <Paper /> },
{ path: 'signals', element: <Signals /> },
{ path: 'limit-ladder', element: <LimitUpLadder /> },
{ path: 'indices', element: <Indices /> },