Files
MoFin/scripts/stale_detector.py
T

287 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""stale_detector.py — 检查所有策略,标记价格偏离/过期的策略
读取 decisions.json 的扁平列表。自选策略和持仓策略分开判断。
可被 cron no_agent 模式调用:stdout 注入到后续 LLM 分析。
输出格式:
[FLAG] [自选/持仓] 股票名(代码) 价XX | 买入A~B | 问题
用法:
python3 stale_detector.py
"""
import json
import sys
import os
from datetime import datetime, timezone
DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json"
PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json"
def fetch_prices(codes):
"""统一价格源:优先 stock_quote.py,腾讯API降级为兜底"""
if not codes:
return {}
# 尝试用 stock_quote.py 获取(脚本强制规范)
try:
import subprocess
script = None
for p in ["/home/hmo/MoFin/scripts/stock_quote.py", "/home/hmo/MoFin/stock_quote.py"]:
if os.path.exists(p):
script = p
break
if script:
result = subprocess.run(
[sys.executable, script] + [str(c) for c in codes],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and result.stdout.strip():
results = {}
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
try:
item = json.loads(line)
code = str(item.get("code", ""))
price = item.get("price")
change = item.get("change_pct", 0)
if code and price is not None:
results[code] = (float(price), float(change))
except (json.JSONDecodeError, ValueError):
continue
if results:
return results
except Exception as e:
print(f"[STALE] stock_quote.py 回退: {e}", file=sys.stderr)
# 兜底:腾讯API(不应依赖,仅作为最后手段)
import urllib.request
symbols, code_map = [], {}
for c in codes:
c = str(c).strip()
p = "sh" if (len(c) == 6 and c[0] in "569") else "sz" if len(c) == 6 else "hk"
sym = f"{p}{c}"
symbols.append(sym)
code_map[sym] = c
try:
req = urllib.request.Request(
f"http://qt.gtimg.cn/q={','.join(symbols)}",
headers={"User-Agent": "curl/7.81"},
)
with urllib.request.urlopen(req, timeout=10) as r:
text = r.read().decode("gbk")
except Exception as e:
print(f"FETCH_FAIL (fallback): {e}", file=sys.stderr)
return {}
results = {}
for line in text.strip().split("\n"):
if "=" not in line:
continue
try:
raw = line.split("=", 1)[1].strip().strip('"').strip(";")
fld = raw.split("~")
if len(fld) < 6:
continue
sym = line.split("=", 1)[0].strip().lstrip("v_")
oc = code_map.get(sym)
if not oc:
continue
p = float(fld[3]) if fld[3] else 0
c = fld[32] if len(fld) > 32 else "0"
results[oc] = (p, c)
except (ValueError, IndexError):
continue
return results
def main():
decisions_list = json.load(open(DECISIONS_PATH))
if not isinstance(decisions_list, list):
decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else []
# 只保留有买入区的条目,排除已关闭的(inactive/closed
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]
if not to_check:
print("[SILENT] 无需要检查的策略")
return 0
# ----- 组合级监测:读取总仓位 + 弱势比例 -----
position_pct = 0
cash = 0
total_assets = 0
try:
with open(PORTFOLIO_PATH) as f:
pf = json.load(f)
position_pct = pf.get("position_pct", 0)
cash = pf.get("cash", 0)
total_assets = pf.get("total_assets", 0)
except Exception:
pass
# 统计持仓策略中弱势/深套的比例
weak_count = 0
holding_count = 0
for d in decisions_list:
if d.get("type") == "持仓策略" and d.get("status") not in ("closed", "inactive"):
holding_count += 1
cat = d.get("stock_category", "")
if cat in ("弱势", "深套"):
weak_count += 1
weak_ratio = (weak_count / holding_count * 100) if holding_count > 0 else 0
prices = fetch_prices([d["code"] for d in to_check])
now = datetime.now(timezone.utc).astimezone()
found = 0
for d in to_check:
code = d["code"]
name = d.get("name", code)
el = d.get("entry_low")
eh = d.get("entry_high")
sl = d.get("stop_loss")
tp = d.get("take_profit")
ts = d.get("created_at") or d.get("timestamp") or d.get("updated_at", "")
is_wl = "自选" in (d.get("type", ""))
pi = prices.get(code)
if not pi:
continue
price, chg = pi
if price <= 0:
continue
issues, flags = [], []
tag = "[自选]" if is_wl else "[持仓]"
# -- 偏离 --
if is_wl and el and eh:
# 读取 timing_signal 判断策略有效性(timing_signal 字段优先,fallback to action
current_str = d.get("current", "") or ""
timing_signal = d.get("timing_signal", "") or current_str
has_nonbuy_signal = any(kw in timing_signal for kw in [
"等企稳再入", "等企稳", "弱势持有", "观望",
"不建议买入", "谨慎买入",
])
# 直接计算 R/R(不依赖文本匹配)
rr_invalid = False
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 是否接近或低于成本(微盈/浮亏止盈)
cost = d.get("cost", 0)
if cost and cost > 0 and tp <= cost * 1.05:
rr_invalid = True
strategy_deficient = has_nonbuy_signal or rr_invalid
# 对自选无止盈位的也标记(策略不完整)
if not tp or tp == 0:
strategy_deficient = True
if el <= price <= eh:
flags.append("[WL_IN]")
if strategy_deficient:
flags.append("[STRATEGY_STALE]")
prefix = "⚠️仓位挤占 " if position_pct > 80 else ""
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:
prefix = "⚠️仓位挤占 " if position_pct > 80 else ""
issues.append(f"[PUSH] {prefix}{price:.2f}入买入区{el}~{eh}")
elif price > eh * 1.35:
flags.append("[WL_HIGH]")
issues.append(f"{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评")
elif price > eh * 1.20:
flags.append("[WL_DRIFT]")
issues.append(f"{price:.2f}高于买入区+{((price/eh)-1)*100:.0f}%")
elif not is_wl and eh:
dp = (price / eh - 1) * 100
if dp > 35:
flags.append("[SEVERE]")
issues.append(f"偏离买入区上沿+{dp:.0f}%")
elif dp > 20:
flags.append("[DRIFT]")
issues.append(f"偏离买入区上沿+{dp:.0f}%")
elif dp > 10:
flags.append("[WARN]")
issues.append(f"偏离买入区上沿+{dp:.0f}%")
# 持仓在买入区内但 R/R 不达标
if el and sl and sl > 0 and tp and tp > 0 and price > sl:
if el <= price <= eh:
rr = (tp - price) / (price - sl)
if rr < 1.5:
flags.append("[RR_WARN]")
issues.append(f"买入区内RR仅{rr:.2f}<1.5,策略需重评")
# -- 距止损/止盈(仅持仓) --
if not is_wl:
if sl and sl > 0:
dsl = (price / sl - 1) * 100
if dsl < 5:
# 成本基准校验:浮盈>5%时止损是利润保护,不是危险信号
# (mirrors NEAR_TP cost_check logic at line 195-198)
cost = d.get("cost")
if cost and cost > 0 and price > cost * 1.05:
flags.append("[PROFIT_PROTECT]")
pnl = (price / cost - 1) * 100
issues.append(f"距止损仅{dsl:.1f}%(利润保护,浮盈{pnl:.0f}%)")
else:
flags.append("[NEAR_SL]")
issues.append(f"距止损仅{dsl:.1f}%")
if tp and tp > 0:
dtp = (tp / price - 1) * 100
if dtp < 5:
# 成本基准校验:止盈标记只有在盈利≥5%时才有效
cost_check = True
cost = d.get("cost")
if cost and cost > 0 and price < cost * 1.05:
cost_check = False
if cost_check:
flags.append("[NEAR_TP]")
issues.append(f"距止盈仅{dtp:.1f}%")
# -- 过期 --
stale_limit = 30 if is_wl else 14
if ts:
try:
ud = datetime.fromisoformat(ts)
if ud.tzinfo is None:
ud = ud.replace(tzinfo=timezone.utc)
days = (now - ud).days
if days > stale_limit:
flags.append("[STALE]")
issues.append(f"{days}天未更新(>{stale_limit})")
except (ValueError, TypeError):
pass
if issues:
print(f"{' '.join(flags)} {tag} {name}({code}) 价{price:.2f}{chg} | 买入{el}~{eh} | {'; '.join(issues)}")
found += 1
if found == 0:
print("[SILENT] 所有策略正常")
# ----- 组合级警报 -----
portfolio_alerts = 0
if holding_count > 0:
if weak_ratio > 40:
print(f"\n[PORTFOLIO_WEAK] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count})!仓位{position_pct:.1f}% → 建议系统性减仓")
portfolio_alerts += 1
elif weak_ratio > 30:
print(f"\n[PORTFOLIO_WEAK_MILD] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count}),仓位{position_pct:.1f}%,关注")
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
if portfolio_alerts > 0:
found += portfolio_alerts
return found
if __name__ == "__main__":
main()