feat: 五维推荐评分 + 优中选优Top5 + exec gate评分驱动 + 自动补tag + XMPP完整策略

This commit is contained in:
hmo
2026-07-27 14:03:09 +08:00
parent 844f556545
commit 27d8d997d4
3 changed files with 125 additions and 18 deletions
+101 -9
View File
@@ -589,6 +589,11 @@ def init_all_tables(conn: sqlite3.Connection):
conn.execute(f"ALTER TABLE holding_strategies ADD COLUMN {_col}")
except sqlite3.OperationalError:
pass
# ── rec_score 迁移(2026-07-27):五维复合推荐评分 0-100 ──
try:
conn.execute("ALTER TABLE holding_strategies ADD COLUMN rec_score INTEGER DEFAULT 0")
except sqlite3.OperationalError:
pass
conn.commit()
@@ -1236,12 +1241,98 @@ def recompute_rr(conn, code: str) -> float:
"UPDATE holding_strategies SET rr_ratio=?, rr_low=?, rr_high=? WHERE code=? AND status='active'",
(rr_mid, rr_low, rr_high, code))
conn.commit()
compute_rec_score(conn, code) # RR 变→评分同步刷新
return rr_mid
except Exception as e:
print(f" [RR] {code} 重算失败: {e}", flush=True)
return 0.0
def compute_rec_score(conn, code: str) -> int:
"""五维复合推荐评分 0-100。RR高≠值得买,趋势+行业+信号综合判断。
维度:RR(0-35) + 信号(0-25) + 趋势(0-20) + 行业(0-10) + 区间(0-10)"""
try:
row = conn.execute(
"SELECT rr_ratio, timing_signal, tech_snapshot, sector_context, entry_low, entry_high "
"FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone()
if not row:
return 0
rr, sig, tech, sector, el, eh = row
rr = rr or 0; el = el or 0; eh = eh or 0
# ── 1. RR (0-35) ──
if rr >= 3.0: s_rr = 35
elif rr >= 2.5: s_rr = 28
elif rr >= 2.0: s_rr = 20
elif rr >= 1.5: s_rr = 10
else: s_rr = 0
# ── 2. 信号强度 (0-25) ──
sig_map = {"买入": 25, "可买入": 20, "可加仓": 15}
s_sig = sig_map.get(sig, 0)
# ── 3. 技术趋势 (0-20) ──
tech_str = str(tech or '')
# 形态判定
if '/bullish' in tech_str or '看涨' in tech_str:
s_trend = 15
elif '/bearish' in tech_str or '看跌' in tech_str:
s_trend = 8
else:
s_trend = 12
# MA 排列加成
import re as _re_ma
ma_vals = {}
for m in _re_ma.finditer(r'MA(\d+)=([\d.]+)', tech_str):
ma_vals[int(m.group(1))] = float(m.group(2))
if all(k in ma_vals for k in [5,10,20,60]):
if ma_vals[5] > ma_vals[10] > ma_vals[20] > ma_vals[60]:
s_trend += 5 # 多头排列
elif ma_vals[5] < ma_vals[10] < ma_vals[20] < ma_vals[60]:
s_trend -= 3 # 空头排列
s_trend = max(0, min(20, s_trend))
# ── 4. 行业强弱 (0-10) ──
sec_str = str(sector or '')
if '领涨' in sec_str:
s_sec = 9
elif '偏强' in sec_str or '上涨' in sec_str:
s_sec = 7
elif '偏弱' in sec_str or '下跌' in sec_str:
s_sec = 3
else:
s_sec = 5
# ── 5. 买入区间质量 (0-10) ──
s_zone = 0
if el > 0 and eh > el:
zone_pct = (eh - el) / el * 100
if zone_pct >= 5: s_zone = 10
elif zone_pct >= 3: s_zone = 7
elif zone_pct >= 2: s_zone = 4
else: s_zone = 2
total = s_rr + s_sig + s_trend + s_sec + s_zone
conn.execute(
"UPDATE holding_strategies SET rec_score=? WHERE code=? AND status='active'",
(total, code))
conn.commit()
# 高评分自动打推荐 tag(补 LLM 未打 tag 的缺口)
if total >= 50 and rr >= 2.0 and sig in ("买入", "可买入", "可加仓"):
_pos_v = conn.execute(
"SELECT position_advice FROM holding_strategies WHERE code=? AND status='active'",
(code,)).fetchone()
if _pos_v and _pos_v[0] and '%' in str(_pos_v[0]):
conn.execute(
"UPDATE holding_strategies SET tag='current_recommend' WHERE code=? AND status='active' AND (tag IS NULL OR tag='')",
(code,))
conn.commit()
return total
except Exception as e:
print(f" [SCORE] {code} 评分失败: {e}", flush=True)
return 0
def sync_recommend_tag(conn, code: str, timing_signal: str):
"""裸 SQL 调用方(batch_reassess / per_stock_reassess)的推荐 tag 同步。
动作级信号 → current_recommend;信号降级 → 清除 current_recommend
@@ -1336,11 +1427,11 @@ def enqueue_recommend(conn, code: str):
from datetime import datetime as _dt
row = conn.execute(
"SELECT name, timing_signal, tag, entry_low, entry_high, stop_loss, take_profit, "
"rr_ratio, rr_low, rr_high, position_advice, full_analysis FROM holding_strategies WHERE code=? AND status='active'",
"rr_ratio, rr_low, rr_high, position_advice, full_analysis, rec_score FROM holding_strategies WHERE code=? AND status='active'",
(code,)).fetchone()
if not row:
return
name, sig, tag, el, eh, sl, tp, rr, rr_lo, rr_hi, pos, fa = row
name, sig, tag, el, eh, sl, tp, rr, rr_lo, rr_hi, pos, fa, score = row
if tag != 'current_recommend' or sig not in ("买入", "可买入", "可加仓", "卖出", "止盈"):
print(f" [REC] {code} 非有效推荐(tag={tag},sig={sig}),不入队", flush=True)
return False
@@ -1377,7 +1468,7 @@ def enqueue_recommend(conn, code: str):
f.write(_j.dumps({"code": code, "name": name, "signal": sig,
"entry_low": el, "entry_high": eh, "stop_loss": sl,
"take_profit": tp, "rr": rr, "rr_low": rr_lo, "rr_high": rr_hi,
"position": pos,
"position": pos, "score": score or 0,
"strategy_excerpt": strat,
"full_analysis": fa_text[:2500],
"ts": _dt.now().isoformat()}, ensure_ascii=False) + "\n")
@@ -1478,7 +1569,7 @@ def flush_rec_digest(max_items=5):
# 卖出/止盈是释放现金的操作,不占买入预算,单独一组排最前
sells = [x for x in items if x.get('signal') in _SELL_SIGS]
buys_all = [x for x in items if x.get('signal') not in _SELL_SIGS]
buys_all.sort(key=lambda x: x.get('rr') or 0, reverse=True)
buys_all.sort(key=lambda x: (x.get('score') or 0, x.get('rr') or 0), reverse=True)
items = sells + buys_all
top = items[:max_items]
@@ -1556,18 +1647,19 @@ def flush_rec_digest(max_items=5):
_eh = it.get('entry_high') or 0
_mid = f"{(_el+_eh)/2:.2f}" if _el > 0 and _eh > _el else ""
_badge = "💰可执行" if it['code'] in _exec_codes else "⏳排队"
lines.append(f"{_badge} {it.get('name') or it['code']}({it['code']}) {it['signal']}"
_score = it.get('score') or 0
_score_txt = f" [{_score}分]" if _score else ""
lines.append(f"{_badge}{_score_txt} {it.get('name') or it['code']}({it['code']}) {it['signal']}"
f"{_el or ''}{_mid}{_eh or ''}"
f"{it.get('stop_loss') or ''}{it.get('take_profit') or ''}"
f" {_rr_txt} 仓位{it.get('position') or ''}")
if it.get('_stale_warn'):
lines.append(f" ⚠️ {it['_stale_warn']}")
# 头部 2 只附策略依据(12维全文节选)
if i < 2:
# 所有推荐都附完整策略依据
if it.get('strategy_excerpt'):
lines.append(f" 依据: {it['strategy_excerpt'][:300]}")
lines.append(f" 依据: {it['strategy_excerpt']}")
elif it.get('full_analysis'):
lines.append(f" 依据: {it['full_analysis'][:400]}")
lines.append(f" 依据: {it['full_analysis']}")
if len(items) > max_items:
lines.append(f"…另有 {len(items) - max_items} 只详见盯盘推荐操作区")
if cash_note:
+18 -7
View File
@@ -149,6 +149,7 @@ def get_watch():
hs.action, hs.position_advice, hs.tag,
hs.entry_low, hs.entry_high, hs.stop_loss, hs.take_profit,
hs.rr_ratio, hs.rr_low, hs.rr_high, hs.full_analysis, hs.reassessed_at,
hs.rec_score,
lp.price, lp.change_pct,
h.shares, h.position_pct
FROM holding_strategies hs
@@ -233,28 +234,29 @@ def get_watch():
# 卖出类永远可执行(释放现金,不占买入预算),排最前;买入类按 RR 降序
_sells = [d for d in _cands if (d.get('timing_signal') or '') in _SELL_SIGS]
_buys = [d for d in _cands if (d.get('timing_signal') or '') not in _SELL_SIGS]
_buys.sort(key=lambda x: x.get('rr_ratio') or 0, reverse=True)
_buys.sort(key=lambda x: (x.get('rec_score') or 0, x.get('rr_ratio') or 0), reverse=True)
for d in _sells:
d['rec_exec'] = True # 卖出不需要现金,永远可执行
d['suggested_position_pct'] = 0.0
# 买入:RR≥1.5 才有可执行资格(prompt 自己的纪律:RR<1.5→不推荐);
# 买入:score≥60 + RR≥2.0 才有可执行资格(2026-07-27 老爸:五维评分替代纯RR)
# 达标者贪心装入现金预算,预算外/不达标标记"排队"(不再降级隐藏)
_cum = 0.0
# 弱信号不可执行2026-07-23 老爸:信号不充分+盈利持有为何可执行?——
# tag是LLM行动信号时的遗留,技术路径降级信号后无权摘tag,徽章层必须自己卡信号)
# 弱信号不可执行
_WEAK_SIGNALS = ('信号不充分', '关注', '弱势持有', '观望', '持有', '')
_TOP_N = 5 # 优中选优:推荐区只展示 Top 5(剩余排入自选区)
for d in _buys:
pct = _sugg_pct(d)
d['suggested_position_pct'] = pct
rr = d.get('rr_ratio') or 0
score = d.get('rec_score') or 0
sig_now = d.get('timing_signal') or ''
# 仓位必须明确%("减仓或观望/中等仓位"不算可执行的仓位——2026-07-24 老爸)
# 仓位必须明确%
_has_pos = bool(_re.search(r'(\d+(?:\.\d+)?)\s*%', d.get('position_advice') or ''))
if sig_now in _WEAK_SIGNALS:
d['rec_exec'] = False # 弱信号永远排队
elif not _has_pos:
d['rec_exec'] = False # 无明确仓位,排队
elif rr >= 2.0 and _cum + pct <= _budget_pct + 1e-9:
elif score >= 60 and rr >= 2.0 and _cum + pct <= _budget_pct + 1e-9:
d['rec_exec'] = True # 可执行(2026-07-24 老爸:门槛1.5→2.0,边缘推荐不算优)
_cum += pct
else:
@@ -264,6 +266,14 @@ def get_watch():
if d['sort_group'] == 0 and not _is_fresh(d):
d['sort_group'] = 1 if d['decision_type'] == '持仓策略' else 2
# ── 优中选优(2026-07-27 老爸):推荐区只展示 Top 5 买入,太多选不过来 ──
# 卖出/止盈永远保留在推荐区;买入按评分降序,第6名起降入自选区
_rec_buys = [d for d in results if d['sort_group'] == 0
and (d.get('timing_signal') or '') not in _SELL_SIGS]
_rec_buys.sort(key=lambda x: (x.get('rec_score') or 0, x.get('rr_ratio') or 0), reverse=True)
for d in _rec_buys[_TOP_N:]:
d['sort_group'] = 2 # 超额买入降入自选区
# 排序:group → signal_rank → group-internal (持仓按position_pct desc, 自选按rr desc)
def skey(x):
g = x['sort_group']
@@ -467,6 +477,7 @@ def api_watchlist():
WHEN hs.timing_signal IN ('弱势持有') THEN 4
ELSE 5
END,
COALESCE(hs.rec_score, 0) DESC,
COALESCE(hs.rr_ratio,0) DESC,
hs.code
""").fetchall()
@@ -535,7 +546,7 @@ def api_stock(code):
SELECT hs.code, hs.name, hs.timing_signal, hs.action, hs.position_advice,
hs.entry_low, hs.entry_high, hs.stop_loss, hs.take_profit,
hs.rr_ratio, hs.rr_low, hs.rr_high, hs.full_analysis, hs.reassessed_at,
hs.tag, hs.decision_type,
hs.tag, hs.decision_type, hs.rec_score,
lp.price AS live_price, lp.change_pct
FROM holding_strategies hs
LEFT JOIN live_prices lp ON hs.code = lp.code
+4
View File
@@ -1549,6 +1549,7 @@ async function renderWatch() {
'<th class="text-left p-2">股票</th>' +
'<th class="text-right p-2">现价</th>' +
'<th class="text-right p-2">涨跌%</th>' +
'<th class="text-right p-2">评分</th>' +
'<th class="text-left p-2">信号</th>' +
'<th class="text-right p-2">买入区间</th>' +
'<th class="text-right p-2">止损/止盈</th>' +
@@ -1583,6 +1584,8 @@ async function renderWatch() {
const sl_ = s.stop_loss || 0;
const tp_ = s.take_profit || 0;
const rr = s.rr_ratio || 0;
const score = s.rec_score || 0;
const scoreColor = score >= 70 ? 'text-green-400' : score >= 50 ? 'text-amber-300' : 'text-slate-400';
const shares = s.shares || 0;
const posPct = s.position_pct || 0;
@@ -1617,6 +1620,7 @@ async function renderWatch() {
'<td class="p-2"><span class="font-medium text-white">' + s.name + '</span><br><span class="text-slate-500">' + s.code + '</span>' + recBadge + '</td>' +
'<td class="p-2 text-right font-mono">' + (p ? p.toFixed(2) : '—') + '</td>' +
'<td class="p-2 text-right font-mono ' + chgColor + '">' + (cp >= 0 ? '+' : '') + (cp ? cp.toFixed(2) : '—') + '%</td>' +
'<td class="p-2 text-right font-mono font-bold ' + scoreColor + '">' + (score || '—') + '</td>' +
'<td class="p-2 text-left"><span class="text-xs px-2 py-1 rounded ' + sigCls + '">' + (sig || '—') + '</span></td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + buyZone + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + sltp + '</td>' +