alphax/app/db/altcoin_db.py
2026-05-14 01:20:47 +08:00

2761 lines
124 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""
山寨币监控 — 数据库层
全量记录筛选结果 + 价格跟踪 + 盈亏验证
"""
import sqlite3
import json
import os
import re
from datetime import datetime, timedelta
from pathlib import Path
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
REPO_ROOT = Path(__file__).resolve().parents[2]
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
def get_conn():
# SQLite 并发治理Web/API/cron 共用同一 DB连接必须等待写锁而不是立即失败。
# journal_mode=WAL 只在初始化/首次连接时设置busy_timeout 让短写事务排队。
conn = sqlite3.connect(DB_PATH, timeout=30, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=30000")
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def init_db():
conn = get_conn()
# 1. 状态跟踪表(原有的)
conn.execute("""
CREATE TABLE IF NOT EXISTS coin_state (
symbol TEXT PRIMARY KEY,
state TEXT NOT NULL DEFAULT '蓄力',
score INTEGER DEFAULT 0,
anomaly_type TEXT DEFAULT '',
sector TEXT DEFAULT '',
leader_status TEXT DEFAULT '',
detected_at TEXT NOT NULL,
last_alert_time TEXT DEFAULT '',
last_alert_level TEXT DEFAULT '',
detail_json TEXT DEFAULT '{}'
)
""")
# 2. 筛选记录表(每次筛选全量写入)
conn.execute("""
CREATE TABLE IF NOT EXISTS screening_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_time TEXT NOT NULL,
layer TEXT NOT NULL, -- '粗筛'/'细筛'/'确认'
symbol TEXT NOT NULL,
state TEXT NOT NULL, -- 蓄力/加速/爆发/过期
score INTEGER DEFAULT 0,
price REAL NOT NULL, -- 筛选时价格
signals TEXT DEFAULT '', -- 信号列表(json array)
sector TEXT DEFAULT '',
leader_status TEXT DEFAULT '',
is_meme INTEGER DEFAULT 0,
change_24h REAL DEFAULT 0,
funding_rate REAL DEFAULT 0,
detail_json TEXT DEFAULT '{}'
)
""")
# 3. 推荐表(加速/爆发时生成推荐记录,跟踪最终盈亏)
conn.execute("""
CREATE TABLE IF NOT EXISTS recommendation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
rec_time TEXT NOT NULL, -- 推荐时间
rec_state TEXT NOT NULL, -- 加速/爆发
rec_score INTEGER DEFAULT 0,
entry_price REAL NOT NULL, -- 推荐时价格
stop_loss REAL DEFAULT 0,
tp1 REAL DEFAULT 0,
tp2 REAL DEFAULT 0,
sector TEXT DEFAULT '',
signals TEXT DEFAULT '', -- 触发信号(json)
is_meme INTEGER DEFAULT 0,
status TEXT DEFAULT 'active', -- active/hit_tp1/hit_tp2/stopped_out/expired
current_price REAL DEFAULT 0, -- 最新跟踪价格
max_price REAL DEFAULT 0, -- 推荐后最高价
min_price REAL DEFAULT 0, -- 推荐后最低价
pnl_pct REAL DEFAULT 0, -- 当前盈亏%
max_pnl_pct REAL DEFAULT 0, -- 最大盈亏%
max_drawdown_pct REAL DEFAULT 0, -- 最大回撤%
hit_tp1_time TEXT DEFAULT '',
hit_tp2_time TEXT DEFAULT '',
stopped_out_time TEXT DEFAULT '',
expired_time TEXT DEFAULT '',
last_track_time TEXT DEFAULT '',
entry_plan_json TEXT DEFAULT '{}',
action_status TEXT DEFAULT '持有', -- 持有/可即刻买入/等回踩/衰减/止损/止盈1/止盈2/跟踪止盈/反转
strategy_version TEXT DEFAULT '',
direction TEXT DEFAULT '中性',
force_reason TEXT DEFAULT '',
base_state TEXT DEFAULT '',
sector_signal_count INTEGER DEFAULT 0,
market_context_json TEXT DEFAULT '{}',
derivatives_context_json TEXT DEFAULT '{}',
sector_context_json TEXT DEFAULT '{}'
)
""")
# 4. 价格跟踪表(定时快照推荐币的当前价格)
conn.execute("""
CREATE TABLE IF NOT EXISTS price_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rec_id INTEGER NOT NULL, -- 关联recommendation.id
symbol TEXT NOT NULL,
track_time TEXT NOT NULL,
price REAL NOT NULL,
pnl_pct REAL DEFAULT 0,
FOREIGN KEY (rec_id) REFERENCES recommendation(id)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_price_tracking_rec_id_id ON price_tracking(rec_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_price_tracking_rec_time ON price_tracking(rec_id, track_time DESC)")
# 4.1 最新价格缓存表(看板/关注池读取现价用小表price_tracking 只保留高频流水/审计用途)
conn.execute("""
CREATE TABLE IF NOT EXISTS latest_price_cache (
symbol TEXT PRIMARY KEY,
price REAL NOT NULL,
updated_at TEXT NOT NULL,
source TEXT DEFAULT 'tracker'
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_latest_price_cache_updated_at ON latest_price_cache(updated_at)")
# 5. Cron运行日志每次粗筛/确认/跟踪执行都写一条汇总)
conn.execute("""
CREATE TABLE IF NOT EXISTS cron_run_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_name TEXT NOT NULL, -- 粗筛/确认/跟踪
script_name TEXT NOT NULL, -- altcoin_screener.py / altcoin_confirm.py / price_tracker.py
run_status TEXT NOT NULL, -- success / error
result_status TEXT DEFAULT '', -- screened / no_candidates / confirmed / tracked / exception ...
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
duration_ms INTEGER DEFAULT 0,
summary_json TEXT DEFAULT '{}',
error_message TEXT DEFAULT ''
)
""")
conn.commit()
# 迁移为已有recommendation表添加action_status字段
try:
conn.execute("ALTER TABLE recommendation ADD COLUMN action_status TEXT DEFAULT '持有'")
conn.commit()
print("DB迁移: recommendation表已添加action_status字段")
except Exception:
pass # 字段已存在,忽略
# 迁移为已有recommendation表添加direction字段(多头启动/空头启动/中性)
try:
conn.execute("ALTER TABLE recommendation ADD COLUMN direction TEXT DEFAULT '中性'")
conn.commit()
print("DB迁移: recommendation表已添加direction字段")
except Exception:
pass # 字段已存在,忽略
# 迁移为recommendation补充增强上下文字段
for sql, message in [
("ALTER TABLE recommendation ADD COLUMN force_reason TEXT DEFAULT ''", "DB迁移: recommendation表已添加force_reason字段"),
("ALTER TABLE recommendation ADD COLUMN base_state TEXT DEFAULT ''", "DB迁移: recommendation表已添加base_state字段"),
("ALTER TABLE recommendation ADD COLUMN sector_signal_count INTEGER DEFAULT 0", "DB迁移: recommendation表已添加sector_signal_count字段"),
("ALTER TABLE recommendation ADD COLUMN market_context_json TEXT DEFAULT '{}'", "DB迁移: recommendation表已添加market_context_json字段"),
("ALTER TABLE recommendation ADD COLUMN derivatives_context_json TEXT DEFAULT '{}'", "DB迁移: recommendation表已添加derivatives_context_json字段"),
("ALTER TABLE recommendation ADD COLUMN sector_context_json TEXT DEFAULT '{}'", "DB迁移: recommendation表已添加sector_context_json字段"),
("ALTER TABLE recommendation ADD COLUMN lifecycle_state TEXT DEFAULT 'watching'", "DB迁移: recommendation表已添加lifecycle_state字段"),
("ALTER TABLE recommendation ADD COLUMN display_bucket TEXT DEFAULT 'watch_pool'", "DB迁移: recommendation表已添加display_bucket字段"),
("ALTER TABLE recommendation ADD COLUMN execution_status TEXT DEFAULT 'observe'", "DB迁移: recommendation表已添加execution_status字段"),
("ALTER TABLE recommendation ADD COLUMN state_reason TEXT DEFAULT ''", "DB迁移: recommendation表已添加state_reason字段"),
("ALTER TABLE recommendation ADD COLUMN entry_triggered INTEGER DEFAULT 0", "DB迁移: recommendation表已添加entry_triggered字段"),
("ALTER TABLE recommendation ADD COLUMN archived_at TEXT DEFAULT ''", "DB迁移: recommendation表已添加archived_at字段"),
("ALTER TABLE recommendation ADD COLUMN signal_codes_json TEXT DEFAULT '[]'", "DB迁移: recommendation表已添加signal_codes_json字段"),
("ALTER TABLE recommendation ADD COLUMN signal_labels_json TEXT DEFAULT '[]'", "DB迁移: recommendation表已添加signal_labels_json字段"),
]:
try:
conn.execute(sql)
conn.commit()
print(message)
except Exception:
pass # 字段已存在,忽略
try:
conn.execute("CREATE INDEX IF NOT EXISTS idx_rec_active_symbol_bucket ON recommendation(symbol, status, display_bucket, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_rec_display_bucket_time ON recommendation(display_bucket, rec_time DESC)")
conn.commit()
except Exception:
pass
# 5. 信号绩效表(每个信号类型的命中率统计,用于动态调权)
conn.execute("""
CREATE TABLE IF NOT EXISTS signal_performance (
signal_type TEXT PRIMARY KEY, -- 信号名称如"N倍放量""MACD金叉""动K起爆"
category TEXT DEFAULT '', -- 分类: 前瞻/滞后/PA/量价
total_count INTEGER DEFAULT 0, -- 总出现次数
hit_count INTEGER DEFAULT 0, -- 命中次数(推荐后48h内涨>5%)
miss_count INTEGER DEFAULT 0, -- 失手次数(推荐后48h内没涨或跌)
hit_rate REAL DEFAULT 0, -- 命中率=hit/(hit+miss)
avg_pnl REAL DEFAULT 0, -- 该信号关联推荐的平均盈亏%
weight REAL DEFAULT 1.0, -- 当前权重(动态调整)
last_updated TEXT DEFAULT '' -- 最后更新时间
)
""")
# 6. 复盘记录表(每条推荐的复盘归因)
conn.execute("""
CREATE TABLE IF NOT EXISTS review_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rec_id INTEGER NOT NULL, -- 关联recommendation.id
symbol TEXT NOT NULL,
review_time TEXT NOT NULL, -- 复盘时间
outcome TEXT NOT NULL, -- 爆发/横盘/失败
pnl_48h REAL DEFAULT 0, -- 48小时盈亏%
max_pnl_48h REAL DEFAULT 0, -- 48小时最大盈亏%
triggered_signals TEXT DEFAULT '', -- 当时触发的信号(json)
hit_signals TEXT DEFAULT '', -- 哪些信号命中了(真正起作用的)
miss_signals TEXT DEFAULT '', -- 哪些信号是假信号(没起作用)
lesson TEXT DEFAULT '', -- 复盘教训总结
FOREIGN KEY (rec_id) REFERENCES recommendation(id)
)
""")
# 7. 漏选复盘表(没选但后来爆发的币)
conn.execute("""
CREATE TABLE IF NOT EXISTS missed_explosions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
detect_time TEXT NOT NULL, -- 发现时间
price_at_detect REAL DEFAULT 0, -- 发现时价格
price_before REAL DEFAULT 0, -- 爆发前价格
gain_pct REAL DEFAULT 0, -- 爆发涨幅%
reason_missed TEXT DEFAULT '', -- 为什么没选(粗筛没过/细筛淘汰/确认没过)
features_detected TEXT DEFAULT '', -- 爆发时有什么特征(json)
lesson TEXT DEFAULT '' -- 漏选教训
)
""")
# 8. 策略迭代日志(每天复盘/优化过程显式落库)
conn.execute("""
CREATE TABLE IF NOT EXISTS strategy_iteration_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_date TEXT NOT NULL,
created_at TEXT NOT NULL,
trigger_source TEXT DEFAULT 'daily_review',
title TEXT NOT NULL,
summary TEXT DEFAULT '',
findings_json TEXT DEFAULT '[]',
problems_json TEXT DEFAULT '[]',
actions_json TEXT DEFAULT '[]',
changed_rules_json TEXT DEFAULT '[]',
metrics_json TEXT DEFAULT '{}',
related_symbols_json TEXT DEFAULT '[]',
config_diff_json TEXT DEFAULT '{}',
effect_summary_json TEXT DEFAULT '{}',
pollution_summary_json TEXT DEFAULT '{}',
strategy_version TEXT DEFAULT '',
version_change_summary TEXT DEFAULT ''
)
""")
# 8.1 候选规则表:研究结论先入候选,不直接发布到主版本
conn.execute("""
CREATE TABLE IF NOT EXISTS strategy_rule_candidate (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
source TEXT DEFAULT '',
rule_type TEXT DEFAULT '',
signal_name TEXT DEFAULT '',
rule_description TEXT DEFAULT '',
support_count INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
avg_pnl REAL DEFAULT 0,
max_gain REAL DEFAULT 0,
max_drawdown REAL DEFAULT 0,
confidence_score REAL DEFAULT 0,
sample_size INTEGER DEFAULT 0,
status TEXT DEFAULT 'candidate',
release_version TEXT DEFAULT '',
notes TEXT DEFAULT '',
source_ref TEXT DEFAULT ''
)
""")
# 8.2 失败模式表:专门记录失败样本与原因分类
conn.execute("""
CREATE TABLE IF NOT EXISTS strategy_failure_pattern (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
symbol TEXT NOT NULL,
version TEXT DEFAULT '',
failure_type TEXT DEFAULT '',
failure_reason TEXT DEFAULT '',
signal_combo TEXT DEFAULT '[]',
market_context_json TEXT DEFAULT '{}',
entry_quality_issue TEXT DEFAULT '',
pnl_pct REAL DEFAULT 0,
max_drawdown_pct REAL DEFAULT 0,
lesson TEXT DEFAULT ''
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS push_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
push_type TEXT NOT NULL,
action_status TEXT DEFAULT '',
pushed_at TEXT NOT NULL
)
""")
_push_cols = [row[1] for row in conn.execute("PRAGMA table_info(push_log)").fetchall()]
if "rec_id" not in _push_cols:
conn.execute("ALTER TABLE push_log ADD COLUMN rec_id INTEGER DEFAULT 0")
conn.execute("CREATE INDEX IF NOT EXISTS idx_push_lookup ON push_log(symbol, push_type, pushed_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_push_log_rec_action ON push_log(rec_id, push_type, action_status, pushed_at)")
# strategy_iteration_log 表字段补齐迁移
iter_cols = [r[1] for r in conn.execute("PRAGMA table_info(strategy_iteration_log)").fetchall()]
if iter_cols:
if "config_diff_json" not in iter_cols:
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN config_diff_json TEXT DEFAULT '{}' ")
if "effect_summary_json" not in iter_cols:
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN effect_summary_json TEXT DEFAULT '{}' ")
if "pollution_summary_json" not in iter_cols:
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN pollution_summary_json TEXT DEFAULT '{}' ")
if "strategy_version" not in iter_cols:
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN strategy_version TEXT DEFAULT '' ")
if "version_change_summary" not in iter_cols:
conn.execute("ALTER TABLE strategy_iteration_log ADD COLUMN version_change_summary TEXT DEFAULT '' ")
for col, default in [
("success_analysis_json", "'{}'"),
("failure_analysis_json", "'{}'"),
("candidate_rules_json", "'[]'"),
("release_decision", "''"),
("release_reason", "''"),
("confidence_level", "''"),
("promotion_state", "'research_only'"),
]:
if col not in iter_cols:
conn.execute(f"ALTER TABLE strategy_iteration_log ADD COLUMN {col} TEXT DEFAULT {default} ")
cand_cols = [row["name"] for row in conn.execute("PRAGMA table_info(strategy_rule_candidate)").fetchall()]
if cand_cols and "source_ref" not in cand_cols:
conn.execute("ALTER TABLE strategy_rule_candidate ADD COLUMN source_ref TEXT DEFAULT '' ")
conn.execute("CREATE INDEX IF NOT EXISTS idx_rule_candidate_status ON strategy_rule_candidate(status, created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_failure_pattern_type ON strategy_failure_pattern(failure_type, created_at)")
# 9. 舆情事件表sentiment_monitor 采集供PA共振加权
conn.execute("""
CREATE TABLE IF NOT EXISTS sentiment_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
name TEXT DEFAULT '',
source TEXT NOT NULL,
event_type TEXT NOT NULL,
trend_rank INTEGER DEFAULT 0,
trend_score INTEGER DEFAULT 0,
market_cap_rank INTEGER DEFAULT 0,
extra_json TEXT DEFAULT '{}',
detected_at TEXT NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_sentiment_lookup ON sentiment_events(symbol, source, detected_at)")
conn.commit()
print("DB初始化完成(9+1表)")
# === 推送去重 ===
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=? AND push_type=? AND pushed_at > ? 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=? AND push_type=? AND pushed_at > ? 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:
cols = [row[1] for row in conn.execute("PRAGMA table_info(push_log)").fetchall()]
if "rec_id" in cols:
conn.execute(
"INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (?,?,?,?,?)",
(symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()),
)
else:
conn.execute(
"INSERT INTO push_log (symbol, push_type, action_status, pushed_at) VALUES (?,?,?,?)",
(symbol, push_type, action_status, 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=?
""", (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
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=? 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=?, rec_score=?, sector=COALESCE(NULLIF(?, ''), sector),
signals=?, signal_codes_json=?, signal_labels_json=?, is_meme=?, direction=?, strategy_version=?,
force_reason=COALESCE(NULLIF(?, ''), force_reason),
base_state=COALESCE(NULLIF(?, ''), base_state),
sector_signal_count=MAX(COALESCE(sector_signal_count,0), ?),
entry_plan_json=CASE WHEN ? != '{}' THEN ? ELSE entry_plan_json END,
market_context_json=?, derivatives_context_json=?, sector_context_json=?,
action_status=CASE
WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈','衰减','反转') THEN action_status
ELSE COALESCE(NULLIF(?, ''), action_status)
END,
execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=?
WHERE id=?
""", (
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
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.lastrowid
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=?", (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=?", (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=?, max_price=?, min_price=?,
pnl_pct=?, max_pnl_pct=?, max_drawdown_pct=?,
status=?, action_status=?, execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=?, last_track_time=?,
hit_tp1_time=CASE WHEN ?='hit_tp1' THEN ? ELSE hit_tp1_time END,
hit_tp2_time=CASE WHEN ?='hit_tp2' THEN ? ELSE hit_tp2_time END,
stopped_out_time=CASE WHEN ?='stopped_out' THEN ? ELSE stopped_out_time END
WHERE id=?
""", (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=?, max_price=?, min_price=?,
pnl_pct=?, max_pnl_pct=?, max_drawdown_pct=?,
last_track_time=?,
hit_tp1_time=CASE WHEN ? THEN COALESCE(NULLIF(hit_tp1_time,''), ?) ELSE hit_tp1_time END
WHERE id=?
""", (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 (?, ?, ?, ?, ?)
""", (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().timestamp() - hours * 3600
conn.execute("""
UPDATE recommendation SET status='expired', expired_time=?
WHERE status='active' AND julianday(?) - julianday(rec_time) > ?
""", (datetime.now().isoformat(), datetime.now().isoformat(), hours / 24.0))
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 (?, ?, ?, ?)
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(["?"] * 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=?
""", (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=?, entry_plan_json=?, current_price=?, max_price=?, min_price=?,
pnl_pct=?, max_pnl_pct=?, max_drawdown_pct=?, last_track_time=?,
execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=?,
rec_time=CASE WHEN ? THEN ? ELSE rec_time END,
entry_price=CASE WHEN ? THEN ? ELSE entry_price END
WHERE id=?
""", (
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=?, execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=?
WHERE id=?""",
(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=?
""", (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=?, entry_plan_json=?, execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=? WHERE id=?
""", (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=?, execution_status=?, display_bucket=?, lifecycle_state=?, entry_triggered=?, state_reason=? WHERE id=?
""", (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=?, entry_price=?, current_price=?, pnl_pct=0 WHERE id=?",
(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 ""
if action_status == "可即刻买入" or execution_status == "buy_now":
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=?"
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(["?"] * len(symbols)) + ")"
params.extend(symbols)
try:
hours = float(hours or 0)
except Exception:
hours = 0
if hours > 0:
where += " AND julianday(?) - julianday(rec_time) <= ?"
params.extend([datetime.now().isoformat(), hours / 24.0])
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:
return all_items
page_items = all_items[offset: offset + limit] if limit else all_items[offset:]
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 julianday(?) - julianday(scan_time) < ?
ORDER BY score DESC, scan_time DESC LIMIT ?
""", (datetime.now().isoformat(), hours / 24.0, 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=?", (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=?, score=?, anomaly_type=?, sector=?,
leader_status=?, detected_at=?, detail_json=?
WHERE symbol=?
""", (
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=?, last_alert_level=? WHERE symbol=?
""", (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
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():
"""获取需要确认层检查的候选(加速状态+score≥5"""
conn = get_conn()
rows = conn.execute("""
SELECT * FROM coin_state WHERE state IN ('加速', '蓄力') AND score >= 5
""").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 julianday(?) - julianday(detected_at) > ?
""", (datetime.now().isoformat(), hours / 24.0))
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (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=?", (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=?, hit_count=?, miss_count=?,
hit_rate=?, avg_pnl=?, weight=?, last_updated=?
WHERE signal_type=?
""", (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 (?, ?, 1, ?, ?, ?, ?, ?, ?)
""", (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 (?, ?, ?, ?, ?, ?, ?, ?)
""", (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=? AND rule_type=? AND signal_name=? AND rule_description=?
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=?, success_count=?, fail_count=?, avg_pnl=?, max_gain=?,
max_drawdown=?, confidence_score=?, sample_size=?, status=?,
release_version=?, notes=?, source_ref=COALESCE(NULLIF(?, ''), source_ref), created_at=?
WHERE id=?
""", (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (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.lastrowid
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
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=?"
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 ?
""", (*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=?", (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=?, release_version=COALESCE(NULLIF(?, ''), release_version), notes=?, created_at=?
WHERE id=?
""", (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 ?
""", (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 ?
""", (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
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 >= ?
ORDER BY rl.review_time DESC
""", (clean_started_at,)).fetchall()
else:
review_rows = conn.execute("""
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
FROM review_log rl
LEFT JOIN recommendation r ON r.id = rl.rec_id
ORDER BY rl.review_time DESC
""").fetchall()
failure_rows = [dict(r) for r in conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall()]
try:
current_version = str(get_meta().get("strategy_version") or "").strip()
except Exception:
current_version = ""
conn.close()
review_items = []
for row in review_rows:
item = dict(row)
triggered = _loads_json_field(item.get("triggered_signals"), []) or []
hit = _loads_json_field(item.get("hit_signals"), []) or []
miss = _loads_json_field(item.get("miss_signals"), []) or []
all_sigs = list(triggered) + list(hit) + list(miss)
item["signal_keys"] = {_candidate_signal_key(x) for x in all_sigs}
item["all_signal_text"] = " ".join(str(x) for x in all_sigs)
review_items.append(item)
evaluated = []
for c in candidates:
status = c.get("status") or "candidate"
source = c.get("source") or ""
rule_type = c.get("rule_type") or ""
signal_name = c.get("signal_name") or ""
source_ref = c.get("source_ref") or ""
dirty_history = _is_dirty_history_candidate(c)
if dirty_history:
evaluated.append({**c, "sample_size": 0, "support_count": 0, "success_count": 0, "fail_count": 0,
"dry_run_status": "dirty_history", "release_gate_passed": False,
"gate_reason": "因子时效修复前的污染历史参考:不参与干净样本统计,不允许发布"})
continue
if status == "active":
evaluated.append({**c, "dry_run_status": "active", "release_gate_passed": True, "gate_reason": "已正式生效不参与dry-run降级"})
continue
if source.startswith("dual_attribution_failure") or source_ref.startswith("failure:") or rule_type == "penalty":
ftype = signal_name or source_ref.replace("failure:", "")
matched = [r for r in failure_rows if (r.get("failure_type") or "") == ftype or ftype in (r.get("failure_reason") or "")]
sample_size = len(matched)
success_count = 0
fail_count = sample_size
pnl_values = [float(r.get("pnl_pct") or 0) for r in matched]
dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched]
confidence = round(min(95, 45 + fail_count * 8), 1) if sample_size else float(c.get("confidence_score") or 0)
else:
key = signal_name or source_ref.replace("review:", "")
matched = [item for item in review_items if key and (key in item["signal_keys"] or key in item["all_signal_text"] or signal_name in item["all_signal_text"])]
sample_size = len(matched)
success_count = sum(1 for r in matched if r.get("outcome") == "爆发")
fail_count = sum(1 for r in matched if r.get("outcome") in ("失败", "横盘"))
pnl_values = [float(r.get("pnl_48h") or 0) for r in matched]
dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched]
resolved = success_count + fail_count
confidence = round(success_count / resolved * 100, 1) if resolved else float(c.get("confidence_score") or 0)
avg_pnl = round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else float(c.get("avg_pnl") or 0)
max_gain = round(max(pnl_values), 2) if pnl_values else float(c.get("max_gain") or 0)
max_drawdown = round(min(dd_values), 2) if dd_values else float(c.get("max_drawdown") or 0)
dry_status = _candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, status, min_gray_samples, min_gray_confidence)
gate_passed = dry_status in ("gray", "active")
if dry_status == "gray":
gate_reason = f"样本{sample_size}{min_gray_samples},置信{confidence}%≥{min_gray_confidence}avg_pnl={avg_pnl}%:可进入灰度,仍不升版"
elif dry_status == "rejected":
gate_reason = f"样本{sample_size}已足够但置信/收益不达标:淘汰,不允许发布"
else:
gate_reason = f"样本{sample_size}或置信{confidence}%不足:只研究不发布"
evaluated.append({
**c,
"sample_size": sample_size,
"support_count": sample_size,
"success_count": success_count,
"fail_count": fail_count,
"avg_pnl": avg_pnl,
"max_gain": max_gain,
"max_drawdown": max_drawdown,
"confidence_score": confidence,
"dry_run_status": dry_status,
"release_gate_passed": gate_passed,
"gate_reason": gate_reason,
})
gray_ready = [x for x in evaluated if x.get("dry_run_status") == "gray"]
active_ready = [x for x in evaluated if x.get("dry_run_status") == "active"]
rejected = [x for x in evaluated if x.get("dry_run_status") == "rejected"]
can_release = False
release_reason = "dry-run只评估候选规则表现不执行 learned_rules 写入或版本升级"
return {
"dry_run": True,
"current_version": current_version,
"review_sample_count": len(review_items),
"clean_started_at": clean_started_at,
"sample_window": "clean_after_factor_recency_fix" if clean_started_at else "all_history",
"dirty_history_candidate_count": sum(1 for c in candidates if _is_dirty_history_candidate(c)),
"candidate_count": len(candidates),
"gray_ready_count": len(gray_ready),
"active_count": len(active_ready),
"rejected_count": len(rejected),
"would_bump_version": can_release,
"release_reason": release_reason,
"gate_policy": {
"gray": f"sample_size≥{min_gray_samples} 且 confidence≥{min_gray_confidence} 且 avg_pnl>0penalty规则可不要求avg_pnl>0",
"reject": "sample_size≥8 且 confidence<35 或 avg_pnl≤-3",
"release": "dry-run不发布正式发布仍由复盘发布闸门统一控制",
},
"evaluated_candidates": sorted(evaluated, key=lambda x: (x.get("dry_run_status") != "gray", -float(x.get("sample_size") or 0), -float(x.get("confidence_score") or 0)))[:80],
}
def refresh_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65):
"""刷新候选规则表现。
用 review_log + recommendation 复盘结果回填候选规则的 sample/success/fail/avg_pnl
并按门槛自动 candidate/gray/rejectedactive 不降级。
"""
conn = get_conn()
candidates = conn.execute("SELECT * FROM strategy_rule_candidate").fetchall()
clean_started_at = _get_factor_recency_fixed_at()
if clean_started_at:
review_rows = conn.execute("""
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
FROM review_log rl
LEFT JOIN recommendation r ON r.id = rl.rec_id
WHERE r.rec_time >= ?
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=?, success_count=?, fail_count=?, avg_pnl=?, max_gain=?,
max_drawdown=?, confidence_score=?, sample_size=?, status=?, notes=?, created_at=?
WHERE id=?
""", (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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
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 ?
"""
params = []
where_clause = ""
if job_name:
where_clause = "WHERE job_name = ?"
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 julianday(?) - julianday(started_at) <= ?
ORDER BY started_at DESC, id DESC
""", (now_iso, hours / 24.0)).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],
}