2446 lines
109 KiB
Python
2446 lines
109 KiB
Python
"""
|
||
山寨币监控 — 数据库层
|
||
全量记录筛选结果 + 价格跟踪 + 盈亏验证
|
||
"""
|
||
|
||
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>0(penalty规则可不要求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/rejected,active 不降级。
|
||
"""
|
||
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],
|
||
}
|