硬性策略质量门禁 validate_strategy()

新增 STRATEGY_QUALITY_GATES 检查清单(9条红线):
CRITICAL: 止损/止盈存在+>0, 买入区下沿<上沿
HIGH: 止损≤买入区, 买入推荐含RR≥1.5, 港股标currency=HKD
MEDIUM: signal短词, tech_snapshot含技术位

enforce_strategy_quality() 插在写入链的两处:
1. reassess_with_context() return前 → 单只重评必过
2. regenerate_all() for d in decisions: 写DB前 → 批量重评必过

不过的:status=review_needed, signal降级→信号不充分
不会写进DB/JSON,除非修复了CRITICAL问题
This commit is contained in:
知微
2026-07-02 13:46:53 +08:00
parent 04b8a6d4bc
commit 7c0e85af28
32 changed files with 12496 additions and 75159 deletions
+200 -2
View File
@@ -20,6 +20,174 @@ import multi_timeframe as mtf
from mo_data import read_portfolio, read_decisions, read_watchlist
from strategy_tree import detect_scenario
# ─── 策略准入门禁 — 硬性质量红线 ───────────────────────────────
# 每一条策略写入前必须过此门禁。不过的不得写入DB/JSON,
# 必须触发重评修复。代码层面硬拦截,不依赖prompt或文档。
#
# 规则列表 + 严重程度 + 修复建议
STRATEGY_QUALITY_GATES = [
{
"id": "GATE_LOSS_EXISTS",
"desc": "止损必须存在且>0",
"check": lambda d: (d.get("stop_loss") or 0) > 0,
"severity": "CRITICAL",
"fix": "调用 technical_analysis 计算支撑位设置止损"
},
{
"id": "GATE_PROFIT_EXISTS",
"desc": "止盈必须存在且>0(纯自选股可放宽)",
"check": lambda d: (d.get("take_profit") or 0) > 0,
"severity": "CRITICAL",
"fix": "调用 technical_analysis 计算阻力位设置止盈目标"
},
{
"id": "GATE_SL_GTE_LOW",
"desc": "止损必须 ≤ 买入区下沿",
"check": lambda d: (d.get("stop_loss") or 0) <= (d.get("entry_low") or 99999),
"severity": "HIGH",
"fix": "止损不能高于买入区,调整止损至买入区以下"
},
{
"id": "GATE_ENTRY_RANGE",
"desc": "买入区下沿 < 上沿",
"check": lambda d: (d.get("entry_low") or 0) < (d.get("entry_high") or 0),
"severity": "CRITICAL",
"fix": "entry_low=现价×0.95, entry_high=现价×1.05 取近似区间"
},
{
"id": "GATE_RR_COMPUTED",
"desc": "买入推荐必须含RR",
"check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) > 0,
"severity": "HIGH",
"fix": "RR = (止盈-现价)/(现价-止损),数据齐全后自动算"
},
{
"id": "GATE_RR_MINIMUM",
"desc": "买入推荐RR≥1.5(非买入信号跳过)",
"check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) >= 1.5,
"severity": "HIGH",
"fix": "RR不足→signal降级为'信号不充分',不进推荐区"
},
{
"id": "GATE_SIGNAL_SHORT",
"desc": "timing_signal 必须是短词(2-4字)",
"check": lambda d: len((d.get("timing_signal") or "").strip().split()) <= 4 and (d.get("timing_signal") or "") not in ("neutral", ""),
"severity": "MEDIUM",
"fix": "使用短词:买入/加仓/观望/持有/关注/信号不充分"
},
{
"id": "GATE_TECH_SNAPSHOT",
"desc": "tech_snapshot 必须包含技术位数值",
"check": lambda d: bool(d.get("tech_snapshot")) and any(c in d["tech_snapshot"] for c in "支撑阻力压强"),
"severity": "MEDIUM",
"fix": "tech_snapshot 包含强撑/弱撑/弱压/强压至少3个数值"
},
{
"id": "GATE_CURRENCY_SET",
"desc": "港股必须标 currency=HKD",
"check": lambda d: not (len(str(d.get("code",""))) == 5 and str(d.get("code",""))[0] in "01") or d.get("currency") == "HKD",
"severity": "HIGH",
"fix": "设置 d['currency']='HKD'"
},
]
def _hk_stock(code):
return bool(len(str(code)) == 5 and str(code)[0] in ('0','1'))
def _is_buy_signal_str(signal):
"""买入/加仓/建仓类信号"""
if not signal:
return False
return any(kw in signal for kw in ["买入", "加仓", "建仓"])
# _is_buy_signal is defined later in this file (~line 1240)
def validate_strategy(d, debug=True):
"""策略评审:硬性门禁检查
返回 (passed: bool, failures: list)
任一 CRITICAL 失败 → 拒绝写入,标记 TODO 触发重评
任一 HIGH 失败 → 标记 quality_check=failed,写入但不出现在推荐区
MEDIUM 失败 → 记录但不拦截
"""
failures = []
for gate in STRATEGY_QUALITY_GATES:
try:
ok = gate["check"](d)
except Exception as e:
ok = False
if debug:
print(f" [VALIDATE] {gate['id']} 检查异常: {e}", flush=True)
if not ok:
failures.append(gate)
if debug:
print(f" [VALIDATE] ✗ {gate['id']} ({gate['severity']}): {gate['desc']}", flush=True)
passed = all(f["severity"] != "CRITICAL" for f in failures)
if debug:
criticals = [f for f in failures if f["severity"] == "CRITICAL"]
highs = [f for f in failures if f["severity"] == "HIGH"]
if passed:
print(f" [VALIDATE] ✅ 通过 ({len(failures)}条警告)" if failures else " [VALIDATE] ✅ 全通过", flush=True)
else:
print(f" [VALIDATE] ❌ {len(criticals)}条CRITICAL未通过 → 拒绝写入", flush=True)
return passed, failures
def enforce_strategy_quality(code, name, result):
"""策略写入前的强制质量门禁
对 result 执行 validate_strategy,不通过则:
- 设置 quality_check: failed + 失败原因列表
- 自动在 changelog 记录
- 返回 False,调用方不写入
- 返回 True 但 result 被修改(标记了 failed,未被写入)
"""
passed, failures = validate_strategy(result)
if not passed:
critical_issues = [f["id"] for f in failures if f["severity"] == "CRITICAL"]
high_issues = [f["id"] for f in failures if f["severity"] == "HIGH"]
# 标记质量失败
result["quality_check"] = "failed"
result["quality_issues"] = {
"critical": critical_issues,
"high": high_issues,
"all": [f["id"] for f in failures],
}
result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M")
# 记录 changelog
cl = result.setdefault("changelog", [])
cl.append({
"time": datetime.now().strftime("%Y-%m-%d %H:%M"),
"event": f"质量门禁拒绝 (failed: {critical_issues})",
})
# 信号降级
result["status"] = "review_needed"
result["timing_signal"] = "信号不充分"
print(f" 🚫 {name}({code}) 质量门禁未通过 ({critical_issues}) → 已标记 review_needed", flush=True)
return False
# 如果有 HIGH 级别失败 → 标记但不拦截写入
high_fails = [f for f in failures if f["severity"] == "HIGH"]
if high_fails:
result["quality_check"] = "warning"
result["quality_issues"] = {"high": [f["id"] for f in high_fails]}
result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M")
print(f" ⚠️ {name}({code}) 有{len(high_fails)}条HIGH警告,标记warning但已写入", flush=True)
else:
result["quality_check"] = "passed"
result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M")
return True
def is_hk_stock(code):
"""判断是否港股(港股代码5位,A股6位带前导零)"""
@@ -427,7 +595,13 @@ def batch_fetch_prices(codes):
"SELECT price, change_pct FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (raw_code,)
).fetchone()
if row and row['price']:
all_results[raw_code] = (row['price'], 0, row['change_pct'] or 0)
all_results[raw_code] = {
"price": row['price'],
"close": row['price'], # 用现价近似昨收,仅用于sentiment计算
"high": row['price'],
"low": row['price'],
"code": raw_code,
}
db.close()
if all_results:
return all_results
@@ -1602,6 +1776,9 @@ def reassess_with_context(code, name, price, cost, shares, current_action,
except Exception:
pass
# ── 策略质量门禁 ──
enforce_strategy_quality(code, name, result)
return result
@@ -2156,6 +2333,23 @@ def regenerate_all(stdout=True):
if added_to_wl and stdout:
print(f" 清仓→自选自动加入: {', '.join(added_to_wl)}")
# 重新计算 portfolio 汇总(保留已存在的 cash,用最新价格算市值)
try:
total_mv = 0.0
for h in existing_pf.get('holdings', []):
p = h.get('price') or 0
s = h.get('shares') or 0
total_mv += p * s
if p and s and total_mv > 0:
h['market_value'] = round(p * s, 2)
old_cash = existing_pf.get('cash') or 80476 # fallback 6/23 backup
existing_pf['cash'] = old_cash
existing_pf['total_mv'] = round(total_mv, 2)
existing_pf['total_assets'] = round(total_mv + old_cash, 2)
existing_pf['position_pct'] = round(total_mv / (total_mv + old_cash) * 100, 2) if (total_mv + old_cash) > 0 else 0
except Exception as e:
print(f" [汇总计算失败] {e}", flush=True)
# DB 写入(替代 JSON dump — 强制币种约束)
try:
from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock, write_holding_strategy
@@ -2166,7 +2360,11 @@ def regenerate_all(stdout=True):
s.setdefault('currency', 'CNY')
write_watchlist_stock(conn, s)
for d in decisions:
write_holding_strategy(conn, d.get('code', ''), d.get('name', ''), d)
# ── 策略质量门禁 ──
code = d.get('code', '')
name = d.get('name', '')
enforce_strategy_quality(code, name, d)
write_holding_strategy(conn, code, name, d)
conn.close()
except Exception as e:
print(f" [DB写入失败] {e}", flush=True)