From 27d8d997d43303bf7dca33a6c7895e3594c14b05 Mon Sep 17 00:00:00 2001 From: hmo Date: Mon, 27 Jul 2026 14:03:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=94=E7=BB=B4=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E8=AF=84=E5=88=86=20+=20=E4=BC=98=E4=B8=AD=E9=80=89=E4=BC=98To?= =?UTF-8?q?p5=20+=20exec=20gate=E8=AF=84=E5=88=86=E9=A9=B1=E5=8A=A8=20+=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=A1=A5tag=20+=20XMPP=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mofin_db.py | 114 +++++++++++++++++++++++++++++++++++++++++----- server.py | 25 +++++++--- static/index.html | 4 ++ 3 files changed, 125 insertions(+), 18 deletions(-) diff --git a/mofin_db.py b/mofin_db.py index 677ef214..2cc26a65 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -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]}") - elif it.get('full_analysis'): - lines.append(f" 依据: {it['full_analysis'][:400]}") + # 所有推荐都附完整策略依据 + if it.get('strategy_excerpt'): + lines.append(f" 依据: {it['strategy_excerpt']}") + elif it.get('full_analysis'): + lines.append(f" 依据: {it['full_analysis']}") if len(items) > max_items: lines.append(f"…另有 {len(items) - max_items} 只详见盯盘推荐操作区") if cash_note: diff --git a/server.py b/server.py index 514b28fb..257f3431 100644 --- a/server.py +++ b/server.py @@ -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 diff --git a/static/index.html b/static/index.html index d3ba001b..523cc849 100644 --- a/static/index.html +++ b/static/index.html @@ -1549,6 +1549,7 @@ async function renderWatch() { '股票' + '现价' + '涨跌%' + + '评分' + '信号' + '买入区间' + '止损/止盈' + @@ -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() { '' + s.name + '
' + s.code + '' + recBadge + '' + '' + (p ? p.toFixed(2) : '—') + '' + '' + (cp >= 0 ? '+' : '') + (cp ? cp.toFixed(2) : '—') + '%' + + '' + (score || '—') + '' + '' + (sig || '—') + '' + '' + buyZone + '' + '' + sltp + '' +