diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index f158adc..7bbec0c 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -1684,11 +1684,19 @@ def get_paper_trading_summary(days: int = 30) -> dict: cutoff = (datetime.now() - timedelta(days=days)).isoformat() conn = get_conn() try: - rows = conn.execute( + open_rows = conn.execute( """ SELECT * FROM paper_trades - WHERE opened_at >= %s + WHERE status='open' ORDER BY opened_at DESC, id DESC + """ + ).fetchall() + closed_rows = conn.execute( + """ + SELECT * FROM paper_trades + WHERE status='closed' + AND COALESCE(closed_at, updated_at, opened_at) >= %s + ORDER BY COALESCE(closed_at, updated_at, opened_at) DESC, id DESC """, (cutoff,), ).fetchall() @@ -1696,7 +1704,7 @@ def get_paper_trading_summary(days: int = 30) -> dict: finally: conn.close() cfg = paper_trading_config() - items = [_decorate_trade(dict(r), cfg) for r in rows] + items = [_decorate_trade(dict(r), cfg) for r in [*open_rows, *closed_rows]] open_items = [x for x in items if x.get("status") == "open"] closed_items = [x for x in items if x.get("status") == "closed"] wins = [x for x in closed_items if _safe_float(x.get("realized_pnl_pct")) > 0] diff --git a/app/db/review_center.py b/app/db/review_center.py index 33ac27a..89c69f1 100644 --- a/app/db/review_center.py +++ b/app/db/review_center.py @@ -140,8 +140,9 @@ def _opportunity_review(conn, since): def _paper_review(conn, since, days): summary = get_paper_trading_summary(days=days) - trade_attribution = (get_strategy_insights().get("trade_attribution") or {}) - watch_order_attribution = (get_strategy_insights().get("watch_order_attribution") or {}) + strategy_insights = get_strategy_insights(days=days) + trade_attribution = (strategy_insights.get("trade_attribution") or {}) + watch_order_attribution = (strategy_insights.get("watch_order_attribution") or {}) trades = [dict(r) for r in conn.execute( """ SELECT * diff --git a/app/db/strategy_insights.py b/app/db/strategy_insights.py index 4792eb6..4817c31 100644 --- a/app/db/strategy_insights.py +++ b/app/db/strategy_insights.py @@ -261,13 +261,14 @@ def get_strategy_evaluation(days: int = 30) -> dict: for row in conn.execute( """ - SELECT strategy_code, + SELECT COALESCE(NULLIF(po.strategy_code, ''), r.strategy_code) AS strategy_code, COUNT(*) AS order_count, - SUM(CASE WHEN status='filled' THEN 1 ELSE 0 END) AS filled_count, - SUM(CASE WHEN status IN ('canceled','rejected','expired') THEN 1 ELSE 0 END) AS canceled_count - FROM paper_orders - WHERE created_at >= %s - GROUP BY strategy_code + SUM(CASE WHEN po.status='filled' THEN 1 ELSE 0 END) AS filled_count, + SUM(CASE WHEN po.status IN ('canceled','rejected','expired') THEN 1 ELSE 0 END) AS canceled_count + FROM paper_orders po + LEFT JOIN recommendation r ON r.id = po.recommendation_id + WHERE po.created_at >= %s + GROUP BY COALESCE(NULLIF(po.strategy_code, ''), r.strategy_code) """, (since,), ).fetchall(): @@ -278,11 +279,15 @@ def get_strategy_evaluation(days: int = 30) -> dict: for row in conn.execute( """ - SELECT strategy_code, side, status, realized_pnl_pct, realized_pnl_usdt, pnl_pct - FROM paper_trades - WHERE opened_at >= %s + SELECT COALESCE(NULLIF(pt.strategy_code, ''), r.strategy_code) AS strategy_code, + pt.side, + pt.status + FROM paper_trades pt + LEFT JOIN recommendation r ON r.id = pt.recommendation_id + WHERE pt.opened_at >= %s + OR (pt.status='closed' AND COALESCE(pt.closed_at, pt.updated_at, pt.opened_at) >= %s) """, - (since,), + (since, since), ).fetchall(): b = bucket(row.get("strategy_code")) status = row.get("status") or "" @@ -294,18 +299,32 @@ def get_strategy_evaluation(days: int = 30) -> dict: b["long_trade_count"] += 1 if status == "open": b["open_trade_count"] += 1 - if status == "closed": - b["closed_trade_count"] += 1 - pnl_pct = _safe_float(row.get("realized_pnl_pct")) - pnl_usdt = _safe_float(row.get("realized_pnl_usdt")) - b["realized_pnl_usdt"] += pnl_usdt - b["pnl_pct_values"].append(pnl_pct) - if pnl_pct > 0: - b["win_count"] += 1 - elif pnl_pct < 0: - b["loss_count"] += 1 - b["best_pnl_pct"] = pnl_pct if b["best_pnl_pct"] is None else max(b["best_pnl_pct"], pnl_pct) - b["worst_pnl_pct"] = pnl_pct if b["worst_pnl_pct"] is None else min(b["worst_pnl_pct"], pnl_pct) + + for row in conn.execute( + """ + SELECT COALESCE(NULLIF(pt.strategy_code, ''), r.strategy_code) AS strategy_code, + pt.side, + pt.realized_pnl_pct, + pt.realized_pnl_usdt + FROM paper_trades pt + LEFT JOIN recommendation r ON r.id = pt.recommendation_id + WHERE pt.status='closed' + AND COALESCE(pt.closed_at, pt.updated_at, pt.opened_at) >= %s + """, + (since,), + ).fetchall(): + b = bucket(row.get("strategy_code")) + b["closed_trade_count"] += 1 + pnl_pct = _safe_float(row.get("realized_pnl_pct")) + pnl_usdt = _safe_float(row.get("realized_pnl_usdt")) + b["realized_pnl_usdt"] += pnl_usdt + b["pnl_pct_values"].append(pnl_pct) + if pnl_pct > 0: + b["win_count"] += 1 + elif pnl_pct < 0: + b["loss_count"] += 1 + b["best_pnl_pct"] = pnl_pct if b["best_pnl_pct"] is None else max(b["best_pnl_pct"], pnl_pct) + b["worst_pnl_pct"] = pnl_pct if b["worst_pnl_pct"] is None else min(b["worst_pnl_pct"], pnl_pct) finally: conn.close() @@ -346,16 +365,30 @@ def get_strategy_evaluation(days: int = 30) -> dict: } -def get_strategy_insights(): +def get_strategy_insights(days: int | None = None): """Strategy attribution based on opportunity and paper-trading conversion. Recommendation rows are opportunities/signals, not an execution ledger. Therefore this read model does not use recommendation.pnl_pct as strategy PnL. Paper-trading PnL is exposed only as an execution-conversion metric. """ + if days is not None: + days = max(1, min(_safe_int(days, 30), 365)) + since = (datetime.now() - timedelta(days=days)).isoformat() + rec_where = "WHERE r.rec_time >= %s" + rec_params = (since,) + trade_where = "WHERE pt.status='closed' AND COALESCE(pt.closed_at, pt.updated_at, pt.opened_at) >= %s" + trade_params = (since,) + else: + days = None + since = None + rec_where = "" + rec_params = () + trade_where = "WHERE pt.status='closed'" + trade_params = () conn = get_conn() rows = conn.execute( - """ + f""" SELECT r.*, pt.id AS paper_trade_id, @@ -374,18 +407,46 @@ def get_strategy_insights(): FROM recommendation r LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id LEFT JOIN paper_orders po ON po.recommendation_id = r.id + {rec_where} ORDER BY r.rec_time DESC, r.id DESC - """ + """, + rec_params, + ).fetchall() + trade_rows = conn.execute( + f""" + SELECT + r.*, + pt.id AS paper_trade_id, + pt.status AS paper_status, + pt.side AS paper_side, + pt.source_status AS paper_source_status, + pt.source_action AS paper_source_action, + pt.strategy_code AS paper_strategy_code, + pt.realized_pnl_pct AS paper_realized_pnl_pct, + pt.realized_pnl_usdt AS paper_realized_pnl_usdt, + pt.pnl_pct AS paper_pnl_pct, + pt.exit_reason AS paper_exit_reason, + po.id AS paper_order_id, + po.status AS paper_order_status, + po.strategy_code AS paper_order_strategy_code + FROM paper_trades pt + LEFT JOIN recommendation r ON r.id = pt.recommendation_id + LEFT JOIN paper_orders po ON po.recommendation_id = pt.recommendation_id + {trade_where} + ORDER BY COALESCE(pt.closed_at, pt.updated_at, pt.opened_at) DESC, pt.id DESC + """, + trade_params, ).fetchall() conn.close() items = [dict(r) for r in rows] + trade_items = [dict(r) for r in trade_rows] actionable_statuses = {"buy_now", "wait_pullback"} total = len(items) actionable = [x for x in items if (x.get("execution_status") or "") in actionable_statuses] buy_now = [x for x in items if (x.get("execution_status") or "") == "buy_now"] paper_items = [x for x in items if x.get("paper_trade_id")] - closed_paper = [x for x in paper_items if x.get("paper_status") == "closed"] + closed_paper = trade_items paper_wins = [x for x in closed_paper if float(x.get("paper_realized_pnl_pct") or 0) > 0] paper_realized_usdt = round(sum(float(x.get("paper_realized_pnl_usdt") or 0) for x in closed_paper), 4) overview = { @@ -479,35 +540,45 @@ def get_strategy_insights(): add_watch_bucket(watch_map, watch_bucket(item), item) if item.get("paper_order_id"): add_order_bucket(order_map, order_bucket(item), item) - if item.get("paper_status") == "closed": - for factor in labels: - add_trade_bucket(trade_factor_map, str(factor).strip(), item) - for group in factor_groups_from_breakdown(factor_breakdown): - add_trade_bucket(trade_factor_group_map, group, item) - add_trade_bucket(trade_entry_map, trade_entry_bucket(item), item) - add_trade_bucket(trade_exit_map, item.get("paper_exit_reason") or "未记录退出原因", item) - add_trade_bucket(trade_entry_map, f"方向:{item.get('paper_side') or item.get('side') or 'long'}", item) - if item.get("paper_order_id"): - add_trade_bucket(trade_entry_map, f"挂单路径:{item.get('paper_order_status') or 'filled'}", item) - add_trade_bucket(trade_score_band_map, score_band("机会分", score_components.get("opportunity_score")), item) - add_trade_bucket(trade_score_band_map, score_band("买点分", score_components.get("entry_score")), item) - add_trade_bucket(trade_score_band_map, score_band("风险分", score_components.get("risk_score")), item) - if regime_name: - add_trade_bucket(trade_regime_map, f"regime:{regime_name}", item) - for bucket in env_buckets_from_market_context(mc): - add_trade_bucket(trade_env_map, bucket, item) - for code in codes: - text = str(code or "").strip() - if text.startswith(("sentiment_", "listing_", "ecosystem_")): - add_trade_bucket(trade_evidence_map, "舆情:" + text, item) - elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_", "onchain_")): - add_trade_bucket(trade_evidence_map, "链上:" + text, item) - if item.get("strategy_version"): - add_trade_bucket(trade_version_map, str(item.get("strategy_version")).strip(), item) - add_trade_bucket(trade_strategy_code_map, strategy_code, item) + for item in trade_items: + labels = safe_list_json(item.get("signal_labels_json")) or safe_list_json(item.get("signals")) + codes = safe_list_json(item.get("signal_codes_json")) + ep = safe_dict_json(item.get("entry_plan_json")) + mc = safe_dict_json(item.get("market_context_json")) + factor_breakdown = safe_dict_json(mc.get("factor_score_breakdown")) or safe_dict_json(ep.get("factor_score_breakdown")) + score_components = safe_dict_json(mc.get("score_components")) or safe_dict_json(ep.get("score_components")) + market_regime = safe_dict_json(mc.get("market_regime")) or safe_dict_json(ep.get("market_regime")) + regime_name = market_regime.get("regime") or mc.get("market_regime") + strategy_code = normalize_strategy_code(item.get("paper_strategy_code") or item.get("strategy_code") or item.get("paper_order_strategy_code")) + for factor in labels: + add_trade_bucket(trade_factor_map, str(factor).strip(), item) + for group in factor_groups_from_breakdown(factor_breakdown): + add_trade_bucket(trade_factor_group_map, group, item) + add_trade_bucket(trade_entry_map, trade_entry_bucket(item), item) + add_trade_bucket(trade_exit_map, item.get("paper_exit_reason") or "未记录退出原因", item) + add_trade_bucket(trade_entry_map, f"方向:{item.get('paper_side') or item.get('side') or 'long'}", item) + if item.get("paper_order_id"): + add_trade_bucket(trade_entry_map, f"挂单路径:{item.get('paper_order_status') or 'filled'}", item) + add_trade_bucket(trade_score_band_map, score_band("机会分", score_components.get("opportunity_score")), item) + add_trade_bucket(trade_score_band_map, score_band("买点分", score_components.get("entry_score")), item) + add_trade_bucket(trade_score_band_map, score_band("风险分", score_components.get("risk_score")), item) + if regime_name: + add_trade_bucket(trade_regime_map, f"regime:{regime_name}", item) + for bucket in env_buckets_from_market_context(mc): + add_trade_bucket(trade_env_map, bucket, item) + for code in codes: + text = str(code or "").strip() + if text.startswith(("sentiment_", "listing_", "ecosystem_")): + add_trade_bucket(trade_evidence_map, "舆情:" + text, item) + elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_", "onchain_")): + add_trade_bucket(trade_evidence_map, "链上:" + text, item) + if item.get("strategy_version"): + add_trade_bucket(trade_version_map, str(item.get("strategy_version")).strip(), item) + add_trade_bucket(trade_strategy_code_map, strategy_code, item) return { "overview": overview, + "days": days, "metric_definition": { "opportunity_count": "进入 opportunity/recommendation 表的机会样本数,不代表交易。", "actionable_count": "确认层输出 buy_now 或 wait_pullback 的样本数。", diff --git a/static/review_center.html b/static/review_center.html index 4a20576..3e1f3f6 100644 --- a/static/review_center.html +++ b/static/review_center.html @@ -57,9 +57,9 @@ function rows(items,label,value,sub){items=items||[];if(!items.length)return '暂无动作';return items.slice(0,4).map(function(x){return '
'+esc(label(x))+''+esc(sub?sub(x):'')+'
'}).join('')} function renderStrategyDigest(d){var it=d.iteration||{},dig=it.digest||{},latest=dig.latest||{},m=latest.metrics||{},decision=latest.decision||'hold';var badgeCls=decision==='release'?'ok':decision==='gray'?'warn':'warn';$('strategyDigest').innerHTML='
策略迭代摘要
'+esc(latest.title||'暂无复盘')+' · '+time(latest.time)+' · 版本 '+esc(latest.strategy_version||'--')+'
'+esc(decision)+'
'+[['因子生效调整',m.factor_weight_updates||0,'blue'],['候选 / 灰度',(it.summary&&it.summary.candidate_count||0)+' / '+(it.summary&&it.summary.gray_count||0),''],['本轮有效复盘',m.effective_review_count||0,''],['发布状态',latest.reason||'继续观察','']].map(function(x){return '
'+esc(x[0])+''+esc(x[1])+'
'}).join('')+'

升权了什么

'+digestItems(dig.upgraded,function(x){return x.signal||'--'},function(x){return '权重 '+x.old_weight+' → '+x.new_weight+' · 样本 '+(x.sample_size||0)+' · 命中 '+(x.hit_rate||0)+'%'})+'

降权 / 淘汰

'+digestItems(dig.downgraded,function(x){return x.signal||'--'},function(x){return (x.action||'调整')+' · '+(x.old_weight!=null?'权重 '+x.old_weight+' → '+x.new_weight:x.detail||'')})+'

灰度观察

'+digestItems(dig.gray,function(x){return x.signal||('候选 #'+x.id)},function(x){return '样本 '+(x.sample_size||0)+' · 置信 '+(x.confidence||0)+'% · '+(x.description||'')})+'

最近迭代

'+digestItems(dig.timeline,function(x){return time(x.time)+' · '+(x.decision||'hold')},function(x){return x.reason||x.title||''})+'
'} function decisionCls(d){return ({promote:'promote',pause:'pause',gray:'gray',tune_entry:'tune_entry',review_entry_gate:'review_entry_gate'}[d]||'')} -function renderStrategyBoard(d){var se=d.strategy_evaluation||{},s=se.summary||{},items=se.strategies||[];var cards=items.slice(0,6).map(function(x){var score=Math.max(0,Math.min(100,Number(x.evaluation_score||0)));return '
'+esc(x.strategy_name||x.strategy_code)+'
'+esc(x.description||'暂无策略说明')+'
'+score.toFixed(0)+'
信号'+esc(x.signal_count||0)+'
机会'+esc(x.opportunity_count||0)+'
平仓'+esc(x.closed_trade_count||0)+'
胜率'+num(x.win_rate_pct,1)+'%
收益'+usd(x.realized_pnl_usdt)+'
均值'+pct(x.avg_realized_pnl_pct)+'
成交'+num(x.order_fill_rate_pct,1)+'%
转化'+num(x.trade_conversion_pct,1)+'%
'+esc(x.decision_label||x.decision)+' '+esc((x.reasons||[])[0]||'继续观察')+'
'+esc((x.next_actions||[])[0]||'等待更多样本')+'
'}).join('')||'
暂无策略评价数据
';$('strategyBoard').innerHTML='
多策略优胜劣汰
'+esc(se.definition||'按策略独立评价发现、执行、收益和风险。')+'
策略 '+esc(s.strategy_count||0)+' · 已交易 '+esc(s.traded_strategy_count||0)+' · 待暂停 '+esc(s.pause_count||0)+'
'+cards+'
'} +function renderStrategyBoard(d){var se=d.strategy_evaluation||{},s=se.summary||{},items=se.strategies||[];var cards=items.slice(0,6).map(function(x){var score=Math.max(0,Math.min(100,Number(x.evaluation_score||0)));return '
'+esc(x.strategy_name||x.strategy_code)+'
'+esc(x.description||'暂无策略说明')+'
'+score.toFixed(0)+'
信号'+esc(x.signal_count||0)+'
机会'+esc(x.opportunity_count||0)+'
窗口平仓'+esc(x.closed_trade_count||0)+'
胜率'+num(x.win_rate_pct,1)+'%
收益'+usd(x.realized_pnl_usdt)+'
均值'+pct(x.avg_realized_pnl_pct)+'
成交'+num(x.order_fill_rate_pct,1)+'%
转化'+num(x.trade_conversion_pct,1)+'%
'+esc(x.decision_label||x.decision)+' '+esc((x.reasons||[])[0]||'继续观察')+'
'+esc((x.next_actions||[])[0]||'等待更多样本')+'
'}).join('')||'
暂无策略评价数据
';$('strategyBoard').innerHTML='
多策略优胜劣汰
'+esc(se.definition||'按策略独立评价发现、执行、收益和风险。')+'
策略 '+esc(s.strategy_count||0)+' · 已交易 '+esc(s.traded_strategy_count||0)+' · 待暂停 '+esc(s.pause_count||0)+'
'+cards+'
'} function renderOpportunity(o){var s=o.summary||{};$('oppDef').textContent=o.definition||'';$('opportunityPanel').innerHTML=kpis([['机会样本',s.total_opportunities||0,'blue'],['可买/等回踩',(s.buy_now_count||0)+' / '+(s.wait_pullback_count||0),''],['策略执行',s.paper_executed_count||0,'green'],['漏选爆发',s.missed_explosion_count||0,'red'],['有效复盘',s.effective_review_count||0,''],['机会命中',num(s.opportunity_hit_rate,1)+'%','green'],['观察样本',s.observe_count||0,''],['失效样本',s.invalid_count||0,'red']])+'
状态分布
'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'
复盘结果
'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'
'} -function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},wo=p.watch_order_attribution||{},tf=ta.factor||[],te=ta.entry_path||[],tg=ta.factor_group||[],ts=ta.strategy_code||[],wr=wo.watch_pool||[],orows=wo.paper_orders||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['开仓/平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'
策略表现
'+rows(ts.slice(0,8),function(x){return x.strategy_name||x.strategy_code||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'
退出原因
'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'
执行事件
'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'
真实交易因子
'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'
因子组表现
'+rows(tg.slice(0,6),function(x){return x.factor_group||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'
入场路径表现
'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'
观察/挂单推进
'+rows(wr.concat(orows).slice(0,6),function(x){return x.watch_bucket||x.order_bucket||'--'},function(x){return (x.executed_pct!=null?num(x.executed_pct,1):num(x.fill_pct,1))+'%'},function(x){return '样本 '+(x.opportunity_count||x.order_count||0)+' · 执行/成交 '+(x.executed_count||x.filled_count||0)})+'
'} +function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},wo=p.watch_order_attribution||{},tf=ta.factor||[],te=ta.entry_path||[],tg=ta.factor_group||[],ts=ta.strategy_code||[],wr=wo.watch_pool||[],orows=wo.paper_orders||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['当前持仓/窗口平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['窗口已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'
策略表现
'+rows(ts.slice(0,8),function(x){return x.strategy_name||x.strategy_code||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '窗口平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'
退出原因
'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'
执行事件
'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'
真实交易因子
'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '窗口平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'
因子组表现
'+rows(tg.slice(0,6),function(x){return x.factor_group||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '窗口平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'
入场路径表现
'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '窗口平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'
观察/挂单推进
'+rows(wr.concat(orows).slice(0,6),function(x){return x.watch_bucket||x.order_bucket||'--'},function(x){return (x.executed_pct!=null?num(x.executed_pct,1):num(x.fill_pct,1))+'%'},function(x){return '样本 '+(x.opportunity_count||x.order_count||0)+' · 执行/成交 '+(x.executed_count||x.filled_count||0)})+'
'} function renderEvidence(e){var s=e.summary||{};$('evidenceDef').textContent=e.definition||'';$('evidencePanel').innerHTML=kpis([['新闻事件',s.news_count||0,'blue'],['有效舆情',s.actionable_news_count||0,''],['链上信号',s.onchain_signal_count||0,'blue'],['高置信链上',s.high_confidence_onchain_count||0,'green'],['原始链上',s.raw_onchain_count||0,''],['已映射原始',s.mapped_raw_onchain_count||0,'green'],['LLM 调用',s.llm_runs||0,''],['LLM 成功',s.llm_success_count||0,'green']])+'
链上信号
'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'
舆情决策
'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'
'} function renderIteration(i){var s=i.summary||{};$('iterationDef').textContent=i.definition||'';$('iterationPanel').innerHTML=kpis([['迭代记录',s.iteration_count||0,'blue'],['候选规则',s.candidate_count||0,''],['灰度规则',s.gray_count||0,'green'],['生效规则',s.active_count||0,'green']])+'
最新发布结论:'+esc(s.latest_release_decision||'hold')+'
'+esc(s.latest_release_reason||'暂无发布说明')+'
闸门
发布决策
'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'
候选状态
'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'
'} function renderRecent(d){var opp=(d.opportunity&&d.opportunity.missed_explosions)||[], trades=(d.paper_trading&&d.paper_trading.recent_trades)||[], news=(d.evidence&&d.evidence.recent_news)||[], chain=(d.evidence&&d.evidence.recent_onchain)||[];$('recentPanel').innerHTML='
最近策略交易
'+rows(trades.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.status||'--')},function(x){return x.status==='closed'?pct(x.realized_pnl_pct):pct(x.pnl_pct)},function(x){return time(x.opened_at)+' · '+(x.exit_reason||x.source_status||'')})+'
漏选爆发
'+rows(opp.slice(0,8),function(x){return x.symbol||'--'},function(x){return pct(x.gain_pct)},function(x){return time(x.detect_time)+' · '+(x.reason_missed||'')})+'
舆情事件
'+rows(news.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.title||'--')},function(x){return x.importance||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.decision||'未处理')})+'
链上信号
'+rows(chain.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.signal_label||x.signal_code||'--')},function(x){return x.severity||x.confidence||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.direction||'')})+'
'} diff --git a/tests/test_review_center.py b/tests/test_review_center.py index 75a9934..12cae4e 100644 --- a/tests/test_review_center.py +++ b/tests/test_review_center.py @@ -1,6 +1,6 @@ from fastapi.testclient import TestClient import json -from datetime import datetime +from datetime import datetime, timedelta from app.db import auth_db from app.db.review_center import get_review_center_dashboard @@ -86,6 +86,70 @@ def test_review_center_separates_opportunity_and_paper_pnl(pg_conn): assert "watch_order_attribution" in data["paper_trading"] +def test_review_center_strategy_counts_use_same_closed_trade_window(pg_conn): + now = datetime.now() + recent_close = now - timedelta(days=2) + old_close = now - timedelta(days=40) + old_open_recent_close = now - timedelta(days=20) + outside_open = now - timedelta(days=60) + strategy_code = "volume_ignition_1h_v1" + + pg_conn.execute( + """ + INSERT INTO recommendation ( + id, symbol, rec_time, rec_state, rec_score, entry_price, + status, execution_status, action_status, display_bucket, entry_triggered, + strategy_code, strategy_version, entry_plan_json + ) VALUES + (101, 'WIN/USDT', %s, '爆发', 30, 10, 'active', 'buy_now', '可即刻买入', 'realtime', 1, %s, 'v-test', %s), + (102, 'OLD/USDT', %s, '爆发', 30, 10, 'active', 'buy_now', '可即刻买入', 'realtime', 1, %s, 'v-test', %s) + """, + ( + old_open_recent_close.isoformat(timespec="seconds"), + strategy_code, + json.dumps({"strategy_code": strategy_code}, ensure_ascii=False), + outside_open.isoformat(timespec="seconds"), + strategy_code, + json.dumps({"strategy_code": strategy_code}, ensure_ascii=False), + ), + ) + pg_conn.execute( + """ + INSERT INTO paper_trades ( + recommendation_id, symbol, side, status, opened_at, closed_at, + entry_price, exit_price, qty, notional_usdt, margin_usdt, leverage, + current_price, pnl_pct, realized_pnl_pct, realized_pnl_usdt, + source_status, source_action, strategy_code, created_at, updated_at + ) VALUES + (101, 'WIN/USDT', 'long', 'closed', %s, %s, 10, 11, 500, 5000, 1000, 5, 11, 10, 10, 500, 'buy_now', '可即刻买入', %s, %s, %s), + (102, 'OLD/USDT', 'long', 'closed', %s, %s, 10, 9, 500, 5000, 1000, 5, 9, -10, -10, -500, 'buy_now', '可即刻买入', %s, %s, %s) + """, + ( + old_open_recent_close.isoformat(timespec="seconds"), + recent_close.isoformat(timespec="seconds"), + strategy_code, + old_open_recent_close.isoformat(timespec="seconds"), + recent_close.isoformat(timespec="seconds"), + outside_open.isoformat(timespec="seconds"), + old_close.isoformat(timespec="seconds"), + strategy_code, + outside_open.isoformat(timespec="seconds"), + old_close.isoformat(timespec="seconds"), + ), + ) + pg_conn.commit() + + data = get_review_center_dashboard(days=30) + board = next(x for x in data["strategy_evaluation"]["strategies"] if x["strategy_code"] == strategy_code) + lower = next(x for x in data["paper_trading"]["trade_attribution"]["strategy_code"] if x["strategy_code"] == strategy_code) + + assert board["closed_trade_count"] == 1 + assert lower["closed_trade_count"] == 1 + assert data["paper_trading"]["summary"]["closed_count"] == 1 + assert board["realized_pnl_usdt"] == 500 + assert lower["realized_pnl_usdt"] == 500 + + def test_review_center_iteration_digest_summarizes_actions(pg_conn): now = datetime.now().isoformat(timespec="seconds") today = now[:10]