feat: 三维共振层落地(技术×资金×消息合成+signal_veto_log全程记录) + 综合评分列(收益30/胜率20/夏普20/盈亏比15/回撤15) + 普适性列(月份分布熵)
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""resonance.py — 三维共振层(技术×资金×消息)
|
||||
在 v7.1 闸门通过后,对买入信号做三维合成判断:
|
||||
否决(veto): 资金持续流出 + 消息利空(双重负面)
|
||||
降级(downgrade): 任一单维度负面
|
||||
共振(resonance): 资金强流入 + 消息利好
|
||||
通过(pass): 其余
|
||||
所有判断写入 signal_veto_log 供后续回测验证(2026-07-29 老爸批准)"""
|
||||
import sqlite3, json, sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB = "/home/hmo/MoFin/data/mofin.db"
|
||||
|
||||
for p in ("/home/hmo/MoFin", "/home/hmo/web-dashboard"):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
NEWS_NEGATIVE = {'利空', '偏空', '中性偏空'}
|
||||
NEWS_POSITIVE = {'利好', '偏多', '中性偏利好'}
|
||||
FLOW_NEG_THRESHOLD = -2.5 # v6归因:持续流出胜率仅37.5%
|
||||
FLOW_STRONG_POS_THRESHOLD = 4 # v6归因:加速流入胜率77.8%
|
||||
|
||||
|
||||
def _flow_state(code):
|
||||
"""资金维度: flow_5d / flow_delta → negative/neutral/positive/strong_positive"""
|
||||
try:
|
||||
conn = sqlite3.connect(DB)
|
||||
rows = conn.execute("""
|
||||
SELECT date, main_pct FROM stock_capital_flow
|
||||
WHERE code=? ORDER BY date DESC LIMIT 10
|
||||
""", (code,)).fetchall()
|
||||
conn.close()
|
||||
except sqlite3.OperationalError:
|
||||
return {'state': 'unknown', 'flow_5d': None, 'flow_delta': None}
|
||||
if len(rows) < 5:
|
||||
return {'state': 'unknown', 'flow_5d': None, 'flow_delta': None}
|
||||
recent = [r[1] for r in rows[:5] if r[1] is not None]
|
||||
prior = [r[1] for r in rows[5:10] if r[1] is not None]
|
||||
flow_5d = sum(recent) / len(recent) if recent else None
|
||||
flow_delta = None
|
||||
if recent and prior:
|
||||
flow_delta = flow_5d - sum(prior) / len(prior)
|
||||
state = 'neutral'
|
||||
if flow_5d is not None and flow_5d < FLOW_NEG_THRESHOLD:
|
||||
state = 'negative'
|
||||
elif flow_delta is not None and flow_delta > FLOW_STRONG_POS_THRESHOLD:
|
||||
state = 'strong_positive'
|
||||
elif flow_5d is not None and flow_5d > 1:
|
||||
state = 'positive'
|
||||
return {'state': state, 'flow_5d': round(flow_5d, 2) if flow_5d is not None else None,
|
||||
'flow_delta': round(flow_delta, 2) if flow_delta is not None else None}
|
||||
|
||||
|
||||
def _news_state(code, name=None, sector=None):
|
||||
"""消息维度: 3日内该股/该板块最新消息情绪"""
|
||||
since = (datetime.now() - timedelta(days=3)).strftime('%Y-%m-%d')
|
||||
try:
|
||||
conn = sqlite3.connect(DB)
|
||||
# 个股级优先(searched_stocks 或 summary 含代码/名称)
|
||||
rows = conn.execute("""
|
||||
SELECT overall_sentiment, summary, sector, created_at FROM signal_news
|
||||
WHERE created_at >= ? AND (
|
||||
searched_stocks LIKE ? OR summary LIKE ? OR sector = ?
|
||||
)
|
||||
ORDER BY id DESC LIMIT 10
|
||||
""", (since, f'%{code}%', f'%{name or code}%', sector or '')).fetchall()
|
||||
conn.close()
|
||||
except sqlite3.OperationalError:
|
||||
return {'state': 'unknown', 'sentiment': None, 'summary': None}
|
||||
sentiment = None
|
||||
summary = None
|
||||
for s, sm, sec, ts in rows:
|
||||
if s in NEWS_NEGATIVE or s in NEWS_POSITIVE:
|
||||
sentiment = s
|
||||
summary = (sm or '')[:120]
|
||||
break
|
||||
if sentiment is None:
|
||||
return {'state': 'neutral', 'sentiment': None, 'summary': None}
|
||||
state = 'negative' if sentiment in NEWS_NEGATIVE else 'positive'
|
||||
return {'state': state, 'sentiment': sentiment, 'summary': summary}
|
||||
|
||||
|
||||
def evaluate_resonance(code, gate, name=None):
|
||||
"""三维合成判断。gate = v71_gate.check_entry_gate 的返回"""
|
||||
flow = _flow_state(code)
|
||||
# 板块名:从 gate factors 拿不到名字,这里用 sector_ctx 的映射
|
||||
sector = None
|
||||
try:
|
||||
from strategy_lab import _STOCK_SECTOR
|
||||
sector = _STOCK_SECTOR.get(code)
|
||||
except Exception:
|
||||
pass
|
||||
news = _news_state(code, name, sector)
|
||||
|
||||
fs, ns = flow['state'], news['state']
|
||||
if fs == 'negative' and ns == 'negative':
|
||||
decision = 'veto'
|
||||
reason = f"资金持续流出({flow['flow_5d']}) + 消息利空({news['sentiment']})"
|
||||
elif fs == 'negative':
|
||||
decision = 'downgrade'
|
||||
reason = f"资金持续流出({flow['flow_5d']})"
|
||||
elif ns == 'negative':
|
||||
decision = 'downgrade'
|
||||
reason = f"消息利空({news['sentiment']})"
|
||||
elif fs == 'strong_positive' and ns == 'positive':
|
||||
decision = 'resonance'
|
||||
reason = f"三维共振: 资金加速流入({flow['flow_delta']}) + 消息{news['sentiment']}"
|
||||
else:
|
||||
decision = 'pass'
|
||||
reason = ''
|
||||
|
||||
return {'decision': decision, 'reason': reason, 'flow': flow, 'news': news, 'sector': sector}
|
||||
|
||||
|
||||
def log_resonance(code, name, price, gate, res, signal_before, signal_after):
|
||||
"""完整记录三维状态 → signal_veto_log(后续回测验证的数据资产)"""
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS signal_veto_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT, name TEXT, price REAL,
|
||||
tech_score INTEGER, tech_summary TEXT,
|
||||
flow_5d REAL, flow_delta REAL, flow_state TEXT,
|
||||
news_sentiment TEXT, news_state TEXT, news_summary TEXT,
|
||||
sector TEXT,
|
||||
decision TEXT, reason TEXT,
|
||||
signal_before TEXT, signal_after TEXT,
|
||||
created_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO signal_veto_log
|
||||
(code, name, price, tech_score, tech_summary, flow_5d, flow_delta, flow_state,
|
||||
news_sentiment, news_state, news_summary, sector, decision, reason,
|
||||
signal_before, signal_after, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (code, name, price, gate.get('score'), gate.get('summary'),
|
||||
res['flow'].get('flow_5d'), res['flow'].get('flow_delta'), res['flow'].get('state'),
|
||||
res['news'].get('sentiment'), res['news'].get('state'), res['news'].get('summary'),
|
||||
res.get('sector'), res['decision'], res['reason'],
|
||||
signal_before, signal_after,
|
||||
datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from v71_gate import check_entry_gate
|
||||
for c in sys.argv[1:] or ['603599']:
|
||||
g = check_entry_gate(c)
|
||||
r = evaluate_resonance(c, g)
|
||||
print(f"{c}: gate={g['pass']} | 资金={r['flow']['state']}({r['flow']['flow_5d']}) "
|
||||
f"消息={r['news']['state']}({r['news']['sentiment']}) → {r['decision']} {r['reason']}")
|
||||
@@ -1474,6 +1474,30 @@ def reassess_strategy(code, name, price, cost, shares, current_action,
|
||||
except Exception as _e:
|
||||
print(f" [v7.1闸门] 评估异常(放行): {_e}", flush=True)
|
||||
|
||||
# ----- 【三维共振层】技术×资金×消息合成判断(2026-07-29 老爸批准,全程记录) -----
|
||||
_res_decision = None
|
||||
if is_new_entry and any(s in timing_signal for s in ("买入", "加仓", "可追")):
|
||||
try:
|
||||
from resonance import evaluate_resonance, log_resonance
|
||||
_res = evaluate_resonance(code, _gate if '_gate' in dir() and _gate else {'score': 0, 'summary': ''}, name)
|
||||
_res_decision = _res["decision"]
|
||||
_sig_before = timing_signal
|
||||
if _res_decision == "veto":
|
||||
timing_signal = "观望"
|
||||
action_note = (action_note + " | 三维否决: " + _res["reason"]) if action_note else ("三维否决: " + _res["reason"])
|
||||
elif _res_decision == "downgrade":
|
||||
timing_signal = "关注"
|
||||
action_note = (action_note + " | 三维降级: " + _res["reason"]) if action_note else ("三维降级: " + _res["reason"])
|
||||
elif _res_decision == "resonance":
|
||||
action_note = (action_note + " | " + _res["reason"]) if action_note else _res["reason"]
|
||||
log_resonance(code, name, price,
|
||||
_gate if '_gate' in dir() and _gate else {'score': 0, 'summary': ''},
|
||||
_res, _sig_before, timing_signal)
|
||||
if _res_decision != "pass":
|
||||
print(f" [三维共振] {_sig_before}→{timing_signal}: {_res['reason']}", flush=True)
|
||||
except Exception as _e:
|
||||
print(f" [三维共振] 评估异常(放行): {_e}", flush=True)
|
||||
|
||||
# ----- 构造 action 描述(供 cron prompt 使用) -----
|
||||
action_parts = []
|
||||
# 非持仓(自选股/未持有,shares=0):盈亏标签无意义——cost=0 → profit_pct=0 →
|
||||
|
||||
+17
-2
@@ -1969,6 +1969,17 @@ function renderStrategyTable(strategies) {
|
||||
if (pf.cagr_pct != null) best.cagr_pct = Math.max(best.cagr_pct, pf.cagr_pct);
|
||||
if (pf.portfolio_max_dd_pct != null) best.portfolio_max_dd_pct = Math.min(best.portfolio_max_dd_pct, pf.portfolio_max_dd_pct);
|
||||
}
|
||||
// 综合评分:总收益30% + 胜率20% + 夏普20% + 盈亏比15% + 资产回撤15%(反向)
|
||||
for (const s of strategies) {
|
||||
const st = s.summary_stats || {}; const pf = st.portfolio || {};
|
||||
if (st.total_trades == null) { s._composite = null; continue; }
|
||||
const ret = Math.min(pf.total_return_pct || 0, 100) / 100 * 30;
|
||||
const wr = (st.win_rate || 0) / 100 * 20;
|
||||
const sh = Math.min(Math.max(st.sharpe_ratio || 0, 0), 20) / 20 * 20;
|
||||
const pfc = Math.min(st.profit_factor || 0, 5) / 5 * 15;
|
||||
const dd = (1 - Math.min(pf.portfolio_max_dd_pct || 0, 50) / 50) * 15;
|
||||
s._composite = Math.round(ret + wr + sh + pfc + dd);
|
||||
}
|
||||
const hl = (val, bestVal, invert) => {
|
||||
if (val == null) return '';
|
||||
const isBest = invert ? (val === bestVal && bestVal !== 999) : (val === bestVal && bestVal !== -999);
|
||||
@@ -1977,12 +1988,12 @@ function renderStrategyTable(strategies) {
|
||||
// 条件格式色条:计算每列 min/max,单元格背景按相对大小画色条
|
||||
const CURRENT_STRATEGY = 'v7.1';
|
||||
const ranges = {};
|
||||
const cols = ['total_return_pct','capital_final','cagr_pct','total_trades','avg_hold_days','win_rate','avg_profit_pct','sharpe_ratio','profit_factor','portfolio_max_dd_pct'];
|
||||
const cols = ['composite','universality_score','total_return_pct','capital_final','cagr_pct','total_trades','avg_hold_days','win_rate','avg_profit_pct','sharpe_ratio','profit_factor','portfolio_max_dd_pct'];
|
||||
for (const c of cols) {
|
||||
let mn = Infinity, mx = -Infinity;
|
||||
for (const s of strategies) {
|
||||
const st = s.summary_stats || {}; const pf = st.portfolio || {};
|
||||
const v = (c in pf) ? pf[c] : st[c];
|
||||
const v = c === 'composite' ? s._composite : (c === 'universality_score' ? (st.universality || {}).score : ((c in pf) ? pf[c] : st[c]));
|
||||
if (v != null) { mn = Math.min(mn, v); mx = Math.max(mx, v); }
|
||||
}
|
||||
ranges[c] = { mn: mn === Infinity ? 0 : mn, mx: mx === -Infinity ? 1 : mx };
|
||||
@@ -2004,6 +2015,8 @@ function renderStrategyTable(strategies) {
|
||||
};
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full text-xs whitespace-nowrap"><thead><tr class="text-slate-500 border-b border-slate-700">' +
|
||||
'<th class="text-left px-2 py-1.5">版本</th><th class="text-left px-2 py-1.5">名称</th>' +
|
||||
'<th class="text-right px-2 py-1.5 text-amber-300">综合</th>' +
|
||||
'<th class="text-right px-2 py-1.5">普适</th>' +
|
||||
'<th class="text-right px-2 py-1.5 text-amber-400">总收益</th>' +
|
||||
'<th class="text-right px-2 py-1.5 text-amber-400">最终资产</th>' +
|
||||
'<th class="text-right px-2 py-1.5">年化</th>' +
|
||||
@@ -2024,6 +2037,8 @@ function renderStrategyTable(strategies) {
|
||||
html += '<tr class="' + rowCls + '" onclick="showStrategyDetail(\'' + s.version + '\')">' +
|
||||
'<td class="px-2 py-1.5 font-mono font-bold ' + (isCurrent ? 'text-emerald-400' : 'text-blue-400') + '">' + s.version + (isCurrent ? ' <span class="text-[10px] bg-emerald-500/20 text-emerald-300 px-1 rounded">当前</span>' : '') + '</td>' +
|
||||
'<td class="px-2 py-1.5" title="' + (s.hypothesis || s.summary || '').replace(/"/g, '"') + '">' + (s.name || '') + (smallSample ? ' <span class="text-amber-500" title="样本<40笔">⚠️</span>' : '') + '</td>' +
|
||||
cell('composite', s._composite, v => v, 'font-bold text-amber-300') +
|
||||
cell('universality_score', (st.universality || {}).score, v => v + '<span class="text-slate-500">/' + (st.universality || {}).months + '月</span>') +
|
||||
cell('total_return_pct', pf.total_return_pct, v => v + '%', 'font-bold ' + retCls + ' ' + hl(pf.total_return_pct, best.total_return_pct)) +
|
||||
cell('capital_final', pf.capital_final, v => '¥' + (v/10000).toFixed(0) + '万') +
|
||||
cell('cagr_pct', pf.cagr_pct, v => v + '%', hl(pf.cagr_pct, best.cagr_pct)) +
|
||||
|
||||
@@ -925,6 +925,23 @@ def calc_summary(trades, capital):
|
||||
for c in curve:
|
||||
peak = max(peak, c)
|
||||
max_dd = max(max_dd, (peak - c) / peak * 100)
|
||||
# 普适性:信号月份分布(分散度越高越普适)
|
||||
from collections import Counter
|
||||
months = Counter(t['entry_date'][:7] for t in trades if t.get('entry_date'))
|
||||
n_months = len(months)
|
||||
peak_pct = round(max(months.values()) / len(trades) * 100) if trades else 0
|
||||
# 香农熵归一化 0-100(分布越均匀越高)
|
||||
entropy = 0.0
|
||||
if n_months > 1:
|
||||
for c in months.values():
|
||||
p = c / len(trades)
|
||||
entropy -= p * math.log(p)
|
||||
entropy = entropy / math.log(n_months) * 100
|
||||
universality = {
|
||||
'months': n_months,
|
||||
'peak_pct': peak_pct,
|
||||
'score': round(entropy, 0),
|
||||
}
|
||||
return {
|
||||
'total_trades': len(trades),
|
||||
'win_rate': round(win_rate, 1),
|
||||
@@ -937,6 +954,7 @@ def calc_summary(trades, capital):
|
||||
'profit_factor': round(abs(avg_w/avg_l), 2) if avg_l != 0 else None,
|
||||
'wins': len(wins), 'losses': len(losses),
|
||||
'capital_end': round(curve[-1], 2),
|
||||
'universality': universality,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user