329 lines
15 KiB
Python
329 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
策略自动挂载 · 步骤一只读探测 (STRATEGY_AUTO_ATTACH_PLAN.md 第九节)
|
||
====================================================================
|
||
只读, 不写任何表, 随时可跑。回答五个问题:
|
||
|
||
1. 吸筹定性的最新日分布 (与数据底座前端清单对读, 口径同源自证)
|
||
2. 每只持仓票: 定性 / 结论日龄 / 热度分 / 按 V1 规则的预判去向
|
||
3. 候选池合格票与「明确吸筹」的重合率 (拍板②的实测依据)
|
||
4. 热度分的分位数与超阈值持仓票 (热度阈值 PMS_AUTO_HEAT_TH 的标定依据)
|
||
5. 两个信号源的新鲜度
|
||
|
||
运行 (桥机 factorevaluation, 新文件要先重建镜像):
|
||
docker compose run --rm --no-deps pms-web python scripts/probe_strategy_signals.py
|
||
|
||
数据源 (都经 153 代理, 严格单表):
|
||
strategy_daily_results.raw_logic_json 的 fund_flow{score,state,pos_tag} —— 吸筹定性
|
||
stock_fund_heat_scores(trade_date, batch_no, stock_code, score) —— 热度分
|
||
"""
|
||
import json
|
||
import os
|
||
import sys
|
||
from datetime import datetime, timedelta
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from app.db.session import fetch_all, fetch_one # noqa: E402
|
||
from app.repo import pms_repo # noqa: E402
|
||
from app.repo.downstream_repo import T_DAILY, to_dot, to_prefix # noqa: E402
|
||
from app.services import param_store # noqa: E402
|
||
|
||
# 决策系统定性词表 (契约; 见方案「口径同源声明」)。不在表里的词一律当无标志。
|
||
# v1.1 (2026-08-25): 归类从前缀匹配改成**子串包含** —— 首跑 62 只「词表外」的根因是
|
||
# state 带前后缀修饰 (如 "→ 高位派发风险"), 数据底座 feed.py 的 _ACCUM_KEEP 用的
|
||
# 正是 any(k in state)。次序即优先级, 派发最先 (两词同现按保守方向), 与 strategy_advisor
|
||
# 的 classify_accum 完全同法 —— 探测读数和线上判定必须是同一双眼睛。
|
||
FIVE_STATES = ("明确吸筹", "潜在吸筹", "无吸筹迹象", "高位派发", "信号不明")
|
||
_CLS_ORDER = (("派发", "高位派发"), ("明确吸筹", "明确吸筹"), ("潜在吸筹", "潜在吸筹"),
|
||
("无吸筹迹象", "无吸筹迹象"), ("不明", "信号不明"))
|
||
ACCUM_WINDOW_DAYS = 45 # 每票取近 45 自然日内最新一条 (方案口径是 30 天, 取宽探测)
|
||
|
||
|
||
def _ymd_int(v):
|
||
"""trade_date 可能是 int / str / date, 统一成 int YYYYMMDD; 解析不了返回 None。"""
|
||
if v is None:
|
||
return None
|
||
if hasattr(v, "strftime"):
|
||
return int(v.strftime("%Y%m%d"))
|
||
s = str(v).strip().replace("-", "")[:8]
|
||
return int(s) if s.isdigit() and len(s) == 8 else None
|
||
|
||
|
||
def _age_days(ymd):
|
||
if not ymd:
|
||
return None
|
||
try:
|
||
d = datetime.strptime(str(ymd), "%Y%m%d").date()
|
||
return (datetime.now().date() - d).days
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _fund_flow(raw):
|
||
"""raw_logic_json → fund_flow dict 或 None。脏 JSON 不炸。"""
|
||
try:
|
||
d = raw if isinstance(raw, dict) else json.loads(raw or "{}")
|
||
ff = d.get("fund_flow")
|
||
return ff if isinstance(ff, dict) and ff.get("state") else None
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def _classify(state):
|
||
s = str(state or "").strip()
|
||
if not s:
|
||
return "无字段"
|
||
for key, cls in _CLS_ORDER:
|
||
if key in s:
|
||
return cls
|
||
return "词表外"
|
||
|
||
|
||
def _variants(dot_codes):
|
||
"""{每种写法: 点式} 的反查表 (前缀式 / 点式 / 纯数字)。"""
|
||
rev = {}
|
||
for c in dot_codes:
|
||
d = to_dot(c)
|
||
if not d:
|
||
continue
|
||
rev[d] = d
|
||
rev[to_prefix(d)] = d
|
||
rev[d.split(".")[0]] = d
|
||
return rev
|
||
|
||
|
||
def _in_clause(values, prefix, params):
|
||
keys = []
|
||
for i, v in enumerate(values):
|
||
keys.append(f":{prefix}{i}")
|
||
params[f"{prefix}{i}"] = v
|
||
return ", ".join(keys)
|
||
|
||
|
||
# ================================================================ 取数
|
||
def latest_accum_day():
|
||
r = fetch_one(f"SELECT MAX(trade_date) AS td FROM {T_DAILY}")
|
||
return (r or {}).get("td")
|
||
|
||
|
||
def accum_distribution(td):
|
||
"""最新一日的定性分布 (全池)。返回 (总数, 分布, 词表外原文样本)。"""
|
||
rows = fetch_all(f"SELECT stock_code, raw_logic_json FROM {T_DAILY} "
|
||
"WHERE trade_date = :td", {"td": td})
|
||
dist, total, oov = {}, 0, {}
|
||
for r in rows:
|
||
total += 1
|
||
ff = _fund_flow(r.get("raw_logic_json"))
|
||
state = (ff or {}).get("state")
|
||
cls = _classify(state)
|
||
dist[cls] = dist.get(cls, 0) + 1
|
||
if cls == "词表外":
|
||
oov[str(state)] = oov.get(str(state), 0) + 1
|
||
samples = sorted(oov.items(), key=lambda kv: -kv[1])[:10]
|
||
return total, dist, samples
|
||
|
||
|
||
def accum_of(dot_codes):
|
||
"""{点式: {state, cls, score, ymd, age}} —— 窗口内每票最新一条。"""
|
||
if not dot_codes:
|
||
return {}
|
||
rev = _variants(dot_codes)
|
||
since = int((datetime.now().date() - timedelta(days=ACCUM_WINDOW_DAYS)).strftime("%Y%m%d"))
|
||
p = {"since": since}
|
||
sql = (f"SELECT stock_code, trade_date, raw_logic_json FROM {T_DAILY} "
|
||
f"WHERE trade_date >= :since AND stock_code IN ({_in_clause(list(rev), 'c', p)})")
|
||
best = {}
|
||
for r in fetch_all(sql, p):
|
||
dot = rev.get(str(r.get("stock_code") or "").strip())
|
||
ymd = _ymd_int(r.get("trade_date"))
|
||
if not dot or not ymd:
|
||
continue
|
||
if dot in best and best[dot]["ymd"] >= ymd:
|
||
continue
|
||
ff = _fund_flow(r.get("raw_logic_json"))
|
||
best[dot] = {"ymd": ymd, "age": _age_days(ymd),
|
||
"state": (ff or {}).get("state"), "cls": _classify((ff or {}).get("state")),
|
||
"score": (ff or {}).get("score"), "pos_tag": (ff or {}).get("pos_tag")}
|
||
return best
|
||
|
||
|
||
def heat_snapshot():
|
||
"""(交易日, 批次, {点式或原码: score}, 全市场分数列表)。"""
|
||
r = fetch_one("SELECT MAX(trade_date) AS td FROM stock_fund_heat_scores")
|
||
td = (r or {}).get("td")
|
||
if td is None:
|
||
return None, None, {}, []
|
||
r2 = fetch_one("SELECT MAX(batch_no) AS b FROM stock_fund_heat_scores "
|
||
"WHERE trade_date = :td", {"td": td})
|
||
b = (r2 or {}).get("b")
|
||
rows = fetch_all("SELECT stock_code, score FROM stock_fund_heat_scores "
|
||
"WHERE trade_date = :td AND batch_no = :b LIMIT 20000",
|
||
{"td": td, "b": b})
|
||
scores, all_scores = {}, []
|
||
for x in rows:
|
||
try:
|
||
v = float(x.get("score"))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
scores[str(x.get("stock_code") or "").strip()] = v
|
||
all_scores.append(v)
|
||
return td, b, scores, sorted(all_scores)
|
||
|
||
|
||
def _pct(sorted_vals, q):
|
||
if not sorted_vals:
|
||
return None
|
||
i = min(len(sorted_vals) - 1, max(0, int(round(q * (len(sorted_vals) - 1)))))
|
||
return sorted_vals[i]
|
||
|
||
|
||
# ================================================================ 主流程
|
||
def main():
|
||
heat_th = param_store.get_float("PMS_AUTO_HEAT_TH", 0.80)
|
||
print("=" * 66)
|
||
print("策略自动挂载 · 只读探测 %s" % datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||
print("(只读, 不写任何表; 各段独立, 一段失败不影响其余)")
|
||
print("=" * 66)
|
||
|
||
# ---- 一、吸筹定性最新日分布 ----
|
||
print("\n【一】吸筹定性最新日分布 (与数据底座前端清单对读)")
|
||
accum_td = None
|
||
try:
|
||
accum_td = latest_accum_day()
|
||
if accum_td is None:
|
||
print(" 结论表里一行都没有 —— 先确认决策系统夜间链在跑")
|
||
else:
|
||
total, dist, oov_samples = accum_distribution(accum_td)
|
||
age = _age_days(_ymd_int(accum_td))
|
||
print(f" 最新结论日 {accum_td} (距今 {age} 个自然日), 当日共 {total} 只")
|
||
for k in list(FIVE_STATES) + ["词表外", "无字段"]:
|
||
if dist.get(k):
|
||
print(f" {k:<6} {dist[k]} 只")
|
||
if dist.get("词表外"):
|
||
print(" ⚠ 仍有子串也认不出的定性 —— 五档契约可能真漂了, 拿下面原文与决策系统核对:")
|
||
for s, n in oov_samples:
|
||
print(f" 「{s}」 × {n}")
|
||
print(" 对读口径: 本段是**单日**分布; 数据底座前端清单是 30 天窗口每票最新一条,"
|
||
" 总数天然更大, 两边比的是档位结构而不是绝对数")
|
||
except Exception as e:
|
||
print(f" ✗ 本段失败: {type(e).__name__}: {e}")
|
||
|
||
# ---- 二、热度分布 ----
|
||
print(f"\n【二】热度分布 (阈值初值 {heat_th}, 用本段读数校准)")
|
||
heat_map, heat_rev = {}, {}
|
||
try:
|
||
htd, hb, heat_map, all_scores = heat_snapshot()
|
||
if htd is None:
|
||
print(" 热度表里一行都没有")
|
||
else:
|
||
print(f" 最新交易日 {htd} 批次 {hb}, 覆盖 {len(all_scores)} 只")
|
||
print(f" 分位数: 一半位 {_pct(all_scores, 0.50)} · 八成位 {_pct(all_scores, 0.80)}"
|
||
f" · 九成位 {_pct(all_scores, 0.90)} · 最高 {all_scores[-1] if all_scores else '—'}")
|
||
n_over = sum(1 for v in all_scores if v >= heat_th)
|
||
print(f" 全市场不低于 {heat_th} 的共 {n_over} 只")
|
||
except Exception as e:
|
||
print(f" ✗ 本段失败: {type(e).__name__}: {e}")
|
||
|
||
# ---- 三、持仓票逐只 ----
|
||
print("\n【三】持仓票逐只 (定性 / 日龄 / 热度 / 按 V1 规则的预判去向)")
|
||
held_dots = []
|
||
try:
|
||
positions = pms_repo.list_positions(only_open=True)
|
||
held_dots = [p["ts_code"] for p in positions]
|
||
if not positions:
|
||
print(" 当前无持仓 —— 边一边二暂无对象, 只剩建仓插队那半边")
|
||
else:
|
||
acc = accum_of(held_dots)
|
||
heat_rev = _variants(held_dots)
|
||
held_heat = {}
|
||
for raw, v in heat_map.items():
|
||
dot = heat_rev.get(raw)
|
||
if dot:
|
||
held_heat[dot] = v
|
||
try:
|
||
strat_codes = {s.get("ts_code") for s in
|
||
pms_repo.list_strategies(statuses=["ACTIVE", "PAUSED"], limit=500)}
|
||
except Exception:
|
||
strat_codes = set()
|
||
stale = param_store.get_int("PMS_AUTO_ACCUM_STALE_TDAYS", 3)
|
||
print(f" {'代码':<10} {'定性':<8} {'结论日':<9} {'热度':<6} {'垫':<7} 预判")
|
||
for p in positions:
|
||
c = p["ts_code"]
|
||
a = acc.get(c) or {}
|
||
cls = a.get("cls") or "无结论"
|
||
ymd = a.get("ymd") or "—"
|
||
hv = held_heat.get(c)
|
||
cushion = p.get("cushion_pct")
|
||
cu = ("%.1f%%" % (float(cushion) * 100)) if cushion is not None else "未知"
|
||
fresh = a.get("age") is not None and a["age"] <= stale * 2
|
||
hot = hv is not None and hv >= heat_th
|
||
pos_ok = cushion is not None and float(cushion) > 0
|
||
if c in strat_codes:
|
||
verdict = "已挂策略, 自动挂载不碰它"
|
||
elif hot and pos_ok:
|
||
verdict = ("双命中取止盈" if (cls == "明确吸筹" and fresh) else "挂跟踪止盈")
|
||
elif cls == "明确吸筹" and fresh:
|
||
verdict = "挂网格" + ("" if (p.get("frozen_reason") or "NONE") == "NONE"
|
||
else " (但该股被冻结, 实扫会跳过)")
|
||
elif cls == "高位派发":
|
||
verdict = "若挂着网格则停买入腿"
|
||
elif hot and not pos_ok:
|
||
verdict = "热度够但垫不正, 不挂止盈 (护补仓评估)"
|
||
elif cls == "明确吸筹":
|
||
verdict = "吸筹结论超日龄, 视为无标志"
|
||
else:
|
||
verdict = "不动"
|
||
print(f" {c:<10} {cls:<8} {str(ymd):<9} "
|
||
f"{('%.3f' % hv) if hv is not None else '—':<6} {cu:<7} {verdict}")
|
||
print(" 注: 预判未含黑名单与在途指令两道排除, 以实盘扫描为准")
|
||
except Exception as e:
|
||
print(f" ✗ 本段失败: {type(e).__name__}: {e}")
|
||
|
||
# ---- 四、候选池与明确吸筹的重合 ----
|
||
print("\n【四】候选池合格票与吸筹的重合 (拍板②「资格不放宽」的实测依据)")
|
||
try:
|
||
from app.services import command_service, plan_feed
|
||
# 只读承诺自证 (2026-08-28 修): plan_feed.get_plan 默认会落一行 pms_plan_snapshot
|
||
# (还可能 upsert 行业映射) —— 探测脚本提前落库会吞掉当天正式链路的榜单变化提示
|
||
# (probe_plan_api 早有注释点过这个坑)。本进程内把两个写函数替换成空操作。
|
||
plan_feed._snapshot_quiet = lambda *a, **kw: {"skipped": "probe 只读, 不落快照"}
|
||
plan_feed._sync_themes_quiet = lambda *a, **kw: {"skipped": "probe 只读, 不灌映射"}
|
||
try:
|
||
black = command_service.blacklist()
|
||
except Exception:
|
||
black = set()
|
||
sel = plan_feed.candidates(held=set(held_dots), black=black)
|
||
cands = [x["ts_code"] for x in (sel.get("items") or [])]
|
||
print(f" 候选池合格票 {len(cands)} 只 (计划日 {sel.get('date')}, "
|
||
f"考察 {sel.get('considered')} 只)")
|
||
if cands:
|
||
acc_c = accum_of(cands)
|
||
clear = [(c, acc_c[c]) for c in cands if acc_c.get(c, {}).get("cls") == "明确吸筹"]
|
||
maybe = [c for c in cands if acc_c.get(c, {}).get("cls") == "潜在吸筹"]
|
||
none_ = [c for c in cands if c not in acc_c]
|
||
print(f" 其中 明确吸筹 {len(clear)} 只 · 潜在吸筹 {len(maybe)} 只 · "
|
||
f"无结论 {len(none_)} 只")
|
||
for c, a in clear:
|
||
print(f" 明确: {c} 结论日 {a.get('ymd')} 评分 {a.get('score')}")
|
||
if not clear:
|
||
print(" ⚠ 重合为零 —— 吸筹建仓插队这半边今天空转, 只剩持仓票挂网格那半边。"
|
||
"多观察几天, 持续为零再议资格")
|
||
except Exception as e:
|
||
print(f" ✗ 本段失败 (计划接口没起或已过期都会走到这里): {type(e).__name__}: {e}")
|
||
|
||
# ---- 五、新鲜度小结 ----
|
||
print("\n【五】信号源新鲜度")
|
||
try:
|
||
a_age = _age_days(_ymd_int(accum_td)) if accum_td is not None else None
|
||
print(f" 吸筹结论: 最新 {accum_td} "
|
||
f"({'距今 %s 天' % a_age if a_age is not None else '取不到'})"
|
||
f"{' ⚠ 超过 6 个自然日, 按方案口径会被判过期' if (a_age or 0) > 6 else ''}")
|
||
except Exception as e:
|
||
print(f" ✗ {e}")
|
||
print("\n探测结束。请把整段输出发回, 用【二】定热度阈值、用【四】看资格结论。")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|