alphax/app/db/altcoin_db.py
2026-05-16 14:52:10 +08:00

2446 lines
109 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
山寨币监控 — 数据库层
全量记录筛选结果 + 价格跟踪 + 盈亏验证
"""
import json
import re
from datetime import datetime, timedelta
from app.config.config_loader import get_meta, get_screener_section, confirm_state_cooldown_hours
from app.core.opportunity_lifecycle import (
apply_entry_quality_gate,
normalize_json_object,
derive_display_bucket,
normalize_action_status,
is_executed_lifecycle,
)
from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels
from app.db.postgres_connection import apply_migrations, connect as pg_connect, table_columns
def get_conn():
return pg_connect()
def init_db():
apply_migrations()
print("PostgreSQL schema migrations checked")
# === 推送去重 ===
PUSH_COOLDOWN_HOURS = 12
def should_push(symbol: str, push_type: str, action_status: str = "") -> bool:
"""检查是否可以推送:
- 如果提供了 action_status同状态+同type冷却期内已推过 → False状态变了无条件放行
- 如果未提供 action_status如burst同type冷却期内任何推过 → False
"""
conn = get_conn()
cutoff = (datetime.now() - timedelta(hours=PUSH_COOLDOWN_HOURS)).isoformat()
if action_status:
# 状态感知:只拦同状态重复推,状态变了永远放行
row = conn.execute(
"SELECT action_status FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1",
(symbol, push_type, cutoff),
).fetchone()
conn.close()
if row is None:
return True # 冷却期内没推过,放行
last_status = row[0]
return last_status != action_status # 状态变了放行,相同则冷却
else:
# 无状态burst同type任何推过就冷却
row = conn.execute(
"SELECT id FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1",
(symbol, push_type, cutoff),
).fetchone()
conn.close()
return row is None
def log_push(symbol: str, push_type: str, action_status: str = "", rec_id: int = 0):
"""记录一次推送。rec_id 可选,作为主链路推荐记录的可追溯来源。"""
conn = get_conn()
try:
conn.execute(
"INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (%s,%s,%s,%s,%s)",
(symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()),
)
conn.commit()
finally:
conn.close()
def get_recommendation_for_push(rec_id: int):
"""读取单条推荐并派生网站同口径展示状态,供推送层消费。
飞书/其他通知渠道只能消费这个主链路派生结果,不能自行基于事件或 entry_plan 做推荐判断。
"""
try:
rec_id = int(rec_id or 0)
except Exception:
rec_id = 0
if rec_id <= 0:
return None
conn = get_conn()
row = conn.execute("""
SELECT r.*,
lpc.price AS latest_cache_price,
lpc.updated_at AS latest_cache_updated_at
FROM recommendation r
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
WHERE r.id=%s
""", (rec_id,)).fetchone()
conn.close()
if not row:
return None
item = dict(row)
rec_result, rec_result_label = _classify_recommendation_result(item)
item["recommendation_result"] = rec_result
item["recommendation_result_label"] = rec_result_label
return _derive_execution_fields(item)
# ==================== 筛选记录 ====================
def log_screening(layer, symbol, state, score, price, signals,
sector="", leader_status="", is_meme=0,
change_24h=0, funding_rate=0, detail=None):
"""记录一次筛选结果"""
conn = get_conn()
conn.execute("""
INSERT INTO screening_log (scan_time, layer, symbol, state, score, price, signals,
sector, leader_status, is_meme, change_24h, funding_rate, detail_json)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
datetime.now().isoformat(), layer, symbol, state, score, price,
json.dumps(signals, ensure_ascii=False) if isinstance(signals, list) else signals,
sector, leader_status, is_meme, change_24h, funding_rate,
json.dumps(detail, ensure_ascii=False) if detail else "{}",
))
conn.commit()
conn.close()
# ==================== 推荐记录 ====================
def _state_fields_for_storage(status, action_status, execution_status="", reason=""):
bucket = derive_display_bucket(status or "active", action_status, execution_status)
return (
bucket.get("execution_status", execution_status or "observe"),
bucket.get("display_bucket", "watch_pool"),
bucket.get("lifecycle_state", "watching"),
1 if is_executed_lifecycle(status or "active", action_status, bucket.get("execution_status")) else 0,
reason or "",
)
def _derive_minimal_state_fields(status, action_status, entry_plan=None):
action = normalize_action_status(action_status, status)
if action == "可即刻买入":
execution_status = "buy_now"
reason = "主链路确认当前入场窗口"
elif action == "等回踩":
execution_status = "wait_pullback"
reason = "等待回踩触发,未触发前不计推荐收益"
elif action == "持有":
execution_status = "holding"
reason = "已进入持仓跟踪"
elif action in ("止盈1", "止盈2", "跟踪止盈"):
execution_status = "completed"
reason = "利润管理/阶段兑现"
elif action in ("止损", "衰减", "反转", "放弃", "过期", "归档") or status in ("stopped_out", "expired", "invalid", "archived"):
execution_status = "invalid"
reason = "机会失效,归入历史复盘"
else:
execution_status = "observe"
reason = "观察池,未触发入场"
return _state_fields_for_storage(status, action, execution_status, reason)
def _serialized_signal_payload(signals):
labels = build_signal_labels(signals if isinstance(signals, list) else _normalize_signals(signals))
codes = build_signal_codes(labels)
stored_signals = json.dumps(labels, ensure_ascii=False) if isinstance(signals, list) else signals
return stored_signals, json.dumps(codes, ensure_ascii=False), json.dumps(labels, ensure_ascii=False)
def create_recommendation(symbol, rec_state, rec_score, entry_price,
stop_loss=0, tp1=0, tp2=0, sector="",
signals="", is_meme=0, entry_plan=None, direction="中性",
force_reason="", base_state="", sector_signal_count=0,
market_context=None, derivatives_context=None, sector_context=None):
"""创建推荐记录(加速/爆发时调用)
direction: 多头启动/空头启动/中性 — 推荐的方向标签
注意rec_score 入参为原始分(0~30范围),落库时转为百分制(0~100),分母 30。
"""
# 原始分 → 百分制(分母 30天花板 100
raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0
rec_score_pct = min(raw_pct, 100)
strategy_version = str(get_meta().get("strategy_version") or "").strip()
now = datetime.now().isoformat()
conn = get_conn()
incoming_action = normalize_action_status((entry_plan or {}).get("entry_action", "观察") if entry_plan else "观察", "active")
incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason = _derive_minimal_state_fields(
"active", incoming_action, entry_plan or {}
)
stored_signals, signal_codes_json, signal_labels_json = _serialized_signal_payload(signals)
# 当前状态唯一:同一 symbol 同一时间只允许一条可执行/观察主记录;
# 但兼容粗筛蓄力→加速/爆发的状态迁移测试:无 entry_plan 的旧粗筛记录仍可新建演化轨迹。
duplicate_cursor = conn.execute(
"""
SELECT * FROM recommendation
WHERE symbol=%s AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
ORDER BY id DESC LIMIT 1
""",
(symbol,),
)
duplicate_row = duplicate_cursor.fetchone() if hasattr(duplicate_cursor, "fetchone") else None
if duplicate_row and (entry_plan or duplicate_row["rec_state"] == rec_state):
# 同一币种已有当前主记录时更新该记录,不再制造多个 active。
# 无 entry_plan 的粗筛状态迁移仍允许保留演化轨迹。
existing_id = duplicate_row["id"] if hasattr(duplicate_row, "keys") else duplicate_row[0]
existing_score = duplicate_row["rec_score"] or 0
merged_state = rec_state
merged_score = max(existing_score, rec_score_pct)
conn.execute("""
UPDATE recommendation
SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector),
signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s,
force_reason=COALESCE(NULLIF(%s, ''), force_reason),
base_state=COALESCE(NULLIF(%s, ''), base_state),
sector_signal_count=GREATEST(COALESCE(sector_signal_count,0), %s),
entry_plan_json=CASE WHEN %s != '{}' THEN %s ELSE entry_plan_json END,
market_context_json=%s, derivatives_context_json=%s, sector_context_json=%s,
action_status=CASE
WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈','衰减','反转') THEN action_status
ELSE COALESCE(NULLIF(%s, ''), action_status)
END,
execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s
WHERE id=%s
""", (
merged_state, merged_score, sector,
stored_signals, signal_codes_json, signal_labels_json,
is_meme, direction, strategy_version,
force_reason or "", base_state or "", int(sector_signal_count or 0),
json.dumps(entry_plan or {}, ensure_ascii=False),
json.dumps(entry_plan or {}, ensure_ascii=False),
json.dumps(market_context or {}, ensure_ascii=False),
json.dumps(derivatives_context or {}, ensure_ascii=False),
json.dumps(sector_context or {}, ensure_ascii=False),
incoming_action if entry_plan else "",
incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason,
existing_id,
))
conn.commit()
conn.close()
return existing_id
cursor = conn.execute("""
INSERT INTO recommendation (symbol, rec_time, rec_state, rec_score, entry_price,
stop_loss, tp1, tp2, sector, signals, signal_codes_json, signal_labels_json, is_meme, direction,
current_price, max_price, min_price, last_track_time, entry_plan_json,
force_reason, base_state, sector_signal_count,
market_context_json, derivatives_context_json, sector_context_json,
action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason,
strategy_version)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
symbol, now, rec_state, rec_score_pct, entry_price,
stop_loss, tp1, tp2, sector,
stored_signals, signal_codes_json, signal_labels_json,
is_meme, direction, entry_price, entry_price, entry_price,
now,
json.dumps(entry_plan, ensure_ascii=False) if entry_plan else "{}",
force_reason or "",
base_state or "",
int(sector_signal_count or 0),
json.dumps(market_context or {}, ensure_ascii=False),
json.dumps(derivatives_context or {}, ensure_ascii=False),
json.dumps(sector_context or {}, ensure_ascii=False),
incoming_action,
incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason,
strategy_version,
))
rec_id = cursor.fetchone()["id"]
conn.commit()
conn.close()
return rec_id
def update_recommendation_tracking(rec_id, current_price):
"""更新推荐记录的跟踪价格和盈亏。
v1.7.9+: TP1 只代表阶段兑现/启动跟踪止盈,不再把记录移出 active
这样 TP1 后继续推高的收益会继续计入 current/max_pnl。
"""
conn = get_conn()
row = conn.execute("SELECT entry_price, max_price, min_price, symbol FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
if not row:
conn.close()
return
entry_price = row["entry_price"]
old_max = row["max_price"] or entry_price
old_min = row["min_price"] or entry_price
new_max = max(old_max, current_price)
new_min = min(old_min, current_price)
pnl_pct = round((current_price / entry_price - 1) * 100, 2)
max_pnl_pct = round((new_max / entry_price - 1) * 100, 2)
max_drawdown_pct = round((new_min / entry_price - 1) * 100, 2)
status = "active"
tp1_reached = False
rec = conn.execute("SELECT stop_loss, tp1, tp2, status, hit_tp1_time FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
if rec and rec["status"] == "active":
if rec["tp2"] and current_price >= rec["tp2"]:
status = "hit_tp2"
elif rec["stop_loss"] and current_price <= rec["stop_loss"]:
status = "stopped_out"
elif rec["tp1"] and current_price >= rec["tp1"]:
status = "hit_tp1"
tp1_reached = True
elif rec["tp1"] == 0 and pnl_pct >= 15:
status = "hit_tp1"
tp1_reached = True
now = datetime.now().isoformat()
if status != "active":
action_for_status = {"hit_tp1": "止盈1", "hit_tp2": "止盈2", "stopped_out": "止损"}.get(status, "持有")
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(status, action_for_status, {})
conn.execute("""
UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s,
pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s,
status=%s, action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s, last_track_time=%s,
hit_tp1_time=CASE WHEN %s='hit_tp1' THEN %s ELSE hit_tp1_time END,
hit_tp2_time=CASE WHEN %s='hit_tp2' THEN %s ELSE hit_tp2_time END,
stopped_out_time=CASE WHEN %s='stopped_out' THEN %s ELSE stopped_out_time END
WHERE id=%s
""", (current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct,
status, action_for_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, now,
status, now, status, now, status, now, rec_id))
else:
conn.execute("""
UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s,
pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s,
last_track_time=%s,
hit_tp1_time=CASE WHEN %s=1 THEN COALESCE(NULLIF(hit_tp1_time,''), %s) ELSE hit_tp1_time END
WHERE id=%s
""", (current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct, now,
1 if tp1_reached else 0, now, rec_id))
symbol = row["symbol"]
update_latest_price_cache(symbol, current_price, updated_at=now, source="tracker", conn=conn)
conn.execute("""
INSERT INTO price_tracking (rec_id, symbol, track_time, price, pnl_pct)
VALUES (%s, %s, %s, %s, %s)
""", (rec_id, symbol, now, current_price, pnl_pct))
conn.commit()
conn.close()
return {"status": status, "tp1_reached": tp1_reached, "pnl_pct": pnl_pct, "max_pnl_pct": max_pnl_pct, "max_drawdown_pct": max_drawdown_pct}
def expire_old_recommendations(hours=48):
"""超过48小时的active推荐标记为expired"""
conn = get_conn()
cutoff = (datetime.now() - timedelta(hours=float(hours or 48))).isoformat()
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(
"expired", "过期", {}
)
conn.execute("""
UPDATE recommendation
SET status='expired',
action_status=CASE WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈') THEN action_status ELSE '过期' END,
expired_time=%s,
execution_status=%s,
display_bucket=%s,
lifecycle_state=%s,
entry_triggered=%s,
state_reason=%s
WHERE status='active' AND rec_time < %s
""", (datetime.now().isoformat(), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, cutoff))
conn.commit()
conn.close()
def _entry_window_policy(entry_price, current_price, rec_time, event_time=None, window_hours=2.0, up_deviation_pct=1.5, down_deviation_pct=1.2):
"""阶段1入场窗口可信度规则。
- 入场窗口默认有效 2 小时;超过后降级观察。
- 当前价向上脱离触发价 >1.5%:不追高,降级等回踩。
- 当前价向下跌破触发价 >1.2%:买点失效,降级观察。
"""
event_time = event_time or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
try:
entry_price = float(entry_price or 0)
current_price = float(current_price or 0)
except Exception:
entry_price = 0
current_price = 0
deviation_pct = round((current_price / entry_price - 1) * 100, 2) if entry_price and current_price else 0.0
age_minutes = 0.0
try:
start = datetime.fromisoformat(str(rec_time))
end = datetime.fromisoformat(str(event_time))
age_minutes = round((end - start).total_seconds() / 60.0, 1)
except Exception:
age_minutes = 0.0
remaining_minutes = round(max(0.0, window_hours * 60.0 - age_minutes), 1)
result = {
"status": "active",
"label": "入场窗口有效",
"reason": "入场窗口仍在有效期内,价格未明显脱离触发价",
"age_minutes": age_minutes,
"remaining_minutes": remaining_minutes,
"window_hours": window_hours,
"entry_price": entry_price,
"current_price": current_price,
"deviation_pct": deviation_pct,
"max_up_deviation_pct": up_deviation_pct,
"max_down_deviation_pct": down_deviation_pct,
}
if age_minutes > window_hours * 60.0:
result.update({
"status": "expired",
"label": "窗口已过期",
"reason": f"入场窗口超过有效期 {window_hours:g} 小时,避免沿用旧信号追入",
"remaining_minutes": 0.0,
})
elif deviation_pct > up_deviation_pct:
result.update({
"status": "price_left_up",
"label": "价格已上脱离",
"reason": f"当前价较触发价上脱离 {deviation_pct:.2f}%,超过 {up_deviation_pct:g}% 阈值,避免追高",
})
elif deviation_pct < -down_deviation_pct:
result.update({
"status": "price_left_down",
"label": "价格已下破",
"reason": f"当前价较触发价下破 {abs(deviation_pct):.2f}%,买点动能失效,转观察",
})
return result
def _risk_suggestion(entry_price, stop_loss, tp1, risk_budget_pct=1.0, max_position_pct=100.0):
"""把入场价/止损转换成可执行仓位建议。"""
try:
entry_price = float(entry_price or 0)
stop_loss = float(stop_loss or 0)
tp1 = float(tp1 or 0)
except Exception:
entry_price = stop_loss = tp1 = 0
stop_distance_pct = round(abs(entry_price - stop_loss) / entry_price * 100, 2) if entry_price and stop_loss else 0.0
suggested_position_pct = round(min(max_position_pct, risk_budget_pct / stop_distance_pct * 100), 2) if stop_distance_pct else 0.0
tp1_profit_pct = round((tp1 / entry_price - 1) * 100, 2) if entry_price and tp1 else 0.0
rr = round(tp1_profit_pct / stop_distance_pct, 2) if stop_distance_pct else 0.0
max_loss_pct = round(suggested_position_pct * stop_distance_pct / 100, 2) if suggested_position_pct else 0.0
return {
"risk_budget_pct": risk_budget_pct,
"stop_distance_pct": stop_distance_pct,
"suggested_position_pct": suggested_position_pct,
"max_loss_pct": max_loss_pct,
"tp1_profit_pct": tp1_profit_pct,
"rr": rr,
"max_position_pct": max_position_pct,
"valid": bool(entry_price and stop_loss and stop_distance_pct > 0),
}
def update_latest_price_cache(symbol, price, updated_at=None, source="tracker", conn=None):
"""Upsert 最新行情缓存。看板读取这张小表,不再依赖 price_tracking 高频流水表。"""
symbol = str(symbol or "").strip().upper()
try:
price = float(price or 0)
except Exception:
price = 0
if not symbol or price <= 0:
return False
updated_at = updated_at or datetime.now().isoformat()
owns_conn = conn is None
if owns_conn:
conn = get_conn()
conn.execute("""
INSERT INTO latest_price_cache (symbol, price, updated_at, source)
VALUES (%s, %s, %s, %s)
ON CONFLICT(symbol) DO UPDATE SET
price=excluded.price,
updated_at=excluded.updated_at,
source=excluded.source
""", (symbol, price, updated_at, source))
if owns_conn:
conn.commit()
conn.close()
return True
def get_latest_price_cache(symbols):
"""批量读取最新行情缓存,返回 {symbol: {price, updated_at, source}}。"""
normalized = []
for sym in symbols or []:
sym = str(sym or "").strip().upper()
if sym and sym not in normalized:
normalized.append(sym)
if not normalized:
return {}
conn = get_conn()
placeholders = ",".join(["%s"] * len(normalized))
rows = conn.execute(
f"SELECT symbol, price, updated_at, source FROM latest_price_cache WHERE symbol IN ({placeholders})",
tuple(normalized),
).fetchall()
conn.close()
return {row["symbol"]: dict(row) for row in rows}
def _latest_tracking_price(rec_id, fallback=0):
"""旧兼容函数:不再读取 price_tracking避免看板/API 依赖高频流水大表。
最新现价应来自 latest_price_cache没有缓存时回退 recommendation.current_price。
"""
return fallback or 0
def _execution_fields_from_persisted_state(item, entry_plan=None):
"""只基于DB主状态派生展示状态不得用 entry_plan.initial_action 反向提升主状态。"""
entry_plan = entry_plan if entry_plan is not None else _normalize_entry_plan(item.get("entry_plan_json"))
status = (item.get("status") or "active").strip()
action_status = normalize_action_status(item.get("action_status") or "持有", status)
bucket = derive_display_bucket(status, action_status, "")
lifecycle = bucket.get("lifecycle_state")
execution_status = bucket.get("execution_status")
if execution_status == "completed":
return "completed", "✅ 已兑现,仅观察", f"该机会已进入{action_status or '利润管理'}阶段,仅作为持仓跟踪记录"
if execution_status == "invalid":
if action_status == "止损":
reason = "该机会已触发风险边界,原入场逻辑失效"
elif action_status == "衰减":
reason = "该机会已出现趋势衰减,追高性价比下降"
elif action_status == "反转":
reason = "该机会已出现趋势反转,原多头逻辑被破坏"
elif action_status == "放弃":
reason = "该机会已被标记为放弃,不再满足入场条件"
else:
reason = "该机会观察周期结束或逻辑失效,已归入历史复盘"
return "invalid", "🔴 已失效,勿追", reason
if execution_status == "buy_now":
stop = str(entry_plan.get("stop_loss", "")) if entry_plan else ""
return "buy_now", "🟢 现在可买", "推荐时就是可即刻买入;主链路确认当前仍在入场窗口" + ((",风险边界 " + stop) if stop else "")
if execution_status == "wait_pullback":
gate = entry_plan.get("entry_quality_gate") or {}
if gate.get("reasons"):
reason = "等待更优位置;" + "".join(gate.get("reasons", [])[:3])
else:
reason = "等待回踩至 " + (str(entry_plan.get("entry_price", "")) if entry_plan else "参考价") + " 附近再评估"
return "wait_pullback", "🟡 等回踩,不追高", reason
if execution_status == "holding":
return "holding", "持仓跟踪", "该机会已触发入场,进入持仓跟踪"
gate = entry_plan.get("entry_quality_gate") or {}
if gate.get("reasons"):
reason = "机会结构仍在观察;" + "".join(gate.get("reasons", [])[:3])
else:
reason = "暂无明确入场窗口,继续观察"
return "observe", "观察池", reason
def apply_recommendation_state_transition(rec_id, requested_action, current_price, event_time=None, signals=None):
"""主链路状态迁移:唯一允许把价格事件落成 action_status 的入口。
返回值同时作为推送层 payload 来源;推送层不得再自行判定交易状态。
"""
event_time = event_time or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
conn = get_conn()
row = conn.execute("""
SELECT * FROM recommendation WHERE id=%s
""", (rec_id,)).fetchone()
if not row:
conn.close()
return {"updated": False, "push_required": False, "reason": "not_found"}
item = dict(row)
previous_action = (item.get("action_status") or "持有").strip()
entry_plan = _normalize_entry_plan(item.get("entry_plan_json"))
terminal_map = {"hit_tp2": "止盈2", "stopped_out": "止损"}
status = (item.get("status") or "active").strip()
final_action = normalize_action_status(terminal_map.get(status, requested_action), status)
if status not in terminal_map:
final_action, entry_plan, gate_reasons = apply_entry_quality_gate(
action_status=final_action,
entry_plan=entry_plan,
signals=signals if signals is not None else item.get("signals"),
current_price=current_price,
market_context=normalize_json_object(item.get("market_context_json")),
derivatives_context=normalize_json_object(item.get("derivatives_context_json")),
sector_context=normalize_json_object(item.get("sector_context_json")),
)
else:
gate_reasons = []
window_entry_price = item.get("entry_price") or current_price or 0
window_rec_time = item.get("rec_time") or event_time
if final_action == "可即刻买入" and previous_action != "可即刻买入":
window_entry_price = current_price
window_rec_time = event_time
entry_window = _entry_window_policy(window_entry_price, current_price, window_rec_time, event_time)
if final_action == "可即刻买入" and previous_action == "可即刻买入":
if entry_window["status"] == "expired":
final_action = "观察"
gate_reasons.append(entry_window["reason"])
elif entry_window["status"] == "price_left_up":
final_action = "等回踩"
gate_reasons.append(entry_window["reason"])
elif entry_window["status"] == "price_left_down":
final_action = "观察"
gate_reasons.append(entry_window["reason"])
should_reset_entry = final_action == "可即刻买入" and previous_action != "可即刻买入"
if should_reset_entry:
max_price = current_price
min_price = current_price
pnl_pct = 0.0
max_pnl_pct = 0.0
max_drawdown_pct = 0.0
rec_time = event_time
entry_price = current_price
else:
old_entry = item.get("entry_price") or current_price or 0
old_max = item.get("max_price") or old_entry
old_min = item.get("min_price") or old_entry
max_price = max(old_max, current_price) if current_price else old_max
min_price = min(old_min, current_price) if current_price else old_min
entry_price = old_entry
rec_time = item.get("rec_time")
pnl_pct = round((current_price / old_entry - 1) * 100, 2) if old_entry and current_price else item.get("pnl_pct", 0)
max_pnl_pct = round((max_price / old_entry - 1) * 100, 2) if old_entry else item.get("max_pnl_pct", 0)
max_drawdown_pct = round((min_price / old_entry - 1) * 100, 2) if old_entry else item.get("max_drawdown_pct", 0)
execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state(
{**item, "action_status": final_action, "status": status}, entry_plan
)
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _state_fields_for_storage(
status, final_action, execution_status, execution_reason
)
push_required = final_action in ("可即刻买入", "跟踪止盈") and previous_action != final_action and execution_status in ("buy_now", "completed")
conn.execute("""
UPDATE recommendation
SET action_status=%s, entry_plan_json=%s, current_price=%s, max_price=%s, min_price=%s,
pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, last_track_time=%s,
execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s,
rec_time=CASE WHEN %s=1 THEN %s ELSE rec_time END,
entry_price=CASE WHEN %s=1 THEN %s ELSE entry_price END
WHERE id=%s
""", (
final_action, json.dumps(entry_plan, ensure_ascii=False), current_price, max_price, min_price,
pnl_pct, max_pnl_pct, max_drawdown_pct, event_time,
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason,
1 if should_reset_entry else 0, rec_time,
1 if should_reset_entry else 0, entry_price,
rec_id,
))
conn.commit()
conn.close()
return {
"updated": True,
"id": rec_id,
"symbol": item.get("symbol"),
"previous_action_status": previous_action,
"action_status": final_action,
"execution_status": execution_status,
"execution_label": execution_label,
"execution_reason": execution_reason,
"display_bucket": display_bucket,
"lifecycle_state": lifecycle_state,
"entry_triggered": entry_triggered,
"entry_price": entry_price,
"current_price": current_price,
"pnl_pct": pnl_pct,
"stop_loss": item.get("stop_loss") or entry_plan.get("stop_loss") or 0,
"tp1": item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0,
"tp2": item.get("tp2") or entry_plan.get("tp2") or 0,
"entry_plan": entry_plan,
"entry_window": entry_window,
"risk_suggestion": _risk_suggestion(
entry_price,
item.get("stop_loss") or entry_plan.get("stop_loss") or 0,
item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0,
),
"gate_reasons": gate_reasons,
"push_required": push_required,
"push_symbol": item.get("symbol"),
"push_entry_price": entry_price,
"push_current_price": current_price,
"push_pnl_pct": pnl_pct,
"push_signals": signals or [],
}
def recompute_all_recommendation_state_fields(conn=None):
"""回填统一状态机派生字段。只读 status/action_status不改变历史交易价格。"""
owns_conn = conn is None
if owns_conn:
conn = get_conn()
rows = conn.execute("SELECT id,status,action_status,entry_plan_json FROM recommendation").fetchall()
updated = 0
for row in rows:
ep = _normalize_entry_plan(row["entry_plan_json"])
action = normalize_action_status(row["action_status"], row["status"])
execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state(
{"status": row["status"], "action_status": action, "entry_plan_json": row["entry_plan_json"]}, ep
)
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _state_fields_for_storage(
row["status"], action, execution_status, execution_reason
)
conn.execute(
"""UPDATE recommendation
SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s
WHERE id=%s""",
(action, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, row["id"]),
)
updated += 1
if owns_conn:
conn.commit()
conn.close()
return updated
def update_recommendation_action_status(rec_id, action_status):
"""更新推荐记录的操作状态。
保护规则:如果推荐已经真实止盈/止损结案,不能再被后续动态入场逻辑覆盖成
“可即刻买入/持有/等回踩”。v1.7.5 进一步增加买点质量闸门:
entry_plan 为等回踩、risk_reward_ok=false、rr过低或追高距离过远时拒绝把
action_status 写成“可即刻买入”。
"""
conn = get_conn()
row = conn.execute("""
SELECT status, action_status, entry_plan_json, signals, current_price,
market_context_json, derivatives_context_json, sector_context_json
FROM recommendation WHERE id=%s
""", (rec_id,)).fetchone()
terminal_map = {
"hit_tp1": "止盈1",
"hit_tp2": "止盈2",
"stopped_out": "止损",
}
entry_plan = {}
if row:
if row["status"] in terminal_map and action_status not in ("止盈1", "止盈2", "止损", "跟踪止盈"):
action_status = terminal_map[row["status"]]
else:
entry_plan = _normalize_entry_plan(row["entry_plan_json"])
gated_action, gated_plan, _ = apply_entry_quality_gate(
action_status=action_status,
entry_plan=entry_plan,
signals=row["signals"],
current_price=row["current_price"] or 0,
market_context=normalize_json_object(row["market_context_json"]),
derivatives_context=normalize_json_object(row["derivatives_context_json"]),
sector_context=normalize_json_object(row["sector_context_json"]),
)
action_status = gated_action
entry_plan = gated_plan
if entry_plan:
execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state(
{"status": row["status"] if row else "active", "action_status": action_status, "entry_plan_json": json.dumps(entry_plan, ensure_ascii=False)},
entry_plan,
)
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _state_fields_for_storage(
row["status"] if row else "active", action_status, execution_status, execution_reason
)
conn.execute("""
UPDATE recommendation SET action_status=%s, entry_plan_json=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s
""", (action_status, json.dumps(entry_plan, ensure_ascii=False), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id))
else:
execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(row["status"] if row else "active", action_status, {})
conn.execute("""
UPDATE recommendation SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s
""", (action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id))
conn.commit()
conn.close()
def update_entry_timing(rec_id: int, entry_price: float, rec_time: str):
"""更新入场到位的时间和价格。当tracker检测到可即刻买入时调用。"""
conn = get_conn()
conn.execute(
"UPDATE recommendation SET rec_time=%s, entry_price=%s, current_price=%s, pnl_pct=0 WHERE id=%s",
(rec_time, entry_price, entry_price, rec_id)
)
conn.commit()
conn.close()
def _normalize_entry_plan(entry_plan_json):
try:
if isinstance(entry_plan_json, dict):
return entry_plan_json
if entry_plan_json:
return json.loads(entry_plan_json)
except Exception:
pass
return {}
def _normalize_json_object(payload):
try:
if isinstance(payload, dict):
return payload
if payload:
parsed = json.loads(payload)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return {}
def _normalize_signals(payload):
"""signals 字段从 SQLite TEXT 列读出是 JSON 字符串,必须解析为数组才能交给前端 JS .map()。"""
try:
if isinstance(payload, list):
return payload
if isinstance(payload, str) and payload.strip():
parsed = json.loads(payload)
if isinstance(parsed, list):
return parsed
except Exception:
pass
return []
def _observe_tier(item):
"""观察池分层strong=值得用户关注weak=弱观察/低质量候选。"""
status = str(item.get("execution_status") or "")
if status in ("buy_now", "wait_pullback") or item.get("display_bucket") == "realtime":
return "strong", "入场/等待类有效机会"
try:
score = float(item.get("rec_score") or 0)
except Exception:
score = 0
signals = item.get("signals") or []
if isinstance(signals, str):
signals = _normalize_signals(signals)
sig_text = " ".join(str(x) for x in signals)
force_reason = str(item.get("force_reason") or "")
derivatives = _normalize_json_object(item.get("derivatives_context_json") or item.get("derivatives_context"))
market = _normalize_json_object(item.get("market_context_json") or item.get("market_context"))
if not derivatives and isinstance(item.get("derivatives_context"), dict):
derivatives = item.get("derivatives_context") or {}
if not market and isinstance(item.get("market_context"), dict):
market = item.get("market_context") or {}
long_pct = 0.0
try:
long_pct = float(derivatives.get("top_trader_long_pct") or 0)
except Exception:
long_pct = 0.0
acc1 = 0.0
acc4 = 0.0
try:
acc1 = float(market.get("turnover_acceleration_1h") or 0)
acc4 = float(market.get("turnover_acceleration_4h") or 0)
except Exception:
pass
stale_only = ("已过期" in sig_text or "历史" in sig_text) and not any(k in sig_text for k in ("当前", "新近", "刚刚", "入场窗口", "量价齐飞"))
weak_reasons = []
if score < 50:
weak_reasons.append(f"评分偏低({int(score)})")
if stale_only:
weak_reasons.append("主要触发来自历史/过期信号")
if "静K蓄力旁路" in force_reason and acc4 < 1.3 and acc1 < 1.3:
weak_reasons.append("静K旁路量能不足")
gate = {}
try:
ep = item.get("entry_plan") or _normalize_json_object(item.get("entry_plan_json"))
gate = ep.get("entry_quality_gate") or {}
except Exception:
gate = {}
gate_reasons = gate.get("reasons") or []
gate_reason_text = "".join(str(x) for x in gate_reasons[:3])
if any("回踩参考已到" in str(x) and "不达标" in str(x) for x in gate_reasons):
return "weak" if score < 55 else "strong", (gate_reason_text or "回踩参考已到,但实时盈亏比不达标") + ";暂不构成入场窗口,继续观察是否重新恢复可买盈亏比"
strong_context = score >= 65 or long_pct >= 75 or max(acc1, acc4) >= 1.5
if weak_reasons and not strong_context:
return "weak", "".join(weak_reasons[:3])
if gate_reason_text:
return "strong", gate_reason_text + ";继续观察结构是否恢复"
return "strong", "观察池有效候选"
def _derive_execution_fields(item):
entry_plan = _normalize_entry_plan(item.get("entry_plan_json"))
market_context = _normalize_json_object(item.get("market_context_json"))
derivatives_context = _normalize_json_object(item.get("derivatives_context_json"))
sector_context = _normalize_json_object(item.get("sector_context_json"))
signals = _normalize_signals(item.get("signals"))
item["signals"] = signals
initial_action = normalize_action_status(entry_plan.get("entry_action") or item.get("action_status") or "持有", item.get("status") or "active")
action_status = normalize_action_status(item.get("action_status") or initial_action or "持有", item.get("status") or "active")
# 新建爆发推荐可能还没被 tracker 跑到DB action_status 仍是默认“持有”。
# 此时以前端展示和实时看板过滤应以确认层写入的 entry_plan.entry_action 为准,
# 但后续 tracker 一旦写入明确状态,仍以 DB 主状态优先。
if action_status == "持有" and initial_action in ("可即刻买入", "等回踩", "观察"):
action_status = initial_action
current_price_for_window = item.get("latest_cache_price") or item.get("current_price") or item.get("entry_price") or 0
action_status, entry_plan, _entry_gate_reasons = apply_entry_quality_gate(
action_status=action_status,
entry_plan=entry_plan,
signals=item.get("signals"),
current_price=current_price_for_window,
market_context=market_context,
derivatives_context=derivatives_context,
sector_context=sector_context,
)
if initial_action == "可即刻买入" and action_status != "可即刻买入":
initial_action = action_status
status = (item.get("status") or "active").strip()
force_reason = (item.get("force_reason") or "").strip()
base_state = (item.get("base_state") or "").strip()
sector_signal_count = item.get("sector_signal_count")
strategy_version = str(item.get("strategy_version") or "").strip()
if not strategy_version:
strategy_version = str(get_meta().get("strategy_version") or "").strip()
if current_price_for_window:
item["current_price"] = current_price_for_window
try:
entry_price_for_pnl = float(item.get("entry_price") or 0)
current_price_float = float(current_price_for_window or 0)
if entry_price_for_pnl > 0 and current_price_float > 0:
item["pnl_pct"] = round((current_price_float - entry_price_for_pnl) / entry_price_for_pnl * 100, 2)
except Exception:
pass
if item.get("latest_cache_updated_at"):
item["current_price_updated_at"] = item.get("latest_cache_updated_at")
entry_window = _entry_window_policy(
item.get("entry_price") or entry_plan.get("entry_price") or 0,
current_price_for_window,
item.get("rec_time") or "",
) if action_status == "可即刻买入" else {}
# 实时看板用 hours 参数过滤过期机会;派生层不再因为旧 rec_time 反向篡改主状态,避免展示/测试口径分裂。
item_for_execution = {**item, "action_status": action_status}
execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state(item_for_execution, entry_plan)
bucket_fields = derive_display_bucket(status, action_status, execution_status)
execution_status = bucket_fields.get("execution_status") or execution_status
item["initial_action"] = initial_action
item["action_status"] = normalize_action_status(action_status, status)
item["execution_status"] = execution_status
item["execution_label"] = execution_label
item["execution_reason"] = execution_reason
item["display_bucket"] = bucket_fields.get("display_bucket")
item["lifecycle_state"] = bucket_fields.get("lifecycle_state")
item["entry_triggered"] = 1 if is_executed_lifecycle(status, item["action_status"], execution_status) else 0
# 派生状态可能被买点质量闸门从“等回踩”降为“观察”,同步刷新展示桶,避免卡片仍停留在旧等待态。
bucket_fields = derive_display_bucket(status, item["action_status"], item["execution_status"])
item["execution_status"] = bucket_fields.get("execution_status") or item["execution_status"]
item["display_bucket"] = bucket_fields.get("display_bucket")
item["lifecycle_state"] = bucket_fields.get("lifecycle_state")
observe_tier, observe_reason = _observe_tier(item)
item["observe_tier"] = observe_tier
item["observe_reason"] = observe_reason
item["entry_plan"] = entry_plan
item["entry_window"] = entry_window
if entry_window and entry_window.get("status") != "active":
item["entry_window_alert"] = entry_window
item["risk_suggestion"] = _risk_suggestion(
item.get("entry_price") or entry_plan.get("entry_price") or 0,
item.get("stop_loss") or entry_plan.get("stop_loss") or 0,
item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0,
)
item["market_context"] = market_context
item["derivatives_context"] = derivatives_context
item["sector_context"] = sector_context
item["force_reason"] = force_reason
item["base_state"] = base_state
item["sector_signal_count"] = sector_signal_count
item["strategy_version"] = strategy_version
item["strategy_version_label"] = f"策略版本 {strategy_version}" if strategy_version else ""
return item
def _is_actionable_execution_status(status):
"""实时可操作口径:包含现在可买 + 等回踩计划;但收益只统计已执行交易。"""
return status in ("buy_now", "wait_pullback")
def _is_executed_trade(item):
"""收益统计口径:只有真实触发入场/持仓/退出的样本才计算收益。
buy_now 是当前入场窗口,不等同于已成交;等回踩/观察永远不计推荐收益。
"""
status = (item.get("status") or "").strip()
action_status = normalize_action_status(item.get("action_status"), status)
execution_status = item.get("execution_status") or ""
try:
entry_triggered = int(item.get("entry_triggered") or 0) == 1
except Exception:
entry_triggered = False
if entry_triggered:
return True
if status in ("hit_tp1", "hit_tp2", "stopped_out"):
return True
if item.get("display_bucket") == "position" or execution_status in ("holding", "completed"):
return True
return is_executed_lifecycle(status, action_status, execution_status)
def _classify_recommendation_result(item):
status = item.get("status") or ""
pnl_pct = item.get("pnl_pct") or 0
max_pnl_pct = item.get("max_pnl_pct") or 0
max_drawdown_pct = item.get("max_drawdown_pct") or 0
if status in ("hit_tp1", "hit_tp2"):
return "success", "✅ 止盈成功"
if status == "stopped_out":
return "failed", "❌ 止损失败"
# 计划/观察未实际触发入场前,不按推荐生成价计算成功/失败。
if not _is_executed_trade(item):
return "pending", "⏳ 未执行"
if status == "expired":
if max_pnl_pct >= 5:
return "success", "✅ 交易成功"
if pnl_pct <= -3 or max_drawdown_pct <= -5:
return "failed", "❌ 交易失败"
return "pending", "⏳ 跟踪中"
if status == "active":
if max_pnl_pct >= 5:
return "success", "✅ 交易成功"
if pnl_pct <= -3 or max_drawdown_pct <= -5:
return "failed", "❌ 交易失败"
return "pending", "⏳ 跟踪中"
return "pending", "⏳ 未执行"
# ==================== 查询API ====================
def get_active_recommendations(actionable_only=False):
"""获取所有active推荐。默认保留全量实时页请使用去重视图的可执行口径。"""
conn = get_conn()
rows = conn.execute("""
SELECT * FROM recommendation
WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
ORDER BY rec_time DESC
""").fetchall()
conn.close()
result = []
for row in rows:
item = _derive_execution_fields(dict(row))
if actionable_only and not _is_actionable_execution_status(item.get("execution_status")):
continue
result.append(item)
return result
def get_active_recommendations_deduped(actionable_only=True, version="", hours=0, watch_symbols=None, limit=0, offset=0, with_meta=False):
"""获取去重后的active推荐同symbol只保留最新一条并附带推荐结果判定。
version 为空时不按版本过滤hours>0 时只取最近 N 小时信号。
with_meta=True 时返回分页对象,兼容实时看板首屏分页加载。"""
conn = get_conn()
where = "status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'"
params = []
version = str(version or "").strip()
if version:
where += " AND strategy_version=%s"
params.append(version)
if watch_symbols:
symbols = [str(s).strip().upper() for s in watch_symbols if str(s).strip()]
if symbols:
where += " AND symbol IN (" + ",".join(["%s"] * len(symbols)) + ")"
params.extend(symbols)
try:
hours = float(hours or 0)
except Exception:
hours = 0
if hours > 0:
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
where += " AND rec_time >= %s"
params.append(cutoff)
try:
limit = max(0, int(limit or 0))
except Exception:
limit = 0
try:
offset = max(0, int(offset or 0))
except Exception:
offset = 0
rows = conn.execute(f"""
SELECT r.*,
lpc.price AS latest_cache_price,
lpc.updated_at AS latest_cache_updated_at
FROM recommendation r
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
JOIN (
SELECT symbol, MAX(id) AS max_id
FROM recommendation
WHERE {where}
GROUP BY symbol
) latest ON latest.max_id = r.id
ORDER BY r.rec_time DESC
""", tuple(params)).fetchall()
conn.close()
all_items = []
# 实时看板只输出当前有效机会;过期/失效样本属于历史/复盘,不再进入实时列表或 summary。
summary = {"buy_now": 0, "wait_pullback": 0, "observe": 0, "observe_strong": 0, "observe_weak": 0, "expired": 0, "total": 0}
now = datetime.now()
for row in rows:
item = dict(row)
rec_result, rec_result_label = _classify_recommendation_result(item)
item["recommendation_result"] = rec_result
item["recommendation_result_label"] = rec_result_label
_derive_execution_fields(item)
is_expired = False
if hours > 0:
try:
rec_time = item.get("rec_time")
if rec_time:
is_expired = (now - datetime.fromisoformat(str(rec_time))).total_seconds() > hours * 3600
except Exception:
is_expired = False
if item.get("execution_status") == "invalid" or item.get("status") in ("invalid", "expired", "archived") or item.get("display_bucket") == "history":
is_expired = True
# 带 hours 的实时看板请求必须过滤旧/脏/过期;不带 hours 的内部/测试查询保留全量派生结果。
if is_expired:
summary["expired"] += 1
continue
if actionable_only and not _is_actionable_execution_status(item.get("execution_status")):
continue
all_items.append(item)
if item.get("execution_status") == "buy_now":
summary["buy_now"] += 1
elif item.get("execution_status") == "wait_pullback":
summary["wait_pullback"] += 1
else:
summary["observe"] += 1
if item.get("observe_tier") == "weak":
summary["observe_weak"] += 1
else:
summary["observe_strong"] += 1
summary["total"] = len(all_items)
# expired 仅作内部审计计数不属于实时机会流API 对外不暴露,避免前端/用户继续看到过期入口。
summary["expired_filtered"] = summary.pop("expired", 0)
if not with_meta:
try:
from app.services.llm_insights import attach_recommendation_insights
return attach_recommendation_insights(all_items)
except Exception:
return all_items
page_items = all_items[offset: offset + limit] if limit else all_items[offset:]
try:
from app.services.llm_insights import attach_recommendation_insights
attach_recommendation_insights(page_items)
except Exception:
pass
return {
"items": page_items,
"total": len(all_items),
"limit": limit,
"offset": offset,
"has_more": bool(limit and offset + len(page_items) < len(all_items)),
"summary": summary,
}
def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False):
"""兼容导出:推荐列表查询已迁移到 analytics 模块。"""
from app.db.analytics import get_all_recommendations as _get_all_recommendations
return _get_all_recommendations(
limit=limit,
decision_only=decision_only,
version=version,
offset=offset,
with_meta=with_meta,
)
def get_screening_history(hours=24, limit=100):
"""获取最近N小时的筛选记录"""
conn = get_conn()
rows = conn.execute("""
SELECT * FROM screening_log
WHERE layer='细筛' AND scan_time >= %s
ORDER BY score DESC, scan_time DESC LIMIT %s
""", ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(), limit)).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_stats():
"""兼容导出:统计聚合已迁移到 analytics 模块。"""
from app.db.analytics import get_stats as _get_stats
return _get_stats()
# ==================== 原有状态跟踪(兼容) ====================
def update_state(symbol, new_state, score=0, anomaly_type="", sector="",
leader_status="", detail=None):
"""更新币状态(兼容旧接口)"""
conn = get_conn()
row = conn.execute("SELECT * FROM coin_state WHERE symbol=%s", (symbol,)).fetchone()
if row:
old_state = row["state"]
old_score = row["score"]
last_alert_time = row["last_alert_time"] or ""
last_alert_level = row["last_alert_level"] or ""
# 状态升级逻辑
state_order = {"过期": 0, "蓄力": 1, "加速": 2, "爆发": 3}
should_alert = False
alert_level = "low"
if state_order.get(new_state, 0) > state_order.get(old_state, 0):
should_alert = True
alert_level = "high" if new_state == "爆发" else "medium" if new_state == "加速" else "low"
elif new_state == old_state:
# 同级别:检查状态冷却,避免频繁重复推荐
cooldown_hours = confirm_state_cooldown_hours()
if last_alert_time:
try:
last_dt = datetime.fromisoformat(last_alert_time)
hours_since = (datetime.now() - last_dt).total_seconds() / 3600
if hours_since < cooldown_hours:
conn.close()
return {"should_alert": False, "alert_level": "none", "reason": f"状态冷却中({hours_since:.1f}h<{cooldown_hours}h)"}
except Exception:
pass
if score > old_score:
# 同级别分数提升,检查冷却
if not last_alert_time:
should_alert = True
alert_level = "medium"
else:
try:
last_dt = datetime.fromisoformat(last_alert_time)
hours_since = (datetime.now() - last_dt).total_seconds() / 3600
cooldown = 12 # 12h同币不重复推送同级别
if hours_since >= cooldown:
should_alert = True
alert_level = "medium"
except Exception:
should_alert = True
alert_level = "medium"
conn.execute("""
UPDATE coin_state SET state=%s, score=%s, anomaly_type=%s, sector=%s,
leader_status=%s, detected_at=%s, detail_json=%s
WHERE symbol=%s
""", (
new_state, score, anomaly_type, sector, leader_status,
datetime.now().isoformat(),
json.dumps(detail, ensure_ascii=False, default=str) if detail else "{}",
symbol,
))
if should_alert:
conn.execute("""
UPDATE coin_state SET last_alert_time=%s, last_alert_level=%s WHERE symbol=%s
""", (datetime.now().isoformat(), alert_level, symbol))
conn.commit()
conn.close()
return {"should_alert": should_alert, "alert_level": alert_level}
else:
# 新币,首次检测
conn.execute("""
INSERT INTO coin_state (symbol, state, score, anomaly_type, sector, leader_status, detected_at, last_alert_time, last_alert_level, detail_json)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
symbol, new_state, score, anomaly_type, sector, leader_status,
datetime.now().isoformat(), datetime.now().isoformat(), "low",
json.dumps(detail, ensure_ascii=False, default=str) if detail else "{}",
))
conn.commit()
conn.close()
return {"should_alert": True, "alert_level": "low"}
def get_candidates_for_confirm():
"""获取需要确认层检查的候选。
优先处理最近一轮粗筛/细筛刚更新的候选,避免旧 coin_state 中的高分候选
抢占确认层,导致链路日志里“细筛通过”和“确认处理”对不上。
"""
try:
_, _, accumulate_threshold = state_score_thresholds()
except Exception:
accumulate_threshold = 3
conn = get_conn()
rows = conn.execute("""
SELECT * FROM coin_state
WHERE state IN ('加速', '蓄力')
AND score >= %s
AND detected_at >= %s
ORDER BY detected_at DESC, score DESC
""", (accumulate_threshold, (datetime.now() - timedelta(minutes=45)).isoformat())).fetchall()
if not rows:
rows = conn.execute("""
SELECT * FROM coin_state
WHERE state IN ('加速', '蓄力')
AND score >= 5
ORDER BY detected_at DESC, score DESC
""").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_all_active():
conn = get_conn()
rows = conn.execute("SELECT * FROM coin_state WHERE state != '过期'").fetchall()
conn.close()
return [dict(r) for r in rows]
def expire_old_states(hours=24):
conn = get_conn()
conn.execute("""
UPDATE coin_state SET state='过期' WHERE state != '过期'
AND detected_at < %s
""", ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),))
conn.commit()
conn.close()
# ==================== 复盘相关 ====================
def record_review(rec_id, symbol, outcome, pnl_48h, max_pnl_48h,
triggered_signals, hit_signals, miss_signals, lesson):
"""写入一条复盘记录"""
conn = get_conn()
conn.execute("""
INSERT INTO review_log (rec_id, symbol, review_time, outcome, pnl_48h, max_pnl_48h,
triggered_signals, hit_signals, miss_signals, lesson)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (rec_id, symbol, datetime.now().isoformat(), outcome, pnl_48h, max_pnl_48h,
json.dumps(triggered_signals, ensure_ascii=False) if isinstance(triggered_signals, list) else triggered_signals,
json.dumps(hit_signals, ensure_ascii=False) if isinstance(hit_signals, list) else hit_signals,
json.dumps(miss_signals, ensure_ascii=False) if isinstance(miss_signals, list) else miss_signals,
lesson))
conn.commit()
conn.close()
def update_signal_performance(signal_type, category, is_hit, pnl):
"""更新信号绩效统计(每次复盘后调用)"""
conn = get_conn()
row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=%s", (signal_type,)).fetchone()
if row:
total = row["total_count"] + 1
hits = row["hit_count"] + (1 if is_hit else 0)
misses = row["miss_count"] + (0 if is_hit else 1)
old_avg_pnl = row["avg_pnl"]
# 滚动平均
new_avg_pnl = round((old_avg_pnl * (total - 1) + pnl) / total, 2)
hit_rate = round(hits / total * 100, 1) if total > 0 else 0
conn.execute("""
UPDATE signal_performance SET total_count=%s, hit_count=%s, miss_count=%s,
hit_rate=%s, avg_pnl=%s, weight=%s, last_updated=%s
WHERE signal_type=%s
""", (total, hits, misses, hit_rate, new_avg_pnl, hit_rate / 50, datetime.now().isoformat(), signal_type))
else:
conn.execute("""
INSERT INTO signal_performance (signal_type, category, total_count, hit_count, miss_count,
hit_rate, avg_pnl, weight, last_updated)
VALUES (%s, %s, 1, %s, %s, %s, %s, %s, %s)
""", (signal_type, category, 1 if is_hit else 0, 0 if is_hit else 1,
100 if is_hit else 0, pnl, 2.0 if is_hit else 0, datetime.now().isoformat()))
conn.commit()
conn.close()
def get_signal_weights():
"""获取所有信号的当前权重screener动态调权用"""
conn = get_conn()
rows = conn.execute("SELECT signal_type, category, weight, hit_rate, avg_pnl, total_count FROM signal_performance").fetchall()
conn.close()
return {row["signal_type"]: dict(row) for row in rows}
def record_missed_explosion(symbol, price_at_detect, price_before, gain_pct,
reason_missed, features_detected, lesson):
"""写入一条漏选复盘记录"""
conn = get_conn()
conn.execute("""
INSERT INTO missed_explosions (symbol, detect_time, price_at_detect, price_before,
gain_pct, reason_missed, features_detected, lesson)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (symbol, datetime.now().isoformat(), price_at_detect, price_before, gain_pct,
json.dumps(reason_missed, ensure_ascii=False) if isinstance(reason_missed, list) else reason_missed,
json.dumps(features_detected, ensure_ascii=False) if isinstance(features_detected, list) else features_detected,
lesson))
conn.commit()
conn.close()
def get_review_stats():
"""兼容导出:复盘统计已迁移到 analytics 模块。"""
from app.db.analytics import get_review_stats as _get_review_stats
return _get_review_stats(
conn_provider=get_conn,
iteration_logs_getter=get_strategy_iteration_logs,
iteration_summary_getter=get_strategy_iteration_summary,
)
def _loads_json_field(value, fallback):
try:
return json.loads(value) if isinstance(value, str) else (value if value is not None else fallback)
except Exception:
return fallback
def log_strategy_iteration(run_date=None, trigger_source="daily_review", title="", summary="",
findings=None, problems=None, actions=None, changed_rules=None,
metrics=None, related_symbols=None, config_diff=None, effect_summary=None,
pollution_summary=None,
strategy_version="", version_change_summary="",
success_analysis=None, failure_analysis=None, candidate_rules=None,
release_decision="", release_reason="", confidence_level="",
promotion_state="research_only"):
"""兼容导出:策略迭代写入已迁移到 review_queries 模块。"""
from app.db.review_queries import log_strategy_iteration as _log_strategy_iteration
return _log_strategy_iteration(
run_date=run_date,
trigger_source=trigger_source,
title=title,
summary=summary,
findings=findings,
problems=problems,
actions=actions,
changed_rules=changed_rules,
metrics=metrics,
related_symbols=related_symbols,
config_diff=config_diff,
effect_summary=effect_summary,
pollution_summary=pollution_summary,
strategy_version=strategy_version,
version_change_summary=version_change_summary,
success_analysis=success_analysis,
failure_analysis=failure_analysis,
candidate_rules=candidate_rules,
release_decision=release_decision,
release_reason=release_reason,
confidence_level=confidence_level,
promotion_state=promotion_state,
conn_provider=get_conn,
)
def get_strategy_iteration_logs(limit=30):
"""兼容导出:策略迭代日志查询已迁移到 review_queries 模块。"""
from app.db.review_queries import get_strategy_iteration_logs as _get_strategy_iteration_logs
return _get_strategy_iteration_logs(limit=limit, conn_provider=get_conn, json_loader=_loads_json_field)
def upsert_strategy_rule_candidate(source, rule_type, signal_name, rule_description,
support_count=0, success_count=0, fail_count=0,
avg_pnl=0, max_gain=0, max_drawdown=0,
confidence_score=0, sample_size=0, status="candidate",
release_version="", notes="", source_ref=""):
"""新增或更新候选规则。研究结论先沉淀到候选池,避免样本不足时污染主策略。"""
conn = get_conn()
now = datetime.now().isoformat()
existing = conn.execute("""
SELECT id FROM strategy_rule_candidate
WHERE source=%s AND rule_type=%s AND signal_name=%s AND rule_description=%s
ORDER BY id DESC LIMIT 1
""", (source or "", rule_type or "", signal_name or "", rule_description or "")).fetchone()
if existing:
conn.execute("""
UPDATE strategy_rule_candidate
SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s,
max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s,
release_version=%s, notes=%s, source_ref=COALESCE(NULLIF(%s, ''), source_ref), created_at=%s
WHERE id=%s
""", (support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown,
confidence_score, sample_size, status, release_version or "", notes or "", source_ref or "", now, existing["id"]))
candidate_id = existing["id"]
else:
cur = conn.execute("""
INSERT INTO strategy_rule_candidate (
created_at, source, rule_type, signal_name, rule_description,
support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown,
confidence_score, sample_size, status, release_version, notes, source_ref
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (now, source or "", rule_type or "", signal_name or "", rule_description or "",
support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown,
confidence_score, sample_size, status, release_version or "", notes or "", source_ref or ""))
candidate_id = cur.fetchone()["id"]
conn.commit()
conn.close()
return candidate_id
def record_strategy_failure_pattern(symbol, version="", failure_type="", failure_reason="",
signal_combo=None, market_context=None,
entry_quality_issue="", pnl_pct=0, max_drawdown_pct=0, lesson=""):
"""记录失败模式,用于失败归因统计。"""
conn = get_conn()
conn.execute("""
INSERT INTO strategy_failure_pattern (
created_at, symbol, version, failure_type, failure_reason, signal_combo,
market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
datetime.now().isoformat(), symbol or "", version or "", failure_type or "",
failure_reason or "", json.dumps(signal_combo or [], ensure_ascii=False, default=str),
json.dumps(market_context or {}, ensure_ascii=False, default=str),
entry_quality_issue or "", pnl_pct or 0, max_drawdown_pct or 0, lesson or "",
))
conn.commit()
conn.close()
def get_strategy_rule_candidates(limit=50, status=None):
"""获取候选规则列表。"""
conn = get_conn()
params = []
where = ""
if status:
where = "WHERE status=%s"
params.append(status)
rows = conn.execute(f"""
SELECT * FROM strategy_rule_candidate
{where}
ORDER BY confidence_score DESC, sample_size DESC, created_at DESC
LIMIT %s
""", (*params, limit)).fetchall()
conn.close()
return [dict(r) for r in rows]
def update_strategy_rule_candidate_status(candidate_id, status, release_version="", notes_append=""):
"""更新候选规则生命周期状态。"""
conn = get_conn()
row = conn.execute("SELECT notes FROM strategy_rule_candidate WHERE id=%s", (candidate_id,)).fetchone()
if not row:
conn.close()
return False
notes = (row["notes"] or "").strip()
if notes_append:
notes = (notes + "\n" if notes else "") + f"[{datetime.now().isoformat()}] {notes_append}"
conn.execute("""
UPDATE strategy_rule_candidate
SET status=%s, release_version=COALESCE(NULLIF(%s, ''), release_version), notes=%s, created_at=%s
WHERE id=%s
""", (status or "candidate", release_version or "", notes, datetime.now().isoformat(), candidate_id))
conn.commit()
conn.close()
return True
def get_strategy_failure_patterns(limit=50):
"""获取失败模式明细。"""
conn = get_conn()
rows = conn.execute("""
SELECT * FROM strategy_failure_pattern
ORDER BY created_at DESC
LIMIT %s
""", (limit,)).fetchall()
conn.close()
items = []
for r in rows:
item = dict(r)
item["signal_combo"] = _loads_json_field(item.get("signal_combo"), [])
item["market_context"] = _loads_json_field(item.get("market_context_json"), {})
items.append(item)
return items
def _candidate_signal_key(signal_text):
"""候选规则归因用的轻量信号归一化。保持与 review_engine._signal_key 语义接近。"""
text = str(signal_text or "")
key_map = {
"量价齐飞": "vp_fly",
"N倍放量": "vol_Nx",
"放量": "1h_vol",
"供需区突破": "zone_break",
"供给区突破": "zone_break",
"站稳突破": "zone_break",
"起爆点": "ignition",
"静K→动K": "ignition",
"静K蓄力": "sk_accum",
"连续3K": "cont3k",
"连续K": "cont_k",
"Q≥7": "q7_break",
"动K": "dyn_k",
"过期": "stale_signal",
"历史": "stale_signal",
"追高": "chase_high",
"假突破": "false_breakout",
"量价背离": "vp_divergence",
}
for marker, key in key_map.items():
if marker in text:
return key
return text[:12]
def _get_factor_recency_fixed_at():
"""因子时效性修复完成时间:此时间前的推荐视为污染历史参考,不参与正式发布。"""
try:
meta = get_meta() or {}
except Exception:
meta = {}
return (meta.get("factor_recency_fixed_at") or meta.get("clean_review_started_at") or "").strip()
def _is_dirty_history_candidate(candidate):
source = str(candidate.get("source") or "")
notes = str(candidate.get("notes") or "")
source_ref = str(candidate.get("source_ref") or "")
return source in ("history_review_auto", "dirty_history_reference") or "dirty_history" in source_ref or "污染历史" in notes
def _candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, current_status="candidate",
min_gray_samples=10, min_gray_confidence=65):
"""候选规则生命周期状态判定。"""
if current_status == "active":
return "active"
if sample_size >= min_gray_samples and confidence >= min_gray_confidence and (avg_pnl > 0 or rule_type == "penalty"):
return "gray"
if sample_size >= 8 and ((rule_type != "penalty" and confidence < 35) or avg_pnl <= -3):
return "rejected"
if current_status in ("gray", "rejected"):
return current_status
return "candidate"
def _classify_failure_type_from_text(review):
"""历史失败模式回填用的本地分类器,避免 altcoin_db 反向依赖 review_engine。"""
signals = review.get("triggered_signals") or []
miss = review.get("miss_signals") or []
lesson = review.get("lesson") or ""
text = " ".join([str(x) for x in signals + miss]) + " " + str(lesson or "")
pnl = float(review.get("pnl_48h") or 0)
outcome = review.get("outcome") or ""
if any(k in text for k in ["过期", "历史", "旧放量", "age_bars", "已过期", "小时前", "旧起爆"]):
return "过期因子误判", "历史放量/起爆/突破不能当作当前触发信号,必须做时效闸门"
if any(k in text for k in ["假突破", "突破失败", "未站稳", "冲高回落"]):
return "假突破", "突破后没有站稳或快速回落,需要增加站稳/承接确认"
if any(k in text for k in ["量价背离", "缩量上涨", "放量下跌", "无量拉升"]):
return "量价背离", "价格动作与成交量不匹配,量能确认不足"
if any(k in text for k in ["高位", "追高", "涨幅过大", "乖离"]):
return "追高风险", "入场位置偏高,盈亏比和回撤风险恶化"
if any(k in text for k in ["承接不足", "无承接", "上影线", "砸盘"]):
return "高位无承接", "高位出现抛压但缺少买盘承接"
if any(k in text for k in ["板块退潮", "热点退潮", "龙头走弱", "板块分歧"]):
return "板块退潮", "板块热度回落,个币信号容易失效"
if any(k in text for k in ["BTC", "大盘", "反向共振", "系统性"]):
return "BTC/大盘反向共振", "大盘方向与个币信号冲突,需要宏观/主流币过滤"
if any(k in text for k in ["止损", "盈亏比", "RR", "止盈"]):
return "止损/盈亏比不合理", "止损或止盈结构不合理,导致信号收益风险不匹配"
if "滞后" in text or "MACD" in text or "RSI" in text:
return "滞后信号追高", "滞后指标占比高,容易形成事后确认/追高失败"
if "缺乏前瞻" in text or "前瞻" not in text:
return "前瞻信号不足", "缺少量价/PA等前瞻性确认"
if "横盘" in text or outcome == "横盘":
return "信号强度不足", "触发后未形成有效爆发,确认条件偏弱"
if "回撤" in text or pnl < -3:
return "入场点太晚", "入场后回撤/亏损明显,买点可能滞后或确认过慢"
return "未分类失败", "需要继续积累样本做二级归因"
def backfill_strategy_failure_patterns(limit=2000, dry_run=False):
"""从历史 review_log 回填失败模式库,按 rec_id 去重。"""
conn = get_conn()
rows = conn.execute("""
SELECT rl.*, r.strategy_version, r.max_drawdown_pct
FROM review_log rl
LEFT JOIN recommendation r ON r.id = rl.rec_id
WHERE rl.outcome IN ('失败','横盘')
ORDER BY rl.review_time DESC
LIMIT %s
""", (limit,)).fetchall()
existing = set()
for r in conn.execute("SELECT market_context_json FROM strategy_failure_pattern").fetchall():
ctx = _loads_json_field(r["market_context_json"], {})
if ctx.get("rec_id") is not None:
existing.add(str(ctx.get("rec_id")))
inserted = 0
skipped = 0
type_counts = {}
examples = []
for row in rows:
item = dict(row)
rec_id = item.get("rec_id")
if str(rec_id) in existing:
skipped += 1
continue
triggered = _loads_json_field(item.get("triggered_signals"), []) or []
miss = _loads_json_field(item.get("miss_signals"), []) or []
item["triggered_signals"] = triggered
item["miss_signals"] = miss
ftype, reason = _classify_failure_type_from_text(item)
type_counts[ftype] = type_counts.get(ftype, 0) + 1
if len(examples) < 10:
examples.append({"rec_id": rec_id, "symbol": item.get("symbol"), "failure_type": ftype, "reason": reason})
if not dry_run:
conn.execute("""
INSERT INTO strategy_failure_pattern (
created_at, symbol, version, failure_type, failure_reason, signal_combo,
market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
datetime.now().isoformat(), item.get("symbol") or "", item.get("strategy_version") or "",
ftype, reason, json.dumps(triggered, ensure_ascii=False, default=str),
json.dumps({"source": "history_backfill", "rec_id": rec_id, "outcome": item.get("outcome"), "review_time": item.get("review_time")}, ensure_ascii=False, default=str),
reason, float(item.get("pnl_48h") or 0), float(item.get("max_drawdown_pct") or 0), item.get("lesson") or "",
))
existing.add(str(rec_id))
inserted += 1
if not dry_run:
conn.commit()
conn.close()
return {"dry_run": dry_run, "scanned": len(rows), "inserted": inserted, "skipped_existing": skipped, "type_counts": type_counts, "examples": examples}
def generate_candidates_from_review_history(min_samples=20, min_bonus_confidence=55, max_penalty_confidence=40, dry_run=False):
"""从历史 review_log 自动生成候选规则池。"""
conn = get_conn()
rows = conn.execute("""
SELECT rl.*, r.max_drawdown_pct
FROM review_log rl
LEFT JOIN recommendation r ON r.id = rl.rec_id
ORDER BY rl.review_time DESC
""").fetchall()
buckets = {}
for row in rows:
item = dict(row)
triggered = _loads_json_field(item.get("triggered_signals"), []) or []
hit = _loads_json_field(item.get("hit_signals"), []) or []
miss = _loads_json_field(item.get("miss_signals"), []) or []
keys = {_candidate_signal_key(x) for x in list(triggered) + list(hit) + list(miss) if str(x).strip()}
if not keys:
continue
for key in keys:
b = buckets.setdefault(key, {"sample_size": 0, "success_count": 0, "fail_count": 0, "pnl_values": [], "dd_values": []})
b["sample_size"] += 1
if item.get("outcome") == "爆发":
b["success_count"] += 1
elif item.get("outcome") in ("失败", "横盘"):
b["fail_count"] += 1
b["pnl_values"].append(float(item.get("pnl_48h") or 0))
b["dd_values"].append(float(item.get("max_drawdown_pct") or 0))
generated = []
for key, b in buckets.items():
sample = b["sample_size"]
if sample < min_samples:
continue
resolved = b["success_count"] + b["fail_count"]
confidence = round(b["success_count"] / resolved * 100, 1) if resolved else 0
avg_pnl = round(sum(b["pnl_values"]) / len(b["pnl_values"]), 2) if b["pnl_values"] else 0
max_gain = round(max(b["pnl_values"]), 2) if b["pnl_values"] else 0
max_drawdown = round(min(b["dd_values"]), 2) if b["dd_values"] else 0
rule_type = "bonus" if confidence >= min_bonus_confidence and avg_pnl > 0 else "penalty" if confidence <= max_penalty_confidence else "observe"
if rule_type == "observe":
continue
status = _candidate_status_for_metrics(rule_type, sample, confidence, avg_pnl, "candidate")
if rule_type == "bonus":
desc = f"历史样本候选加分因子:{key},样本{sample},成功{b['success_count']},失败/横盘{b['fail_count']},置信{confidence}%,均值{avg_pnl}%"
else:
desc = f"历史样本候选惩罚因子:{key},样本{sample},成功{b['success_count']},失败/横盘{b['fail_count']},置信{confidence}%,均值{avg_pnl}%"
candidate = {
"source": "dirty_history_reference",
"rule_type": rule_type,
"signal_name": key,
"rule_description": desc,
"support_count": sample,
"success_count": b["success_count"],
"fail_count": b["fail_count"],
"avg_pnl": avg_pnl,
"max_gain": max_gain,
"max_drawdown": max_drawdown,
"confidence_score": confidence,
"sample_size": sample,
"status": status,
"source_ref": f"dirty_history:{key}",
}
generated.append(candidate)
if not dry_run:
upsert_strategy_rule_candidate(
source=candidate["source"], rule_type=rule_type, signal_name=key,
rule_description=desc, support_count=sample, success_count=b["success_count"],
fail_count=b["fail_count"], avg_pnl=avg_pnl, max_gain=max_gain,
max_drawdown=max_drawdown, confidence_score=confidence, sample_size=sample,
status=status, notes="历史review_log自动生成候选规则仍需灰度验证后才可发布",
source_ref=candidate["source_ref"],
)
if not dry_run:
conn.commit()
conn.close()
generated.sort(key=lambda x: (-x["sample_size"], x["rule_type"], -x["confidence_score"]))
return {"dry_run": dry_run, "review_rows": len(rows), "generated_count": len(generated), "generated": generated[:80]}
def dry_run_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65):
"""不写库的候选规则表现 dry-run用于复盘系统验收。"""
conn = get_conn()
candidates = [dict(r) for r in conn.execute("SELECT * FROM strategy_rule_candidate").fetchall()]
clean_started_at = _get_factor_recency_fixed_at()
if clean_started_at:
review_rows = conn.execute("""
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
FROM review_log rl
LEFT JOIN recommendation r ON r.id = rl.rec_id
WHERE r.rec_time >= %s
ORDER BY rl.review_time DESC
""", (clean_started_at,)).fetchall()
else:
review_rows = conn.execute("""
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
FROM review_log rl
LEFT JOIN recommendation r ON r.id = rl.rec_id
ORDER BY rl.review_time DESC
""").fetchall()
failure_rows = [dict(r) for r in conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall()]
try:
current_version = str(get_meta().get("strategy_version") or "").strip()
except Exception:
current_version = ""
conn.close()
review_items = []
for row in review_rows:
item = dict(row)
triggered = _loads_json_field(item.get("triggered_signals"), []) or []
hit = _loads_json_field(item.get("hit_signals"), []) or []
miss = _loads_json_field(item.get("miss_signals"), []) or []
all_sigs = list(triggered) + list(hit) + list(miss)
item["signal_keys"] = {_candidate_signal_key(x) for x in all_sigs}
item["all_signal_text"] = " ".join(str(x) for x in all_sigs)
review_items.append(item)
evaluated = []
for c in candidates:
status = c.get("status") or "candidate"
source = c.get("source") or ""
rule_type = c.get("rule_type") or ""
signal_name = c.get("signal_name") or ""
source_ref = c.get("source_ref") or ""
dirty_history = _is_dirty_history_candidate(c)
if dirty_history:
evaluated.append({**c, "sample_size": 0, "support_count": 0, "success_count": 0, "fail_count": 0,
"dry_run_status": "dirty_history", "release_gate_passed": False,
"gate_reason": "因子时效修复前的污染历史参考:不参与干净样本统计,不允许发布"})
continue
if status == "active":
evaluated.append({**c, "dry_run_status": "active", "release_gate_passed": True, "gate_reason": "已正式生效不参与dry-run降级"})
continue
if source.startswith("dual_attribution_failure") or source_ref.startswith("failure:") or rule_type == "penalty":
ftype = signal_name or source_ref.replace("failure:", "")
matched = [r for r in failure_rows if (r.get("failure_type") or "") == ftype or ftype in (r.get("failure_reason") or "")]
sample_size = len(matched)
success_count = 0
fail_count = sample_size
pnl_values = [float(r.get("pnl_pct") or 0) for r in matched]
dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched]
confidence = round(min(95, 45 + fail_count * 8), 1) if sample_size else float(c.get("confidence_score") or 0)
else:
key = signal_name or source_ref.replace("review:", "")
matched = [item for item in review_items if key and (key in item["signal_keys"] or key in item["all_signal_text"] or signal_name in item["all_signal_text"])]
sample_size = len(matched)
success_count = sum(1 for r in matched if r.get("outcome") == "爆发")
fail_count = sum(1 for r in matched if r.get("outcome") in ("失败", "横盘"))
pnl_values = [float(r.get("pnl_48h") or 0) for r in matched]
dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched]
resolved = success_count + fail_count
confidence = round(success_count / resolved * 100, 1) if resolved else float(c.get("confidence_score") or 0)
avg_pnl = round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else float(c.get("avg_pnl") or 0)
max_gain = round(max(pnl_values), 2) if pnl_values else float(c.get("max_gain") or 0)
max_drawdown = round(min(dd_values), 2) if dd_values else float(c.get("max_drawdown") or 0)
dry_status = _candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, status, min_gray_samples, min_gray_confidence)
gate_passed = dry_status in ("gray", "active")
if dry_status == "gray":
gate_reason = f"样本{sample_size}{min_gray_samples},置信{confidence}%≥{min_gray_confidence}avg_pnl={avg_pnl}%:可进入灰度,仍不升版"
elif dry_status == "rejected":
gate_reason = f"样本{sample_size}已足够但置信/收益不达标:淘汰,不允许发布"
else:
gate_reason = f"样本{sample_size}或置信{confidence}%不足:只研究不发布"
evaluated.append({
**c,
"sample_size": sample_size,
"support_count": sample_size,
"success_count": success_count,
"fail_count": fail_count,
"avg_pnl": avg_pnl,
"max_gain": max_gain,
"max_drawdown": max_drawdown,
"confidence_score": confidence,
"dry_run_status": dry_status,
"release_gate_passed": gate_passed,
"gate_reason": gate_reason,
})
gray_ready = [x for x in evaluated if x.get("dry_run_status") == "gray"]
active_ready = [x for x in evaluated if x.get("dry_run_status") == "active"]
rejected = [x for x in evaluated if x.get("dry_run_status") == "rejected"]
can_release = False
release_reason = "dry-run只评估候选规则表现不执行 learned_rules 写入或版本升级"
return {
"dry_run": True,
"current_version": current_version,
"review_sample_count": len(review_items),
"clean_started_at": clean_started_at,
"sample_window": "clean_after_factor_recency_fix" if clean_started_at else "all_history",
"dirty_history_candidate_count": sum(1 for c in candidates if _is_dirty_history_candidate(c)),
"candidate_count": len(candidates),
"gray_ready_count": len(gray_ready),
"active_count": len(active_ready),
"rejected_count": len(rejected),
"would_bump_version": can_release,
"release_reason": release_reason,
"gate_policy": {
"gray": f"sample_size≥{min_gray_samples} 且 confidence≥{min_gray_confidence} 且 avg_pnl>0penalty规则可不要求avg_pnl>0",
"reject": "sample_size≥8 且 confidence<35 或 avg_pnl≤-3",
"release": "dry-run不发布正式发布仍由复盘发布闸门统一控制",
},
"evaluated_candidates": sorted(evaluated, key=lambda x: (x.get("dry_run_status") != "gray", -float(x.get("sample_size") or 0), -float(x.get("confidence_score") or 0)))[:80],
}
def refresh_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65):
"""刷新候选规则表现。
用 review_log + recommendation 复盘结果回填候选规则的 sample/success/fail/avg_pnl
并按门槛自动 candidate/gray/rejectedactive 不降级。
"""
conn = get_conn()
candidates = conn.execute("SELECT * FROM strategy_rule_candidate").fetchall()
clean_started_at = _get_factor_recency_fixed_at()
if clean_started_at:
review_rows = conn.execute("""
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
FROM review_log rl
LEFT JOIN recommendation r ON r.id = rl.rec_id
WHERE r.rec_time >= %s
ORDER BY rl.review_time DESC
""", (clean_started_at,)).fetchall()
else:
review_rows = conn.execute("""
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
FROM review_log rl
LEFT JOIN recommendation r ON r.id = rl.rec_id
ORDER BY rl.review_time DESC
""").fetchall()
failure_rows = conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall()
def loads(value, fallback):
return _loads_json_field(value, fallback)
review_items = []
for row in review_rows:
item = dict(row)
triggered = loads(item.get("triggered_signals"), []) or []
hit = loads(item.get("hit_signals"), []) or []
miss = loads(item.get("miss_signals"), []) or []
all_sigs = list(triggered) + list(hit) + list(miss)
item["signal_keys"] = {_candidate_signal_key(x) for x in all_sigs}
item["all_signal_text"] = " ".join(str(x) for x in all_sigs)
review_items.append(item)
updated = []
for cand in candidates:
c = dict(cand)
cid = c["id"]
status = c.get("status") or "candidate"
if status == "active":
continue
source = c.get("source") or ""
rule_type = c.get("rule_type") or ""
signal_name = c.get("signal_name") or ""
source_ref = c.get("source_ref") or ""
desc = c.get("rule_description") or ""
if _is_dirty_history_candidate(c):
updated.append({
"id": cid, "signal_name": signal_name, "source": source, "rule_type": rule_type,
"sample_size": 0, "success_count": 0, "fail_count": 0, "confidence_score": c.get("confidence_score") or 0,
"avg_pnl": c.get("avg_pnl") or 0, "status": "dirty_history", "description": desc,
"gate_reason": "污染历史参考,不参与干净样本刷新",
})
continue
matched = []
if source.startswith("dual_attribution_failure") or source_ref.startswith("failure:") or rule_type == "penalty":
ftype = signal_name or source_ref.replace("failure:", "")
frows = [dict(r) for r in failure_rows if (r["failure_type"] or "") == ftype or ftype in (r["failure_reason"] or "")]
sample_size = len(frows)
success_count = 0
fail_count = sample_size
pnl_values = [float(r.get("pnl_pct") or 0) for r in frows]
dd_values = [float(r.get("max_drawdown_pct") or 0) for r in frows]
confidence = round(min(95, 45 + fail_count * 8), 1) if sample_size else float(c.get("confidence_score") or 0)
else:
key = signal_name or source_ref.replace("review:", "")
for item in review_items:
if key and (key in item["signal_keys"] or key in item["all_signal_text"] or signal_name in item["all_signal_text"]):
matched.append(item)
sample_size = len(matched)
success_count = sum(1 for r in matched if r.get("outcome") == "爆发")
fail_count = sum(1 for r in matched if r.get("outcome") in ("失败", "横盘"))
pnl_values = [float(r.get("pnl_48h") or 0) for r in matched]
dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched]
resolved = success_count + fail_count
confidence = round(success_count / resolved * 100, 1) if resolved else float(c.get("confidence_score") or 0)
avg_pnl = round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else float(c.get("avg_pnl") or 0)
max_gain = round(max(pnl_values), 2) if pnl_values else float(c.get("max_gain") or 0)
max_drawdown = round(min(dd_values), 2) if dd_values else float(c.get("max_drawdown") or 0)
new_status = _candidate_status_for_metrics(
rule_type, sample_size, confidence, avg_pnl, status,
min_gray_samples=min_gray_samples, min_gray_confidence=min_gray_confidence,
)
note = (c.get("notes") or "").strip()
audit_note = f"[{datetime.now().isoformat()}] 自动评估: 样本{sample_size}, 成功{success_count}, 失败{fail_count}, 置信{confidence}%, avg_pnl={avg_pnl}%, status={new_status}"
if audit_note not in note:
note = (note + "\n" if note else "") + audit_note
conn.execute("""
UPDATE strategy_rule_candidate
SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s,
max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s, notes=%s, created_at=%s
WHERE id=%s
""", (sample_size, success_count, fail_count, avg_pnl, max_gain, max_drawdown,
confidence, sample_size, new_status, note, datetime.now().isoformat(), cid))
updated.append({
"id": cid,
"signal_name": signal_name,
"source": source,
"rule_type": rule_type,
"sample_size": sample_size,
"success_count": success_count,
"fail_count": fail_count,
"confidence_score": confidence,
"avg_pnl": avg_pnl,
"status": new_status,
"description": desc,
})
conn.commit()
conn.close()
return updated
def get_strategy_iteration_dashboard(days=30):
"""迭代页聚合数据:总览 + 候选规则 + 失败模式 + 时间线。"""
summary = get_strategy_iteration_summary(days=days)
candidates = get_strategy_rule_candidates(limit=80)
failures = get_strategy_failure_patterns(limit=80)
logs = get_strategy_iteration_logs(limit=40)
status_counts = {}
source_counts = {}
for c in candidates:
status_counts[c.get("status") or "candidate"] = status_counts.get(c.get("status") or "candidate", 0) + 1
source_counts[c.get("source") or "unknown"] = source_counts.get(c.get("source") or "unknown", 0) + 1
failure_counts = {}
for f in failures:
ft = f.get("failure_type") or "未分类"
failure_counts[ft] = failure_counts.get(ft, 0) + 1
release_counts = {}
for log in logs:
rd = log.get("release_decision") or "unknown"
release_counts[rd] = release_counts.get(rd, 0) + 1
dry_run = dry_run_strategy_candidate_performance()
latest_log = logs[0] if logs else {}
return {
"summary": summary,
"overview": {
"total_logs": len(logs),
"candidate_count": len(candidates),
"candidate_status_counts": status_counts,
"candidate_source_counts": source_counts,
"failure_type_counts": [{"type": k, "count": v} for k, v in sorted(failure_counts.items(), key=lambda x: (-x[1], x[0]))],
"release_decision_counts": release_counts,
"latest_release_decision": latest_log.get("release_decision") or "hold",
"latest_release_reason": latest_log.get("release_reason") or latest_log.get("version_change_summary") or "暂无发布决策说明",
"dry_run_summary": {
"review_sample_count": dry_run.get("review_sample_count", 0),
"clean_started_at": dry_run.get("clean_started_at", ""),
"sample_window": dry_run.get("sample_window", "all_history"),
"dirty_history_candidate_count": dry_run.get("dirty_history_candidate_count", 0),
"candidate_count": dry_run.get("candidate_count", 0),
"gray_ready_count": dry_run.get("gray_ready_count", 0),
"rejected_count": dry_run.get("rejected_count", 0),
"would_bump_version": dry_run.get("would_bump_version", False),
"release_reason": dry_run.get("release_reason", ""),
},
},
"dry_run": dry_run,
"candidates": candidates,
"failures": failures,
"logs": logs,
}
def get_strategy_iteration_summary(days=30):
"""兼容导出:策略迭代汇总已迁移到 review_queries 模块。"""
from app.db.review_queries import get_strategy_iteration_summary as _get_strategy_iteration_summary
return _get_strategy_iteration_summary(days=days, conn_provider=get_conn, json_loader=_loads_json_field)
def log_cron_run(job_name, script_name, run_status, result_status="", started_at="", finished_at="",
duration_ms=0, summary=None, error_message=""):
"""记录一次 cron 运行汇总"""
conn = get_conn()
conn.execute("""
INSERT INTO cron_run_log (
job_name, script_name, run_status, result_status,
started_at, finished_at, duration_ms, summary_json, error_message
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
job_name,
script_name,
run_status,
result_status,
started_at or datetime.now().isoformat(),
finished_at or datetime.now().isoformat(),
int(duration_ms or 0),
json.dumps(summary or {}, ensure_ascii=False, default=str),
(error_message or "")[:1000],
))
conn.commit()
conn.close()
def get_cron_run_logs(limit=50, job_name=None):
"""获取 cron 运行日志列表"""
conn = get_conn()
sql = """
SELECT * FROM cron_run_log
{where_clause}
ORDER BY started_at DESC, id DESC
LIMIT %s
"""
params = []
where_clause = ""
if job_name:
where_clause = "WHERE job_name = %s"
params.append(job_name)
params.append(limit)
rows = conn.execute(sql.format(where_clause=where_clause), tuple(params)).fetchall()
conn.close()
result = []
for row in rows:
item = dict(row)
try:
item["summary_json"] = json.loads(item.get("summary_json") or "{}")
except Exception:
item["summary_json"] = {}
result.append(item)
return result
def get_cron_run_summary(hours=24):
"""获取 cron 运行汇总统计"""
conn = get_conn()
now_iso = datetime.now().isoformat()
rows = conn.execute("""
SELECT * FROM cron_run_log
WHERE started_at >= %s
ORDER BY started_at DESC, id DESC
""", ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),)).fetchall()
conn.close()
logs = []
job_stats = {}
total_runs = 0
success_runs = 0
error_runs = 0
total_duration = 0
for row in rows:
item = dict(row)
try:
item["summary_json"] = json.loads(item.get("summary_json") or "{}")
except Exception:
item["summary_json"] = {}
logs.append(item)
total_runs += 1
total_duration += item.get("duration_ms") or 0
if item.get("run_status") == "success":
success_runs += 1
else:
error_runs += 1
job = item.get("job_name") or "unknown"
stat = job_stats.setdefault(job, {
"job_name": job,
"runs": 0,
"success_runs": 0,
"error_runs": 0,
"avg_duration_ms": 0,
"last_status": "",
"last_result_status": "",
"last_started_at": "",
"last_finished_at": "",
"last_error_message": "",
})
stat["runs"] += 1
if item.get("run_status") == "success":
stat["success_runs"] += 1
else:
stat["error_runs"] += 1
stat["avg_duration_ms"] += item.get("duration_ms") or 0
if not stat["last_started_at"]:
stat["last_status"] = item.get("run_status", "")
stat["last_result_status"] = item.get("result_status", "")
stat["last_started_at"] = item.get("started_at", "")
stat["last_finished_at"] = item.get("finished_at", "")
stat["last_error_message"] = item.get("error_message", "")
for stat in job_stats.values():
stat["success_rate"] = round(stat["success_runs"] / stat["runs"] * 100, 1) if stat["runs"] else 0
stat["avg_duration_ms"] = round(stat["avg_duration_ms"] / stat["runs"]) if stat["runs"] else 0
overall = {
"hours": hours,
"total_runs": total_runs,
"success_runs": success_runs,
"error_runs": error_runs,
"success_rate": round(success_runs / total_runs * 100, 1) if total_runs else 0,
"avg_duration_ms": round(total_duration / total_runs) if total_runs else 0,
}
return {
"overall": overall,
"job_stats": sorted(job_stats.values(), key=lambda x: x["job_name"]),
"recent_logs": logs[:20],
}
if __name__ == "__main__":
init_db()
stats = get_stats()
print(f"DB初始化完成: {stats}")
def _safe_list_json(value):
try:
if isinstance(value, list):
return value
if isinstance(value, str) and value.strip():
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else []
except Exception:
pass
return []
def _safe_dict_json(value):
try:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
pass
return {}
def get_strategy_insights():
"""阶段3策略可信度看板数据 — 总体表现、因子归因、市场环境归因。
只统计已出结果的样本,避免策略页把仍在实时看板里的 active 浮亏/回撤
与历史推荐页的已完成样本混在一起,造成口径不一致。
"""
conn = get_conn()
rows = conn.execute("SELECT * FROM recommendation ORDER BY rec_time DESC").fetchall()
conn.close()
raw_items = [dict(r) for r in rows]
def outcome(item):
status = item.get("status") or ""
if status in ("hit_tp1", "hit_tp2"):
return "success"
if status == "stopped_out":
return "failed"
if (item.get("max_pnl_pct") or 0) >= 5:
return "success"
if (item.get("pnl_pct") or 0) <= -3 or (item.get("max_drawdown_pct") or 0) <= -5:
return "failed"
return "pending"
items = [x for x in raw_items if outcome(x) in ("success", "failed")]
total = len(items)
success = sum(1 for x in items if outcome(x) == "success")
failed = sum(1 for x in items if outcome(x) == "failed")
resolved = success + failed
pnl_values = [float(x.get("pnl_pct") or 0) for x in items]
gains = [p for p in pnl_values if p > 0]
losses = [p for p in pnl_values if p < 0]
overview = {
"total_signals": total,
"resolved_count": resolved,
"success_count": success,
"failed_count": failed,
"pending_count": total - resolved,
"win_rate_pct": round(success / resolved * 100, 1) if resolved else 0,
"avg_pnl_pct": round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else 0,
"avg_gain_pct": round(sum(gains) / len(gains), 2) if gains else 0,
"avg_loss_pct": round(sum(losses) / len(losses), 2) if losses else 0,
"max_gain_pct": round(max([float(x.get("max_pnl_pct") or x.get("pnl_pct") or 0) for x in items] or [0]), 2),
"max_drawdown_pct": round(min([float(x.get("max_drawdown_pct") or 0) for x in items] or [0]), 2),
}
def add_bucket(bucket_map, key, item):
if not key:
return
b = bucket_map.setdefault(key, {"total_count": 0, "success_count": 0, "failed_count": 0, "pending_count": 0, "pnl_values": [], "max_gains": [], "drawdowns": []})
b["total_count"] += 1
oc = outcome(item)
if oc == "success": b["success_count"] += 1
elif oc == "failed": b["failed_count"] += 1
else: b["pending_count"] += 1
b["pnl_values"].append(float(item.get("pnl_pct") or 0))
b["max_gains"].append(float(item.get("max_pnl_pct") or item.get("pnl_pct") or 0))
b["drawdowns"].append(float(item.get("max_drawdown_pct") or 0))
def env_buckets_from_market_context(mc):
"""把当前实际存在的 market_context_json 数值字段转成可归因桶。
旧版本只读取 btc_trend/market_regime 等枚举字段,但当前入库字段主要是
change_24h、turnover_acceleration、volume_24h/funding_rate导致市场环境归因为空。
"""
buckets = []
try:
change_24h = float(mc.get("change_24h", 0) or 0)
turn_1h = float(mc.get("turnover_acceleration_1h", 0) or 0)
turn_4h = float(mc.get("turnover_acceleration_4h", 0) or 0)
volume_24h = float(mc.get("volume_24h") or mc.get("quote_volume_24h") or 0)
funding = float(mc.get("funding_rate", 0) or 0)
except Exception:
change_24h = turn_1h = turn_4h = volume_24h = funding = 0
if change_24h >= 8:
buckets.append("24h涨幅:强势拉升≥8%")
elif change_24h >= 3:
buckets.append("24h涨幅:温和上涨3-8%")
elif change_24h <= -3:
buckets.append("24h涨幅:回撤≤-3%")
else:
buckets.append("24h涨幅:震荡-3~3%")
if turn_1h >= 3:
buckets.append("1h成交加速:爆量≥3x")
elif turn_1h >= 1.5:
buckets.append("1h成交加速:放量1.5-3x")
elif turn_1h > 0:
buckets.append("1h成交加速:平量<1.5x")
if turn_4h >= 3:
buckets.append("4h成交加速:爆量≥3x")
elif turn_4h >= 1.5:
buckets.append("4h成交加速:放量1.5-3x")
elif turn_4h > 0:
buckets.append("4h成交加速:平量<1.5x")
if volume_24h >= 100_000_000:
buckets.append("24h成交额:高流动性≥1亿")
elif volume_24h >= 10_000_000:
buckets.append("24h成交额:中等流动性1千万-1亿")
elif volume_24h > 0:
buckets.append("24h成交额:低流动性<1千万")
if funding >= 0.0005:
buckets.append("资金费率:多头拥挤")
elif funding <= -0.0005:
buckets.append("资金费率:空头拥挤")
return buckets
factor_map = {}
env_map = {}
version_map = {}
for item in items:
for factor in _safe_list_json(item.get("signals")):
add_bucket(factor_map, str(factor).strip(), item)
mc = _safe_dict_json(item.get("market_context_json"))
added_env = False
for key in ("btc_trend", "market_regime", "altcoin_regime", "sentiment"):
if mc.get(key):
add_bucket(env_map, f"{key}:{mc.get(key)}", item)
added_env = True
for bucket in env_buckets_from_market_context(mc):
add_bucket(env_map, bucket, item)
added_env = True
if item.get("strategy_version"):
add_bucket(version_map, str(item.get("strategy_version")).strip(), item)
def version_sort_key(version: str):
text = str(version or '').strip()
if text.startswith('v') or text.startswith('V'):
text = text[1:]
parts = []
for chunk in text.replace('-', '.').split('.'):
if chunk.isdigit():
parts.append(int(chunk))
else:
m = re.match(r'^(\d+)', chunk)
if m:
parts.append(int(m.group(1)))
else:
parts.append(chunk)
return tuple(parts)
def serialize(name_key, bucket_map, sort_by_version=False):
rows = []
for key, b in bucket_map.items():
resolved_count = b["success_count"] + b["failed_count"]
rows.append({
name_key: key,
"total_count": b["total_count"],
"success_count": b["success_count"],
"failed_count": b["failed_count"],
"pending_count": b["pending_count"],
"win_rate_pct": round(b["success_count"] / resolved_count * 100, 1) if resolved_count else 0,
"avg_pnl_pct": round(sum(b["pnl_values"]) / len(b["pnl_values"]), 2) if b["pnl_values"] else 0,
"max_gain_pct": round(max(b["max_gains"] or [0]), 2),
"max_drawdown_pct": round(min(b["drawdowns"] or [0]), 2),
})
if sort_by_version:
rows.sort(key=lambda x: (version_sort_key(x[name_key]), x["total_count"], x["win_rate_pct"]), reverse=True)
else:
rows.sort(key=lambda x: (-x["total_count"], -x["win_rate_pct"], x[name_key]))
return rows
return {
"overview": overview,
"factor_attribution": serialize("factor", factor_map)[:30],
"market_environment": serialize("environment", env_map)[:20],
"version_performance": serialize("strategy_version", version_map, sort_by_version=True)[:20],
}