fix(parser): 节标题锚定解析+脏区间清洗+三值RR

- parse_response: 只认行首【买入区间】【综合结论】等节行(原先取首个关键词行,
  命中修改点段落引用的旧脏值→95~99区间反复写回,17只股票受害);
  信号锚定】后首词,防'观望(不建议买入)'误判; 【买入区间】无→显式清空
- save_result: 区间-现价距离门禁(eh<0.5px或el>1.5px→拒写并清空,不再保留原值养脏)
- recompute_rr: 三值RR(rr_low/rr_ratio中/rr_high,分别对应区下沿/中值/上沿入场)
- 迁移: holding_strategies+rr_low/rr_high列; digest显示RR中(低~高); watch API+前端展示
This commit is contained in:
hmo
2026-07-22 22:59:18 +08:00
parent 56e1326569
commit f7f7fe3ca0
4 changed files with 120 additions and 65 deletions
+68 -32
View File
@@ -336,49 +336,68 @@ PE={data.get('pe','?')}(最新财报) 市值={data.get('mcap','?')}亿
2. 禁止输出 <structured_data> 或任何 XML/JSON/代码块
3. 所有【】节标题一个都不能少"""
def parse_response(text):
"""从LLM回复中提取策略参数"""
result = {"signal": "", "entry_low": 0, "entry_high": 0, "stop_loss": 0, "take_profit": 0, "position": ""}
"""从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}
# 信号
sl = [l for l in text.split("\n") if "综合结论" in l]
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
m = re.search(r'综合结论】\s*[(]?\s*(弱势持有|可加仓|可买入|买入|卖出|止盈|关注|观望|持有)', sl)
if m:
result["signal"] = m.group(1)
# 买入区间(只认【买入区间】节行;"无"→显式清空,不保留旧值)
zl = _section_line("买入区间")
if zl:
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
# 买入区间
zl = [l for l in text.split("\n") if "买入区间" in l]
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 name in ("建议止盈", "止盈"):
l = _section_line(name)
if l:
nums = re.findall(r'\d+\.?\d*', l)
if nums:
result["take_profit"] = float(nums[0])
break
# 止损
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])
# 仓位:只有买入信号才需要,提取百分比数字
# 仓位:只有买入信号才需要,提取百分比数字(只认【建议仓位】节行)
result["position"] = ""
if result["signal"] == "买入":
for l in text.split("\n"):
if "建议仓位" in l:
nums = re.findall(r'[\d.]+', l)
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
break
return result
@@ -402,7 +421,24 @@ 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:
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=?")
+37 -18
View File
@@ -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'):
+1 -1
View File
@@ -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
+1 -1
View File
@@ -453,7 +453,7 @@ function renderWatchlist() {
<td class="p-3 text-right font-mono text-sm">${priceDisplay}</td>
<td class="p-3 text-right font-mono text-sm ${chg>=0?'text-green-400':'text-red-400'}">${chg>=0?'+':''}${chg.toFixed(2)}</td>
<td class="p-3 text-right font-mono text-xs text-slate-300">${buyZone}<br><span class="text-slate-500">损${sl}${tp}</span></td>
<td class="p-3 text-right font-mono text-sm ${rr>=1.5?'text-green-400':rr>0?'text-yellow-400':'text-slate-500'}">${rr>0?rr.toFixed(2):'—'}</td>
<td class="p-3 text-right font-mono text-sm ${rr>=1.5?'text-green-400':rr>0?'text-yellow-400':'text-slate-500'}">${rr>0?rr.toFixed(2):'—'}${(s.rr_low&&s.rr_high&&s.rr_low!==s.rr_high)?'<br><span class="text-[10px] text-slate-500">'+s.rr_low.toFixed(2)+'~'+s.rr_high.toFixed(2)+'</span>':''}</td>
<td class="p-3 text-right"><span class="text-xs px-2 py-1 rounded ${signal.includes('买入')?'bg-green-900/50 text-green-300':signal.includes('关注')?'bg-yellow-900/50 text-yellow-300':'bg-slate-800 text-slate-400'}">${signal||'—'}</span></td>
<td class="p-3 text-right text-xs text-slate-400">${s.position_advice || '—'}</td>
</tr>