diff --git a/mofin_db.py b/mofin_db.py index 0e7a6e8c..cb0e11aa 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -1144,11 +1144,46 @@ def reconcile_signal_from_analysis(conn, code: str) -> str: return "" +def recompute_rr(conn, code: str) -> float: + """用现价+已存止损/止盈重算 RR 并写回 rr_ratio。 + 根治"LLM 不输出 RR → rr_ratio 永远 0"的断链(红线:RR 由系统算,不信 LLM)。 + 公式: RR = (止盈 - 基准价) / (基准价 - 止损);基准价=现价,兜底区间上沿。 + 损/盈缺失或基准价<=止损 → RR=0(不达标,不参与排序)。""" + try: + row = conn.execute( + "SELECT entry_high, stop_loss, take_profit FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if not row: + return 0.0 + eh, sl, tp = (row[0] or 0), (row[1] or 0), (row[2] or 0) + ref = 0.0 + try: + pr = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + if pr and (pr[0] or 0) > 0: + ref = float(pr[0]) + except Exception: + pass + if ref <= 0: + ref = float(eh or 0) + rr = 0.0 + if sl > 0 and tp > 0 and ref > sl: + rr = round((tp - ref) / (ref - sl), 2) + if rr < 0: + rr = 0.0 + conn.execute("UPDATE holding_strategies SET rr_ratio=? WHERE code=? AND status='active'", (rr, code)) + conn.commit() + return rr + except Exception as e: + print(f" [RR] {code} 重算失败: {e}", flush=True) + return 0.0 + + def sync_recommend_tag(conn, code: str, timing_signal: str): """裸 SQL 调用方(batch_reassess / per_stock_reassess)的推荐 tag 同步。 动作级信号 → current_recommend;信号降级 → 清除 current_recommend; active_manual(人工标记)永不动。与 XMPP 动作级告警同源(红线#12)。""" try: + recompute_rr(conn, code) # 先入先算:保证 tag/入队/盯盘排序拿到新鲜 RR _old_tag_row = conn.execute( "SELECT tag FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() _old_tag = (_old_tag_row[0] or '') if _old_tag_row else '' @@ -1234,10 +1269,15 @@ def flush_rec_digest(max_items=5): if not items: return 0 _os.remove(qf) - items.sort(key=lambda x: x.get('rr') or 0, reverse=True) + _SELL_SIGS = ("卖出", "止盈") + # 卖出/止盈是释放现金的操作,不占买入预算,单独一组排最前 + 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) + items = sells + buys_all top = items[:max_items] - # ── 现金预算(决定操盘建议 + 换仓策略)── + # ── 现金预算(决定操盘建议 + 换仓策略):只对买入项计算,卖出不占预算 ── cash_note = "" rotation_note = "" try: @@ -1250,11 +1290,11 @@ def flush_rec_digest(max_items=5): cum = 0.0 buys = [] queued = [] - for it in top: + for it in buys_all: # 只遍历买入项;sells 永远可执行 import re as _re m = _re.search(r'(\d+(?:\.\d+)?)\s*%', it.get('position') or '') pct = float(m.group(1)) if m else 8.0 - if cum + pct <= budget_pct + 1e-9: + if (it.get('rr') or 0) >= 1.5 and cum + pct <= budget_pct + 1e-9: buys.append((it, pct)) cum += pct else: diff --git a/server.py b/server.py index e0c5c4d5..6e708858 100644 --- a/server.py +++ b/server.py @@ -227,18 +227,27 @@ def get_watch(): pass return 8.0 + _SELL_SIGS = ("卖出", "止盈") _cands = [d for d in results if d['sort_group'] == 0 and _is_fresh(d)] - _cands.sort(key=lambda x: x.get('rr_ratio') or 0, reverse=True) - # 现金预算内标记"可执行",预算外标记"排队"(不再降级隐藏) + # 卖出类永远可执行(释放现金,不占买入预算),排最前;买入类按 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) + for d in _sells: + d['rec_exec'] = True # 卖出不需要现金,永远可执行 + d['suggested_position_pct'] = 0.0 + # 买入:RR≥1.5 才有可执行资格(prompt 自己的纪律:RR<1.5→不推荐); + # 达标者贪心装入现金预算,预算外/不达标标记"排队"(不再降级隐藏) _cum = 0.0 - for d in _cands: + for d in _buys: pct = _sugg_pct(d) d['suggested_position_pct'] = pct - if _cum + pct <= _budget_pct + 1e-9: + rr = d.get('rr_ratio') or 0 + if rr >= 1.5 and _cum + pct <= _budget_pct + 1e-9: d['rec_exec'] = True # 可执行 _cum += pct else: - d['rec_exec'] = False # 排队(现金不足) + d['rec_exec'] = False # 排队(现金不足或RR不达标) # 落选(tag 但非新鲜)仍降回自然分组;新鲜者全部留在推荐区 for d in results: if d['sort_group'] == 0 and not _is_fresh(d):