fix: NEAR_SL false alarm — add cost check, relabel as PROFIT_PROTECT when floating profit >5%

This commit is contained in:
知微
2026-06-30 23:33:37 +08:00
parent 0a6c659e45
commit 236e67fa71
+252 -251
View File
@@ -1,251 +1,252 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""stale_detector.py — 检查所有策略,标记价格偏离/过期的策略 """stale_detector.py — 检查所有策略,标记价格偏离/过期的策略
读取 decisions.json 的扁平列表。自选策略和持仓策略分开判断。 读取 decisions.json 的扁平列表。自选策略和持仓策略分开判断。
可被 cron no_agent 模式调用:stdout 注入到后续 LLM 分析。 可被 cron no_agent 模式调用:stdout 注入到后续 LLM 分析。
输出格式: 输出格式:
[FLAG] [自选/持仓] 股票名(代码) 价XX | 买入A~B | 问题 [FLAG] [自选/持仓] 股票名(代码) 价XX | 买入A~B | 问题
用法: 用法:
python3 stale_detector.py python3 stale_detector.py
""" """
import json import json
import sys import sys
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json"
PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json"
def fetch_prices(codes): def fetch_prices(codes):
import urllib.request import urllib.request
if not codes: if not codes:
return {} return {}
symbols, code_map = [], {} symbols, code_map = [], {}
for c in codes: for c in codes:
c = str(c).strip() c = str(c).strip()
p = "sh" if (len(c) == 6 and c[0] in "569") else "sz" if len(c) == 6 else "hk" p = "sh" if (len(c) == 6 and c[0] in "569") else "sz" if len(c) == 6 else "hk"
sym = f"{p}{c}" sym = f"{p}{c}"
symbols.append(sym) symbols.append(sym)
code_map[sym] = c code_map[sym] = c
try: try:
req = urllib.request.Request( req = urllib.request.Request(
f"http://qt.gtimg.cn/q={','.join(symbols)}", f"http://qt.gtimg.cn/q={','.join(symbols)}",
headers={"User-Agent": "curl/7.81"}, headers={"User-Agent": "curl/7.81"},
) )
with urllib.request.urlopen(req, timeout=10) as r: with urllib.request.urlopen(req, timeout=10) as r:
text = r.read().decode("gbk") text = r.read().decode("gbk")
except Exception as e: except Exception as e:
print(f"FETCH_FAIL: {e}", file=sys.stderr) print(f"FETCH_FAIL: {e}", file=sys.stderr)
return {} return {}
results = {} results = {}
for line in text.strip().split("\n"): for line in text.strip().split("\n"):
if "=" not in line: if "=" not in line:
continue continue
try: try:
raw = line.split("=", 1)[1].strip().strip('"').strip(";") raw = line.split("=", 1)[1].strip().strip('"').strip(";")
fld = raw.split("~") fld = raw.split("~")
if len(fld) < 6: if len(fld) < 6:
continue continue
sym = line.split("=", 1)[0].strip().lstrip("v_") sym = line.split("=", 1)[0].strip().lstrip("v_")
oc = code_map.get(sym) oc = code_map.get(sym)
if not oc: if not oc:
continue continue
p = float(fld[3]) if fld[3] else 0 p = float(fld[3]) if fld[3] else 0
# NOTE: HK stock prices kept in HKD — decisions.json also stores HK values in HKD c = fld[32] if len(fld) > 32 else "0"
# (stop_loss/take_profit/entry). Never convert here or we mismatch CNY price vs HKD stop. results[oc] = (p, c)
# Downstream tools that need CNY should convert at display time. except (ValueError, IndexError):
c = fld[32] if len(fld) > 32 else "0" continue
results[oc] = (p, c) return results
except (ValueError, IndexError):
continue
return results def main():
decisions_list = json.load(open(DECISIONS_PATH))
if not isinstance(decisions_list, list):
def main(): decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else []
decisions_list = json.load(open(DECISIONS_PATH))
if not isinstance(decisions_list, list): # 只保留有买入区的条目,排除已关闭的(inactive/closed
decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else [] EXCLUDED_STATUSES = ("closed", "inactive")
to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES]
# 只保留有买入区的条目,排除已关闭的(inactive/closed if not to_check:
EXCLUDED_STATUSES = ("closed", "inactive") print("[SILENT] 无需要检查的策略")
to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES] return 0
if not to_check:
print("[SILENT] 无需要检查的策略") # ----- 组合级监测:读取总仓位 + 弱势比例 -----
return 0 position_pct = 0
cash = 0
# ----- 组合级监测:读取总仓位 + 弱势比例 ----- total_assets = 0
position_pct = 0 try:
cash = 0 with open(PORTFOLIO_PATH) as f:
total_assets = 0 pf = json.load(f)
try: position_pct = pf.get("position_pct", 0)
with open(PORTFOLIO_PATH) as f: cash = pf.get("cash", 0)
pf = json.load(f) total_assets = pf.get("total_assets", 0)
position_pct = pf.get("position_pct", 0) except Exception:
cash = pf.get("cash", 0) pass
total_assets = pf.get("total_assets", 0) # 统计持仓策略中弱势/深套的比例
except Exception: weak_count = 0
pass holding_count = 0
# 统计持仓策略中弱势/深套的比例 for d in decisions_list:
weak_count = 0 if d.get("type") == "持仓策略" and d.get("status") not in ("closed", "inactive"):
holding_count = 0 holding_count += 1
for d in decisions_list: cat = d.get("stock_category", "")
if d.get("type") == "持仓策略" and d.get("status") not in ("closed", "inactive"): if cat in ("弱势", "深套"):
holding_count += 1 weak_count += 1
cat = d.get("stock_category", "") weak_ratio = (weak_count / holding_count * 100) if holding_count > 0 else 0
if cat in ("弱势", "深套"):
weak_count += 1 prices = fetch_prices([d["code"] for d in to_check])
weak_ratio = (weak_count / holding_count * 100) if holding_count > 0 else 0 now = datetime.now(timezone.utc).astimezone()
found = 0
prices = fetch_prices([d["code"] for d in to_check])
now = datetime.now(timezone.utc).astimezone() for d in to_check:
found = 0 code = d["code"]
name = d.get("name", code)
for d in to_check: el = d.get("entry_low")
code = d["code"] eh = d.get("entry_high")
name = d.get("name", code) sl = d.get("stop_loss")
el = d.get("entry_low") tp = d.get("take_profit")
eh = d.get("entry_high") ts = d.get("created_at") or d.get("timestamp") or d.get("updated_at", "")
sl = d.get("stop_loss") is_wl = "自选" in (d.get("type", ""))
tp = d.get("take_profit")
ts = d.get("created_at") or d.get("timestamp") or d.get("updated_at", "") pi = prices.get(code)
is_wl = "自选" in (d.get("type", "")) if not pi:
continue
pi = prices.get(code) price, chg = pi
if not pi: if price <= 0:
continue continue
price, chg = pi
if price <= 0: issues, flags = [], []
continue tag = "[自选]" if is_wl else "[持仓]"
issues, flags = [], [] # -- 偏离 --
tag = "[自选]" if is_wl else "[持仓]" if is_wl and el and eh:
# 币种标记(港股HKD vs A股CNY,辅助下游LLM避免混读 # 读取 timing_signal 判断策略有效性(timing_signal 字段优先,fallback to action
currency_suffix = "(HKD)" if len(str(code)) == 5 and str(code)[0] in '01' else "" current_str = d.get("current", "") or ""
price_str = f"{price:.2f}{currency_suffix}" timing_signal = d.get("timing_signal", "") or current_str
buy_zone_str = f"{el}~{eh}{currency_suffix}" if currency_suffix else f"{el}~{eh}" has_nonbuy_signal = any(kw in timing_signal for kw in [
"等企稳再入", "等企稳", "弱势持有", "观望",
# -- 偏离 -- "不建议买入", "谨慎买入",
if is_wl and el and eh: ])
# 读取 timing_signal 判断策略有效性(timing_signal 字段优先,fallback to action
current_str = d.get("current", "") or "" # 直接计算 R/R(不依赖文本匹配)
timing_signal = d.get("timing_signal", "") or current_str rr_invalid = False
has_nonbuy_signal = any(kw in timing_signal for kw in [ if sl and sl > 0 and tp and tp > 0 and price > sl:
"等企稳再入", "等企稳", "弱势持有", "观望", rr = (tp - price) / (price - sl)
"不建议买入", "谨慎买入", if rr < 1.5:
]) rr_invalid = True
# 也检查 tp 是否接近或低于成本(微盈/浮亏止盈)
# 直接计算 R/R(不依赖文本匹配) cost = d.get("cost", 0)
rr_invalid = False if cost and cost > 0 and tp <= cost * 1.05:
if sl and sl > 0 and tp and tp > 0 and price > sl: rr_invalid = True
rr = (tp - price) / (price - sl)
if rr < 1.5: strategy_deficient = has_nonbuy_signal or rr_invalid
rr_invalid = True # 对自选无止盈位的也标记(策略不完整)
# 也检查 tp 是否接近或低于成本(微盈/浮亏止盈) if not tp or tp == 0:
cost = d.get("cost", 0) strategy_deficient = True
if cost and cost > 0 and tp <= cost * 1.05:
rr_invalid = True if el <= price <= eh:
flags.append("[WL_IN]")
strategy_deficient = has_nonbuy_signal or rr_invalid if strategy_deficient:
# 对自选无止盈位的也标记(策略不完整) flags.append("[STRATEGY_STALE]")
if not tp or tp == 0: prefix = "⚠️仓位挤占 " if position_pct > 80 else ""
strategy_deficient = True issues.append(f"[STRATEGY_STALE] {prefix}{price:.2f}在买入区{el}~{eh}但策略不完整({'RR='+f'{rr:.2f}<1.5' if rr_invalid else '无止盈位' if not tp else '非买入信号'}),买入区需重评")
else:
if el <= price <= eh: prefix = "⚠️仓位挤占 " if position_pct > 80 else ""
flags.append("[WL_IN]") issues.append(f"[PUSH] {prefix}{price:.2f}入买入区{el}~{eh}")
if strategy_deficient: elif price > eh * 1.35:
flags.append("[STRATEGY_STALE]") flags.append("[WL_HIGH]")
prefix = "⚠️仓位挤占 " if position_pct > 80 else "" issues.append(f"{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评")
issues.append(f"[STRATEGY_STALE] {prefix}{price_str}在买入区{buy_zone_str}但策略不完整({'RR='+f'{rr:.2f}<1.5' if rr_invalid else '无止盈位' if not tp else '非买入信号'}),买入区需重评") elif price > eh * 1.20:
else: flags.append("[WL_DRIFT]")
prefix = "⚠️仓位挤占 " if position_pct > 80 else "" issues.append(f"{price:.2f}高于买入区+{((price/eh)-1)*100:.0f}%")
issues.append(f"[PUSH] {prefix}{price_str}入买入区{buy_zone_str}") elif not is_wl and eh:
elif price > eh * 1.35: dp = (price / eh - 1) * 100
flags.append("[WL_HIGH]") if dp > 35:
issues.append(f"{price_str}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评") flags.append("[SEVERE]")
elif price > eh * 1.20: issues.append(f"偏离买入区上沿+{dp:.0f}%")
flags.append("[WL_DRIFT]") elif dp > 20:
issues.append(f"{price_str}高于买入区+{((price/eh)-1)*100:.0f}%") flags.append("[DRIFT]")
elif not is_wl and eh: issues.append(f"偏离买入区上沿+{dp:.0f}%")
dp = (price / eh - 1) * 100 elif dp > 10:
if dp > 35: flags.append("[WARN]")
flags.append("[SEVERE]") issues.append(f"偏离买入区上沿+{dp:.0f}%")
issues.append(f"偏离买入区上沿+{dp:.0f}%") # 持仓在买入区内但 R/R 不达标
elif dp > 20: if el and sl and sl > 0 and tp and tp > 0 and price > sl:
flags.append("[DRIFT]") if el <= price <= eh:
issues.append(f"偏离买入区上沿+{dp:.0f}%") rr = (tp - price) / (price - sl)
elif dp > 10: if rr < 1.5:
flags.append("[WARN]") flags.append("[RR_WARN]")
issues.append(f"偏离买入区上沿+{dp:.0f}%") issues.append(f"买入区内RR仅{rr:.2f}<1.5,策略需重评")
# 持仓在买入区内但 R/R 不达标
if el and sl and sl > 0 and tp and tp > 0 and price > sl: # -- 距止损/止盈(仅持仓) --
if el <= price <= eh: if not is_wl:
rr = (tp - price) / (price - sl) if sl and sl > 0:
if rr < 1.5: dsl = (price / sl - 1) * 100
flags.append("[RR_WARN]") if dsl < 5:
issues.append(f"买入区内RR仅{rr:.2f}<1.5,策略需重评") # 成本基准校验:浮盈>5%时止损是利润保护,不是危险信号
# (mirrors NEAR_TP cost_check logic at line 195-198)
# -- 距止损/止盈(仅持仓) -- cost = d.get("cost")
if not is_wl: if cost and cost > 0 and price > cost * 1.05:
if sl and sl > 0: flags.append("[PROFIT_PROTECT]")
dsl = (price / sl - 1) * 100 pnl = (price / cost - 1) * 100
if dsl < 5: issues.append(f"距止损仅{dsl:.1f}%(利润保护,浮盈{pnl:.0f}%)")
flags.append("[NEAR_SL]") else:
issues.append(f"距止损仅{dsl:.1f}%") flags.append("[NEAR_SL]")
if tp and tp > 0: issues.append(f"距止损仅{dsl:.1f}%")
dtp = (tp / price - 1) * 100 if tp and tp > 0:
if dtp < 5: dtp = (tp / price - 1) * 100
# 成本基准校验:止盈标记只有在盈利≥5%时才有效 if dtp < 5:
cost_check = True # 成本基准校验:止盈标记只有在盈利≥5%时才有效
cost = d.get("cost") cost_check = True
if cost and cost > 0 and price < cost * 1.05: cost = d.get("cost")
cost_check = False if cost and cost > 0 and price < cost * 1.05:
if cost_check: cost_check = False
flags.append("[NEAR_TP]") if cost_check:
issues.append(f"距止盈仅{dtp:.1f}%") flags.append("[NEAR_TP]")
issues.append(f"距止盈仅{dtp:.1f}%")
# -- 过期 --
stale_limit = 30 if is_wl else 14 # -- 过期 --
if ts: stale_limit = 30 if is_wl else 14
try: if ts:
ud = datetime.fromisoformat(ts) try:
if ud.tzinfo is None: ud = datetime.fromisoformat(ts)
ud = ud.replace(tzinfo=timezone.utc) if ud.tzinfo is None:
days = (now - ud).days ud = ud.replace(tzinfo=timezone.utc)
if days > stale_limit: days = (now - ud).days
flags.append("[STALE]") if days > stale_limit:
issues.append(f"{days}天未更新(>{stale_limit})") flags.append("[STALE]")
except (ValueError, TypeError): issues.append(f"{days}天未更新(>{stale_limit})")
pass except (ValueError, TypeError):
pass
if issues:
print(f"{' '.join(flags)} {tag} {name}({code}) 价{price_str}{chg} | 买入{el}~{eh} | {'; '.join(issues)}") if issues:
found += 1 print(f"{' '.join(flags)} {tag} {name}({code}) 价{price:.2f}{chg} | 买入{el}~{eh} | {'; '.join(issues)}")
found += 1
if found == 0:
print("[SILENT] 所有策略正常") if found == 0:
print("[SILENT] 所有策略正常")
# ----- 组合级警报 -----
portfolio_alerts = 0 # ----- 组合级警报 -----
if holding_count > 0: portfolio_alerts = 0
if weak_ratio > 40: if holding_count > 0:
print(f"\n[PORTFOLIO_WEAK] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count})!仓位{position_pct:.1f}% → 建议系统性减仓") if weak_ratio > 40:
portfolio_alerts += 1 print(f"\n[PORTFOLIO_WEAK] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count})!仓位{position_pct:.1f}% → 建议系统性减仓")
elif weak_ratio > 30: portfolio_alerts += 1
print(f"\n[PORTFOLIO_WEAK_MILD] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count}),仓位{position_pct:.1f}%,关注") elif weak_ratio > 30:
portfolio_alerts += 1 print(f"\n[PORTFOLIO_WEAK_MILD] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count}),仓位{position_pct:.1f}%,关注")
if position_pct > 80 and holding_count > 0: portfolio_alerts += 1
# 仓位过满提醒 if position_pct > 80 and holding_count > 0:
print(f"[PORTFOLIO_FULL] 总仓位{position_pct:.1f}% > 80%,现金{cash:.0f}({cash/total_assets*100:.1f}%)") # 仓位过满提醒
portfolio_alerts += 1 print(f"[PORTFOLIO_FULL] 总仓位{position_pct:.1f}% > 80%,现金{cash:.0f}({cash/total_assets*100:.1f}%)")
if portfolio_alerts > 0: portfolio_alerts += 1
found += portfolio_alerts if portfolio_alerts > 0:
found += portfolio_alerts
return found
return found
if __name__ == "__main__":
main() if __name__ == "__main__":
main()