mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat(paper): 模拟盘虚拟账户 — 模拟撮合/自动跟单/多账户/涨跌停排队/统计告警
- 撮合域: append-only 成交台账唯一事实源, T+1/涨跌停/停牌顺延/除权调整,
佣金印花税滑点与回测引擎同口径
- 下单: 即时/次日开盘/当日收盘, 盘中快照撮合 + 盘后结算幂等;
涨跌停排队次日重试可选 (账户级开关, 顺延上限 3 日)
- 自动跟单: 监控事件触发按规则下单 (跟策略/跟规则, 固定金额/权益%,
同标的冷却去重), 下单复用撮合域同一把锁与校验
- 多账户: data/paper/accounts/{id}/ 完全隔离, 旧单账户布局自动迁移,
API ?account= 贯穿全部端点, 盘中钩子与盘后结算逐账户执行
- 统计: FIFO 回合/胜率/盈亏比/均持天数/最大回撤; 成交落告警记录
- 前端: 模拟盘页 (净值对比沪深300/持仓/订单/成交台账/下单/规则面板/
账户切换), 买入红卖出绿 A 股配色
- 测试: 域模块 33 项 + API 契约 8 项
This commit is contained in:
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 大盘复盘
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user