diff --git a/deploy/profile-scripts/batch_reassess.py b/deploy/profile-scripts/batch_reassess.py index 044eef66..11490344 100644 --- a/deploy/profile-scripts/batch_reassess.py +++ b/deploy/profile-scripts/batch_reassess.py @@ -336,50 +336,69 @@ PE={data.get('pe','?')}(最新财报) 市值={data.get('mcap','?')}亿 2. 禁止输出 或任何 XML/JSON/代码块 3. 所有【】节标题一个都不能少""" def parse_response(text): - """从LLM回复中提取策略参数""" - result = {"signal": "", "entry_low": 0, "entry_high": 0, "stop_loss": 0, "take_profit": 0, "position": ""} - - # 信号 - sl = [l for l in text.split("\n") if "综合结论" in l] + """从LLM回复中提取策略参数。 + ⚠️ 节标题精确匹配:只认行首【买入区间】【综合结论】等节行。 + 绝不用"包含关键词的第一行"——修改点段落会引用旧脏值(如"原买入区间95.0~99.0"), + 曾导致脏数据被反复写回(17只股票背着95~99区间,LLM新区间形同虚设)。""" + result = {"signal": "", "entry_low": 0, "entry_high": 0, "stop_loss": 0, "take_profit": 0, "position": "", + "zone_cleared": False} + + def _section_line(name): + """匹配节标题行:行首(可含空白)【名称】,返回该行内容""" + for l in text.split("\n"): + if re.match(r'^\s*【' + name + r'】', l): + return l + return "" + + # 信号(只认【综合结论】节行,且锚定】后的首个词,防"观望(不建议买入)"误判为买入) + sl = _section_line("综合结论") if sl: - for kw in ["买入","关注","观望","卖出"]: - if kw in sl[0]: - result["signal"] = kw - break - - # 买入区间 - zl = [l for l in text.split("\n") if "买入区间" in l] + m = re.search(r'综合结论】\s*[((]?\s*(弱势持有|可加仓|可买入|买入|卖出|止盈|关注|观望|持有)', sl) + if m: + result["signal"] = m.group(1) + + # 买入区间(只认【买入区间】节行;"无"→显式清空,不保留旧值) + zl = _section_line("买入区间") if zl: - nums = re.findall(r'[\d.]+', zl[0]) - if len(nums) >= 2: - result["entry_low"] = float(nums[0]) - result["entry_high"] = float(nums[1]) - - # 止损 - for l in text.split("\n"): - if "建议止损" in l: - nums = re.findall(r'[\d.]+', l) - if nums: result["stop_loss"] = float(nums[0]) - - # 止盈 - for l in text.split("\n"): - if "建议止盈" in l: - nums = re.findall(r'[\d.]+', l) - if nums: result["take_profit"] = float(nums[0]) - - # 仓位:只有买入信号才需要,提取百分比数字 + if re.search(r'】\s*(无|不设|不参与|空仓)', zl): + result["zone_cleared"] = True + else: + nums = re.findall(r'\d+\.?\d*', zl) + if len(nums) >= 2: + a, b = float(nums[0]), float(nums[1]) + result["entry_low"] = min(a, b) + result["entry_high"] = max(a, b) + + # 止损(只认【建议止损】节行) + for name in ("建议止损", "止损"): + l = _section_line(name) + if l: + nums = re.findall(r'\d+\.?\d*', l) + if nums: + result["stop_loss"] = float(nums[0]) + break + + # 止盈(只认【建议止盈】节行) + for name in ("建议止盈", "止盈"): + l = _section_line(name) + if l: + nums = re.findall(r'\d+\.?\d*', l) + if nums: + result["take_profit"] = float(nums[0]) + break + + # 仓位:只有买入信号才需要,提取百分比数字(只认【建议仓位】节行) result["position"] = "" if result["signal"] == "买入": - for l in text.split("\n"): - if "建议仓位" in l: - nums = re.findall(r'[\d.]+', l) - for n in nums: - f = float(n) - if 1 <= f <= 30: # 合理的仓位范围 - result["position"] = f"{f:.0f}%" - break - break - + l = _section_line("建议仓位") + if l: + nums = re.findall(r'\d+\.?\d*', l) + for n in nums: + f = float(n) + if 1 <= f <= 30: # 合理的仓位范围 + result["position"] = f"{f:.0f}%" + break + return result def save_result(code, full_text, parsed): @@ -402,11 +421,28 @@ def save_result(code, full_text, parsed): # 区间写入门禁:上下沿都必须为正且 下沿<上沿<下沿x3,否则视为解析错误整体跳过 # (防 214.68~2.52 类解析污染,与 GATE_ZONE_SANITY 同级防护) _el, _eh = parsed["entry_low"], parsed["entry_high"] - if _el > 0 and _eh > _el and _eh < _el * 3: - updates.append("entry_low=?") - params.append(_el) - updates.append("entry_high=?") - params.append(_eh) + if parsed.get("zone_cleared"): + # LLM 显式输出【买入区间】无 → 清空区间(不再保留可能脏的旧值) + updates.append("entry_low=0") + updates.append("entry_high=0") + elif _el > 0 and _eh > _el and _eh < _el * 3: + # 区间-现价距离门禁:整体偏离现价过远(区上沿<现价0.5x 或 区下沿>现价1.5x) + # → 判定脏数据/解析错误,拒写并清空(不再"保留原值"养脏,如95~99 vs 现价60) + _px = 0.0 + try: + _pr = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + _px = float(_pr[0]) if _pr and _pr[0] else 0.0 + except Exception: + pass + if _px > 0 and (_eh < _px * 0.5 or _el > _px * 1.5): + print(f" ⚠️ 买入区{_el}~{_eh}偏离现价{_px}过远,拒写并清空(防脏数据残留)", flush=True) + updates.append("entry_low=0") + updates.append("entry_high=0") + else: + updates.append("entry_low=?") + params.append(_el) + updates.append("entry_high=?") + params.append(_eh) elif _el > 0 or _eh > 0: print(f" ⚠️ 买入区解析异常({_el}~{_eh}),跳过区间写入(保留原值)", flush=True) # 止损/止盈一致性门禁:损>0 时必须在区间下沿之下(0.5x~1.0x),盈>0 时必须在区间上沿之上 diff --git a/mofin_db.py b/mofin_db.py index 1b0aad2b..dadc9d44 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -582,6 +582,13 @@ def init_all_tables(conn: sqlite3.Connection): conn.execute("ALTER TABLE holding_strategies ADD COLUMN tag TEXT DEFAULT ''") except sqlite3.OperationalError: pass + # ── 三值 RR 迁移(2026-07-22 老爸):买入区下沿/中值/上沿三个 RR ── + # rr_ratio 保留=中值 RR(排序/门槛沿用),新增 rr_low / rr_high 展示用。 + for _col in ("rr_low REAL DEFAULT 0", "rr_high REAL DEFAULT 0"): + try: + conn.execute(f"ALTER TABLE holding_strategies ADD COLUMN {_col}") + except sqlite3.OperationalError: + pass conn.commit() @@ -1145,11 +1152,11 @@ def reconcile_signal_from_analysis(conn, code: str) -> str: def recompute_rr(conn, code: str) -> float: - """用买入区中值+已存止损/止盈重算 RR 并写回 rr_ratio。 + """用买入区+已存止损/止盈重算三值 RR 并写回(rr_low/rr_ratio中值/rr_high)。 根治"LLM 不输出 RR → rr_ratio 永远 0"的断链(红线:RR 由系统算,不信 LLM)。 - 公式: RR = (止盈 - 基准价) / (基准价 - 止损);基准价=买入区中值(与策略自洽、 - 不随价格波动、脏区间自动算出 RR=0 现形),区间缺失时兜底现价。 - 损/盈缺失或基准价<=止损 → RR=0(不达标,不参与排序)。""" + 公式: RR(x) = (止盈 - x) / (x - 止损);x 分别取买入区下沿/中值/上沿。 + rr_ratio=中值 RR 用于排序与1.5门槛;rr_low/rr_high 展示入场价敏感度。 + 区间缺失 → 中值兜底现价(low/high=0);损/盈缺失或 x<=止损 → 该值=0。""" try: row = conn.execute( "SELECT entry_low, entry_high, stop_loss, take_profit FROM holding_strategies WHERE code=? AND status='active'", @@ -1157,24 +1164,31 @@ def recompute_rr(conn, code: str) -> float: if not row: return 0.0 el, eh, sl, tp = (row[0] or 0), (row[1] or 0), (row[2] or 0), (row[3] or 0) - ref = 0.0 + + def _rr(x): + if sl > 0 and tp > 0 and x > sl: + v = round((tp - x) / (x - sl), 2) + return v if v > 0 else 0.0 + return 0.0 + + rr_low = rr_mid = rr_high = 0.0 if el > 0 and eh > el: - ref = (el + eh) / 2.0 # 买入区中值:策略自洽的期望入场价 + rr_low = _rr(el) # 下沿买入:最乐观 + rr_mid = _rr((el + eh) / 2.0) + rr_high = _rr(eh) # 上沿买入:最保守 else: + # 区间缺失 → 中值兜底现价 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]) + rr_mid = _rr(float(pr[0])) except Exception: pass - 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.execute( + "UPDATE holding_strategies SET rr_ratio=?, rr_low=?, rr_high=? WHERE code=? AND status='active'", + (rr_mid, rr_low, rr_high, code)) conn.commit() - return rr + return rr_mid except Exception as e: print(f" [RR] {code} 重算失败: {e}", flush=True) return 0.0 @@ -1223,11 +1237,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, position_advice, full_analysis FROM holding_strategies WHERE code=? AND status='active'", + "rr_ratio, rr_low, rr_high, position_advice, full_analysis FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() if not row: return - name, sig, tag, el, eh, sl, tp, rr, pos, fa = row + name, sig, tag, el, eh, sl, tp, rr, rr_lo, rr_hi, pos, fa = row if tag != 'current_recommend' or sig not in ("买入", "可买入", "可加仓", "卖出", "止盈"): print(f" [REC] {code} 非有效推荐(tag={tag},sig={sig}),不入队", flush=True) return False @@ -1245,7 +1259,8 @@ def enqueue_recommend(conn, code: str): with open(qf, 'a', encoding='utf-8') as f: f.write(_j.dumps({"code": code, "name": name, "signal": sig, "entry_low": el, "entry_high": eh, "stop_loss": sl, - "take_profit": tp, "rr": rr, "position": pos, + "take_profit": tp, "rr": rr, "rr_low": rr_lo, "rr_high": rr_hi, + "position": pos, "strategy_excerpt": strat, "full_analysis": fa_text[:2500], "ts": _dt.now().isoformat()}, ensure_ascii=False) + "\n") @@ -1337,10 +1352,14 @@ def flush_rec_digest(max_items=5): lines = [f"📈 新增推荐 {len(items)} 只(按RR排序):"] for i, it in enumerate(top): + _rr_mid = it.get('rr') or 0 + _rr_lo, _rr_hi = it.get('rr_low') or 0, it.get('rr_high') or 0 + _rr_txt = (f"RR={_rr_mid}({_rr_lo}~{_rr_hi})" if _rr_lo and _rr_hi and _rr_lo != _rr_hi + else f"RR={_rr_mid}") lines.append(f"• {it.get('name') or it['code']}({it['code']}) {it['signal']}" f" 区{it.get('entry_low') or '—'}~{it.get('entry_high') or '—'}" f" 损{it.get('stop_loss') or '—'} 盈{it.get('take_profit') or '—'}" - f" RR={it.get('rr') or 0} 仓位{it.get('position') or '—'}") + f" {_rr_txt} 仓位{it.get('position') or '—'}") # 头部 2 只附策略依据(12维全文节选) if i < 2: if it.get('strategy_excerpt'): diff --git a/server.py b/server.py index 6e708858..0f17c981 100644 --- a/server.py +++ b/server.py @@ -147,7 +147,7 @@ def get_watch(): SELECT hs.code, hs.name, hs.decision_type, hs.timing_signal, hs.action, hs.position_advice, hs.tag, hs.entry_low, hs.entry_high, hs.stop_loss, hs.take_profit, - hs.rr_ratio, hs.full_analysis, hs.reassessed_at, + hs.rr_ratio, hs.rr_low, hs.rr_high, hs.full_analysis, hs.reassessed_at, lp.price, lp.change_pct, h.shares, h.position_pct FROM holding_strategies hs diff --git a/static/index.html b/static/index.html index 03623f30..107feb3c 100644 --- a/static/index.html +++ b/static/index.html @@ -453,7 +453,7 @@ function renderWatchlist() { ${priceDisplay} ${chg>=0?'+':''}${chg.toFixed(2)} ${buyZone}
损${sl} 盈${tp} - ${rr>0?rr.toFixed(2):'—'} + ${rr>0?rr.toFixed(2):'—'}${(s.rr_low&&s.rr_high&&s.rr_low!==s.rr_high)?'
'+s.rr_low.toFixed(2)+'~'+s.rr_high.toFixed(2)+'':''} ${signal||'—'} ${s.position_advice || '—'}