feat(paper): V3 成交推送(事件走监控SSE/语音/系统通知/留痕/Webhook) + 成交台账导出 CSV

- evaluate_intraday 返回成交事件列表(字段对齐 AlertEvent, source=paper), 域模块只产出不投递
- quote_service 钩子收集事件: SSE toast + alert_store 留痕 + 系统通知(复用既有开关) + 已配置 Webhook 渠道异步投递
- 前端: AlertToast 模拟盘徽章、语音播报 paper 文案分支、模拟盘页成交台账一键导出 CSV(BOM)
- 测试: evaluate_intraday 契约更新(int→list) + 新增事件字段断言, 41 项全过
This commit is contained in:
shy3130
2026-09-24 17:35:56 +08:00
parent dbb2e6dd14
commit 28bd46df2a
6 changed files with 142 additions and 18 deletions
+52 -1
View File
@@ -45,6 +45,7 @@ SOURCE_LABELS = {
"strategy": "策略", "signal": "信号", "price": "价格",
"market": "异动", "ladder": "连板梯队", "sector": "板块",
"volume_delta": "放量", "abnormal": "异动", "date": "日期提醒",
"paper": "模拟盘",
}
# final 定版确认容差: 快照时间戳允许早于边界 5s 内 (供应商时间戳精度不一)
@@ -1332,10 +1333,23 @@ class QuoteService:
enriched_today["raw_close"].to_list(),
strict=False,
))
paper_events: list[dict] = []
for acc_id in paper_trading.list_account_ids(data_dir):
paper_trading.evaluate_intraday(data_dir, snapshot, account_id=acc_id)
paper_events.extend(
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)
# 成交推送 (V3): 复用监控中心既有管道 —— SSE toast / 语音 (前端按
# source 拼文案) / 系统通知 / alert_store 留痕 / Webhook。全部静默降级。
if paper_events:
self._broadcast_alerts(paper_events)
try:
from app.services import alert_store
alert_store.append_many(data_dir, paper_events)
except Exception as e: # noqa: BLE001
logger.warning("模拟盘成交留痕失败: %s", e)
self._maybe_send_system_notifications(paper_events)
self._maybe_send_paper_webhook(paper_events)
except Exception as e:
logger.warning("模拟盘钩子失败 (不影响监控): %s", e)
@@ -1683,6 +1697,43 @@ class QuoteService:
except Exception as e: # noqa: BLE001
logger.warning("Webhook 提交异常 (不影响告警主流程): %s", e)
def _maybe_send_paper_webhook(self, events: list[dict]) -> None:
"""模拟盘成交 → 已配置的飞书/企业微信/自定义 Webhook (异步投递, 失败静默)。
与监控规则的按规则勾选不同: 成交事件无规则载体, 渠道地址已配置即投递。
模拟盘成交低频 (手动 + cooldown 规则), 刷屏风险低。
"""
try:
from app import secrets_store
from app.services import preferences, webhook_adapter
feishu_url = preferences.get_feishu_webhook_url()
feishu_secret = preferences.get_feishu_webhook_secret()
wecom_url = preferences.get_wecom_webhook_url()
custom_url = preferences.get_custom_webhook_url()
custom_secret = secrets_store.get_custom_webhook_secret()
if not any((feishu_url, wecom_url, custom_url)):
return
enqueued = 0
for ev in events:
body = f"{ev.get('symbol', '')} {ev.get('message', '')}".strip()
if feishu_url:
_WEBHOOK_EXECUTOR.submit(
webhook_adapter.send_feishu, feishu_url, "模拟盘成交", body, feishu_secret)
enqueued += 1
if wecom_url:
_WEBHOOK_EXECUTOR.submit(webhook_adapter.send_wecom, wecom_url, "模拟盘成交", body)
enqueued += 1
if custom_url:
_WEBHOOK_EXECUTOR.submit(
webhook_adapter.send_custom, custom_url, "模拟盘成交", body, "paper_fill", ev, custom_secret)
enqueued += 1
if enqueued:
logger.info("模拟盘 Webhook 已提交 %d 条 (异步投递, 失败记 WARNING)", enqueued)
except Exception as e: # noqa: BLE001
logger.warning("模拟盘 Webhook 提交异常 (不影响主流程): %s", e)
def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None:
"""把告警转发到操作系统通知中心 (由 preferences 开关控制)。
+28 -6
View File
@@ -713,13 +713,34 @@ def _queue_or_expire(data_dir: Path, order: dict, acc: dict, reason: str, accoun
_expire(data_dir, order, reason, account_id)
def evaluate_intraday(data_dir: Path, snapshot: dict[str, float], account_id: str = DEFAULT_ACCOUNT_ID) -> int:
"""盘中钩子: 用最新快照价撮合 pending 即时单。返回成交笔数。
def _fill_event(fill: dict, account_id: str) -> dict:
"""成交台账行 → 推送事件。字段对齐监控告警 (AlertEvent), 供 SSE toast /
语音播报 / 系统通知 / alert_store 留痕 / Webhook 复用, 前端无需新管道。
ts 用 fill.seq (毫秒), 与 alert_store 的 ts 口径一致; 文案用中文可读措辞。
"""
side_label = "买入" if fill["side"] == "buy" else "卖出"
return {
"source": "paper",
"type": "fill",
"severity": "info",
"ts": int(fill["seq"]),
"symbol": fill["symbol"],
"side": fill["side"],
"qty": fill["qty"],
"price": fill["price"],
"account_id": account_id,
"message": f"模拟盘{side_label}成交 {fill['qty']}股 @ {fill['price']:.2f}",
}
def evaluate_intraday(data_dir: Path, snapshot: dict[str, float], account_id: str = DEFAULT_ACCOUNT_ID) -> list[dict]:
"""盘中钩子: 用最新快照价撮合 pending 即时单。返回本次成交事件列表。
snapshot: {symbol: raw 最新价} (来自 enriched raw_close)。仅处理 market 单;
调用方 (quote_service) 已保证交易时段。ETF 不在快照内自然顺延。
事件由调用方广播 (SSE/语音/系统通知/留痕/Webhook), 域模块只产出不投递。
"""
filled = 0
events: list[dict] = []
with PAPER_LOCK:
today = cn_today().isoformat()
pending_orders = sorted(
@@ -730,9 +751,10 @@ def evaluate_intraday(data_dir: Path, snapshot: dict[str, float], account_id: st
price = snapshot.get(order["symbol"])
if price is None or price <= 0:
continue # 无快照顺延
if _fill_order(data_dir, order, float(price), today, account_id) is not None:
filled += 1
return filled
fill = _fill_order(data_dir, order, float(price), today, account_id)
if fill is not None:
events.append(_fill_event(fill, account_id))
return events
# ── 行情读取 (结算用, 与账户无关) ───────────────────────
+18 -11
View File
@@ -118,7 +118,7 @@ def test_sell_requires_holding_and_t1(tmp_path, monkeypatch):
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
assert len(paper.evaluate_intraday(tmp_path, {SYM: 10.0})) == 1
# T+1: 当日买入当日不可卖
_, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
assert "可卖数量不足" in err
@@ -150,8 +150,15 @@ def test_immediate_fill_cash_and_positions_consistent(tmp_path, monkeypatch):
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
events = paper.evaluate_intraday(tmp_path, {SYM: 10.0, "000002.SZ": 5.0})
assert len(events) == 1
# V3 成交事件: 字段对齐监控告警 (source=paper), ts 为毫秒, 文案中文可读
ev = events[0]
assert ev["source"] == "paper" and ev["type"] == "fill" and ev["severity"] == "info"
assert ev["symbol"] == SYM and ev["side"] == "buy" and ev["qty"] == 1000
assert ev["price"] == pytest.approx(10.005)
assert isinstance(ev["ts"], int) and ev["ts"] > 0
assert "买入成交" in ev["message"] and "1000股" in ev["message"]
filled = paper.get_order(tmp_path, order["id"])
# 滑点向上: 10 * 1.0005
assert filled["fill_price"] == pytest.approx(10.005)
@@ -175,7 +182,7 @@ def test_limit_up_buy_rejected(tmp_path, monkeypatch):
_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
assert paper.evaluate_intraday(tmp_path, {SYM: 11.0}) == []
got = paper.get_order(tmp_path, order["id"])
assert got["status"] == "expired"
assert "涨停" in got["reason"]
@@ -194,7 +201,7 @@ def test_limit_down_sell_rejected(tmp_path, monkeypatch):
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
assert paper.evaluate_intraday(tmp_path, {SYM: 9.0}) == []
got = paper.get_order(tmp_path, sell["id"])
assert got["status"] == "expired" and "跌停" in got["reason"]
@@ -230,7 +237,7 @@ def test_pending_sell_occupies_available_quota(tmp_path, monkeypatch):
_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
assert len(paper.evaluate_intraday(tmp_path, {SYM: 10.0})) == 1
# 次日: 可卖 200
monkeypatch.setattr(paper, "cn_today", lambda: day + timedelta(days=1))
paper._materialize(tmp_path)
@@ -505,7 +512,7 @@ def test_limit_queue_retries_next_open_then_expires(tmp_path, monkeypatch):
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
assert paper.evaluate_intraday(tmp_path, {SYM: 11.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"]
@@ -533,7 +540,7 @@ def test_limit_queue_fills_when_open_below_limit(tmp_path, monkeypatch):
(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.evaluate_intraday(tmp_path, {SYM: 11.0}) == [] # 涨停排队
assert paper.get_order(tmp_path, order["id"])["status"] == "pending"
summary = paper.settle_day(tmp_path, day.isoformat())
assert summary["filled"] == 1
@@ -551,12 +558,12 @@ def test_multi_account_isolation(tmp_path, monkeypatch):
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 # 无订单
assert paper.evaluate_intraday(tmp_path, {SYM: 10.0}, account_id="default") == [] # 无订单
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 len(paper.evaluate_intraday(tmp_path, {SYM: 10.0}, account_id="default")) == 1
assert len(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"]]
+1
View File
@@ -97,6 +97,7 @@ const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
sell_signal: { label: '卖出', cls: 'bg-bear/15 text-bear' },
new_entry: { label: '进入', cls: 'bg-danger/15 text-danger' },
dropped: { label: '移出', cls: 'bg-bear/15 text-bear' },
paper: { label: '模拟盘', cls: 'bg-sky-400/15 text-sky-500 dark:text-sky-300' },
}
// ===== 容器 — 挂在 Layout =====
+7
View File
@@ -143,6 +143,13 @@ function buildSingleText(a: AlertEvent): string {
return parts.join(' ')
}
// 模拟盘成交: message 已是中文可读文案 ("模拟盘买入成交 1000股 @ 10.01"), 补名称即可
if (a.source === 'paper') {
const parts = [name]
if (a.message) parts.push(a.message)
return parts.join(' ')
}
// 价格/异动/其他: message 是条件摘要 (如 "现价 ≥ 100 · 涨幅 5%")
const parts = [name]
if (a.message) parts.push(a.message)
+36
View File
@@ -543,11 +543,37 @@ export function Paper() {
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 allFills: PaperFill[] = tradesQ.data?.fills ?? []
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 ?? []
/** 导出全部成交台账 CSV (带 BOM, Excel 可直接打开); 口径与页面「成交台账」一致 */
const exportTradesCsv = () => {
if (allFills.length === 0) return
const esc = (v: string | number | null | undefined) => {
const s = v == null ? '' : String(v)
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
const lines = ['日期,代码,方向,数量,成交价,费用,类型,订单号']
for (const f of allFills) {
lines.push([
f.date, f.symbol,
f.kind === 'corp_action' ? '除权' : f.side === 'buy' ? '买入' : '卖出',
f.qty ?? '', f.price ?? '', f.fee ?? '',
f.kind ?? 'fill', f.order_id ?? '',
].map(esc).join(','))
}
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `模拟盘成交台账_${accId}_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<PageHeader
@@ -665,6 +691,16 @@ export function Paper() {
{t === 'orders' ? '订单' : '成交台账'}
</button>
))}
{tab === 'trades' && (
<button
onClick={exportTradesCsv}
disabled={allFills.length === 0}
className="ml-auto rounded-btn border border-border px-2 py-1 text-[10px] text-muted transition-colors hover:border-accent/30 hover:text-accent disabled:cursor-not-allowed disabled:opacity-40"
title="导出全部成交台账 (CSV, Excel 可直接打开)"
>
导出 CSV
</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}天 · 回撤{' '}