From c5adcde53faaa07bc69f1ac2cbf1e6f90c9b9032 Mon Sep 17 00:00:00 2001 From: zlt Date: Tue, 25 Aug 2026 12:51:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=A4=E6=98=93=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- STRATEGY_AUTO_ATTACH_PLAN.md | 220 +++++++++++++++++++++ scripts/probe_strategy_signals.py | 308 ++++++++++++++++++++++++++++++ 2 files changed, 528 insertions(+) create mode 100644 STRATEGY_AUTO_ATTACH_PLAN.md create mode 100644 scripts/probe_strategy_signals.py diff --git a/STRATEGY_AUTO_ATTACH_PLAN.md b/STRATEGY_AUTO_ATTACH_PLAN.md new file mode 100644 index 0000000..d777f3c --- /dev/null +++ b/STRATEGY_AUTO_ATTACH_PLAN.md @@ -0,0 +1,220 @@ +# PMS 策略自动挂载方案(个股打法状态机)· V2 定稿 + +> 日期:2026-08-25。V1 当日经两轮问答修订为本版。读第一到第九节即可,附录都标了「可不读」。 +> +> **拍板记录(2026-08-25,用户,两轮)**: +> ① 吸筹标志只认「明确吸筹」这一档,潜在吸筹不触发。 +> ② 建仓资格不放宽,仍然只从候选池合格票里选(候选宁缺毋滥的纪律不动)。 +> ③ 自动化直接上全自动档:系统算出来就挂,挂上就跑,不设人工确认期。 +> ④ 实现骨架采用「个股打法状态机」,V1 即开四条边,**含接力切换(网格换止盈)且默认开启**。 +> ⑤ 判分闭环进 V1(留痕补齐 + 月度只读统计脚本)。 +> +> **随方案生效的四条小口径**(V1 交付时列出,两轮问答无异议,其中第四条由优先级讨论补出): +> 双命中(吸筹加高热)取跟踪止盈;负安全垫不挂跟踪止盈;每日新挂上限默认两条; +> 人工撤下的自动策略,同票同规则冷却十个交易日不再自动挂。 +> +> 纪律沿袭:只做加法,不改既有执行器、闸门、动作引擎的任何一行;重大行为变化先在本方案说破。 + +--- + +## 一、这件事为最终目标买什么 + +现在三种交易方案(做T、网格、跟踪止盈)都要你逐股手动挂。哪只票该用哪种打法,这个判断每天靠人。本方案把这个判断变成系统的例行动作:**决策系统判断某只票主力在明确吸筹,它适合网格;某只票热度冲高,它需要跟踪止盈保护利润;网格票突破区间且热度起来,打法自动接力。系统自己看出来、自己挂上、自己切换,全程留痕,你只在页面上看结果。** + +它为总目标买的东西是:把「用什么打法」这层判断从人身上卸下来,让持仓系统离无人值守更近一步;再配上判分闭环,让这层判断的好坏从第一个月起就有数据可查。 + +**输出级判收(实机,全部可查)**: + +1. 某个交易日,一只持仓票的吸筹定性是「明确吸筹」,当天上午系统自动给它挂出一条网格策略,策略备注写明依据,网格随后按档位正常买卖。 +2. 一只持仓票热度分超过阈值且有浮盈,系统自动给它挂出跟踪止盈,创新高抬线、回落触发卖出。 +3. 一只自动网格票放量站上区间上界且热度超阈值,系统撤下网格、换挂跟踪止盈,账本留一条接力记录。 +4. 候选池合格票里带明确吸筹标志的,自主建仓时排在前面;建成底仓后的下一个扫描日,网格自动接管它。 +5. 关掉总开关后一条都不再自动挂;关掉接力开关后只挂不换;已挂的不受影响。 +6. 你手动撤下一条自动策略,十个交易日内系统不会把它挂回来。 +7. 每一次「挂了、换了、想挂没挂成」都能在评审账本查到原因;满两周后判分脚本能报出止盈保住的回吐与网格赚的差价。 + +--- + +## 二、现状、数据源与口径同源声明(以代码为准) + +**已经有的**:三种策略的自动机、挂载前校验、暂停恢复撤下、清仓清场、买入暂停表、宏观闸和全部熔断,这一整套都在跑。策略挂上之后的执行链完全不用动。缺的只有一层:从「信号」到「挂载」之间没有桥,今天这座桥是你的手。 + +**两个信号的数据源(已查实,不需要任何上游系统配合)**: + +| 信号 | 存放位置 | 口径 | +|---|---|---| +| 吸筹定性 | 153 代理的 `strategy_daily_results` 表,`raw_logic_json` 列里的 `fund_flow` 字段 | 决策系统每晚产出。含评分 score、定性 state(字符串,以「明确吸筹 / 潜在吸筹 / 无吸筹迹象 / 高位派发 / 信号不明」五档之一开头)、位置标签 pos_tag。8 月 24 日实测分布:明确 192、潜在 278、不明 82、派发 8 | +| 热度分 | 153 代理的 `stock_fund_heat_scores` 表 | 生态热度表,0 到 1 的分数,每天收盘后更新(T+1),一天可能多个批次,取最新交易日的最大批次。代码写法是前缀式(SZ002625) | + +**口径同源声明(回答「数据底座已有吸筹功能,要不要重复开发」)**:算吸筹的只有决策系统一家,数据底座和持仓系统都只是读者,谁都不重算。数据底座那份读出来给人看(明确加潜在都算有标记,存它自己的库);持仓系统这份读出来触发交易(只认明确档),开发量是一个几十行的读取函数,与它今天读支撑压力位同表同路。不走数据底座的接口,是为了不给交易系统新增一条运行时依赖。两家共享的契约是决策系统的五档定性词表:PMS 读到不认识的词一律当无标志处理(宁可不挂),探测脚本会把 PMS 的读数与数据底座前端清单的分布对读一遍,两个读者对不上就说明契约漂了,要去问决策系统。 + +**一个说破的风险**:候选池默认只收强传导档、前三十名,明确吸筹的一百九十二只票和这个小集合的重合可能很小甚至为零。资格不放宽是拍板结论,照此设计;探测脚本会把真实重合率量出来。如果实测为零,「吸筹建仓」这半边空转,只剩持仓票挂网格这半边生效,届时拿读数再议,现在不预设。 + +--- + +## 三、骨架:个股打法状态机 + +每只持仓票在任一时刻处于一个阶段,每个阶段有它该用的打法。阶段由三样东西合成判定:决策系统的吸筹定性、热度分、价格与支撑压力区间的相对位置。 + +| 阶段 | 判定要点 | 该用的打法 | +|---|---|---| +| 吸筹震荡期 | 定性为明确吸筹,价格在区间内 | 网格:跌档买、涨档卖,吃震荡摊成本 | +| 启动拉升期 | 热度超阈值,有浮盈;或网格票放量越上界 | 跟踪止盈:创新高抬线,回落触发卖 | +| 高位派发期 | 定性转高位派发 | 停买收紧:暂停买入腿,卖出照常 | +| 深亏修复期 | 安全垫为负 | 什么都不挂,把位置留给补仓评估与清弱票 | +| 中性 | 以上都不是 | 不动,留给动作引擎四类自主动作 | + +状态机的「边」就是自动动作。**V1 开四条边**,做成注册表结构,以后加边不加层: + +| 边 | 从哪到哪 | 动作 | 详见 | +|---|---|---|---| +| 边一 | 中性到吸筹震荡 | 挂网格 | 第四节 | +| 边二 | 中性到启动拉升 | 挂跟踪止盈 | 第五节 | +| 边三 | 吸筹震荡到高位派发 | 暂停网格买入腿 | 第四节末 | +| 边四(接力) | 吸筹震荡到启动拉升 | 撤网格、换挂跟踪止盈 | 第六节 | + +扫描器每交易日 09:40 跑一次(行情和参考位都已就位),对每只持仓票判阶段、走边。每条边的动作先过既有挂载校验,过了就挂(全自动档),挂载动作写进评审账本和策略备注;没过的也留痕。**挂载和切换本身不下单**,真正的买卖仍由策略运行器每分钟评估、每一笔过规则闸和全部熔断,这条链一个字不改。 + +--- + +## 四、边一与边三:明确吸筹挂网格,转派发停买腿 + +**道理**:明确吸筹意味着主力在低位反复收集筹码,股价大概率区间震荡。网格正是吃震荡的工具,和吸筹期的走势形态天然匹配。 + +**边一,对已持仓的票**:同时满足以下条件就挂网格。持仓大于零;没有挂着任何策略(生效中或暂停中都算有);吸筹定性是明确吸筹且结论日龄不超过三个交易日;不被冻结、不在黑名单(这两种票网格买腿必被闸拦,挂了只剩半条腿);没有未走完的建仓命令或在途指令(有就缓到下一个扫描日);不在人工撤下或接力切换的冷却期内。 + +**对没持仓的票**:不新开建仓入口。吸筹标志只做一件事:候选池合格票里带明确吸筹的,在自主建仓排序里插队靠前(盘中转多信号仍排最前)。建仓照旧走完整链路:规则闸、研判闸、择时区间一道不少。底仓建成后它就是「已持仓的票」,下一个扫描日由边一自动补挂网格。「吸筹网格建仓」由此拆成两个既有机制的接力,没有任何新的下单路径。 + +**网格参数自动生成**(原则在此,公式在附录二):区间优先锚定昨夜支撑压力,拿不到就退回围绕现价的百分比带;档距默认百分之二;最大投入默认取该股离单股上限余量的一半;每档一手起。默认值全进参数页。 + +**边三,吸筹转向**:定性转成高位派发,或标志消失且超过日龄,就暂停该票网格的买入腿(复用既有买入暂停表,来源标 accum),页面可见,卖出与已买档位照常。定性回到明确吸筹时自动解除,但只解除来源是 accum 的暂停,不碰风控信号触发的那种。不自动清仓,不自动撤策略。 + +--- + +## 五、边二:高热度挂跟踪止盈 + +**道理**:热度冲高的持仓票涨得快也可能跌得快,跟踪止盈是纯离场保护,涨得越高保得越多。 + +**触发条件**:持仓大于零;没有挂着任何策略;热度分不低于阈值(初值 0.80,探测脚本出分布后校准定稿);**安全垫为正**。最后这条单独解释:负垫票挂上止盈永远不会武装,却会把该票从动作引擎排除,深亏补仓的评估被白白挡掉,所以亏损票不挂,只给赚钱的票配保护。 + +**参数默认值**:启动线沿用厚垫线(浮盈百分之三开始跟踪);回撤触发默认从高点回落百分之五;触发时卖出可卖量的一半,剩余继续跟踪;不设硬止盈目标。都进参数页。 + +**热度回落不自动撤**:恰恰是退潮时最需要保护。撤下只有三条路:触发全清后由清场机制撤、你手动撤、清仓命令覆盖。 + +**双命中口径**:一只票同时明确吸筹又高热度,挂跟踪止盈不挂网格。与全系统「减持永远比增持畅通」同向:保住利润优先于做波段。 + +--- + +## 六、边四:接力切换(网格换跟踪止盈,默认开启) + +**道理**:吸筹震荡有走完的一天。价格放量站上网格上界、热度同时起来,说明这只票从「收集期」进了「拉升期」,继续按网格逢涨卖会把趋势腿卖飞,该换成跟踪止盈吃趋势。这是状态机最值钱也最激进的一条边,按拍板 V1 默认开启,配独立开关随时可关。 + +**触发条件(09:40 扫描时判,全部同时成立)**:该票挂着**自动挂载的**网格(人工挂的不动);现价已站上网格区间上界;热度分不低于阈值(与边二同一个);安全垫为正;该策略没有在途未终态的委托(有就缓到下一个扫描日,与策略层「上一笔走完才动」同一纪律)。 + +**动作**:撤下网格(状态置撤销,备注写接力原因),已买的档位作为持仓保留,立即挂上跟踪止盈,高水位从切换时点的当日高点起算,账本落一条接力留痕(前后策略号都在)。 + +**防抖三条**:每票每天最多切换一次;切换后该票的网格边进入冷却(默认十个交易日),防止区间上沿来回震把两种策略翻来覆去换;接力挂出的止盈与普通止盈完全同规格,撤下路径也相同。 + +--- + +## 七、秩序与安全:自动挂的策略就是普通策略 + +总纲:人最大,停机开关其次,用户命令再次,紧急风控可以插队,策略排第六,自主提议排最末;减持永远比增持畅通。自动挂载只改变「谁做挂载这个动作」,不给策略任何新地位。七个具体情形,前六个全是既有行为的自动继承: + +一,清仓类命令下达的瞬间即时撤该票策略、驳回买入提议、撤在途买单,然后紧急直通卖出。自动挂的网格同样被撤,命令至上。 + +二,决策系统对策略票发出够门槛的卖出信号:不自动清仓,落提议进「等我拍板」,同时暂停该票策略买入腿,卖出照常。你采纳等于撤策略并清仓。风控不被网格挡住,网格也不被风控直接掀翻,裁决在你。 + +三,吸筹定性转派发(本方案的边三)与上一条完全同向:停买不停卖。决策系统对策略票的影响,不论走风控信号还是定性转向,都收敛到这一个动作上。 + +四,降仓命令不掐策略腿(8 月 20 日拍板):网格照跑;降仓卖掉一部分持仓后,网格档位账与真实持仓自动对齐,不会拿已卖掉的档卖空气。 + +五,各路增持闸门照拦:组合刹车拦网格买腿(网格是自主口径)、全局暂停买入拦一切买入、宏观偏热闸拦策略买开腿;跟踪止盈只卖,全不受影响;做T规则照旧。 + +六,止盈全清后持仓归零,清场机制撤策略;之后重新建仓且热度仍超阈值,次日会挂一条新的止盈。新持仓配新保护,说破备查。 + +七,唯一的新交互规矩(小口径第四条):你手动撤下一条自动策略,同票同规则冷却十个交易日不再自动挂,杜绝人机拉锯;冷却期内想用就手动挂,人工挂载永远不受冷却限制,也永远不会被系统替换。 + +安全阀汇总:总开关读不到参数表按关(fail-closed);每条边各自开关;每日新挂上限默认两条(防部署首日一口气挂满);收盘后部署,第一轮真实扫描发生在次日 09:40 人在场的时候。行为变化说破:被挂上策略的票,动作引擎四类自主动作让位(既有语义),网格接管逢跌买入、止盈接管离场保护,负垫不挂正是为了不误挡补仓评估。吸筹所在结论表盘中会被补扫改写(有实证),扫描每天只在 09:40 读一次判一次,不追盘中改写。 + +--- + +## 八、判分闭环(进 V1) + +沿用本系统「拒了的后来涨了多少」的判分文化,两件事: + +**留痕补齐(随边动作落)**:每次挂载、接力、想挂没挂成(含每日上限溢出的「没轮上」名单),都在评审账本落行,硬数字里存当时的依据快照:吸筹定性与评分、热度分、现价、生成的策略参数。这份快照是事后对照的锚,缺了它判分无从谈起。 + +**月度只读统计脚本**(`scripts/report_strategy_score.py`,步骤二一并交付,满两周后首跑才有意义):三张对照表。第一张,跟踪止盈每次触发保住了多少回吐(触发卖价对比其后十个交易日最低价与最新价)。第二张,网格赚了多少差价(已实现批次盈亏加做T利润口径,对比同期死拿不动)。第三张,对照组:明确吸筹但没轮上挂、热度超阈但被条件挡下的票,事后表现如何。输出写成人话报告,热度阈值、回撤比例、投入比例此后按月用它调。 + +--- + +## 九、分步落地与验收 + +**步骤一:只读探测(脚本已随本方案交付)。** `scripts/probe_strategy_signals.py`,只读不写。打出五组读数:吸筹五档的最新日分布(与数据底座前端清单对读);每只持仓票的定性、日龄、热度分、按 V1 规则的预判去向;候选池合格票与明确吸筹的重合清单;热度分的分位数与超阈值持仓票;两个信号源的新鲜度。 + +在桥机 factorevaluation 上运行(新文件要先重建镜像,重建不影响正在运行的容器): + +```bash +git pull +make build +docker compose run --rm --no-deps pms-web python scripts/probe_strategy_signals.py +``` + +预期读数:五档计数与 8 月 24 日分布同量级;持仓票逐只有定性或标注无结论;重合清单打出具体只数。把输出发我,热度阈值和重合率结论用它定。 + +**步骤二:状态机上线(两到三天)。** 信号读取、状态机四条边、参数、调度位、试算端点、判分留痕、单测第十七批。收盘后部署: + +```bash +make deploy +make test +``` + +预期读数:ALL SUITES PASS,总例数在 519 基础上增加。部署当晚先手动试算一轮(只算不挂): + +```bash +curl -s -X POST 'http://127.0.0.1:38100/api/ops/strategy-attach-scan?dry_run=true' | python3 -m json.tool --no-ensure-ascii +``` + +预期读数:返回里能看到每只持仓票的阶段判定、将走哪条边、想挂被什么挡下。次日 09:40 第一轮真实扫描后: + +```bash +curl -s 'http://127.0.0.1:38100/api/strategies' | python3 -m json.tool --no-ensure-ascii +make t-gate +``` + +预期读数:新策略备注以「自动挂载」开头并写明依据;评审账本有 ATTACH 行;若发生接力另有 HANDOFF 行。 + +**步骤三:一周回看与首月判分。** 看四个数:自动挂了几条、网格成交与档位推进质量、止盈与接力触发是否合理、有没有误挂。据此校准阈值与带宽;满两周跑一次判分脚本,此后每月一次。 + +--- + +## 附录一:新增参数清单(可不读) + +| 参数 | 初值 | 说明 | +|---|---|---| +| PMS_AUTO_STRATEGY_ENABLED | True | 总开关,读不到参数表按关(fail-closed) | +| PMS_AUTO_STRATEGY_RULES | accum_grid,heat_trail,accum_exit,handoff | 启用的边清单,注册表键名 | +| PMS_AUTO_STRATEGY_DAILY_MAX | 2 | 每日新挂上限(接力切换不占此额度,它是换不是增) | +| PMS_AUTO_ACCUM_STALE_TDAYS | 3 | 吸筹结论日龄上限(交易日,自然日乘二宽松换算) | +| PMS_AUTO_GRID_BAND | 0.08 | 支撑压力缺失时网格上下界的百分比带 | +| PMS_AUTO_GRID_STEP_PCT | 0.02 | 网格档距 | +| PMS_AUTO_GRID_CAP_RATIO | 0.5 | 网格最大投入占单股上限余量的比例 | +| PMS_AUTO_HEAT_TH | 0.80 | 热度阈值(探测后校准定稿,边二与边四共用) | +| PMS_AUTO_TRAIL_GIVEBACK | 0.05 | 跟踪止盈回撤触发比例 | +| PMS_AUTO_TRAIL_SELL_RATIO | 0.5 | 触发时卖出可卖量的比例 | +| PMS_AUTO_HANDOFF_ENABLED | True | 接力切换开关(拍板默认开,页面可关) | +| PMS_AUTO_HANDOFF_COOLDOWN_TDAYS | 10 | 接力后同票网格边冷却(交易日) | +| PMS_AUTO_OPTOUT_COOLDOWN_TDAYS | 10 | 人工撤下后同票同规则冷却(交易日) | + +启动线沿用既有 PMS_CUSHION_SOLID,不新设。 + +## 附录二:网格参数生成公式(可不读) + +参照价 P 取 09:40 实时价,无实时价当日不挂。支撑 S、压力 R 取持仓行的参考位(盘前已由既有链路取好)。上界取 R 乘 1.01(要求 R 高于 P),否则取 P 乘(1 加带宽);下界取 S 乘 0.99(要求 S 低于 P 且为正),否则取 P 乘(1 减带宽)。中枢取 P。最大投入等于单股上限余量乘投入比例。每档股数等于最大投入除以下方档数再除以 P,向下取整到一手,最低一手。任何一步算出的区间不满足「零 小于 下界 小于 中枢 小于 上界」就当日放弃并留痕,不硬凑。 + +## 附录三:信号读法与工程落点(可不读) + +吸筹:按持仓与候选的代码集转前缀式,单表查 `strategy_daily_results` 近三十天窗口内每票最新一行,解析 `raw_logic_json` 的 `fund_flow`,state 前缀匹配五档词表,未知词当无标志。热度:先查 `stock_fund_heat_scores` 最新交易日与最大批次,再按代码集取分数。两个查询都走既有 153 代理连接与单表守卫,各自失败只让对应的边本轮不动作并留痕,不拖垮扫描。冷却与每日计数用独立运行参数承载(与买入暂停表同一手法),不进策略 state。 + +新文件三个:信号读取与阶段判定(app/services/strategy_advisor.py)、探测脚本(已交付)、判分脚本。修改点:settings 与 param_store 加参数并把总开关列入 fail-closed、scheduler 加 09:40 调度位 strategy_attach_scan、web 加试算端点、run_tests 登记第十七批。策略表与挂载校验零改动。测试第十七批覆盖:定性解析五档与未知词、日龄判废、阶段判定矩阵、四条边的命中与排除、双命中取止盈、负垫不挂、接力条件与防抖、两种冷却、区间公式边界、每日上限、幂等、留痕断言。 diff --git a/scripts/probe_strategy_signals.py b/scripts/probe_strategy_signals.py new file mode 100644 index 0000000..05f8b05 --- /dev/null +++ b/scripts/probe_strategy_signals.py @@ -0,0 +1,308 @@ +# -*- 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 to_dot, to_prefix # noqa: E402 +from app.services import param_store # noqa: E402 + +# 决策系统定性词表 (契约; 见方案「口径同源声明」)。不在表里的词一律当无标志。 +FIVE_STATES = ("明确吸筹", "潜在吸筹", "无吸筹迹象", "高位派发", "信号不明") +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() + for k in FIVE_STATES: + if s.startswith(k): + return k + return "词表外" if s else "无字段" + + +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("SELECT MAX(trade_date) AS td FROM strategy_daily_results") + return (r or {}).get("td") + + +def accum_distribution(td): + """最新一日的定性分布 (全池)。""" + rows = fetch_all("SELECT stock_code, raw_logic_json FROM strategy_daily_results " + "WHERE trade_date = :td", {"td": td}) + dist, total = {}, 0 + for r in rows: + total += 1 + ff = _fund_flow(r.get("raw_logic_json")) + dist[_classify((ff or {}).get("state"))] = dist.get(_classify((ff or {}).get("state")), 0) + 1 + return total, dist + + +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 = ("SELECT stock_code, trade_date, raw_logic_json FROM strategy_daily_results " + 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 = 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(" ⚠ 出现词表外定性 —— 五档契约可能漂了, 需与决策系统核对") + print(" 对读: 8 月 24 日前端清单读数为 明确192 / 潜在278 / 不明82 / 派发8, " + "同量级即两个读者一致") + 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 + 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()