diff --git a/data/candidate_pool.json b/data/candidate_pool.json index 1499a2b..2337737 100644 --- a/data/candidate_pool.json +++ b/data/candidate_pool.json @@ -1,5 +1,5 @@ { - "last_updated": "2026-07-03 14:18", + "last_updated": "2026-07-03 15:40", "total_candidates": 15, "sectors_analyzed_today": [ "半导体", diff --git a/data/mofin.db-shm b/data/mofin.db-shm deleted file mode 100644 index f5156c6..0000000 Binary files a/data/mofin.db-shm and /dev/null differ diff --git a/data/mofin.db-wal b/data/mofin.db-wal deleted file mode 100644 index 0795349..0000000 Binary files a/data/mofin.db-wal and /dev/null differ diff --git a/data/portfolio.json b/data/portfolio.json index c1621c5..224d15b 100644 --- a/data/portfolio.json +++ b/data/portfolio.json @@ -5,9 +5,9 @@ "name": "中际旭创", "shares": 100, "cost": 1316.53, - "price": 1124.57, - "market_value": 112300.0, - "change_pct": -1.61, + "price": 1116.0, + "market_value": 111600.0, + "change_pct": -2.36, "currency": "CNY", "position_pct": 15.27, "_currency": "CNY" @@ -16,34 +16,34 @@ "code": "06869", "name": "长飞光纤光缆", "shares": 500, - "cost": 263.73, - "price": 204.2, - "market_value": 88175.0, - "change_pct": 3.152, - "currency": "HKD", + "cost": 228.65, + "price": 174.44, + "market_value": 87220.0, + "change_pct": 1.64, + "currency": "CNY", "position_pct": 13.47, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "01478", "name": "丘钛科技", "shares": 11000, - "cost": 13.47, - "price": 7.07, - "market_value": 67320.0, - "change_pct": 5.208, - "currency": "HKD", + "cost": 11.68, + "price": 6.06, + "market_value": 66660.0, + "change_pct": 4.02, + "currency": "CNY", "position_pct": 7.97, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "601899", "name": "紫金矿业", "shares": 2400, "cost": 39.89, - "price": 27.88, - "market_value": 66888.0, - "change_pct": 6.01, + "price": 27.82, + "market_value": 66768.0, + "change_pct": 5.78, "currency": "CNY", "position_pct": 7.34, "_currency": "CNY" @@ -53,9 +53,9 @@ "name": "海博思创", "shares": 200, "cost": 266.95, - "price": 252.41, - "market_value": 50496.0, - "change_pct": -1.29, + "price": 251.15, + "market_value": 50230.0, + "change_pct": -1.78, "currency": "CNY", "position_pct": 6.31, "_currency": "CNY" @@ -65,9 +65,9 @@ "name": "中芯国际", "shares": 300, "cost": 126.07, - "price": 141.28, - "market_value": 42324.0, - "change_pct": -1.96, + "price": 140.31, + "market_value": 42093.0, + "change_pct": -2.63, "currency": "CNY", "position_pct": 5.44, "_currency": "CNY" @@ -76,22 +76,22 @@ "code": "01888", "name": "建滔积层板", "shares": 500, - "cost": 88.24, - "price": 86.0, - "market_value": 37520.0, - "change_pct": 2.625, - "currency": "HKD", + "cost": 76.5, + "price": 73.09, + "market_value": 36545.0, + "change_pct": 0.597, + "currency": "CNY", "position_pct": 5.28, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "688639", "name": "华恒生物", "shares": 2800, "cost": 21.51, - "price": 16.63, - "market_value": 46564.0, - "change_pct": -1.89, + "price": 16.66, + "market_value": 46648.0, + "change_pct": -1.71, "currency": "CNY", "position_pct": 5.25, "_currency": "CNY" @@ -101,9 +101,9 @@ "name": "宁德时代", "shares": 100, "cost": 401.78, - "price": 380.78, - "market_value": 38105.0, - "change_pct": -0.41, + "price": 380.0, + "market_value": 38000.0, + "change_pct": -0.61, "currency": "CNY", "position_pct": 4.64, "_currency": "CNY" @@ -112,58 +112,58 @@ "code": "01211", "name": "比亚迪股份", "shares": 600, - "cost": 104.87, - "price": 83.4, - "market_value": 43542.0, - "change_pct": 6.513, - "currency": "HKD", + "cost": 90.92, + "price": 73.22, + "market_value": 43932.0, + "change_pct": 7.854, + "currency": "CNY", "position_pct": 4.62, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "02202", "name": "万科企业", "shares": 19700, - "cost": 4.67, - "price": 2.32, - "market_value": 39794.0, - "change_pct": 4.036, - "currency": "HKD", + "cost": 4.05, + "price": 2.04, + "market_value": 40188.0, + "change_pct": 5.381, + "currency": "CNY", "position_pct": 4.6, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "00700", "name": "腾讯", "shares": 100, - "cost": 0.0, - "price": 435.2, - "market_value": 37801.0, - "change_pct": 1.162, - "currency": "HKD", + "cost": null, + "price": 374.37, + "market_value": 37437.0, + "change_pct": 0.372, + "currency": "CNY", "position_pct": null, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "00981", "name": "中芯国际", "shares": 500, - "cost": 75.94, - "price": 78.15, - "market_value": 33900.0, - "change_pct": -2.799, - "currency": "HKD", + "cost": 65.84, + "price": 67.06, + "market_value": 33530.0, + "change_pct": -3.794, + "currency": "CNY", "position_pct": 4.2, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "300548", "name": "长芯博创", "shares": 100, "cost": 231.46, - "price": 222.45, - "market_value": 22236.0, - "change_pct": 0.2, + "price": 221.01, + "market_value": 22101.0, + "change_pct": -0.45, "currency": "CNY", "position_pct": 3.2, "_currency": "CNY" @@ -173,9 +173,9 @@ "name": "黄金ETF华安", "shares": 2400, "cost": 12.19, - "price": 8.66, - "market_value": 20784.0, - "change_pct": 2.22, + "price": 8.67, + "market_value": 20808.0, + "change_pct": 2.32, "currency": "CNY", "position_pct": 2.45, "_currency": "CNY" @@ -185,9 +185,9 @@ "name": "中科电气", "shares": 1400, "cost": 22.29, - "price": 14.3, + "price": 14.29, "market_value": 20006.0, - "change_pct": 0.92, + "change_pct": 0.85, "currency": "CNY", "position_pct": 2.42, "_currency": "CNY" @@ -198,7 +198,7 @@ "shares": 1400, "cost": 14.83, "price": 17.6, - "market_value": 24654.0, + "market_value": 24640.0, "change_pct": 4.33, "currency": "CNY", "position_pct": 2.41, @@ -209,9 +209,9 @@ "name": "法拉电子", "shares": 100, "cost": 147.18, - "price": 157.93, - "market_value": 15800.0, - "change_pct": -3.88, + "price": 157.06, + "market_value": 15706.0, + "change_pct": -4.41, "currency": "CNY", "position_pct": 2.3, "_currency": "CNY" @@ -220,22 +220,21 @@ "code": "01088", "name": "中国神华", "shares": 500, - "cost": 45.89, - "price": 39.97, - "market_value": 17340.0, - "change_pct": 0.909, - "currency": "HKD", + "cost": 39.79, + "price": 34.71, + "market_value": 17355.0, + "change_pct": 1.111, + "currency": "CNY", "position_pct": 2.14, - "_currency": "HKD" + "_currency": "CNY" } ], - "total_assets": 957656.13, - "total_mv": 825534.2, + "total_assets": 853681.69, + "total_mv": 773205.69, "stock_value": null, - "cash": 132121.93, - "frozen_cash": 0, - "position_pct": 86.2, + "cash": 80476.0, + "frozen_cash": 0.0, + "position_pct": 90.57, "currency": "CNY", - "updated_at": "2026-07-03 14:45", - "_source": "db_sync" + "updated_at": "2026-07-03 16:56" } \ No newline at end of file diff --git a/data/price_events.json b/data/price_events.json index aeb126f..0133ab6 100644 --- a/data/price_events.json +++ b/data/price_events.json @@ -9149,6 +9149,26 @@ "event_label": "", "timestamp": "2026-07-03T14:36:05.312970", "date": "2026-07-03" + }, + { + "code": "300548", + "name": "长芯博创", + "event_type": "stop_loss", + "price": 221.27, + "trigger_value": "221.38", + "event_label": "", + "timestamp": "2026-07-03T14:52:17.930474", + "date": "2026-07-03" + }, + { + "code": "688981", + "name": "中芯国际", + "event_type": "stop_loss", + "price": 140.59, + "trigger_value": "140.94", + "event_label": "", + "timestamp": "2026-07-03T14:52:19.831851", + "date": "2026-07-03" } ] } \ No newline at end of file diff --git a/data/price_history.json b/data/price_history.json index c0799db..bede509 100644 --- a/data/price_history.json +++ b/data/price_history.json @@ -26,7 +26,7 @@ "date": "2026-07-03", "high": 1215.52, "low": 1185.0, - "close": 1195.45 + "close": 1194.45 } ], "02202": [ @@ -62,7 +62,7 @@ "date": "2026-07-03", "high": 50.2, "low": 48.31, - "close": 49.06 + "close": 49.09 } ], "02359": [ @@ -118,7 +118,7 @@ "date": "2026-07-03", "high": 502.0, "low": 444.55, - "close": 482.68 + "close": 480.32 } ], "06160": [ @@ -154,7 +154,7 @@ "date": "2026-07-03", "high": 687.04, "low": 633.01, - "close": 644.5 + "close": 643.81 } ], "09868": [ @@ -196,7 +196,7 @@ "date": "2026-07-03", "high": 757.88, "low": 713.0, - "close": 738.2 + "close": 738.38 } ], "300124": [ @@ -210,7 +210,7 @@ "date": "2026-07-03", "high": 74.63, "low": 67.31, - "close": 72.22 + "close": 72.15 } ], "000657": [ @@ -224,7 +224,7 @@ "date": "2026-07-03", "high": 101.5, "low": 87.88, - "close": 90.33 + "close": 89.63 } ], "000711": [ @@ -252,7 +252,7 @@ "date": "2026-07-03", "high": 892.1, "low": 795.0, - "close": 882.5 + "close": 881.91 } ], "002594": [ @@ -264,9 +264,9 @@ }, { "date": "2026-07-03", - "high": 88.78, + "high": 88.88, "low": 81.9, - "close": 88.72 + "close": 88.47 } ], "00700": [ @@ -334,7 +334,7 @@ "date": "2026-07-03", "high": 646.85, "low": 574.1, - "close": 620.31 + "close": 618.02 } ] } \ No newline at end of file diff --git a/data/xiaoguo_insights.json b/data/xiaoguo_insights.json index 3f15ba3..69262b0 100644 --- a/data/xiaoguo_insights.json +++ b/data/xiaoguo_insights.json @@ -1,218 +1,78 @@ { - "timestamp": "2026-07-01 16:14", - "analyses": [ + "timestamp": "2026-07-03 16:00", + "source": "xiaoguo_fallback", + "stocks": [ { - "name": "中际旭创", - "code": "300308", - "price": 1223.17, - "sentiment": "negative", - "confidence": 0.59, - "brief": "中际旭创成交额超200亿元。数据宝统计,截至11:12,中际旭创成交额200.1...", - "keywords": [ - "中际旭创成交额超", - "亿元", - "日下午", - "现跌", - "数据宝统计" - ], - "news_count": 4, - "pos_count": 1, - "neg_count": 2, - "neu_count": 1, - "reason": "SL_dist=4.4% TP_dist=9.5%", - "priority": 1 - }, - { - "name": "丘钛科技", - "code": "01478", - "price": 5.95, - "sentiment": "positive", - "confidence": 0.57, - "brief": "丘钛科技5月手机摄像头模组销售数量增长39.4%。丘钛科技5月手机摄像头模组销售...", - "keywords": [ - "汇顶科技", - "丘钛科技是公司的供应商之一", - "证券日报网讯", - "汇顶科技在互动平台回答投资者提问时表示", - "丘钛科技" - ], - "news_count": 5, - "pos_count": 3, - "neg_count": 0, - "neu_count": 2, - "reason": "SL_dist=3.9% TP_dist=21.0%", - "priority": 1 - }, - { - "name": "紫金矿业", - "code": "601899", - "price": 25.11, - "sentiment": "neutral", - "confidence": 0.6, - "brief": "紫金矿业成交额超上一日全天。数据宝统计,截至14:10,紫金矿业成交额116.5...", - "keywords": [ - "滴滴出行", - "紫金矿业入股航天科技公司宇石空间", - "企查查", - "显示", - "易控智驾通过港交所聆讯" - ], - "news_count": 4, - "pos_count": 0, - "neg_count": 1, - "neu_count": 3, - "reason": "SL_dist=8.1% TP_dist=1.6%", - "priority": 1 - }, - { - "name": "中芯国际", "code": "688981", - "price": 154.48, - "sentiment": "positive", - "confidence": 0.58, - "brief": "南向资金今日净买入约59亿港元中芯国际获净买入目前。中芯国际、建滔积层板分别获净...", - "keywords": [ - "中芯国际成交额超", - "亿元", - "数据宝统计", - "截至", - "中芯国际成交额超上一日全天" - ], - "news_count": 5, - "pos_count": 2, - "neg_count": 0, - "neu_count": 3, - "reason": "SL_dist=3.0% TP_dist=14.2%", - "priority": 1 + "name": "中芯国际", + "sentiment": "negative", + "confidence": 0.75, + "reason": "芯片板块近期回调,国家大基金持股概念7/2日跌6.28%,主力资金大幅净流出;已跌破止损位140.94(现价140.31,距损-0.45%)", + "priority": "已跌破止损-0.45%" + }, + { + "code": "00981", + "name": "中芯国际(H)", + "sentiment": "negative", + "confidence": 0.65, + "reason": "大基金减持00981等芯片股,半导体板块承压;南向资金虽有爆买但价格已跌破止损67.24(现价67.06,距损-0.27%)", + "priority": "已跌破止损-0.27%" }, { - "name": "长芯博创", "code": "300548", - "price": 254.0, + "name": "长芯博创", + "sentiment": "negative", + "confidence": 0.80, + "reason": "通信行业7/2日暴跌7.36%,F5G/铜缆高速连接概念大幅回调,主力资金持续净流出;已跌破止损221.38(现价221.01,距损-0.17%)", + "priority": "已跌破止损-0.17%" + }, + { + "code": "00700", + "name": "腾讯", "sentiment": "positive", - "confidence": 0.62, - "brief": "长芯博创龙虎榜数据(6月15日)。长芯博创今日涨停,全天换手率9.65%,成交额...", - "keywords": [ - "长芯博创", - "收购控股子公司少数股东股份实施完成", - "公告称", - "公司已完成收购控股子公司长芯盛", - "长芯博创成交额创上市以来新高" - ], - "news_count": 5, - "pos_count": 4, - "neg_count": 0, - "neu_count": 1, - "reason": "SL_dist=3.0% TP_dist=14.8%", - "priority": 1 + "confidence": 0.78, + "reason": "连续31日回购,每日5.01亿港元,累计回购超249亿,管理层信心强烈;股价距止损366.84仅2.06%,回购托底意愿明确", + "priority": "距止损2.06%" }, { - "name": "黄金ETF华安", - "code": "518880", - "price": 8.27, + "code": "300308", + "name": "中际旭创", + "sentiment": "neutral", + "confidence": 0.60, + "reason": "6月获融资客40亿净买入,CPO概念年初至今涨幅可观;但通信板块7/2大跌7.36%,短期面临获利回吐压力,距止损1090.4约2.29%", + "priority": "距止损2.29%" + }, + { + "code": "06869", + "name": "长飞光纤光缆", "sentiment": "positive", - "confidence": 0.61, - "brief": "ETF融资榜|黄金ETF华安(518880)融资净买入558.22万元,居可比基...", - "keywords": [ - "融资榜", - "黄金", - "华安", - "融资净买入", - "融券榜" - ], - "news_count": 5, - "pos_count": 4, - "neg_count": 0, - "neu_count": 1, - "reason": "SL_dist=9.9% TP_dist=3.7%", - "priority": 1 + "confidence": 0.72, + "reason": "7/3除净派息0.339港元/股,南向资金7/2净买入1.77亿港元,6/30曾涨超7%;距止损169.46约2.56%,股息+资金面支撑", + "priority": "距止损2.56%" }, { - "name": "中科电气", - "code": "300035", - "price": 14.44, - "sentiment": "neutral", - "confidence": 0.56, - "brief": "中科电气终止三大锂电负极项目涉及年产能33万吨。对于此次项目终止事宜,中科电气方...", - "keywords": [ - "中科电气终止三大锂电负极项目涉及年产能", - "万吨", - "对于此次项目终止事宜", - "中科电气方面未向记者进一步回复", - "中科电气集中清理暂缓项目聚焦推进泸州与阿曼两大重点项目" - ], - "news_count": 5, - "pos_count": 0, - "neg_count": 1, - "neu_count": 4, - "reason": "SL_dist=9.9% TP_dist=4.8%", - "priority": 1 - }, - { - "name": "模塑科技", - "code": "000700", - "price": 15.34, - "sentiment": "neutral", - "confidence": 0.56, - "brief": "模塑科技:基于商业保密要求,公司不便透露与客户的具体合作信息。证券日报网讯6月1...", - "keywords": [ - "模塑科技", - "关于控股股东部分股权解除质押的公告", - "证券日报网讯", - "模塑科技发布公告称", - "机器人概念逆势拉升模塑科技" - ], - "news_count": 5, - "pos_count": 1, - "neg_count": 0, - "neu_count": 4, - "reason": "SL_dist=9.3% TP_dist=1.3%", - "priority": 1 - }, - { - "name": "法拉电子", - "code": "600563", - "price": 182.55, + "code": "01088", + "name": "中国神华", "sentiment": "neutral", "confidence": 0.55, - "brief": "法拉电子:2025年年度权益分派实施公告。证券日报网讯6月4日,法拉电子发布20...", - "keywords": [ - "法拉电子", - "年年度权益分派实施公告", - "证券日报网讯", - "法拉电子发布", - "三季度订单充足生产处于满产状态" - ], - "news_count": 5, - "pos_count": 0, - "neg_count": 0, - "neu_count": 5, - "reason": "SL_dist=6.9% TP_dist=3.1%", - "priority": 1 + "reason": "定州三期/沧东三期机组投运,子公司签约8.6亿合同,基本面稳健;但煤炭板块近期走弱,港股煤炭股6/30集体下跌,消息面中性偏淡", + "priority": "距止损2.63%" }, { - "name": "长飞光纤光缆", - "code": "06869", - "price": 221.69, - "sentiment": "negative", - "confidence": 0.58, - "brief": "港股评级汇总:野村维持对长飞光纤光缆买入评级。财联社6月29日讯以下为各家机构对...", - "keywords": [ - "港股光通信股走强长飞光纤光缆涨超", - "截至发稿", - "长飞光纤光缆", - "海光芯正", - "日将派发股息" - ], - "news_count": 5, - "pos_count": 1, - "neg_count": 2, - "neu_count": 2, - "reason": "涨跌幅+6.68%", - "priority": 2 + "code": "601899", + "name": "紫金矿业", + "sentiment": "positive", + "confidence": 0.82, + "reason": "贵金属概念7/3大涨4.84%,紫金矿业涨5.32%,美联储释放通胀回落信号利好金价;公司布局航天投资拓展成长空间", + "priority": "涨跌幅+5.78%" + }, + { + "code": "01211", + "name": "比亚迪股份", + "sentiment": "positive", + "confidence": 0.85, + "reason": "新能源汽车零售渗透率高达63%,板块持续走强,7/2比亚迪涨超9%领涨;行业景气度高位,动力电池/整车双轮驱动", + "priority": "涨跌幅+6.90%" } - ], - "status": "ok", - "total_analyzed": 10, - "total_candidates": 10 -} \ No newline at end of file + ] +} diff --git a/price_monitor.py b/price_monitor.py index 0df65d9..bad4f11 100644 --- a/price_monitor.py +++ b/price_monitor.py @@ -250,14 +250,12 @@ def refresh_data_prices(): if s['code'] in prices: price, _, change_pct = prices[s['code']] if price > 0: - # 港股:API返回HKD,需转CNY。系统统一存CNY标价 - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) + # 港股API返回HKD,直接存HKD原值。港股标价存HKD,A股标价存CNY old = s.get('price') or 0 if abs(old - price) > 0.001: s['price'] = round(price, 2) s['change_pct'] = float(change_pct) if change_pct else 0 - s['currency'] = 'CNY' + s['currency'] = 'HKD' if is_hk_stock(s['code']) else 'CNY' updated += 1 changed = True if changed: @@ -301,13 +299,11 @@ def refresh_data_prices(): if s['code'] in prices: price, _, change_pct = prices[s['code']] if price > 0: - # 港股:API返回HKD,需转CNY - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) old = s.get('price') or 0 if abs(old - price) > 0.001: s['price'] = round(price, 2) s['change_pct'] = float(change_pct) if change_pct else 0 + s['currency'] = 'HKD' if is_hk_stock(s['code']) else 'CNY' updated += 1 changed = True if changed: diff --git a/scripts/per_stock_reassess.py b/scripts/per_stock_reassess.py index 78c2108..456a664 100644 --- a/scripts/per_stock_reassess.py +++ b/scripts/per_stock_reassess.py @@ -1,167 +1,163 @@ -#!/usr/bin/env python3 -""" -per_stock_reassess.py — 按个股触发重评 - -对每只传进来的 code 执行 reassess_strategy(),然后只更新 -decisions.json 中对应的那一条记录。不碰 portfolio.json,不跑全量。 -""" -import sys, json, os, re - -sys.path.insert(0, "/home/hmo/web-dashboard") -from strategy_lifecycle import reassess_with_context as reassess_strategy -from mo_data import read_decisions, read_portfolio - -sys.path.insert(0, "/home/hmo/MoFin") -from mofin_db import get_conn, write_holding_strategy - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" - - -def main(): - codes = [a for a in sys.argv[1:] if not a.startswith("-")] - if not codes: - print("[FULL] 无指定编码,跑全量 regenerate_all()") - from strategy_lifecycle import regenerate_all - regenerate_all(stdout=False) - print("[FULL] 全量重评完成") - return - - # 读现有 decisions - raw = read_decisions() - decisions_map = {d["code"]: d for d in raw.get("decisions", []) if d.get("code")} - - ok = 0 - errors = 0 - skipped = 0 - for code in codes: - entry = decisions_map.get(code) - if not entry: - print(f"[SKIP] {code}: 不在 decisions.json 中") - errors += 1 - continue - - try: - # Always fetch live price for accurate reassessment - price = 0 - try: - # 价格从 DB 读取(price_monitor 每2分钟更新,唯一价格入口) - code_raw = entry.get("code", "") - price = 0 - import sqlite3 - db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') - db.row_factory = sqlite3.Row - row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code_raw,)).fetchone() - if not row: - row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code_raw,)).fetchone() - if not row: - row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (code_raw,)).fetchone() - if row: - price = row['price'] or 0 - db.close() - if price > 0: - print(f" 实时价: {price} (来自DB)") - if price <= 0: - price = entry.get("current_price") or entry.get("price") or 0 - except Exception as e: - print(f" 价格获取失败: {e}", file=sys.stderr) - price = entry.get("current_price") or entry.get("price") or 0 - - # Price diff debounce: skip reassessment if price changed < 1% since last update - last_price = entry.get("last_reassessed_price", 0) - if last_price > 0 and price > 0: - diff_pct = abs(price - last_price) / last_price * 100 - if diff_pct < 1.0: - print(f" 价差仅{diff_pct:.2f}% (<1%),跳过重评(上次价={last_price},现价={price})") - skipped += 1 - continue - result = reassess_strategy( - code=code, - name=entry.get("name", ""), - price=price, - cost=entry.get("cost", 0), - shares=entry.get("shares", 0), - current_action=entry.get("action", ""), - is_watchlist=entry.get("type", "") in ("自选策略", "watchlist"), - ) - if result and result.get("action"): - # 持仓股止损不下移(移动止损规则):已有仓位的止损只上不下 - is_held = entry.get("cost", 0) > 0 and entry.get("shares", 0) > 0 and \ - entry.get("type", "") not in ("自选策略", "watchlist") - old_stop = entry.get("stop_loss", 0) - new_stop = result.get("stop_loss", 0) - if is_held and old_stop > 0 and new_stop > 0 and new_stop < old_stop: - print(f" 移动止损保护: {new_stop}→保持{old_stop} (持仓止损不下移)") - result["stop_loss"] = old_stop - # 同时更新 action 字符串中的止损值 - act = result.get("action", "") - if act: - act = re.sub(r'止损[\d.]+', f'止损{old_stop}', act) - result["action"] = act - - # 更新 decisions_map 中对应的条目 - updated = entry.copy() - # 币种标记:HK股保留HKD原始值,A股为CNY - is_hk = len(str(code)) == 5 and str(code)[0] in '01' - updated.update({ - "action": result["action"], - "stop_loss": result.get("stop_loss", entry.get("stop_loss")), - "entry_low": result.get("entry_low", entry.get("entry_low")), - "entry_high": result.get("entry_high", entry.get("entry_high")), - "take_profit": result.get("take_profit"), - "tech_snapshot": result.get("tech_snapshot", entry.get("tech_snapshot")), - "timing_signal": result.get("timing_signal", entry.get("timing_signal")), - "rr_ratio": result.get("rr_ratio", entry.get("rr_ratio", 0)), - "status": result.get("status", "updated"), - "price": price, - "currency": "HKD" if is_hk else "CNY", - }) - # Save last reassessed price for debounce tracking - updated["last_reassessed_price"] = price - decisions_map[code] = updated - # ——— 初始化多分支策略树 ——— - try: - sys.path.insert(0, '/home/hmo/MoFin') - from strategy_tree import init_default_branches - branches = init_default_branches( - code, - entry.get('name', ''), - result.get('entry_low', 0), - result.get('entry_high', 0), - result.get('stop_loss', 0), - result.get('take_profit', 0), - ) - st = updated.setdefault('strategy_tree', {}) - st['branches'] = branches - except Exception: - pass - print(f"[OK] {code} {entry.get('name','')}: {result['action'][:80]}") - ok += 1 - else: - print(f"[SYNCED] {code}: 无变更") - ok += 1 - except Exception as e: - print(f"[ERROR] {code}: {e}", file=sys.stderr) - errors += 1 - - # 写回 decisions.json(只更新被修改的那条,其余保留原样) - raw["decisions"] = list(decisions_map.values()) - raw["total"] = len(raw["decisions"]) - from datetime import datetime - raw["regenerated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") - # DB 写入(替代 json.dump) - try: - conn = get_conn() - for d in raw.get("decisions", []): - write_holding_strategy(conn, d.get("code", ""), d.get("name", ""), d) - conn.close() - except Exception: - pass - # [migrated to DB] — cold backup removed - # with open(DECISIONS_PATH, "w") as f: - # json.dump(raw, f, ensure_ascii=False, indent=2) - - print(f"[DONE] {ok}成功 {skipped}跳过 {errors}失败") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +per_stock_reassess.py — 按个股触发重评 + +对每只传进来的 code 执行 reassess_strategy(),然后只更新 +decisions.json 中对应的那一条记录。不碰 portfolio.json,不跑全量。 +""" +import sys, json, os, re + +sys.path.insert(0, "/home/hmo/web-dashboard") +sys.path.insert(0, "/home/hmo/MoFin") +from strategy_lifecycle import reassess_with_context as reassess_strategy +from mo_data import read_decisions, read_portfolio + +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" + + +def main(): + codes = [a for a in sys.argv[1:] if not a.startswith("-")] + if not codes: + print("[FULL] 无指定编码,跑全量 regenerate_all()") + from strategy_lifecycle import regenerate_all + regenerate_all(stdout=False) + print("[FULL] 全量重评完成") + return + + # 读现有 decisions + raw = read_decisions() + decisions_map = {d["code"]: d for d in raw.get("decisions", []) if d.get("code")} + + ok = 0 + errors = 0 + skipped = 0 + for code in codes: + entry = decisions_map.get(code) + if not entry: + print(f"[SKIP] {code}: 不在 decisions.json 中") + errors += 1 + continue + + try: + # Always fetch live price for accurate reassessment + price = 0 + try: + # 价格从 DB 读取(price_monitor 每2分钟更新,唯一价格入口) + code_raw = entry.get("code", "") + price = 0 + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code_raw,)).fetchone() + if not row: + row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code_raw,)).fetchone() + if not row: + row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (code_raw,)).fetchone() + if row: + price = row['price'] or 0 + db.close() + if price > 0: + print(f" 实时价: {price} (来自DB)") + else: + # fallback to portfolio.json + _pf_data = read_portfolio() + for _h in _pf_data.get("holdings", []): + if _h["code"] == code_raw: + price = float(_h.get("price", 0)) + break + if price <= 0: + price = entry.get("current_price") or entry.get("price") or 0 + except Exception as e: + print(f" 价格获取失败: {e}", file=sys.stderr) + price = entry.get("current_price") or entry.get("price") or 0 + + # Price diff debounce: skip reassessment if price changed < 1% since last update + last_price = entry.get("last_reassessed_price", 0) + if last_price > 0 and price > 0: + diff_pct = abs(price - last_price) / last_price * 100 + if diff_pct < 1.0: + print(f" 价差仅{diff_pct:.2f}% (<1%),跳过重评(上次价={last_price},现价={price})") + skipped += 1 + continue + result = reassess_strategy( + code=code, + name=entry.get("name", ""), + price=price, + cost=entry.get("cost", 0), + shares=entry.get("shares", 0), + current_action=entry.get("action", ""), + is_watchlist=entry.get("type", "") in ("自选策略", "watchlist"), + ) + if result and result.get("action"): + # 持仓股止损不下移(移动止损规则):已有仓位的止损只上不下 + is_held = entry.get("cost", 0) > 0 and entry.get("shares", 0) > 0 and \ + entry.get("type", "") not in ("自选策略", "watchlist") + old_stop = entry.get("stop_loss", 0) + new_stop = result.get("stop_loss", 0) + if is_held and old_stop > 0 and new_stop > 0 and new_stop < old_stop: + print(f" 移动止损保护: {new_stop}→保持{old_stop} (持仓止损不下移)") + result["stop_loss"] = old_stop + # 同时更新 action 字符串中的止损值 + act = result.get("action", "") + if act: + act = re.sub(r'止损[\d.]+', f'止损{old_stop}', act) + result["action"] = act + + # 更新 decisions_map 中对应的条目 + updated = entry.copy() + # 币种标记:HK股保留HKD原始值,A股为CNY + is_hk = len(str(code)) == 5 and str(code)[0] in '01' + updated.update({ + "action": result["action"], + "stop_loss": result.get("stop_loss", entry.get("stop_loss")), + "entry_low": result.get("entry_low", entry.get("entry_low")), + "entry_high": result.get("entry_high", entry.get("entry_high")), + "take_profit": result.get("take_profit"), + "tech_snapshot": result.get("tech_snapshot", entry.get("tech_snapshot")), + "timing_signal": result.get("timing_signal", entry.get("timing_signal")), + "rr_ratio": result.get("rr_ratio", entry.get("rr_ratio", 0)), + "status": result.get("status", "updated"), + "price": price, + "currency": "HKD" if is_hk else "CNY", + }) + # Save last reassessed price for debounce tracking + updated["last_reassessed_price"] = price + decisions_map[code] = updated + # ——— 初始化多分支策略树 ——— + try: + sys.path.insert(0, '/home/hmo/MoFin') + from strategy_tree import init_default_branches + branches = init_default_branches( + code, + entry.get('name', ''), + result.get('entry_low', 0), + result.get('entry_high', 0), + result.get('stop_loss', 0), + result.get('take_profit', 0), + ) + st = updated.setdefault('strategy_tree', {}) + st['branches'] = branches + except Exception: + pass + print(f"[OK] {code} {entry.get('name','')}: {result['action'][:80]}") + ok += 1 + else: + print(f"[SYNCED] {code}: 无变更") + ok += 1 + except Exception as e: + print(f"[ERROR] {code}: {e}", file=sys.stderr) + errors += 1 + + # 写回 decisions.json(只更新被修改的那条,其余保留原样) + raw["decisions"] = list(decisions_map.values()) + raw["total"] = len(raw["decisions"]) + from datetime import datetime + raw["regenerated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + with open(DECISIONS_PATH, "w") as f: + json.dump(raw, f, ensure_ascii=False, indent=2) + + print(f"[DONE] {ok}成功 {skipped}跳过 {errors}失败") + + +if __name__ == "__main__": + main() diff --git a/scripts/stale_detector.py b/scripts/stale_detector.py index 675cfca..ef6e54f 100644 --- a/scripts/stale_detector.py +++ b/scripts/stale_detector.py @@ -1,286 +1,288 @@ -#!/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 -from mo_data import read_portfolio, read_decisions, read_watchlist - -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 = mo_data.read_decisions() - 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: - pf = read_portfolio() - 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() +#!/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 +sys.path.insert(0, '/home/hmo/MoFin') +from mo_data import read_portfolio, read_decisions, read_watchlist + +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 = read_decisions() + 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() diff --git a/scripts/stale_push_wlin.py b/scripts/stale_push_wlin.py index 3d8493f..8ad3324 100644 --- a/scripts/stale_push_wlin.py +++ b/scripts/stale_push_wlin.py @@ -1,900 +1,901 @@ -#!/usr/bin/env python3 -""" -stale_push_wlin.py — 按5步逻辑推送自选股买入区提醒 + 自动触发重评 - -5步逻辑: -1. 筛选 is_watchlist=true 且价在买入区 -2. RR<1.5/无止盈位/非买入signal → 标记 STRATEGY_STALE → 触发自动重评 -3. 可推的:计算每手买入金额和现金占比 -4. 发现 STRATEGY_STALE → 后台跑 per_stock_reassess.py 自动重评 - -no_agent模式:有推送→输出;无→静默 -搭配 cron: no_agent=True, 交易日每30分跑一次 -""" -import subprocess -import sys -import re -import json -import os -import threading -import time -from datetime import datetime, time -from mo_data import read_portfolio, read_decisions - -# ── MoFin unified model ────────────────────────────────────────────── -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from mo_models import is_hk_stock, get_hk_rate, to_cny, calc_total_assets - -# 市场时段检查 -_MARKET_HOURS = { - 'ashare': (time(9, 30), time(15, 0)), - 'hk': (time(9, 30), time(16, 0)), -} - -def is_ashare(code: str) -> bool: - """判断是否A股代码""" - return code.isdigit() and (code.startswith(('6', '5')) or len(code) in (6,)) - -def market_is_open(code: str, now: datetime = None) -> bool: - """检查某股票对应市场是否在交易时段内""" - if not code: - return True - now = now or datetime.now() - t = now.time() - code_str = str(code) - if code_str.startswith(('0', '1')) and len(code_str) == 5: - # 港股 - start, end = _MARKET_HOURS['hk'] - else: - # A股(含ETF、科创板) - start, end = _MARKET_HOURS['ashare'] - return start <= t <= end -try: - from urllib.request import Request, urlopen -except ImportError: - from urllib2 import Request, urlopen -# 6维评分系统 -sys.path.insert(0, "/home/hmo/MoFin/scripts") -from stock_scorer import score_future_outlook, is_hk_stock, settlement_delay_note - -# ── 趋势检查 ──────────────────────────────────────────────────── -def fetch_trend_data(code): - """取均线数据判断趋势状态。价格从 DB 读取(price_monitor 唯一入口)。返回 (current_price, ma5, trend_label) 或 None""" - # 价格从 DB 读取,不再自拉腾讯 API - current = 0 - try: - import sqlite3 - db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') - db.row_factory = sqlite3.Row - row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() - if not row: - row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code,)).fetchone() - if not row: - row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (code,)).fetchone() - if row: - current = row['price'] or 0 - db.close() - except Exception: - pass - - if current <= 0: - return None - - # K线数据仍从腾讯取(均线计算需要历史K线,DB 里 stock_daily 表有但不一定有最新数据) - try: - prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" - url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,30,qfq" - req = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) - resp = urlopen(req, timeout=5).read().decode('utf-8') - data = json.loads(resp) - day_key = 'qfqday' if prefix != 'hk' else 'day' - bars = data.get('data', {}).get(f'{prefix}{code}', {}).get(day_key, []) - except: - return None - - if not bars or current <= 0: - return None - closes = [float(b[2]) for b in bars] - if len(closes) < 5: - return None - - def ma(n): - return sum(closes[-n:]) / n - ma5 = ma(5) - ma10 = ma(10) if len(closes) >= 10 else None - ma20 = ma(20) if len(closes) >= 20 else None - - # 趋势分析 - pct_above_ma5 = (current - ma5) / ma5 * 100 - uptrend = False - - if ma20 and ma10: - if ma5 > ma10 > ma20: - trend_label = "多头排列" - uptrend = True - elif current < ma5 and ma5 < ma10 and current < ma10: - trend_label = "空头排列" - elif current > ma5 and ma5 > ma10: - trend_label = "短期转强" - uptrend = True - else: - trend_label = "震荡" - if current > ma5 > ma10: - uptrend = True - else: - trend_label = "数据不足" - - return { - 'price': current, - 'ma5': round(ma5, 2), - 'ma10': round(ma10, 2) if ma10 else None, - 'ma20': round(ma20, 2) if ma20 else None, - 'pct_above_ma5': round(pct_above_ma5, 1), - 'trend': trend_label, - 'uptrend': uptrend, - } - -# ── XMPP -XMPP_BRIDGE = "http://127.0.0.1:5805/" -XMPP_USER = "hmo@yoin.fun" - -STALENESS_REPORT = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" -DETECTOR = "/home/hmo/.hermes/profiles/position-analyst/scripts/stale_detector.py" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" -REGEN_SCRIPT = "/home/hmo/.hermes/profiles/position-analyst/scripts/per_stock_reassess.py" -REGEN_LOCK = "/tmp/.stale_push_wlin_regen.lock" -MACRO_CTX = "/home/hmo/web-dashboard/data/macro_context.json" -MARKET_JSON = "/home/hmo/web-dashboard/data/market.json" -COOLDOWN_PATH = "/home/hmo/web-dashboard/data/push_cooldown.json" - -NON_BUY_SIGNALS = ["观望", "弱势持有", "深套持有"] - - -def load_macro_line(): - """加载大盘和市场的简要描述""" - parts = [] - try: - # 优先 DB - import sqlite3 - db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") - row = db.execute( - "SELECT structure FROM macro_context_log " - "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" - ).fetchone() - db.close() - if row and row[0]: - m = json.loads(row[0]) - else: - raise ValueError("no db data") - overall = m.get("overall", "neutral") - desc = m.get("description", "") - if "bearish" in overall: - parts.append("大盘偏弱") - elif overall == "bullish": - parts.append("大盘偏强") - elif desc: - parts.append(f"大盘{desc}") - except Exception: - try: - with open(MACRO_CTX) as f: - m = json.load(f).get("structure", {}) - overall = m.get("overall", "neutral") - desc = m.get("description", "") - if "bearish" in overall: - parts.append("大盘偏弱") - elif overall == "bullish": - parts.append("大盘偏强") - elif desc: - parts.append(f"大盘{desc}") - except Exception: - pass - try: - with open(MARKET_JSON) as f: - mk = json.load(f) - mood = mk.get("mood", "") - if mood: - parts.append(f"市场{mood}") - except Exception: - pass - return " | ".join(parts) if parts else "" - - -def is_actionable(cur, timing_signal=""): - """检查信号是否可操作。空文本/含非买入关键词 → 不可操作""" - if not cur and not timing_signal: - return False # 空文本默认不安全 - for kw in NON_BUY_SIGNALS: - if cur and kw.lower() in cur.lower(): - return False - if timing_signal and kw.lower() in timing_signal.lower(): - return False - return True - - -def trigger_regen_sync(stock_codes=None): - """同步执行指定个股的重评(等重评完再发报告)""" - if not stock_codes: - return - try: - cmd = ["python3", REGEN_SCRIPT] + stock_codes - subprocess.run(cmd, capture_output=True, text=True, timeout=60) - except subprocess.TimeoutExpired: - print("[REGEN] 重评超时(60s)", file=sys.stderr) - except Exception as e: - print(f"[REGEN] 重评失败: {e}", file=sys.stderr) - - -def load_cash(): - """从 portfolio.json 实时读可用现金(可用 ≈ 实时买力),不硬编码""" - try: - data = read_portfolio() - if isinstance(data, dict): - # 先读 cash_available(拆分了可用/冻结),fallback 到 cash - return data.get("cash_available", data.get("cash", 0)) - if isinstance(data, list) and len(data) > 1 and isinstance(data[1], dict): - return data[1].get("cash_available", data[1].get("cash", 0)) - return 0 - except Exception: - return 0 - - -_HK_LOT_CACHE = {} - -def hk_lot_size(code): - """从腾讯行情API获取港股实际每手股数(字段[60]),带缓存""" - if code in _HK_LOT_CACHE: - return _HK_LOT_CACHE[code] - try: - url = f"http://qt.gtimg.cn/q=hk{code}" - req = Request(url, headers={"User-Agent": "curl/7.81"}) - with urlopen(req, timeout=5) as r: - text = r.read().decode("gbk") - raw = text.split("=", 1)[1].strip().strip('"').strip(";") - fld = raw.split("~") - lot = int(fld[60]) if len(fld) > 60 and fld[60] else 1000 - _HK_LOT_CACHE[code] = lot - return lot - except Exception: - _HK_LOT_CACHE[code] = 1000 - return 1000 - - -def lot_cost(code, price): - if str(code).startswith("688"): - return 200 * price - elif is_hk_stock(code): - lot = hk_lot_size(code) - rate = get_hk_rate() - return int(lot * price * rate) - else: - return 100 * price - - -def push_to_xmpp(text): - """通过知微 HTTP bridge 推送到老爸私信""" - if not text.strip(): - return - try: - payload = json.dumps({ - "to": XMPP_USER, - "body": text.strip(), - "type": "chat", - }).encode("utf-8") - req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) - urlopen(req, timeout=5) - except Exception as e: - print(f"[XMPP推送失败] {e}", file=sys.stderr) - - -def load_cooldown(): - try: - with open(COOLDOWN_PATH) as f: - return json.load(f) - except Exception: - return {} - - -def save_cooldown(cd): - try: - with open(COOLDOWN_PATH, "w") as f: - json.dump(cd, f, indent=2) - except Exception: - pass - - -def in_cooldown(code, action_type, cooldown_dict, minutes=30): - key = f"{code}_{action_type}" - last = cooldown_dict.get(key, 0) - elapsed = datetime.now().timestamp() - last - return elapsed < minutes * 60, elapsed, key - - -def main(): - r = subprocess.run( - ["python3", DETECTOR], capture_output=True, text=True, timeout=60 - ) - if r.returncode != 0 and r.stderr: - print(f"[stderr] {r.stderr.strip()}", file=sys.stderr) - - wl_lines = [ - l for l in r.stdout.split("\n") - if "[WL_IN]" in l and "[自选]" in l - ] - if not wl_lines: - return 0 - - # 读 stale report - try: - with open(STALENESS_REPORT) as f: - report = json.load(f) - except Exception: - report = {"flagged": []} - code_cur = {i["code"]: i.get("current", "") for i in report.get("flagged", [])} - - # 加载冷却状态 - cooldown = load_cooldown() - now_ts = datetime.now().timestamp() - - # 读 decisions.json 获取完整策略数据 - code_data = {} - try: - dec = read_decisions() - for e in dec.get("decisions", []): - code_data[e["code"]] = e - except Exception: - pass - - cash = load_cash() - stocks = [] - stale_list = [] - all_candidates = [] # 所有在买入区的自选(stale+non-stale) - - for l in wl_lines: - m = re.match(r'\[WL_IN\](?:\s+\[\w+\])*\s+\[自选\]\s+(\S+)\((\d+)\)', l) - if not m: - continue - name, code = m.group(1), m.group(2) - pm = re.search(r'价(\d+\.\d{2})', l) - if not pm: - continue - price = float(pm.group(1)) - zm = re.search(r'买入([\d.]+)~([\d.]+)', l) - if not zm: - continue - buy_low, buy_high = float(zm.group(1)), float(zm.group(2)) - is_stale = "[STRATEGY_STALE]" in l - cur = code_cur.get(code, "") - - all_candidates.append((name, code, price, buy_low, buy_high, cur, is_stale)) - - if not is_actionable(cur, code_data.get(code, {}).get("timing_signal", "")) or is_stale: - stale_list.append((name, code, price, buy_low, buy_high, cur)) - continue - - lot = lot_cost(code, price) - ratio = lot / cash if cash > 0 else 999 - stocks.append((name, code, price, buy_low, buy_high, lot, ratio)) - - if not stocks and not stale_list: - return 0 - - now = datetime.now().strftime("%H:%M") - lines = [] - - # 市场背景 - macro_line = load_macro_line() - if macro_line: - lines.append(f"【市场背景】{macro_line}") - - # [关键修复: 2026-06-25] 所有预推票先重评,再出报告 - # 不只是 stale 的重评,所有在买入区的自选都先刷新策略,确保推荐不滞后 - to_reassess = list(set(s[1] for s in stocks) | set(s[1] for s in stale_list)) - if to_reassess: - trigger_regen_sync(to_reassess) - # 重评完成,re-read decisions.json 获取最新策略 - code_data = {} - try: - dec = read_decisions() - for e in dec.get("decisions", []): - code_data[e["code"]] = e - except Exception: - pass - - # 重新过滤:重评后可能有策略变化(止盈/止损/信号变动) - # 重建 stocks 列表,用新数据判断(不再用旧 is_stale 标记,因为已全部重评) - stocks = [] - for (name, code, price, buy_low, buy_high, cur, is_stale) in all_candidates: - # 重评后重新检查 actionability(用新 timing_signal) - sig = code_data.get(code, {}).get("timing_signal", "") - if not is_actionable(cur, sig): - continue - lot = lot_cost(code, price) - ratio = lot / cash if cash > 0 else 999 - stocks.append((name, code, price, buy_low, buy_high, lot, ratio)) - - # 加载portfolio获取持仓信息(A/H去重用) - pf = {"holdings": []} - try: - pf = read_portfolio() - except Exception: - pass - - stocks.sort(key=lambda s: ( - 0 if len(str(s[1])) == 6 else 1, - -code_data.get(s[1], {}).get("rr_ratio", 0) - )) - - # 只展示有清晰操作信号的个股 - # timing_signal 必须是明确操作方向:买入/加仓/观望/关注/信号不充分 - # 行业描述(行业偏弱/行业偏强/大盘变盘等)不是操作信号,一律跳过 - VALID_SIGNALS = {"买入", "加仓", "观望", "关注", "信号不充分"} - SKIP_KEYWORDS = ["等企稳", "信号不充分"] - - actionable = [] - for s in stocks: - sig = code_data.get(s[1], {}).get("timing_signal", "") - if not sig: - continue - # 跳过非操作信号 - if any(kw in sig for kw in SKIP_KEYWORDS): - continue - # 中性信号跳过 - stripped = sig.strip() - if not stripped or stripped.lower() in ("", "neutral", "持有", "深套持有", "弱势持有"): - continue - # 信号必须含买入/加仓才推荐——其他非操作信号跳过 - if not any(kw in sig for kw in ["买入", "加仓"]): - continue - # 趋势检查:必须不是空头排列(价格在MA5以下且MA5= 5: - theo_pct = 25 - elif rr >= 3: - theo_pct = 18 - elif rr >= 2: - theo_pct = 12 - else: - theo_pct = 8 - if "偏弱" in market_factor: - theo_pct = int(theo_pct * 0.8) - elif "偏强" in market_factor: - theo_pct = int(theo_pct * 1.15) - if cat in ("蓝筹", "白马"): - theo_pct = int(theo_pct * 1.2) - elif cat in ("题材", "短线"): - theo_pct = int(theo_pct * 0.6) - elif cat in ("高波动", "成长"): - theo_pct = int(theo_pct * 0.85) - theo_pct = max(5, min(30, theo_pct)) - - # 当前建议仓位:理论占总资产% → 按现金锁死 - ideal_budget = total_assets * theo_pct / 100 - # 可操作N只时,现金分配不超过 available_cash / n * 1.5 - max_use_cash = (available_cash / max(n, 1)) * 1.5 - budget = min(ideal_budget, max_use_cash, available_cash) - lots = int(budget / lot_cost) if lot_cost > 0 else 0 - - if lots == 0 and lot_cost > 0 and budget > lot_cost * 0.8: - # 预算覆盖超过80%的1手金额 → 至少1手(仅差一档) - lots = 1 - - lot_cost_total = lots * lot_cost - if lots == 0: - pct_actual = 0 - elif total_assets > 0: - pct_actual = round(lot_cost_total / total_assets * 100) - else: - pct_actual = 0 - - if lots == 0: - details = f"预算不足1手({budget:,.0f}/{lot_cost:,.0f}元)" - else: - if len(str(code)) == 5: - hk_lot = hk_lot_size(code) - shares = lots * hk_lot - elif code.startswith("688"): - shares = lots * 200 - else: - shares = lots * 100 - details = f"{lots}手({shares}股,{lot_cost_total:,.0f}元)" - - return theo_pct, pct_actual, details, lots, lot_cost_total - - # ── 换仓评估 ────────────────────────────────────────────────────── - # score_future_outlook 从 stock_scorer 模块导入(6维评分) - - def evaluate_swap(lot_cost_target, rr, sig, tp, sl, name, code, price_in, - total_assets_in, cash_in, pf_in, cd_in): - """现金不足时评估是否卖差票换推荐股。 - - 核心逻辑: - - 已发生的亏损是沉没成本,不参与决策 - - 用6维评分法评估每个持仓的未来前景(基于决策系统既有数据) - - 优先卖前景最差的票,保留前景好的票(无论当前盈亏%) - - 卖港股→买A股需T+2到账,如果推荐此方案则标注延迟风险 - - 对目标票(RR>=3+买入信号)才有换仓资格 - - 返回(推荐文案str, 缺口float)或 (None, gap) - """ - gap = lot_cost_target - cash_in - # 目标票质量门槛 - if rr < 3.0 or gap <= 0 or gap > total_assets_in * 0.5: - return None, gap - if not any(kw in sig for kw in ["买入", "加仓", "建仓"]): - return None, gap - - # 收集持仓数据 + 前景评分 - ph = [] - for h in pf_in.get("holdings", []): - hs = h.get("shares", 0) or 0 - hp = h.get("price", 0) or 0 - hc = h.get("cost", 0) or 0 - if hs <= 0 or hp <= 0: - continue - hmv = hs * hp - # 港股价格已是 CNY(price_monitor 写入时已转),不需要再乘汇率 - hpl_pct = (hp - hc) / hc * 100 if hc else 0 - - # 6维全面评分(越低越差,越建议卖) - fscore, _ = score_future_outlook(h_code, cd_in) - - ph.append({ - "code": h_code, - "name": h.get("name", ""), - "shares": hs, - "price": hp, - "cost": hc, - "mv": round(hmv), - "pl_pct": round(hpl_pct, 1), - "score": fscore, - }) - - # 按前景评分升序(最差的排最前面) - ph.sort(key=lambda x: x["score"]) - - # 打印调试信息:所有持仓的前景评分 - # print(f"[SWAP_DEBUG] 前景评分(越低越差):", file=sys.stderr) - # for x in ph[:10]: - # print(f" {x['name']}({x['code']}) 评分{x['score']} 亏{x['pl_pct']}% 市值{x['mv']:,}", file=sys.stderr) - - # 只考虑评分<=0(前景差或中性偏弱)的作为减仓候选 - candidates = [h for h in ph if h["score"] <= 0] - if not candidates: - return None, gap - - # 贪心选评分最差的,凑够现金缺口(最多2只) - selected = [] - cash_freed = 0 - for h in candidates: - if cash_freed >= gap: - break - cash_freed += h["mv"] - selected.append(h) - - if cash_freed < gap or len(selected) > 2: - return None, gap - - # 计算目标票的预期涨幅 - if tp and tp > 0: - target_gain_pct = (tp - price_in) / price_in * 100 - else: - target_gain_pct = rr * 3 - - # 构建推荐文案 - buy_is_a = not is_hk_stock(code) # 目标是否是A股 - sell_parts = [] - sell_names = [] - settlement_warnings = [] - for h in selected: - # 每个被选股票配一句"为什么卖它" - reason = f"评分{h['score']}" - if h['pl_pct'] <= -30: - reason += "深套" - elif h['pl_pct'] <= -15: - reason += f"亏损{h['pl_pct']}%" - sell_parts.append(f"{h['name']}({h['code']}) {h['shares']}股 亏{h['pl_pct']}% ({reason})") - sell_names.append(h['name']) - # 检查结算延迟:卖港股→买A股 - if is_hk_stock(h['code']) and buy_is_a: - settlement_warnings.append(f"{h['name']}是港股通,卖出需T+2到账才能买A股") - sell_desc = ";".join(sell_parts) - - new_budget = cash_in + cash_freed - new_lots = int(new_budget / lot_cost_target) if lot_cost_target > 0 else 0 - if new_lots == 0: - return None, gap - if code.startswith("688"): - new_shares = new_lots * 200 - elif len(code) <= 5: - new_shares = new_lots * hk_lot_size(code) - else: - new_shares = new_lots * 100 - new_cost = new_lots * lot_cost_target - new_pct = round(new_cost / total_assets_in * 100) if total_assets_in > 0 else 0 - - text = ( - f"换仓建议:卖{sell_desc}" - f"→腾{round(cash_freed):,}元" - f"→买{name}({code}) {new_lots}手({new_shares}股,{round(new_cost):,}元)" - f"占{new_pct}%仓位" - f"(止损{sl}(-{round((price_in-sl)/price_in*100,1)}%)" - f"止盈{tp}(+{round(target_gain_pct,1)}%)" - f" RR={rr})\n" - f" 理由:{', '.join(sell_names)}评分最低," - f"继续持有无积极信号且技术偏弱;" - f"换到有明确信号和止损的标的,预期收益更优。" - ) - if settlement_warnings: - text += "\n ⚠️ " + " | ".join(settlement_warnings) - return text, gap - - # 标准格式:每个可操作标的 — 大盘/行业/个股三面 + 仓位 - lines.append(f"【💡 操作建议】(当前{n}只自选可操作 | 总资产{total_assets:,.0f}元 现金{available_cash:,.0f}元)") - for s in actionable: - name, code, price, buy_low, buy_high, lot, ratio = s - d = code_data.get(code, {}) - sl = d.get("stop_loss", 0) - tp = d.get("take_profit", 0) - rr = d.get("rr_ratio", 0) - sig = d.get("timing_signal", "") - sector = d.get("sector_context", "") - tech = d.get("tech_snapshot", "") - mtf_ctx = d.get("multi_tf_context", "") - note = d.get("note", "") - d_factors = d.get("signal_factors", []) - cat = d.get("stock_category", "") - - # 提取技术位 - ss = {"强撑":"-", "弱撑":"-", "弱压":"-", "强压":"-"} - for tag in ss: - m = re.search(rf'{tag}:([\d.]+)', tech) - if m: - ss[tag] = m.group(1) - - # 基本面 - fund = fund_cache.get(code, {}) - pe = fund.get("pe", 0) - eps = fund.get("eps", 0) - pe_str = f"PE{pe:.0f}" if pe else "" - eps_str = f"EPS{eps:.2f}" if eps else "" - - # 从 signal_factors 提取各维度 - def _match_factor(prefix): - for f in d_factors: - if f.startswith(prefix): - return f - return "" - - market_factor = _match_factor("大盘") - sector_factor = _match_factor("行业") - value_factor = _match_factor("高估值") or _match_factor("低估值") or _match_factor("蓝筹") or pe_str or "" - news_factor = _match_factor("消息") - tech_factor = _match_factor("净利") or _match_factor("组合") or "" - - # 构建分析行 - parts = [] - if market_factor: - parts.append(f"大盘{market_factor.replace('大盘','')}") - if sector_factor: - parts.append(f"行业{sector_factor.replace('行业','')}") - if pe_str or value_factor: - parts.append(value_factor or pe_str) - if news_factor: - parts.append(news_factor) - if not parts: - parts.append(sector or cat or "") - - analysis = " | ".join(p for p in parts if p) - - # 仓位计算 - theo_pct, actual_pct, details, lots, lot_cost_total = calc_position( - lot, rr, market_factor, cat, code - ) - - pfx = "" if len(code) == 6 else "HK$" - - # 取分支动作类型 - branch_action = "hold" - branch_rationale = "" - if st and scenario_id: - try: - results = st.evaluate_branches(code, scenario_id, price, d.get("shares", 0), d.get("cost", 0)) - applicable = [r for r in results if r.get("applicable")] - if applicable: - best = min(applicable, key=lambda r: r.get("priority", 999)) - branch_action = best.get("action_type", "hold") - branch_rationale = best.get("rationale", "") - except Exception: - pass - - # 冷却检查:相同股+相同操作30分钟内不发 - cooled, elapsed, cd_key = in_cooldown(code, branch_action, cooldown) - if cooled: - continue - - # 策略质量过滤:只有正向/中性信号才推操作建议 - bad_keywords = ["偏弱", "弱势", "观望", "卖出", "回避", "回避"] - if any(kw in sig for kw in bad_keywords): - continue - - # 行业背景过滤:行业大跌时不在买入区推荐(即使个股信号好) - if "大跌" in sector: - continue - - # 换仓评估:现金不足时评估是否卖差票换推荐股 - swap_text = None - if lots == 0: - swap_text, _ = evaluate_swap( - lot, rr, sig, tp, sl, name, code, price, - total_assets, available_cash, pf, code_data - ) - - action_tag = "🛒" if (lots > 0 or swap_text) else "⚠️" - - lines.append( - f" {action_tag} {name}({code}) {pfx}{price:.2f} 买区{buy_low}~{buy_high} | " - f"1手{lot:,.0f}元 RR={rr:.1f} 损{sl} 盈{tp}\n" - f" {analysis}\n" - f" 技术{ss['强撑']}→{ss['弱撑']}→{ss['弱压']}→{ss['强压']} | 信号{sig}\n" - f" 仓位:理论{theo_pct}%×总资产 | 建议{actual_pct}%({details})" - ) - - if mtf_ctx: - lines[-1] += f"\n 均线{mtf_ctx}" - - if swap_text: - lines[-1] += f"\n {swap_text}" - - # 分支描述 - branch_line = "" - if branch_action != "hold": - branch_line = f" 【{scenario_label}→{branch_action}】{branch_rationale}" - if branch_line: - lines[-1] += f"\n{branch_line}" - - # 记录推送时间(冷却计时用) - cooldown[cd_key] = now_ts - - save_cooldown(cooldown) - - # 修正可操作数量(剔除冷却跳过后的实际数量) - actual_n = len(lines) - (1 if macro_line else 0) - 1 # 减去市场背景 + 操作建议标题 - if actual_n != n: - # 更新操作建议行 - for i, ln in enumerate(lines): - if "【💡 操作建议】" in ln: - lines[i] = f"【💡 操作建议】(当前{actual_n}只自选可操作 | 总资产{total_assets:,.0f}元 现金{available_cash:,.0f}元)" - break - - if actual_n <= 0: - return 0 # 全部冷却中 → 静默,不推 - - # ── T+2前瞻:扫描近期可能入买区的A股,提前准备现金 ── - t2_lines = [] - try: - dec_t2 = read_decisions() - for entry in dec_t2.get("decisions", []): - if entry.get("status") == "closed" or entry.get("type") != "自选策略": - continue - ec = entry["code"] - el = entry.get("entry_low", 0) or 0 - eh = entry.get("entry_high", 0) or 0 - ep = entry.get("price", 0) or 0 - if not eh or not ep or el <= 0: - continue - # A股+价格在买入区上方5%以内(即将进入买入区) - if not is_hk_stock(ec) and el <= ep <= eh * 1.05 and ep > eh: - anticipation_pct = (ep - eh) / eh * 100 - lot = lot_cost(ec, ep) - if lot > available_cash: - # 现金不足 → 卖港股提前准备 - ph = [] - for h in pf.get("holdings", []): - hs = h.get("shares", 0) or 0 - hp = h.get("price", 0) or 0 - hc = h.get("cost", 0) or 0 - if hs <= 0 or hp <= 0 or not is_hk_stock(h.get("code","")): - continue - sc = score_future_outlook(h.get("code",""), code_data) - ph.append((sc, h)) - ph.sort(key=lambda x: x[0]) - if ph: - worst = ph[0][1] - w_name = worst.get("name","?") - w_code = worst.get("code","") - w_price = worst.get("price",0) - w_shares = worst.get("shares",0) - w_value = w_price * w_shares - if w_value >= lot: - name_e = entry.get("name","") - t2_lines.append( - f" ⏳ {name_e}({ec})距买入区仅{anticipation_pct:.0f}%," - f"需{lot:,.0f}元。建议提前卖{w_name}({w_code})" - f"腾{w_value:,.0f}元(T+2到账后可用)" - ) - except: - pass - - if t2_lines: - lines.append("") - lines.append("【⏳ 提前准备(T+2港股提前出清)】") - lines.extend(t2_lines) - - lines.insert(0, f"【知微】自选买入提醒 {now} | 总资产{total_assets:,.0f}元") - out = "\n".join(lines) - print(out) - push_to_xmpp(out) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) +#!/usr/bin/env python3 +""" +stale_push_wlin.py — 按5步逻辑推送自选股买入区提醒 + 自动触发重评 + +5步逻辑: +1. 筛选 is_watchlist=true 且价在买入区 +2. RR<1.5/无止盈位/非买入signal → 标记 STRATEGY_STALE → 触发自动重评 +3. 可推的:计算每手买入金额和现金占比 +4. 发现 STRATEGY_STALE → 后台跑 per_stock_reassess.py 自动重评 + +no_agent模式:有推送→输出;无→静默 +搭配 cron: no_agent=True, 交易日每30分跑一次 +""" +import subprocess +import sys +import re +import json +import os +import threading +from mo_data import read_portfolio, read_decisions +import time +from datetime import datetime, time + +# ── MoFin unified model ────────────────────────────────────────────── +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mo_models import is_hk_stock, get_hk_rate, to_cny, calc_total_assets + +# 市场时段检查 +_MARKET_HOURS = { + 'ashare': (time(9, 30), time(15, 0)), + 'hk': (time(9, 30), time(16, 0)), +} + +def is_ashare(code: str) -> bool: + """判断是否A股代码""" + return code.isdigit() and (code.startswith(('6', '5')) or len(code) in (6,)) + +def market_is_open(code: str, now: datetime = None) -> bool: + """检查某股票对应市场是否在交易时段内""" + if not code: + return True + now = now or datetime.now() + t = now.time() + code_str = str(code) + if code_str.startswith(('0', '1')) and len(code_str) == 5: + # 港股 + start, end = _MARKET_HOURS['hk'] + else: + # A股(含ETF、科创板) + start, end = _MARKET_HOURS['ashare'] + return start <= t <= end +try: + from urllib.request import Request, urlopen +except ImportError: + from urllib2 import Request, urlopen +# 6维评分系统 +sys.path.insert(0, "/home/hmo/MoFin/scripts") +from stock_scorer import score_future_outlook, is_hk_stock, settlement_delay_note + +# ── 趋势检查 ──────────────────────────────────────────────────── +def fetch_trend_data(code): + """取均线数据判断趋势状态。价格从 DB 读取(price_monitor 唯一入口)。返回 (current_price, ma5, trend_label) 或 None""" + # 价格从 DB 读取,不再自拉腾讯 API + current = 0 + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() + if not row: + row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code,)).fetchone() + if not row: + row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (code,)).fetchone() + if row: + current = row['price'] or 0 + db.close() + except Exception: + pass + + if current <= 0: + return None + + # K线数据仍从腾讯取(均线计算需要历史K线,DB 里 stock_daily 表有但不一定有最新数据) + try: + prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,30,qfq" + req = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + resp = urlopen(req, timeout=5).read().decode('utf-8') + data = json.loads(resp) + day_key = 'qfqday' if prefix != 'hk' else 'day' + bars = data.get('data', {}).get(f'{prefix}{code}', {}).get(day_key, []) + except: + return None + + if not bars or current <= 0: + return None + closes = [float(b[2]) for b in bars] + if len(closes) < 5: + return None + + def ma(n): + return sum(closes[-n:]) / n + ma5 = ma(5) + ma10 = ma(10) if len(closes) >= 10 else None + ma20 = ma(20) if len(closes) >= 20 else None + + # 趋势分析 + pct_above_ma5 = (current - ma5) / ma5 * 100 + uptrend = False + + if ma20 and ma10: + if ma5 > ma10 > ma20: + trend_label = "多头排列" + uptrend = True + elif current < ma5 and ma5 < ma10 and current < ma10: + trend_label = "空头排列" + elif current > ma5 and ma5 > ma10: + trend_label = "短期转强" + uptrend = True + else: + trend_label = "震荡" + if current > ma5 > ma10: + uptrend = True + else: + trend_label = "数据不足" + + return { + 'price': current, + 'ma5': round(ma5, 2), + 'ma10': round(ma10, 2) if ma10 else None, + 'ma20': round(ma20, 2) if ma20 else None, + 'pct_above_ma5': round(pct_above_ma5, 1), + 'trend': trend_label, + 'uptrend': uptrend, + } + +# ── XMPP +XMPP_BRIDGE = "http://127.0.0.1:5805/" +XMPP_USER = "hmo@yoin.fun" + +STALENESS_REPORT = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" +DETECTOR = "/home/hmo/.hermes/profiles/position-analyst/scripts/stale_detector.py" +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" +REGEN_SCRIPT = "/home/hmo/.hermes/profiles/position-analyst/scripts/per_stock_reassess.py" +REGEN_LOCK = "/tmp/.stale_push_wlin_regen.lock" +MACRO_CTX = "/home/hmo/web-dashboard/data/macro_context.json" +MARKET_JSON = "/home/hmo/web-dashboard/data/market.json" +COOLDOWN_PATH = "/home/hmo/web-dashboard/data/push_cooldown.json" + +NON_BUY_SIGNALS = ["观望", "弱势持有", "深套持有"] + + +def load_macro_line(): + """加载大盘和市场的简要描述""" + parts = [] + try: + # 优先 DB + import sqlite3 + db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") + row = db.execute( + "SELECT structure FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + db.close() + if row and row[0]: + m = json.loads(row[0]) + else: + raise ValueError("no db data") + overall = m.get("overall", "neutral") + desc = m.get("description", "") + if "bearish" in overall: + parts.append("大盘偏弱") + elif overall == "bullish": + parts.append("大盘偏强") + elif desc: + parts.append(f"大盘{desc}") + except Exception: + try: + with open(MACRO_CTX) as f: + m = json.load(f).get("structure", {}) + overall = m.get("overall", "neutral") + desc = m.get("description", "") + if "bearish" in overall: + parts.append("大盘偏弱") + elif overall == "bullish": + parts.append("大盘偏强") + elif desc: + parts.append(f"大盘{desc}") + except Exception: + pass + try: + with open(MARKET_JSON) as f: + mk = json.load(f) + mood = mk.get("mood", "") + if mood: + parts.append(f"市场{mood}") + except Exception: + pass + return " | ".join(parts) if parts else "" + + +def is_actionable(cur, timing_signal=""): + """检查信号是否可操作。空文本/含非买入关键词 → 不可操作""" + if not cur and not timing_signal: + return False # 空文本默认不安全 + for kw in NON_BUY_SIGNALS: + if cur and kw.lower() in cur.lower(): + return False + if timing_signal and kw.lower() in timing_signal.lower(): + return False + return True + + +def trigger_regen_sync(stock_codes=None): + """同步执行指定个股的重评(等重评完再发报告)""" + if not stock_codes: + return + try: + cmd = ["python3", REGEN_SCRIPT] + stock_codes + subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + print("[REGEN] 重评超时(60s)", file=sys.stderr) + except Exception as e: + print(f"[REGEN] 重评失败: {e}", file=sys.stderr) + + +def load_cash(): + """从 portfolio.json 实时读可用现金(可用 ≈ 实时买力),不硬编码""" + try: + with open(PORTFOLIO_PATH) as f: + data = json.load(f) + if isinstance(data, dict): + # 先读 cash_available(拆分了可用/冻结),fallback 到 cash + return data.get("cash_available", data.get("cash", 0)) + if isinstance(data, list) and len(data) > 1 and isinstance(data[1], dict): + return data[1].get("cash_available", data[1].get("cash", 0)) + return 0 + except Exception: + return 0 + + +_HK_LOT_CACHE = {} + +def hk_lot_size(code): + """从腾讯行情API获取港股实际每手股数(字段[60]),带缓存""" + if code in _HK_LOT_CACHE: + return _HK_LOT_CACHE[code] + try: + url = f"http://qt.gtimg.cn/q=hk{code}" + req = Request(url, headers={"User-Agent": "curl/7.81"}) + with urlopen(req, timeout=5) as r: + text = r.read().decode("gbk") + raw = text.split("=", 1)[1].strip().strip('"').strip(";") + fld = raw.split("~") + lot = int(fld[60]) if len(fld) > 60 and fld[60] else 1000 + _HK_LOT_CACHE[code] = lot + return lot + except Exception: + _HK_LOT_CACHE[code] = 1000 + return 1000 + + +def lot_cost(code, price): + if str(code).startswith("688"): + return 200 * price + elif is_hk_stock(code): + lot = hk_lot_size(code) + rate = get_hk_rate() + return int(lot * price * rate) + else: + return 100 * price + + +def push_to_xmpp(text): + """通过知微 HTTP bridge 推送到老爸私信""" + if not text.strip(): + return + try: + payload = json.dumps({ + "to": XMPP_USER, + "body": text.strip(), + "type": "chat", + }).encode("utf-8") + req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) + urlopen(req, timeout=5) + except Exception as e: + print(f"[XMPP推送失败] {e}", file=sys.stderr) + + +def load_cooldown(): + try: + with open(COOLDOWN_PATH) as f: + return json.load(f) + except Exception: + return {} + + +def save_cooldown(cd): + try: + with open(COOLDOWN_PATH, "w") as f: + json.dump(cd, f, indent=2) + except Exception: + pass + + +def in_cooldown(code, action_type, cooldown_dict, minutes=30): + key = f"{code}_{action_type}" + last = cooldown_dict.get(key, 0) + elapsed = datetime.now().timestamp() - last + return elapsed < minutes * 60, elapsed, key + + +def main(): + r = subprocess.run( + ["python3", DETECTOR], capture_output=True, text=True, timeout=60 + ) + if r.returncode != 0 and r.stderr: + print(f"[stderr] {r.stderr.strip()}", file=sys.stderr) + + wl_lines = [ + l for l in r.stdout.split("\n") + if "[WL_IN]" in l and "[自选]" in l + ] + if not wl_lines: + return 0 + + # 读 stale report + try: + with open(STALENESS_REPORT) as f: + report = json.load(f) + except Exception: + report = {"flagged": []} + code_cur = {i["code"]: i.get("current", "") for i in report.get("flagged", [])} + + # 加载冷却状态 + cooldown = load_cooldown() + now_ts = datetime.now().timestamp() + + # 读 decisions 获取完整策略数据 + code_data = {} + try: + dec = read_decisions() + for e in dec.get("decisions", []): + code_data[e["code"]] = e + except Exception: + pass + + cash = load_cash() + stocks = [] + stale_list = [] + all_candidates = [] # 所有在买入区的自选(stale+non-stale) + + for l in wl_lines: + m = re.match(r'\[WL_IN\](?:\s+\[\w+\])*\s+\[自选\]\s+(\S+)\((\d+)\)', l) + if not m: + continue + name, code = m.group(1), m.group(2) + pm = re.search(r'价(\d+\.\d{2})', l) + if not pm: + continue + price = float(pm.group(1)) + zm = re.search(r'买入([\d.]+)~([\d.]+)', l) + if not zm: + continue + buy_low, buy_high = float(zm.group(1)), float(zm.group(2)) + is_stale = "[STRATEGY_STALE]" in l + cur = code_cur.get(code, "") + + all_candidates.append((name, code, price, buy_low, buy_high, cur, is_stale)) + + if not is_actionable(cur, code_data.get(code, {}).get("timing_signal", "")) or is_stale: + stale_list.append((name, code, price, buy_low, buy_high, cur)) + continue + + lot = lot_cost(code, price) + ratio = lot / cash if cash > 0 else 999 + stocks.append((name, code, price, buy_low, buy_high, lot, ratio)) + + if not stocks and not stale_list: + return 0 + + now = datetime.now().strftime("%H:%M") + lines = [] + + # 市场背景 + macro_line = load_macro_line() + if macro_line: + lines.append(f"【市场背景】{macro_line}") + + # [关键修复: 2026-06-25] 所有预推票先重评,再出报告 + # 不只是 stale 的重评,所有在买入区的自选都先刷新策略,确保推荐不滞后 + to_reassess = list(set(s[1] for s in stocks) | set(s[1] for s in stale_list)) + if to_reassess: + trigger_regen_sync(to_reassess) + # 重评完成,re-read decisions 获取最新策略 + code_data = {} + try: + dec = read_decisions() + for e in dec.get("decisions", []): + code_data[e["code"]] = e + except Exception: + pass + + # 重新过滤:重评后可能有策略变化(止盈/止损/信号变动) + # 重建 stocks 列表,用新数据判断(不再用旧 is_stale 标记,因为已全部重评) + stocks = [] + for (name, code, price, buy_low, buy_high, cur, is_stale) in all_candidates: + # 重评后重新检查 actionability(用新 timing_signal) + sig = code_data.get(code, {}).get("timing_signal", "") + if not is_actionable(cur, sig): + continue + lot = lot_cost(code, price) + ratio = lot / cash if cash > 0 else 999 + stocks.append((name, code, price, buy_low, buy_high, lot, ratio)) + + # 加载portfolio获取持仓信息(A/H去重用) + pf = {"holdings": []} + try: + pf = read_portfolio() + except Exception: + pass + + stocks.sort(key=lambda s: ( + 0 if len(str(s[1])) == 6 else 1, + -code_data.get(s[1], {}).get("rr_ratio", 0) + )) + + # 只展示有清晰操作信号的个股 + # timing_signal 必须是明确操作方向:买入/加仓/观望/关注/信号不充分 + # 行业描述(行业偏弱/行业偏强/大盘变盘等)不是操作信号,一律跳过 + VALID_SIGNALS = {"买入", "加仓", "观望", "关注", "信号不充分"} + SKIP_KEYWORDS = ["等企稳", "信号不充分"] + + actionable = [] + for s in stocks: + sig = code_data.get(s[1], {}).get("timing_signal", "") + if not sig: + continue + # 跳过非操作信号 + if any(kw in sig for kw in SKIP_KEYWORDS): + continue + # 中性信号跳过 + stripped = sig.strip() + if not stripped or stripped.lower() in ("", "neutral", "持有", "深套持有", "弱势持有"): + continue + # 信号必须含买入/加仓才推荐——其他非操作信号跳过 + if not any(kw in sig for kw in ["买入", "加仓"]): + continue + # 趋势检查:必须不是空头排列(价格在MA5以下且MA5= 5: + theo_pct = 25 + elif rr >= 3: + theo_pct = 18 + elif rr >= 2: + theo_pct = 12 + else: + theo_pct = 8 + if "偏弱" in market_factor: + theo_pct = int(theo_pct * 0.8) + elif "偏强" in market_factor: + theo_pct = int(theo_pct * 1.15) + if cat in ("蓝筹", "白马"): + theo_pct = int(theo_pct * 1.2) + elif cat in ("题材", "短线"): + theo_pct = int(theo_pct * 0.6) + elif cat in ("高波动", "成长"): + theo_pct = int(theo_pct * 0.85) + theo_pct = max(5, min(30, theo_pct)) + + # 当前建议仓位:理论占总资产% → 按现金锁死 + ideal_budget = total_assets * theo_pct / 100 + # 可操作N只时,现金分配不超过 available_cash / n * 1.5 + max_use_cash = (available_cash / max(n, 1)) * 1.5 + budget = min(ideal_budget, max_use_cash, available_cash) + lots = int(budget / lot_cost) if lot_cost > 0 else 0 + + if lots == 0 and lot_cost > 0 and budget > lot_cost * 0.8: + # 预算覆盖超过80%的1手金额 → 至少1手(仅差一档) + lots = 1 + + lot_cost_total = lots * lot_cost + if lots == 0: + pct_actual = 0 + elif total_assets > 0: + pct_actual = round(lot_cost_total / total_assets * 100) + else: + pct_actual = 0 + + if lots == 0: + details = f"预算不足1手({budget:,.0f}/{lot_cost:,.0f}元)" + else: + if len(str(code)) == 5: + hk_lot = hk_lot_size(code) + shares = lots * hk_lot + elif code.startswith("688"): + shares = lots * 200 + else: + shares = lots * 100 + details = f"{lots}手({shares}股,{lot_cost_total:,.0f}元)" + + return theo_pct, pct_actual, details, lots, lot_cost_total + + # ── 换仓评估 ────────────────────────────────────────────────────── + # score_future_outlook 从 stock_scorer 模块导入(6维评分) + + def evaluate_swap(lot_cost_target, rr, sig, tp, sl, name, code, price_in, + total_assets_in, cash_in, pf_in, cd_in): + """现金不足时评估是否卖差票换推荐股。 + + 核心逻辑: + - 已发生的亏损是沉没成本,不参与决策 + - 用6维评分法评估每个持仓的未来前景(基于决策系统既有数据) + - 优先卖前景最差的票,保留前景好的票(无论当前盈亏%) + - 卖港股→买A股需T+2到账,如果推荐此方案则标注延迟风险 + - 对目标票(RR>=3+买入信号)才有换仓资格 + + 返回(推荐文案str, 缺口float)或 (None, gap) + """ + gap = lot_cost_target - cash_in + # 目标票质量门槛 + if rr < 3.0 or gap <= 0 or gap > total_assets_in * 0.5: + return None, gap + if not any(kw in sig for kw in ["买入", "加仓", "建仓"]): + return None, gap + + # 收集持仓数据 + 前景评分 + ph = [] + for h in pf_in.get("holdings", []): + hs = h.get("shares", 0) or 0 + hp = h.get("price", 0) or 0 + hc = h.get("cost", 0) or 0 + if hs <= 0 or hp <= 0: + continue + hmv = hs * hp + # 港股价格已是 CNY(price_monitor 写入时已转),不需要再乘汇率 + hpl_pct = (hp - hc) / hc * 100 if hc else 0 + + # 6维全面评分(越低越差,越建议卖) + fscore, _ = score_future_outlook(h_code, cd_in) + + ph.append({ + "code": h_code, + "name": h.get("name", ""), + "shares": hs, + "price": hp, + "cost": hc, + "mv": round(hmv), + "pl_pct": round(hpl_pct, 1), + "score": fscore, + }) + + # 按前景评分升序(最差的排最前面) + ph.sort(key=lambda x: x["score"]) + + # 打印调试信息:所有持仓的前景评分 + # print(f"[SWAP_DEBUG] 前景评分(越低越差):", file=sys.stderr) + # for x in ph[:10]: + # print(f" {x['name']}({x['code']}) 评分{x['score']} 亏{x['pl_pct']}% 市值{x['mv']:,}", file=sys.stderr) + + # 只考虑评分<=0(前景差或中性偏弱)的作为减仓候选 + candidates = [h for h in ph if h["score"] <= 0] + if not candidates: + return None, gap + + # 贪心选评分最差的,凑够现金缺口(最多2只) + selected = [] + cash_freed = 0 + for h in candidates: + if cash_freed >= gap: + break + cash_freed += h["mv"] + selected.append(h) + + if cash_freed < gap or len(selected) > 2: + return None, gap + + # 计算目标票的预期涨幅 + if tp and tp > 0: + target_gain_pct = (tp - price_in) / price_in * 100 + else: + target_gain_pct = rr * 3 + + # 构建推荐文案 + buy_is_a = not is_hk_stock(code) # 目标是否是A股 + sell_parts = [] + sell_names = [] + settlement_warnings = [] + for h in selected: + # 每个被选股票配一句"为什么卖它" + reason = f"评分{h['score']}" + if h['pl_pct'] <= -30: + reason += "深套" + elif h['pl_pct'] <= -15: + reason += f"亏损{h['pl_pct']}%" + sell_parts.append(f"{h['name']}({h['code']}) {h['shares']}股 亏{h['pl_pct']}% ({reason})") + sell_names.append(h['name']) + # 检查结算延迟:卖港股→买A股 + if is_hk_stock(h['code']) and buy_is_a: + settlement_warnings.append(f"{h['name']}是港股通,卖出需T+2到账才能买A股") + sell_desc = ";".join(sell_parts) + + new_budget = cash_in + cash_freed + new_lots = int(new_budget / lot_cost_target) if lot_cost_target > 0 else 0 + if new_lots == 0: + return None, gap + if code.startswith("688"): + new_shares = new_lots * 200 + elif len(code) <= 5: + new_shares = new_lots * hk_lot_size(code) + else: + new_shares = new_lots * 100 + new_cost = new_lots * lot_cost_target + new_pct = round(new_cost / total_assets_in * 100) if total_assets_in > 0 else 0 + + text = ( + f"换仓建议:卖{sell_desc}" + f"→腾{round(cash_freed):,}元" + f"→买{name}({code}) {new_lots}手({new_shares}股,{round(new_cost):,}元)" + f"占{new_pct}%仓位" + f"(止损{sl}(-{round((price_in-sl)/price_in*100,1)}%)" + f"止盈{tp}(+{round(target_gain_pct,1)}%)" + f" RR={rr})\n" + f" 理由:{', '.join(sell_names)}评分最低," + f"继续持有无积极信号且技术偏弱;" + f"换到有明确信号和止损的标的,预期收益更优。" + ) + if settlement_warnings: + text += "\n ⚠️ " + " | ".join(settlement_warnings) + return text, gap + + # 标准格式:每个可操作标的 — 大盘/行业/个股三面 + 仓位 + lines.append(f"【💡 操作建议】(当前{n}只自选可操作 | 总资产{total_assets:,.0f}元 现金{available_cash:,.0f}元)") + for s in actionable: + name, code, price, buy_low, buy_high, lot, ratio = s + d = code_data.get(code, {}) + sl = d.get("stop_loss", 0) + tp = d.get("take_profit", 0) + rr = d.get("rr_ratio", 0) + sig = d.get("timing_signal", "") + sector = d.get("sector_context", "") + tech = d.get("tech_snapshot", "") + mtf_ctx = d.get("multi_tf_context", "") + note = d.get("note", "") + d_factors = d.get("signal_factors", []) + cat = d.get("stock_category", "") + + # 提取技术位 + ss = {"强撑":"-", "弱撑":"-", "弱压":"-", "强压":"-"} + for tag in ss: + m = re.search(rf'{tag}:([\d.]+)', tech) + if m: + ss[tag] = m.group(1) + + # 基本面 + fund = fund_cache.get(code, {}) + pe = fund.get("pe", 0) + eps = fund.get("eps", 0) + pe_str = f"PE{pe:.0f}" if pe else "" + eps_str = f"EPS{eps:.2f}" if eps else "" + + # 从 signal_factors 提取各维度 + def _match_factor(prefix): + for f in d_factors: + if f.startswith(prefix): + return f + return "" + + market_factor = _match_factor("大盘") + sector_factor = _match_factor("行业") + value_factor = _match_factor("高估值") or _match_factor("低估值") or _match_factor("蓝筹") or pe_str or "" + news_factor = _match_factor("消息") + tech_factor = _match_factor("净利") or _match_factor("组合") or "" + + # 构建分析行 + parts = [] + if market_factor: + parts.append(f"大盘{market_factor.replace('大盘','')}") + if sector_factor: + parts.append(f"行业{sector_factor.replace('行业','')}") + if pe_str or value_factor: + parts.append(value_factor or pe_str) + if news_factor: + parts.append(news_factor) + if not parts: + parts.append(sector or cat or "") + + analysis = " | ".join(p for p in parts if p) + + # 仓位计算 + theo_pct, actual_pct, details, lots, lot_cost_total = calc_position( + lot, rr, market_factor, cat, code + ) + + pfx = "" if len(code) == 6 else "HK$" + + # 取分支动作类型 + branch_action = "hold" + branch_rationale = "" + if st and scenario_id: + try: + results = st.evaluate_branches(code, scenario_id, price, d.get("shares", 0), d.get("cost", 0)) + applicable = [r for r in results if r.get("applicable")] + if applicable: + best = min(applicable, key=lambda r: r.get("priority", 999)) + branch_action = best.get("action_type", "hold") + branch_rationale = best.get("rationale", "") + except Exception: + pass + + # 冷却检查:相同股+相同操作30分钟内不发 + cooled, elapsed, cd_key = in_cooldown(code, branch_action, cooldown) + if cooled: + continue + + # 策略质量过滤:只有正向/中性信号才推操作建议 + bad_keywords = ["偏弱", "弱势", "观望", "卖出", "回避", "回避"] + if any(kw in sig for kw in bad_keywords): + continue + + # 行业背景过滤:行业大跌时不在买入区推荐(即使个股信号好) + if "大跌" in sector: + continue + + # 换仓评估:现金不足时评估是否卖差票换推荐股 + swap_text = None + if lots == 0: + swap_text, _ = evaluate_swap( + lot, rr, sig, tp, sl, name, code, price, + total_assets, available_cash, pf, code_data + ) + + action_tag = "🛒" if (lots > 0 or swap_text) else "⚠️" + + lines.append( + f" {action_tag} {name}({code}) {pfx}{price:.2f} 买区{buy_low}~{buy_high} | " + f"1手{lot:,.0f}元 RR={rr:.1f} 损{sl} 盈{tp}\n" + f" {analysis}\n" + f" 技术{ss['强撑']}→{ss['弱撑']}→{ss['弱压']}→{ss['强压']} | 信号{sig}\n" + f" 仓位:理论{theo_pct}%×总资产 | 建议{actual_pct}%({details})" + ) + + if mtf_ctx: + lines[-1] += f"\n 均线{mtf_ctx}" + + if swap_text: + lines[-1] += f"\n {swap_text}" + + # 分支描述 + branch_line = "" + if branch_action != "hold": + branch_line = f" 【{scenario_label}→{branch_action}】{branch_rationale}" + if branch_line: + lines[-1] += f"\n{branch_line}" + + # 记录推送时间(冷却计时用) + cooldown[cd_key] = now_ts + + save_cooldown(cooldown) + + # 修正可操作数量(剔除冷却跳过后的实际数量) + actual_n = len(lines) - (1 if macro_line else 0) - 1 # 减去市场背景 + 操作建议标题 + if actual_n != n: + # 更新操作建议行 + for i, ln in enumerate(lines): + if "【💡 操作建议】" in ln: + lines[i] = f"【💡 操作建议】(当前{actual_n}只自选可操作 | 总资产{total_assets:,.0f}元 现金{available_cash:,.0f}元)" + break + + if actual_n <= 0: + return 0 # 全部冷却中 → 静默,不推 + + # ── T+2前瞻:扫描近期可能入买区的A股,提前准备现金 ── + t2_lines = [] + try: + dec_t2 = read_decisions() + for entry in dec_t2.get("decisions", []): + if entry.get("status") == "closed" or entry.get("type") != "自选策略": + continue + ec = entry["code"] + el = entry.get("entry_low", 0) or 0 + eh = entry.get("entry_high", 0) or 0 + ep = entry.get("price", 0) or 0 + if not eh or not ep or el <= 0: + continue + # A股+价格在买入区上方5%以内(即将进入买入区) + if not is_hk_stock(ec) and el <= ep <= eh * 1.05 and ep > eh: + anticipation_pct = (ep - eh) / eh * 100 + lot = lot_cost(ec, ep) + if lot > available_cash: + # 现金不足 → 卖港股提前准备 + ph = [] + for h in pf.get("holdings", []): + hs = h.get("shares", 0) or 0 + hp = h.get("price", 0) or 0 + hc = h.get("cost", 0) or 0 + if hs <= 0 or hp <= 0 or not is_hk_stock(h.get("code","")): + continue + sc = score_future_outlook(h.get("code",""), code_data) + ph.append((sc, h)) + ph.sort(key=lambda x: x[0]) + if ph: + worst = ph[0][1] + w_name = worst.get("name","?") + w_code = worst.get("code","") + w_price = worst.get("price",0) + w_shares = worst.get("shares",0) + w_value = w_price * w_shares + if w_value >= lot: + name_e = entry.get("name","") + t2_lines.append( + f" ⏳ {name_e}({ec})距买入区仅{anticipation_pct:.0f}%," + f"需{lot:,.0f}元。建议提前卖{w_name}({w_code})" + f"腾{w_value:,.0f}元(T+2到账后可用)" + ) + except: + pass + + if t2_lines: + lines.append("") + lines.append("【⏳ 提前准备(T+2港股提前出清)】") + lines.extend(t2_lines) + + lines.insert(0, f"【知微】自选买入提醒 {now} | 总资产{total_assets:,.0f}元") + out = "\n".join(lines) + print(out) + push_to_xmpp(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/strategy_lifecycle.py b/strategy_lifecycle.py index c475b3f..81c839e 100644 --- a/strategy_lifecycle.py +++ b/strategy_lifecycle.py @@ -84,11 +84,11 @@ STRATEGY_QUALITY_GATES = [ "fix": "tech_snapshot 包含强撑/弱撑/弱压/强压至少3个数值" }, { - "id": "GATE_CURRENCY_SET", - "desc": "港股决策金额应为人民币标价(currency=CNY)", - "check": lambda d: not is_hk_stock(d.get("code","")) or d.get("currency") in ("CNY", None), - "severity": "MEDIUM", - "fix": "设置 d['currency']='CNY'(系统统一存CNY)" + "id": "GATE_CURRENCY", + "severity": "CRITICAL", + "desc": "港股决策金额应为港币标价(currency=HKD),A股为CNY", + "check": lambda d: d.get("currency") in ("HKD", "CNY") or not d.get("code"), + "fix": "设置 d['currency']='HKD' for HK stocks" }, # --- 第4条 CRITICAL 红线:9维交叉验证 (2026-07-02 Dad要求) --- # 策略不能只有价格数字,必须有证据经过了多维分析: @@ -2293,10 +2293,10 @@ def regenerate_all(stdout=True): sector_ctx_str = f"大盘上涨比{market_breadth}%" new_entry = { "code": code, "name": name, "price": price, - "cost": old_entry.get("cost", cost) if old_entry else cost, # 优先保留旧成本(holding.xls权威) - "shares": shares, # 当前实际持仓股数(不继承旧决策的可能为0的值) - "avg_price": old_entry.get("avg_price", 0), # 保留持仓均价 - "currency": "CNY", # 系统统一存人民币标价(mo_models规范) + "cost": old_entry.get("cost", cost) if old_entry else cost, + "shares": shares, + "avg_price": old_entry.get("avg_price", 0), + "currency": "HKD" if is_hk_stock(code) else "CNY", "action": result["action"], "stop_loss": result.get("stop_loss"), "entry_low": result["entry_low"], @@ -2526,7 +2526,7 @@ def regenerate_all(stdout=True): write_holdings_batch(conn, existing_pf.get('holdings', [])) write_portfolio_summary(conn, existing_pf) for s in wl.get('stocks', []): - s.setdefault('currency', 'CNY') + s.setdefault('currency', 'HKD') if is_hk_stock(str(s.get('code',''))) else s.setdefault('currency', 'CNY') write_watchlist_stock(conn, s) for d in decisions: # ── 策略质量门禁 ──