diff --git a/__pycache__/strategy_lifecycle.cpython-312.pyc b/__pycache__/strategy_lifecycle.cpython-312.pyc index 1355dc7..bda4403 100644 Binary files a/__pycache__/strategy_lifecycle.cpython-312.pyc and b/__pycache__/strategy_lifecycle.cpython-312.pyc differ diff --git a/data/candidate_pool.json b/data/candidate_pool.json index 2ad62c8..8a2d11a 100644 --- a/data/candidate_pool.json +++ b/data/candidate_pool.json @@ -1,6 +1,6 @@ { - "last_updated": "2026-06-30 11:11", - "total_candidates": 3, + "last_updated": "2026-07-03 11:32", + "total_candidates": 5, "sectors_analyzed_today": [ "半导体", "金属新材料", @@ -439,6 +439,70 @@ "drop_reason": null, "trend_warning": false, "trend_note": "" + }, + { + "code": "300699", + "name": "光威复材", + "sector": "军工装备", + "xiaoguo_score": 8.5, + "xiaoguo_reason": "碳纤维核心供应商,与中简科技同属新材料主线,业绩确定性强,机构持仓集中。技术面放量突破震荡平台,量价配合健康。", + "xiaoguo_strategy": { + "entry_range": "38.5-40.2元", + "stop_loss": "36.8元", + "target": "45.0-48.0元" + }, + "verified_price": 30.28, + "verified_change": 6.62, + "added_at": "2026-07-03 11:10", + "last_updated": "2026-07-03 11:10", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-03 11:10", + "score": 8.5 + } + ], + "zhiwei_star": null, + "zhiwei_reviewed": false, + "zhiwei_reviewed_at": null, + "promoted": false, + "promoted_at": null, + "dropped": false, + "drop_reason": null, + "trend_warning": false, + "trend_note": "" + }, + { + "code": "600760", + "name": "中航沈飞", + "sector": "军工装备", + "xiaoguo_score": 8.0, + "xiaoguo_reason": "歼击机核心总装平台,订单饱满且估值处于历史中低位。板块情绪升温时具备高弹性,适合作为稳健底仓。", + "xiaoguo_strategy": { + "entry_range": "32.0-33.5元", + "stop_loss": "30.5元", + "target": "36.5-38.0元" + }, + "verified_price": 41.39, + "verified_change": 1.5, + "added_at": "2026-07-03 11:10", + "last_updated": "2026-07-03 11:10", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-03 11:10", + "score": 8.0 + } + ], + "zhiwei_star": null, + "zhiwei_reviewed": false, + "zhiwei_reviewed_at": null, + "promoted": false, + "promoted_at": null, + "dropped": false, + "drop_reason": null, + "trend_warning": false, + "trend_note": "" } ] } \ No newline at end of file diff --git a/data/decisions.json b/data/decisions.json index 86b2838..4edb796 100644 --- a/data/decisions.json +++ b/data/decisions.json @@ -3,21 +3,22 @@ { "code": "000657", "name": "中钨高新", - "price": 88.5, + "price": 91.88, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | 目标104.56 | 止损85.44 | 买入区86.73~90.27", - "stop_loss": 85.44, - "entry_low": 86.73, - "entry_high": 90.27, - "tech_snapshot": "形态:光头光脚阴线/bearish 量价:主动买盘占优 强撑:77.4 弱撑:85.44 弱压:97.03 强压:104.56 | MA5=96.75 MA10=99.88 MA20=89.04 MA60=68.13", + "currency": "CNY", + "action": "盈利持有 | 目标105.72 | 止损87.66 | 买入区90.04~93.72", + "stop_loss": 87.66, + "entry_low": 90.04, + "entry_high": 93.72, + "tech_snapshot": "形态:带下影阴线/neutral 量价:主动买盘占优 强撑:87.33 弱撑:87.66 弱压:97.03 强压:105.72 | MA5=96.75 MA10=99.88 MA20=89.04 MA60=68.13", "timing_signal": "信号不充分", - "rr_ratio": 5.25, + "rr_ratio": 3.28, "status": "updated", "note": "", - "timestamp": "2026-07-03 10:14", - "updated_at": "2026-07-03 10:14", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "自选策略", "source": "auto", "sector_context": "", @@ -25,18 +26,18 @@ "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 85.44, - "entry_zone": "86.73~90.27", - "take_profit_zone": "0~104.56" + "stop_loss": 87.66, + "entry_zone": "90.04~93.72", + "take_profit_zone": "0~105.72" }, - "created_at": "2026-07-03 10:14", - "take_profit": 104.56, + "created_at": "2026-07-03 12:06", + "take_profit": 105.72, "changelog": [ { - "date": "2026-07-03 10:14", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "盈利持有 | 目标104.56 | 止损85.44 | 买入区86.73~90.27", - "reason": "初始策略创建 | 形态:光头光脚阴线/bearish 量价:主动买盘占优 强撑:77.4 弱撑:85.44 弱压:97.03 强压:104", + "new_action": "盈利持有 | 目标105.72 | 止损87.66 | 买入区90.04~93.72", + "reason": "初始策略创建 | 形态:带下影阴线/neutral 量价:主动买盘占优 强撑:87.33 弱撑:87.66 弱压:97.03 强压:105", "trigger": "初始创建" } ] @@ -44,38 +45,39 @@ { "code": "000700", "name": "模塑科技", - "price": 17.31, + "price": 17.37, "cost": 14.83, - "shares": 0, + "shares": 1400, "avg_price": 0, - "action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损16.79 | 目标0 | 买入区15.58~18.18 | 信号:持有", - "stop_loss": 16.79, - "entry_low": 15.58, - "entry_high": 18.18, + "currency": "CNY", + "action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损16.85 | 目标0 | 买入区15.63~18.24 | 信号:持有", + "stop_loss": 16.85, + "entry_low": 15.63, + "entry_high": 18.24, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=14.99 MA10=14.9 MA20=15.45 MA60=13.81", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:14", - "updated_at": "2026-07-03 10:14", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业汽车零部件大涨+3.7%,可适度积极", + "sector_context": "行业汽车零部件大涨+4.0%,可适度积极", "stock_category": "中短线", "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 16.79, - "entry_zone": "15.58~18.18" + "stop_loss": 16.85, + "entry_zone": "15.63~18.24" }, - "created_at": "2026-07-03 10:14", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:14", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损16.79 | 目标0 | 买入区15.58~18.18 | 信号:持有", + "new_action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损16.85 | 目标0 | 买入区15.63~18.24 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -84,39 +86,40 @@ { "code": "000711", "name": "ST京蓝", - "price": 5.25, + "price": 5.26, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标5.63 | 止损5.09 | 买入区5.14~5.26 | 信号:观望", - "stop_loss": 5.09, - "entry_low": 5.14, + "currency": "CNY", + "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标5.63 | 止损5.01 | 买入区5.15~5.26 | 信号:观望", + "stop_loss": 5.01, + "entry_low": 5.15, "entry_high": 5.26, "tech_snapshot": "形态:带下影阳线/bullish 量价:主动卖盘占优 强撑:4.79 弱撑:5.01 弱压:5.31 强压:5.63 | MA5=4.64 MA10=4.87 MA20=5.25 MA60=5.15", "timing_signal": "观望", - "rr_ratio": 1.58, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.6),谨慎买入", - "timestamp": "2026-07-03 10:14", - "updated_at": "2026-07-03 10:14", + "rr_ratio": 1.48, + "status": "review", + "note": "⚠️盈亏比不足1:1.5,不建议买入", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "自选策略", "source": "auto", - "sector_context": "行业房地产上涨+1.2%,正常", + "sector_context": "行业房地产+0.7%,中性", "stock_category": "弱势", "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 5.09, - "entry_zone": "5.14~5.26", + "stop_loss": 5.01, + "entry_zone": "5.15~5.26", "take_profit_zone": "0~5.63" }, - "created_at": "2026-07-03 10:14", + "created_at": "2026-07-03 12:06", "take_profit": 5.63, "changelog": [ { - "date": "2026-07-03 10:14", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标5.63 | 止损5.09 | 买入区5.14~5.26 | 信号:观望", + "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标5.63 | 止损5.01 | 买入区5.15~5.26 | 信号:观望", "reason": "初始策略创建 | 形态:带下影阳线/bullish 量价:主动卖盘占优 强撑:4.79 弱撑:5.01 弱压:5.31 强压:5.63 |", "trigger": "初始创建" } @@ -125,40 +128,41 @@ { "code": "001309", "name": "德明利", - "price": 835.0, + "price": 874.23, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.8),谨慎买入 | 目标892.1 | 止损803.09 | 买入区818.3~838.69 | 信号:买入", - "stop_loss": 803.09, - "entry_low": 818.3, - "entry_high": 838.69, - "tech_snapshot": "形态:光头光脚阳线/bullish 量价:主动买盘占优 强撑:748.49 弱撑:803.09 弱压:857.64 强压:892.1 | MA5=913.62 MA10=847.32 MA20=744.16 MA60=628.45", - "timing_signal": "买入", - "rr_ratio": 1.79, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.8),谨慎买入", - "timestamp": "2026-07-03 10:14", - "updated_at": "2026-07-03 10:14", + "currency": "CNY", + "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标892.1 | 止损811.0 | 买入区856.75~843.44 | 信号:买入", + "stop_loss": 811.0, + "entry_low": 856.75, + "entry_high": 843.44, + "tech_snapshot": "形态:光头光脚阳线/bullish 量价:主动买盘占优 强撑:763.08 弱撑:811.0 弱压:906.15 强压:892.1 | MA5=913.62 MA10=847.32 MA20=744.16 MA60=628.45", + "timing_signal": "信号不充分", + "rr_ratio": 0.28, + "status": "review", + "note": "⚠️盈亏比不足1:1.5,不建议买入", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "自选策略", "source": "auto", - "sector_context": "行业半导体-1.0%,中性", + "sector_context": "行业半导体-0.9%,中性", "stock_category": "中短线", "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 803.09, - "entry_zone": "818.3~838.69", + "stop_loss": 811.0, + "entry_zone": "856.75~843.44", "take_profit_zone": "0~892.1" }, - "created_at": "2026-07-03 10:14", + "created_at": "2026-07-03 12:06", "take_profit": 892.1, "changelog": [ { - "date": "2026-07-03 10:14", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.8),谨慎买入 | 目标892.1 | 止损803.09 | 买入区818.3~838.69 | 信号:买入", - "reason": "初始策略创建 | 形态:光头光脚阳线/bullish 量价:主动买盘占优 强撑:748.49 弱撑:803.09 弱压:857.64 强压", + "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标892.1 | 止损811.0 | 买入区856.75~843.44 | 信号:买入", + "reason": "初始策略创建 | 形态:光头光脚阳线/bullish 量价:主动买盘占优 强撑:763.08 弱撑:811.0 弱压:906.15 强压:", "trigger": "初始创建" } ] @@ -166,40 +170,41 @@ { "code": "002594", "name": "比亚迪", - "price": 86.9, + "price": 86.56, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.5),谨慎买入 | 目标91.93 | 止损84.29 | 买入区85.16~86.91 | 信号:观望", - "stop_loss": 84.29, - "entry_low": 85.16, + "currency": "CNY", + "action": "盈利持有 | ⚠️盈亏比偏低(1:1.8),谨慎买入 | 目标91.93 | 止损83.96 | 买入区84.83~86.91 | 信号:观望", + "stop_loss": 83.96, + "entry_low": 84.83, "entry_high": 86.91, - "tech_snapshot": "形态:光头光脚阳线/bullish 量价:主动卖盘占优 强撑:78.97 弱撑:83.57 弱压:88.24 强压:91.93 | MA5=98.63 MA10=97.78 MA20=96.0 MA60=96.51", + "tech_snapshot": "形态:光头光脚阳线/bullish 量价:主动卖盘占优 强撑:78.89 弱撑:83.57 弱压:88.03 强压:91.93 | MA5=98.63 MA10=97.78 MA20=96.0 MA60=96.51", "timing_signal": "观望", - "rr_ratio": 1.51, + "rr_ratio": 1.8, "status": "updated", - "note": "⚠️盈亏比偏低(1:1.5),谨慎买入", - "timestamp": "2026-07-03 10:14", - "updated_at": "2026-07-03 10:14", + "note": "⚠️盈亏比偏低(1:1.8),谨慎买入", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "自选策略", "source": "auto", - "sector_context": "行业汽车整车上涨+1.7%,正常", + "sector_context": "行业汽车整车上涨+1.6%,正常", "stock_category": "弱势", "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 84.29, - "entry_zone": "85.16~86.91", + "stop_loss": 83.96, + "entry_zone": "84.83~86.91", "take_profit_zone": "0~91.93" }, - "created_at": "2026-07-03 10:14", + "created_at": "2026-07-03 12:06", "take_profit": 91.93, "changelog": [ { - "date": "2026-07-03 10:14", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.5),谨慎买入 | 目标91.93 | 止损84.29 | 买入区85.16~86.91 | 信号:观望", - "reason": "初始策略创建 | 形态:光头光脚阳线/bullish 量价:主动卖盘占优 强撑:78.97 弱撑:83.57 弱压:88.24 强压:91", + "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.8),谨慎买入 | 目标91.93 | 止损83.96 | 买入区84.83~86.91 | 信号:观望", + "reason": "初始策略创建 | 形态:光头光脚阳线/bullish 量价:主动卖盘占优 强撑:78.89 弱撑:83.57 弱压:88.03 强压:91", "trigger": "初始创建" } ] @@ -207,200 +212,121 @@ { "code": "00700", "name": "腾讯", - "price": 443.8, + "price": 437.4, "cost": 0, - "shares": 0, + "shares": 100, "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 损426.05 | 盈0 | 买434.92~452.68", - "stop_loss": 430.49, - "entry_low": 434.92, - "entry_high": 452.68, + "currency": "CNY", + "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 损419.9 | 盈0 | 买428.65~446.15", + "stop_loss": 424.28, + "entry_low": 428.65, + "entry_high": 446.15, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=624.1 MA10=623.7 MA20=615.5 MA60=559.52", "timing_signal": "信号不充分", "rr_ratio": 0.0, "status": "review", "note": "⚠️盈亏比不足1:1.5,不建议买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业软件开发+0.9%,中性", + "sector_context": "行业软件开发+0.4%,中性", "stock_category": "弱势", "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 430.49, - "entry_zone": "434.92~452.68" + "stop_loss": 424.28, + "entry_zone": "428.65~446.15" }, - "created_at": "2026-07-03 10:15", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 损426.05 | 盈0 | 买434.92~452.68", + "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 损419.9 | 盈0 | 买428.65~446.15", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } ] }, - { - "code": "00968", - "name": "信义光能", - "price": 2.12, - "cost": 0, - "shares": 0, - "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标2.22 | 止损2.04 | 买入区2.08~2.09 | 信号:弱势持有", - "stop_loss": 2.04, - "entry_low": 2.08, - "entry_high": 2.09, - "tech_snapshot": "形态:光头光脚阳线/bullish 量价:数据不足 强撑:1.98 弱撑:2.07 弱压:2.14 强压:2.22 | MA5=3.16 MA10=3.16 MA20=3.24 MA60=3.33", - "timing_signal": "弱势持有", - "rr_ratio": 0.83, - "status": "review", - "note": "⚠️盈亏比不足1:1.5,不建议买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", - "type": "自选策略", - "source": "auto", - "sector_context": "行业电力-0.3%,中性", - "stock_category": "弱势", - "position_advice": "减仓或观望", - "time_horizon": "观望", - "trigger": { - "stop_loss": 2.04, - "entry_zone": "2.08~2.09", - "take_profit_zone": "0~2.22" - }, - "created_at": "2026-07-03 10:15", - "take_profit": 2.22, - "changelog": [ - { - "date": "2026-07-03 10:15", - "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标2.22 | 止损2.04 | 买入区2.08~2.09 | 信号:弱势持有", - "reason": "初始策略创建 | 形态:光头光脚阳线/bullish 量价:数据不足 强撑:1.98 弱撑:2.07 弱压:2.14 强压:2.22 | ", - "trigger": "初始创建" - } - ] - }, { "code": "00981", "name": "中芯国际", - "price": 78.85, - "cost": 75.94, - "shares": 0, + "price": 79.8, + "cost": 65.84, + "shares": 500, "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比极低,关注 | 止损73.55 | 目标0 | 买入区70.97~82.79 | 信号:持有", - "stop_loss": 73.55, - "entry_low": 70.97, - "entry_high": 82.79, + "currency": "CNY", + "action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损77.41 | 目标0 | 买入区71.82~83.79 | 信号:持有", + "stop_loss": 77.41, + "entry_low": 71.82, + "entry_high": 83.79, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=84.12 MA10=81.44 MA20=78.5 MA60=71.93", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业半导体-1.0%,中性", + "sector_context": "行业半导体-0.9%,中性", "stock_category": "中短线", "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 73.55, - "entry_zone": "70.97~82.79" + "stop_loss": 77.41, + "entry_zone": "71.82~83.79" }, - "created_at": "2026-07-03 10:15", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比极低,关注 | 止损73.55 | 目标0 | 买入区70.97~82.79 | 信号:持有", + "new_action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损77.41 | 目标0 | 买入区71.82~83.79 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } ] }, - { - "code": "01070", - "name": "TCL电子", - "price": 13.12, - "cost": 0, - "shares": 0, - "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标14.1 | 止损12.73 | 买入区12.86~13.13 | 信号:弱势持有", - "stop_loss": 12.73, - "entry_low": 12.86, - "entry_high": 13.13, - "tech_snapshot": "形态:光头光脚阳线/bullish 量价:数据不足 强撑:12.0 弱撑:12.83 弱压:13.26 强压:14.1 | MA5=12.78 MA10=13.12 MA20=13.6 MA60=14.33", - "timing_signal": "弱势持有", - "rr_ratio": 1.56, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.6),谨慎买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", - "type": "自选策略", - "source": "auto", - "sector_context": "行业消费电子上涨+2.2%,正常", - "stock_category": "弱势", - "position_advice": "减仓或观望", - "time_horizon": "观望", - "trigger": { - "stop_loss": 12.73, - "entry_zone": "12.86~13.13", - "take_profit_zone": "0~14.1" - }, - "created_at": "2026-07-03 10:15", - "take_profit": 14.1, - "changelog": [ - { - "date": "2026-07-03 10:15", - "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标14.1 | 止损12.73 | 买入区12.86~13.13 | 信号:弱势持有", - "reason": "初始策略创建 | 形态:光头光脚阳线/bullish 量价:数据不足 强撑:12.0 弱撑:12.83 弱压:13.26 强压:14.1 ", - "trigger": "初始创建" - } - ] - }, { "code": "01088", "name": "中国神华", - "price": 39.92, - "cost": 45.89, - "shares": 0, + "price": 40.22, + "cost": 39.79, + "shares": 500, "avg_price": 0, - "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损37.24 | 目标0 | 买入区35.93~41.92 | 信号:持有", - "stop_loss": 37.24, - "entry_low": 35.93, - "entry_high": 41.92, + "currency": "CNY", + "action": "盈利持有 | ⚠️盈亏比极低,关注 | 止损37.51 | 目标0 | 买入区36.2~42.23 | 信号:持有", + "stop_loss": 37.51, + "entry_low": 36.2, + "entry_high": 42.23, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=39.82 MA10=40.55 MA20=41.11 MA60=43.93", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业煤炭开采加工+0.5%,中性", + "sector_context": "行业煤炭开采加工+1.0%,中性", "stock_category": "弱势", "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 37.24, - "entry_zone": "35.93~41.92" + "stop_loss": 37.51, + "entry_zone": "36.2~42.23" }, - "created_at": "2026-07-03 10:15", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损37.24 | 目标0 | 买入区35.93~41.92 | 信号:持有", + "new_action": "盈利持有 | ⚠️盈亏比极低,关注 | 止损37.51 | 目标0 | 买入区36.2~42.23 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -409,38 +335,39 @@ { "code": "01211", "name": "比亚迪股份", - "price": 82.55, - "cost": 104.87, - "shares": 0, + "price": 82.6, + "cost": 90.92, + "shares": 600, "avg_price": 0, - "action": "深套持有 | 深套持有 | 止损75.87 | 目标0 | 买入区74.3~86.68 | 信号:持有", - "stop_loss": 75.87, - "entry_low": 74.3, - "entry_high": 86.68, + "currency": "CNY", + "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损78.59 | 目标0 | 买入区74.34~86.73 | 信号:持有", + "stop_loss": 78.59, + "entry_low": 74.34, + "entry_high": 86.73, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=96.74 MA10=96.79 MA20=98.23 MA60=98.39", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", - "note": "深套持有", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "note": "⚠️盈亏比极低,关注", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业汽车整车上涨+1.7%,正常", - "stock_category": "深套", - "position_advice": "不补不割", - "time_horizon": "长期", + "sector_context": "行业汽车整车上涨+1.6%,正常", + "stock_category": "弱势", + "position_advice": "减仓或观望", + "time_horizon": "观望", "trigger": { - "stop_loss": 75.87, - "entry_zone": "74.3~86.68" + "stop_loss": 78.59, + "entry_zone": "74.34~86.73" }, - "created_at": "2026-07-03 10:15", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "深套持有 | 深套持有 | 止损75.87 | 目标0 | 买入区74.3~86.68 | 信号:持有", + "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损78.59 | 目标0 | 买入区74.34~86.73 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -449,38 +376,39 @@ { "code": "01478", "name": "丘钛科技", - "price": 6.96, - "cost": 13.47, - "shares": 0, + "price": 7.05, + "cost": 11.68, + "shares": 11000, "avg_price": 0, - "action": "深套持有 | 深套持有 | 止损6.41 | 目标0 | 买入区6.26~7.31 | 信号:持有", - "stop_loss": 6.41, - "entry_low": 6.26, - "entry_high": 7.31, + "currency": "CNY", + "action": "深套持有 | 深套持有 | 止损6.48 | 目标0 | 买入区6.34~7.4 | 信号:持有", + "stop_loss": 6.48, + "entry_low": 6.34, + "entry_high": 7.4, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=9.11 MA10=9.29 MA20=9.5 MA60=8.86", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "深套持有", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业消费电子上涨+2.2%,正常", + "sector_context": "行业消费电子上涨+2.5%,正常", "stock_category": "深套", "position_advice": "不补不割", "time_horizon": "长期", "trigger": { - "stop_loss": 6.41, - "entry_zone": "6.26~7.31" + "stop_loss": 6.48, + "entry_zone": "6.34~7.4" }, - "created_at": "2026-07-03 10:15", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "深套持有 | 深套持有 | 止损6.41 | 目标0 | 买入区6.26~7.31 | 信号:持有", + "new_action": "深套持有 | 深套持有 | 止损6.48 | 目标0 | 买入区6.34~7.4 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -489,38 +417,39 @@ { "code": "01888", "name": "建滔积层板", - "price": 85.9, - "cost": 88.23, - "shares": 0, + "price": 85.6, + "cost": 76.5, + "shares": 500, "avg_price": 0, - "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损80.13 | 目标0 | 买入区77.31~90.2 | 信号:持有", - "stop_loss": 80.13, - "entry_low": 77.31, - "entry_high": 90.2, + "currency": "CNY", + "action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损83.03 | 目标0 | 买入区77.04~89.88 | 信号:持有", + "stop_loss": 83.03, + "entry_low": 77.04, + "entry_high": 89.88, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=96.4 MA10=93.52 MA20=78.64 MA60=51.2", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业元件+0.3%,中性", + "sector_context": "行业元件上涨+2.0%,正常", "stock_category": "中短线", "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 80.13, - "entry_zone": "77.31~90.2" + "stop_loss": 83.03, + "entry_zone": "77.04~89.88" }, - "created_at": "2026-07-03 10:15", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损80.13 | 目标0 | 买入区77.31~90.2 | 信号:持有", + "new_action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损83.03 | 目标0 | 买入区77.04~89.88 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -529,405 +458,121 @@ { "code": "02202", "name": "万科企业", - "price": 2.35, - "cost": 4.67, - "shares": 0, + "price": 2.32, + "cost": 4.05, + "shares": 19700, "avg_price": 0, - "action": "深套持有 | 深套持有 | 止损2.16 | 目标0 | 买入区2.12~2.47 | 信号:持有", - "stop_loss": 2.16, - "entry_low": 2.12, - "entry_high": 2.47, + "currency": "CNY", + "action": "深套持有 | 深套持有 | 止损2.09 | 目标0 | 买入区2.09~2.44 | 信号:持有", + "stop_loss": 2.09, + "entry_low": 2.09, + "entry_high": 2.44, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=3.41 MA10=3.42 MA20=3.53 MA60=3.51", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "深套持有", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业房地产上涨+1.2%,正常", + "sector_context": "行业房地产+0.7%,中性", "stock_category": "深套", "position_advice": "不补不割", "time_horizon": "长期", "trigger": { - "stop_loss": 2.16, - "entry_zone": "2.12~2.47" + "stop_loss": 2.09, + "entry_zone": "2.09~2.44" }, - "created_at": "2026-07-03 10:15", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "深套持有 | 深套持有 | 止损2.16 | 目标0 | 买入区2.12~2.47 | 信号:持有", + "new_action": "深套持有 | 深套持有 | 止损2.09 | 目标0 | 买入区2.09~2.44 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } ] }, - { - "code": "02318", - "name": "中国平安", - "price": 52.65, - "cost": 0, - "shares": 0, - "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标55.25 | 止损51.07 | 买入区51.6~52.74 | 信号:弱势持有", - "stop_loss": 51.07, - "entry_low": 51.6, - "entry_high": 52.74, - "tech_snapshot": "形态:倒T线/射击之星/bearish 量价:数据不足 强撑:49.99 弱撑:52.23 弱压:52.98 强压:55.25 | MA5=69.74 MA10=69.55 MA20=69.3 MA60=67.19", - "timing_signal": "弱势持有", - "rr_ratio": 1.65, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.6),谨慎买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", - "type": "自选策略", - "source": "auto", - "sector_context": "行业保险-0.5%,中性", - "stock_category": "弱势", - "position_advice": "减仓或观望", - "time_horizon": "观望", - "trigger": { - "stop_loss": 51.07, - "entry_zone": "51.6~52.74", - "take_profit_zone": "0~55.25" - }, - "created_at": "2026-07-03 10:15", - "take_profit": 55.25, - "changelog": [ - { - "date": "2026-07-03 10:15", - "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标55.25 | 止损51.07 | 买入区51.6~52.74 | 信号:弱势持有", - "reason": "初始策略创建 | 形态:倒T线/射击之星/bearish 量价:数据不足 强撑:49.99 弱撑:52.23 弱压:52.98 强压:55", - "trigger": "初始创建" - } - ] - }, - { - "code": "02359", - "name": "药明康德", - "price": 153.1, - "cost": 0, - "shares": 0, - "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.7),谨慎买入 | 目标161.29 | 止损148.38 | 买入区150.04~153.54 | 信号:买入", - "stop_loss": 148.38, - "entry_low": 150.04, - "entry_high": 153.54, - "tech_snapshot": "形态:倒T线/射击之星/bearish 量价:数据不足 强撑:145.98 弱撑:150.87 弱压:155.87 强压:161.29 | MA5=149.96 MA10=140.63 MA20=131.92 MA60=130.66", - "timing_signal": "买入", - "rr_ratio": 1.74, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.7),谨慎买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", - "type": "自选策略", - "source": "auto", - "sector_context": "行业医疗服务-0.1%,中性", - "stock_category": "中短线", - "position_advice": "中等仓位", - "time_horizon": "2~6周", - "trigger": { - "stop_loss": 148.38, - "entry_zone": "150.04~153.54", - "take_profit_zone": "0~161.29" - }, - "created_at": "2026-07-03 10:15", - "take_profit": 161.29, - "changelog": [ - { - "date": "2026-07-03 10:15", - "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.7),谨慎买入 | 目标161.29 | 止损148.38 | 买入区150.04~153.54 | 信号:买入", - "reason": "初始策略创建 | 形态:倒T线/射击之星/bearish 量价:数据不足 强撑:145.98 弱撑:150.87 弱压:155.87 强压", - "trigger": "初始创建" - } - ] - }, - { - "code": "02388", - "name": "中银香港", - "price": 42.76, - "cost": 0, - "shares": 0, - "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标44.82 | 止损41.48 | 买入区41.9~42.82 | 信号:弱势持有", - "stop_loss": 41.48, - "entry_low": 41.9, - "entry_high": 42.82, - "tech_snapshot": "形态:光头光脚阳线/bullish 量价:数据不足 强撑:40.54 弱撑:42.3 弱压:42.94 强压:44.82 | MA5=44.03 MA10=45.75 MA20=46.75 MA60=45.87", - "timing_signal": "弱势持有", - "rr_ratio": 1.61, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.6),谨慎买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", - "type": "自选策略", - "source": "auto", - "sector_context": "行业银行+0.3%,中性", - "stock_category": "弱势", - "position_advice": "减仓或观望", - "time_horizon": "观望", - "trigger": { - "stop_loss": 41.48, - "entry_zone": "41.9~42.82", - "take_profit_zone": "0~44.82" - }, - "created_at": "2026-07-03 10:15", - "take_profit": 44.82, - "changelog": [ - { - "date": "2026-07-03 10:15", - "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标44.82 | 止损41.48 | 买入区41.9~42.82 | 信号:弱势持有", - "reason": "初始策略创建 | 形态:光头光脚阳线/bullish 量价:数据不足 强撑:40.54 弱撑:42.3 弱压:42.94 强压:44.82", - "trigger": "初始创建" - } - ] - }, - { - "code": "02628", - "name": "中国人寿", - "price": 28.64, - "cost": 0, - "shares": 0, - "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.8),谨慎买入 | 目标30.17 | 止损27.78 | 买入区28.07~28.74 | 信号:弱势持有", - "stop_loss": 27.78, - "entry_low": 28.07, - "entry_high": 28.74, - "tech_snapshot": "形态:带上影阳线/neutral 量价:数据不足 强撑:27.01 弱撑:28.08 弱压:29.09 强压:30.17 | MA5=30.54 MA10=31.35 MA20=32.21 MA60=31.07", - "timing_signal": "弱势持有", - "rr_ratio": 1.78, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.8),谨慎买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", - "type": "自选策略", - "source": "auto", - "sector_context": "行业保险-0.5%,中性", - "stock_category": "弱势", - "position_advice": "减仓或观望", - "time_horizon": "观望", - "trigger": { - "stop_loss": 27.78, - "entry_zone": "28.07~28.74", - "take_profit_zone": "0~30.17" - }, - "created_at": "2026-07-03 10:15", - "take_profit": 30.17, - "changelog": [ - { - "date": "2026-07-03 10:15", - "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.8),谨慎买入 | 目标30.17 | 止损27.78 | 买入区28.07~28.74 | 信号:弱势持有", - "reason": "初始策略创建 | 形态:带上影阳线/neutral 量价:数据不足 强撑:27.01 弱撑:28.08 弱压:29.09 强压:30.17", - "trigger": "初始创建" - } - ] - }, - { - "code": "06160", - "name": "百济神州", - "price": 183.4, - "cost": 0, - "shares": 0, - "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标196.3 | 止损174.7 | 买入区179.73~183.34 | 信号:买入", - "stop_loss": 174.7, - "entry_low": 179.73, - "entry_high": 183.34, - "tech_snapshot": "形态:光头光脚阳线/bullish 量价:数据不足 强撑:167.03 弱撑:174.7 弱压:185.13 强压:196.3 | MA5=193.84 MA10=198.45 MA20=200.76 MA60=193.12", - "timing_signal": "信号不充分", - "rr_ratio": 1.48, - "status": "review", - "note": "⚠️盈亏比不足1:1.5,不建议买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", - "type": "自选策略", - "source": "auto", - "sector_context": "行业生物制品+0.4%,中性", - "stock_category": "中短线", - "position_advice": "中等仓位", - "time_horizon": "2周~3月", - "trigger": { - "stop_loss": 174.7, - "entry_zone": "179.73~183.34", - "take_profit_zone": "0~196.3" - }, - "created_at": "2026-07-03 10:15", - "take_profit": 196.3, - "changelog": [ - { - "date": "2026-07-03 10:15", - "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标196.3 | 止损174.7 | 买入区179.73~183.34 | 信号:买入", - "reason": "初始策略创建 | 形态:光头光脚阳线/bullish 量价:数据不足 强撑:167.03 弱撑:174.7 弱压:185.13 强压:19", - "trigger": "初始创建" - } - ] - }, { "code": "06869", "name": "长飞光纤光缆", - "price": 198.4, - "cost": 263.72, - "shares": 0, + "price": 205.4, + "cost": 228.65, + "shares": 500, "avg_price": 0, - "action": "深套持有 | 深套持有 | 止损182.34 | 目标0 | 买入区178.56~208.32 | 信号:持有", - "stop_loss": 182.34, - "entry_low": 178.56, - "entry_high": 208.32, + "currency": "CNY", + "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损195.03 | 目标0 | 买入区184.86~215.67 | 信号:持有", + "stop_loss": 195.03, + "entry_low": 184.86, + "entry_high": 215.67, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=246.26 MA10=248.81 MA20=244.56 MA60=231.58", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", - "note": "深套持有", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "note": "⚠️盈亏比极低,关注", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业通信设备上涨+1.5%,正常", - "stock_category": "深套", - "position_advice": "不补不割", - "time_horizon": "长期", + "sector_context": "行业通信设备上涨+1.6%,正常", + "stock_category": "弱势", + "position_advice": "减仓或观望", + "time_horizon": "观望", "trigger": { - "stop_loss": 182.34, - "entry_zone": "178.56~208.32" + "stop_loss": 195.03, + "entry_zone": "184.86~215.67" }, - "created_at": "2026-07-03 10:15", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "深套持有 | 深套持有 | 止损182.34 | 目标0 | 买入区178.56~208.32 | 信号:持有", + "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损195.03 | 目标0 | 买入区184.86~215.67 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } ] }, - { - "code": "09868", - "name": "小鹏汽车-W", - "price": 50.75, - "cost": 0, - "shares": 0, - "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.5),谨慎买入 | 目标54.75 | 止损49.11 | 买入区49.73~50.79", - "stop_loss": 49.11, - "entry_low": 49.73, - "entry_high": 50.79, - "tech_snapshot": "形态:锤子线/T字线/neutral 量价:数据不足 强撑:45.95 弱撑:49.65 弱压:51.8 强压:54.75 | MA5=78.77 MA10=79.8 MA20=78.25 MA60=72.85", - "timing_signal": "信号不充分", - "rr_ratio": 1.54, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.5),谨慎买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", - "type": "自选策略", - "source": "auto", - "sector_context": "行业汽车整车上涨+1.7%,正常", - "stock_category": "弱势", - "position_advice": "减仓或观望", - "time_horizon": "观望", - "trigger": { - "stop_loss": 49.11, - "entry_zone": "49.73~50.79", - "take_profit_zone": "0~54.75" - }, - "created_at": "2026-07-03 10:15", - "take_profit": 54.75, - "changelog": [ - { - "date": "2026-07-03 10:15", - "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.5),谨慎买入 | 目标54.75 | 止损49.11 | 买入区49.73~50.79", - "reason": "初始策略创建 | 形态:锤子线/T字线/neutral 量价:数据不足 强撑:45.95 弱撑:49.65 弱压:51.8 强压:54.7", - "trigger": "初始创建" - } - ] - }, - { - "code": "09988", - "name": "阿里巴巴-W", - "price": 95.75, - "cost": 0, - "shares": 0, - "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标101.32 | 止损91.09 | 买入区93.83~95.18 | 信号:弱势持有", - "stop_loss": 91.09, - "entry_low": 93.83, - "entry_high": 95.18, - "tech_snapshot": "形态:光头光脚阳线/bullish 量价:数据不足 强撑:89.15 弱撑:94.47 弱压:96.47 强压:101.32 | MA5=148.22 MA10=153.54 MA20=160.0 MA60=147.22", - "timing_signal": "弱势持有", - "rr_ratio": 1.2, - "status": "review", - "note": "⚠️盈亏比不足1:1.5,不建议买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", - "type": "自选策略", - "source": "auto", - "sector_context": "行业软件开发+0.9%,中性", - "stock_category": "弱势", - "position_advice": "减仓或观望", - "time_horizon": "观望", - "trigger": { - "stop_loss": 91.09, - "entry_zone": "93.83~95.18", - "take_profit_zone": "0~101.32" - }, - "created_at": "2026-07-03 10:15", - "take_profit": 101.32, - "changelog": [ - { - "date": "2026-07-03 10:15", - "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标101.32 | 止损91.09 | 买入区93.83~95.18 | 信号:弱势持有", - "reason": "初始策略创建 | 形态:光头光脚阳线/bullish 量价:数据不足 强撑:89.15 弱撑:94.47 弱压:96.47 强压:101.", - "trigger": "初始创建" - } - ] - }, { "code": "300035", "name": "中科电气", - "price": 14.49, + "price": 14.3, "cost": 22.29, - "shares": 0, + "shares": 1400, "avg_price": 0, - "action": "深套持有 | 深套持有 | 止损13.06 | 目标0 | 买入区13.04~15.21 | 信号:持有", - "stop_loss": 13.06, - "entry_low": 13.04, - "entry_high": 15.21, + "currency": "CNY", + "action": "深套持有 | 深套持有 | 止损12.89 | 目标0 | 买入区12.87~15.02 | 信号:持有", + "stop_loss": 12.89, + "entry_low": 12.87, + "entry_high": 15.02, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=21.7 MA10=21.88 MA20=22.15 MA60=21.44", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "深套持有", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "timestamp": "2026-07-03 12:06", + "updated_at": "2026-07-03 12:06", "type": "持仓策略", "source": "auto", - "sector_context": "行业电池+0.5%,中性", + "sector_context": "行业电池+0.4%,中性", "stock_category": "深套", "position_advice": "不补不割", "time_horizon": "长期", "trigger": { - "stop_loss": 13.06, - "entry_zone": "13.04~15.21" + "stop_loss": 12.89, + "entry_zone": "12.87~15.02" }, - "created_at": "2026-07-03 10:15", + "created_at": "2026-07-03 12:06", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:06", "old_action": "", - "new_action": "深套持有 | 深套持有 | 止损13.06 | 目标0 | 买入区13.04~15.21 | 信号:持有", + "new_action": "深套持有 | 深套持有 | 止损12.89 | 目标0 | 买入区12.87~15.02 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -936,40 +581,41 @@ { "code": "300124", "name": "汇川技术", - "price": 71.23, + "price": 72.29, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标75.77 | 止损69.09 | 买入区69.81~71.35 | 信号:观望", - "stop_loss": 69.09, - "entry_low": 69.81, - "entry_high": 71.35, - "tech_snapshot": "形态:光头光脚阳线/neutral 量价:主动卖盘占优 强撑:64.37 弱撑:68.4 弱压:72.74 强压:75.77 | MA5=76.83 MA10=77.47 MA20=78.08 MA60=73.46", + "currency": "CNY", + "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标76.47 | 止损70.12 | 买入区70.84~71.63 | 信号:观望", + "stop_loss": 70.12, + "entry_low": 70.84, + "entry_high": 71.63, + "tech_snapshot": "形态:光头光脚阳线/neutral 量价:主动卖盘占优 强撑:64.91 弱撑:68.4 弱压:73.98 强压:76.47 | MA5=76.83 MA10=77.47 MA20=78.08 MA60=73.46", "timing_signal": "观望", - "rr_ratio": 1.6, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.6),谨慎买入", - "timestamp": "2026-07-03 10:15", - "updated_at": "2026-07-03 10:15", + "rr_ratio": 1.07, + "status": "review", + "note": "⚠️盈亏比不足1:1.5,不建议买入", + "timestamp": "2026-07-03 12:07", + "updated_at": "2026-07-03 12:07", "type": "自选策略", "source": "auto", - "sector_context": "行业自动化设备上涨+2.9%,正常", + "sector_context": "行业自动化设备大涨+4.0%,可适度积极", "stock_category": "弱势", "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 69.09, - "entry_zone": "69.81~71.35", - "take_profit_zone": "0~75.77" + "stop_loss": 70.12, + "entry_zone": "70.84~71.63", + "take_profit_zone": "0~76.47" }, - "created_at": "2026-07-03 10:15", - "take_profit": 75.77, + "created_at": "2026-07-03 12:07", + "take_profit": 76.47, "changelog": [ { - "date": "2026-07-03 10:15", + "date": "2026-07-03 12:07", "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标75.77 | 止损69.09 | 买入区69.81~71.35 | 信号:观望", - "reason": "初始策略创建 | 形态:光头光脚阳线/neutral 量价:主动卖盘占优 强撑:64.37 弱撑:68.4 弱压:72.74 强压:75.", + "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标76.47 | 止损70.12 | 买入区70.84~71.63 | 信号:观望", + "reason": "初始策略创建 | 形态:光头光脚阳线/neutral 量价:主动卖盘占优 强撑:64.91 弱撑:68.4 弱压:73.98 强压:76.", "trigger": "初始创建" } ] @@ -977,21 +623,22 @@ { "code": "300308", "name": "中际旭创", - "price": 1141.9, + "price": 1157.97, "cost": 1316.53, - "shares": 0, + "shares": 100, "avg_price": 0, - "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损1065.16 | 目标0 | 买入区1027.71~1199.0 | 信号:持有", - "stop_loss": 1065.16, - "entry_low": 1027.71, - "entry_high": 1199.0, + "currency": "CNY", + "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损1080.15 | 目标0 | 买入区1042.17~1215.87 | 信号:持有", + "stop_loss": 1080.15, + "entry_low": 1042.17, + "entry_high": 1215.87, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=1222.01 MA10=1280.59 MA20=1239.5 MA60=1033.3", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:07", + "updated_at": "2026-07-03 12:07", "type": "持仓策略", "source": "auto", "sector_context": "", @@ -999,16 +646,16 @@ "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 1065.16, - "entry_zone": "1027.71~1199.0" + "stop_loss": 1080.15, + "entry_zone": "1042.17~1215.87" }, - "created_at": "2026-07-03 10:16", + "created_at": "2026-07-03 12:07", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:07", "old_action": "", - "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损1065.16 | 目标0 | 买入区1027.71~1199.0 | 信号:持有", + "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损1080.15 | 目标0 | 买入区1042.17~1215.87 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -1017,38 +664,39 @@ { "code": "300548", "name": "长芯博创", - "price": 224.76, + "price": 227.0, "cost": 231.46, - "shares": 0, + "shares": 100, "avg_price": 0, - "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损209.66 | 目标0 | 买入区202.28~236.0 | 信号:持有", - "stop_loss": 209.66, - "entry_low": 202.28, - "entry_high": 236.0, + "currency": "CNY", + "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损203.28 | 目标0 | 买入区204.3~238.35 | 信号:持有", + "stop_loss": 203.28, + "entry_low": 204.3, + "entry_high": 238.35, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=253.97 MA10=270.01 MA20=255.06 MA60=248.13", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:07", + "updated_at": "2026-07-03 12:07", "type": "持仓策略", "source": "auto", - "sector_context": "行业光学光电子-0.7%,中性", + "sector_context": "行业光学光电子下跌-1.0%,适度防御", "stock_category": "弱势", "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 209.66, - "entry_zone": "202.28~236.0" + "stop_loss": 203.28, + "entry_zone": "204.3~238.35" }, - "created_at": "2026-07-03 10:16", + "created_at": "2026-07-03 12:07", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:07", "old_action": "", - "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损209.66 | 目标0 | 买入区202.28~236.0 | 信号:持有", + "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损203.28 | 目标0 | 买入区204.3~238.35 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -1057,38 +705,39 @@ { "code": "300750", "name": "宁德时代", - "price": 386.33, + "price": 384.16, "cost": 401.78, - "shares": 0, + "shares": 100, "avg_price": 0, - "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损360.37 | 目标0 | 买入区347.7~405.65 | 信号:持有", - "stop_loss": 360.37, - "entry_low": 347.7, - "entry_high": 405.65, + "currency": "CNY", + "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损358.34 | 目标0 | 买入区345.74~403.37 | 信号:持有", + "stop_loss": 358.34, + "entry_low": 345.74, + "entry_high": 403.37, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=386.51 MA10=392.29 MA20=394.64 MA60=413.88", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:07", + "updated_at": "2026-07-03 12:07", "type": "持仓策略", "source": "auto", - "sector_context": "行业电池+0.5%,中性", + "sector_context": "行业电池+0.4%,中性", "stock_category": "弱势", "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 360.37, - "entry_zone": "347.7~405.65" + "stop_loss": 358.34, + "entry_zone": "345.74~403.37" }, - "created_at": "2026-07-03 10:16", + "created_at": "2026-07-03 12:07", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:07", "old_action": "", - "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损360.37 | 目标0 | 买入区347.7~405.65 | 信号:持有", + "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损358.34 | 目标0 | 买入区345.74~403.37 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -1097,21 +746,22 @@ { "code": "301308", "name": "江波龙", - "price": 602.0, + "price": 620.0, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标参考0 | 止损577.92 | 买入区589.96~614.04", - "stop_loss": 577.92, - "entry_low": 589.96, - "entry_high": 614.04, - "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=674.71 MA10=646.08 MA20=586.34 MA60=501.02", + "currency": "CNY", + "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标666.01 | 止损585.55 | 买入区607.6~617.73", + "stop_loss": 585.55, + "entry_low": 607.6, + "entry_high": 617.73, + "tech_snapshot": "形态:光头光脚阳线/neutral 量价:主动买盘占优 强撑:551.09 弱撑:585.55 弱压:643.01 强压:666.01 | MA5=674.71 MA10=646.08 MA20=586.34 MA60=501.02", "timing_signal": "信号不充分", - "rr_ratio": 0.0, + "rr_ratio": 1.34, "status": "review", "note": "⚠️盈亏比不足1:1.5,不建议买入", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:07", + "updated_at": "2026-07-03 12:07", "type": "自选策略", "source": "auto", "sector_context": "", @@ -1119,17 +769,18 @@ "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 577.92, - "entry_zone": "589.96~614.04" + "stop_loss": 585.55, + "entry_zone": "607.6~617.73", + "take_profit_zone": "0~666.01" }, - "created_at": "2026-07-03 10:16", - "take_profit": 0, + "created_at": "2026-07-03 12:07", + "take_profit": 666.01, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:07", "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标参考0 | 止损577.92 | 买入区589.96~614.04", - "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", + "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标666.01 | 止损585.55 | 买入区607.6~617.73", + "reason": "初始策略创建 | 形态:光头光脚阳线/neutral 量价:主动买盘占优 强撑:551.09 弱撑:585.55 弱压:643.01 强压", "trigger": "初始创建" } ] @@ -1137,38 +788,39 @@ { "code": "518880", "name": "黄金ETF华安", - "price": 8.683, + "price": 8.686, "cost": 12.19, - "shares": 0, + "shares": 2400, "avg_price": 0, - "action": "深套持有 | 深套持有 | 止损8.21 | 目标0 | 买入区7.81~9.12 | 信号:持有", + "currency": "CNY", + "action": "深套持有 | 深套持有 | 止损8.21 | 目标0 | 买入区7.82~9.12 | 信号:持有", "stop_loss": 8.21, - "entry_low": 7.81, + "entry_low": 7.82, "entry_high": 9.12, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=9.45 MA10=9.58 MA20=10.01 MA60=10.34", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "深套持有", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:07", + "updated_at": "2026-07-03 12:07", "type": "持仓策略", "source": "auto", - "sector_context": "行业贵金属大涨+6.0%,可适度积极", + "sector_context": "行业贵金属大涨+6.9%,可适度积极", "stock_category": "深套", "position_advice": "不补不割", "time_horizon": "长期", "trigger": { "stop_loss": 8.21, - "entry_zone": "7.81~9.12" + "entry_zone": "7.82~9.12" }, - "created_at": "2026-07-03 10:16", + "created_at": "2026-07-03 12:07", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:07", "old_action": "", - "new_action": "深套持有 | 深套持有 | 止损8.21 | 目标0 | 买入区7.81~9.12 | 信号:持有", + "new_action": "深套持有 | 深套持有 | 止损8.21 | 目标0 | 买入区7.82~9.12 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -1177,21 +829,22 @@ { "code": "600519", "name": "贵州茅台", - "price": 1204.0, + "price": 1191.31, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标1263.52 | 止损1167.88 | 买入区1179.92~1206.14 | 信号:关注", - "stop_loss": 1167.88, - "entry_low": 1179.92, - "entry_high": 1206.14, - "tech_snapshot": "形态:锤子线/T字线/bullish 量价:主动买盘占优 强撑:1143.14 弱撑:1196.52 弱压:1210.6 强压:1263.52 | MA5=1380.83 MA10=1382.77 MA20=1355.23 MA60=1398.7", + "currency": "CNY", + "action": "盈利持有 | 目标1292.62 | 止损1155.57 | 买入区1167.48~1210.39 | 信号:关注", + "stop_loss": 1155.57, + "entry_low": 1167.48, + "entry_high": 1210.39, + "tech_snapshot": "形态:光头光脚阴线/bearish 量价:主动买盘占优 强撑:1102.01 弱撑:1184.49 弱压:1204.13 强压:1292.62 | MA5=1380.83 MA10=1382.77 MA20=1355.23 MA60=1398.7", "timing_signal": "关注", - "rr_ratio": 1.65, + "rr_ratio": 2.83, "status": "updated", - "note": "⚠️盈亏比偏低(1:1.6),谨慎买入", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "note": "", + "timestamp": "2026-07-03 12:08", + "updated_at": "2026-07-03 12:08", "type": "自选策略", "source": "auto", "sector_context": "", @@ -1199,18 +852,18 @@ "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 1167.88, - "entry_zone": "1179.92~1206.14", - "take_profit_zone": "0~1263.52" + "stop_loss": 1155.57, + "entry_zone": "1167.48~1210.39", + "take_profit_zone": "0~1292.62" }, - "created_at": "2026-07-03 10:16", - "take_profit": 1263.52, + "created_at": "2026-07-03 12:08", + "take_profit": 1292.62, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:08", "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标1263.52 | 止损1167.88 | 买入区1179.92~1206.14 | 信号:关注", - "reason": "初始策略创建 | 形态:锤子线/T字线/bullish 量价:主动买盘占优 强撑:1143.14 弱撑:1196.52 弱压:1210.6", + "new_action": "盈利持有 | 目标1292.62 | 止损1155.57 | 买入区1167.48~1210.39 | 信号:关注", + "reason": "初始策略创建 | 形态:光头光脚阴线/bearish 量价:主动买盘占优 强撑:1102.01 弱撑:1184.49 弱压:1204.13", "trigger": "初始创建" } ] @@ -1218,38 +871,39 @@ { "code": "600563", "name": "法拉电子", - "price": 157.92, + "price": 161.71, "cost": 147.18, - "shares": 0, + "shares": 100, "avg_price": 0, - "action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损153.18 | 目标0 | 买入区142.13~165.82 | 信号:持有", - "stop_loss": 153.18, - "entry_low": 142.13, - "entry_high": 165.82, + "currency": "CNY", + "action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损156.86 | 目标0 | 买入区145.54~169.8 | 信号:持有", + "stop_loss": 156.86, + "entry_low": 145.54, + "entry_high": 169.8, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=183.16 MA10=178.82 MA20=169.12 MA60=145.24", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:08", + "updated_at": "2026-07-03 12:08", "type": "持仓策略", "source": "auto", - "sector_context": "行业元件+0.3%,中性", + "sector_context": "行业元件上涨+2.0%,正常", "stock_category": "中短线", "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 153.18, - "entry_zone": "142.13~165.82" + "stop_loss": 156.86, + "entry_zone": "145.54~169.8" }, - "created_at": "2026-07-03 10:16", + "created_at": "2026-07-03 12:08", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:08", "old_action": "", - "new_action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损153.18 | 目标0 | 买入区142.13~165.82 | 信号:持有", + "new_action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损156.86 | 目标0 | 买入区145.54~169.8 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -1258,21 +912,22 @@ { "code": "601318", "name": "中国平安", - "price": 49.1, + "price": 48.7, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | 目标53.16 | 止损47.63 | 买入区48.12~49.84 | 信号:观望", - "stop_loss": 47.63, - "entry_low": 48.12, - "entry_high": 49.84, - "tech_snapshot": "形态:带上影阴线/neutral 量价:主动卖盘占优 强撑:45.32 弱撑:48.69 弱压:49.56 强压:53.16 | MA5=69.72 MA10=68.2 MA20=65.99 MA60=62.55", + "currency": "CNY", + "action": "盈利持有 | 目标52.92 | 止损47.24 | 买入区47.73~49.51 | 信号:观望", + "stop_loss": 47.24, + "entry_low": 47.73, + "entry_high": 49.51, + "tech_snapshot": "形态:光头光脚阴线/neutral 量价:主动卖盘占优 强撑:45.13 弱撑:48.27 弱压:49.45 强压:52.92 | MA5=69.72 MA10=68.2 MA20=65.99 MA60=62.55", "timing_signal": "观望", - "rr_ratio": 2.76, + "rr_ratio": 2.89, "status": "updated", "note": "", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:08", + "updated_at": "2026-07-03 12:08", "type": "自选策略", "source": "auto", "sector_context": "", @@ -1280,18 +935,18 @@ "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 47.63, - "entry_zone": "48.12~49.84", - "take_profit_zone": "0~53.16" + "stop_loss": 47.24, + "entry_zone": "47.73~49.51", + "take_profit_zone": "0~52.92" }, - "created_at": "2026-07-03 10:16", - "take_profit": 53.16, + "created_at": "2026-07-03 12:08", + "take_profit": 52.92, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:08", "old_action": "", - "new_action": "盈利持有 | 目标53.16 | 止损47.63 | 买入区48.12~49.84 | 信号:观望", - "reason": "初始策略创建 | 形态:带上影阴线/neutral 量价:主动卖盘占优 强撑:45.32 弱撑:48.69 弱压:49.56 强压:53.", + "new_action": "盈利持有 | 目标52.92 | 止损47.24 | 买入区47.73~49.51 | 信号:观望", + "reason": "初始策略创建 | 形态:光头光脚阴线/neutral 量价:主动卖盘占优 强撑:45.13 弱撑:48.27 弱压:49.45 强压:52", "trigger": "初始创建" } ] @@ -1299,38 +954,39 @@ { "code": "601899", "name": "紫金矿业", - "price": 27.99, + "price": 28.04, "cost": 39.89, - "shares": 0, + "shares": 2400, "avg_price": 0, - "action": "深套持有 | 深套持有 | 止损25.72 | 目标0 | 买入区25.19~29.39 | 信号:持有", - "stop_loss": 25.72, - "entry_low": 25.19, - "entry_high": 29.39, + "currency": "CNY", + "action": "深套持有 | 深套持有 | 止损25.77 | 目标0 | 买入区25.24~29.44 | 信号:持有", + "stop_loss": 25.77, + "entry_low": 25.24, + "entry_high": 29.44, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=35.14 MA10=36.13 MA20=37.18 MA60=36.52", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "深套持有", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:08", + "updated_at": "2026-07-03 12:08", "type": "持仓策略", "source": "auto", - "sector_context": "行业工业金属上涨+1.3%,正常", + "sector_context": "行业工业金属上涨+2.2%,正常", "stock_category": "深套", "position_advice": "不补不割", "time_horizon": "长期", "trigger": { - "stop_loss": 25.72, - "entry_zone": "25.19~29.39" + "stop_loss": 25.77, + "entry_zone": "25.24~29.44" }, - "created_at": "2026-07-03 10:16", + "created_at": "2026-07-03 12:08", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:08", "old_action": "", - "new_action": "深套持有 | 深套持有 | 止损25.72 | 目标0 | 买入区25.19~29.39 | 信号:持有", + "new_action": "深套持有 | 深套持有 | 止损25.77 | 目标0 | 买入区25.24~29.44 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -1339,38 +995,39 @@ { "code": "688411", "name": "海博思创", - "price": 259.0, + "price": 257.52, "cost": 266.95, - "shares": 0, + "shares": 200, "avg_price": 0, - "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损241.6 | 目标0 | 买入区233.1~271.95 | 信号:持有", - "stop_loss": 241.6, - "entry_low": 233.1, - "entry_high": 271.95, + "currency": "CNY", + "action": "持有观察 | ⚠️盈亏比极低,关注 | 止损240.22 | 目标0 | 买入区231.77~270.4 | 信号:持有", + "stop_loss": 240.22, + "entry_low": 231.77, + "entry_high": 270.4, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=270.94 MA10=273.7 MA20=265.98 MA60=254.7", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:08", + "updated_at": "2026-07-03 12:08", "type": "持仓策略", "source": "auto", - "sector_context": "行业光伏设备-0.3%,中性", + "sector_context": "行业光伏设备-0.5%,中性", "stock_category": "中短线", "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 241.6, - "entry_zone": "233.1~271.95" + "stop_loss": 240.22, + "entry_zone": "231.77~270.4" }, - "created_at": "2026-07-03 10:16", + "created_at": "2026-07-03 12:08", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:08", "old_action": "", - "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损241.6 | 目标0 | 买入区233.1~271.95 | 信号:持有", + "new_action": "持有观察 | ⚠️盈亏比极低,关注 | 止损240.22 | 目标0 | 买入区231.77~270.4 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -1379,40 +1036,41 @@ { "code": "688630", "name": "芯碁微装", - "price": 484.9, + "price": 495.95, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标529.15 | 止损466.67 | 买入区475.2~486.17 | 信号:买入", - "stop_loss": 466.67, - "entry_low": 475.2, - "entry_high": 486.17, - "tech_snapshot": "形态:带下影阳线/bullish 量价:主动买盘占优 强撑:418.35 弱撑:457.52 弱压:502.94 强压:529.15 | MA5=518.14 MA10=511.3 MA20=462.1 MA60=333.6", - "timing_signal": "买入", - "rr_ratio": 1.62, - "status": "updated", - "note": "⚠️盈亏比偏低(1:1.6),谨慎买入", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "currency": "CNY", + "action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标538.28 | 止损468.86 | 买入区486.03~491.11 | 信号:买入", + "stop_loss": 468.86, + "entry_low": 486.03, + "entry_high": 491.11, + "tech_snapshot": "形态:带下影阳线/bullish 量价:主动买盘占优 强撑:423.38 弱撑:459.67 弱压:517.12 强压:538.28 | MA5=518.14 MA10=511.3 MA20=462.1 MA60=333.6", + "timing_signal": "信号不充分", + "rr_ratio": 1.17, + "status": "review", + "note": "⚠️盈亏比不足1:1.5,不建议买入", + "timestamp": "2026-07-03 12:08", + "updated_at": "2026-07-03 12:08", "type": "自选策略", "source": "auto", - "sector_context": "行业专用设备上涨+1.6%,正常", + "sector_context": "行业专用设备上涨+2.0%,正常", "stock_category": "中短线", "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 466.67, - "entry_zone": "475.2~486.17", - "take_profit_zone": "0~529.15" + "stop_loss": 468.86, + "entry_zone": "486.03~491.11", + "take_profit_zone": "0~538.28" }, - "created_at": "2026-07-03 10:16", - "take_profit": 529.15, + "created_at": "2026-07-03 12:08", + "take_profit": 538.28, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:08", "old_action": "", - "new_action": "盈利持有 | ⚠️盈亏比偏低(1:1.6),谨慎买入 | 目标529.15 | 止损466.67 | 买入区475.2~486.17 | 信号:买入", - "reason": "初始策略创建 | 形态:带下影阳线/bullish 量价:主动买盘占优 强撑:418.35 弱撑:457.52 弱压:502.94 强压:", + "new_action": "盈利持有 | ⚠️盈亏比不足1:1.5,不建议买入 | 目标538.28 | 止损468.86 | 买入区486.03~491.11 | 信号:买入", + "reason": "初始策略创建 | 形态:带下影阳线/bullish 量价:主动买盘占优 强撑:423.38 弱撑:459.67 弱压:517.12 强压:", "trigger": "初始创建" } ] @@ -1420,38 +1078,39 @@ { "code": "688639", "name": "华恒生物", - "price": 16.75, + "price": 16.69, "cost": 21.51, - "shares": 0, + "shares": 2800, "avg_price": 0, - "action": "深套持有 | 深套持有 | 止损14.49 | 目标0 | 买入区15.08~17.59 | 信号:持有", - "stop_loss": 14.49, - "entry_low": 15.08, - "entry_high": 17.59, + "currency": "CNY", + "action": "深套持有 | 深套持有 | 止损14.44 | 目标0 | 买入区15.02~17.52 | 信号:持有", + "stop_loss": 14.44, + "entry_low": 15.02, + "entry_high": 17.52, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=33.68 MA10=35.7 MA20=36.69 MA60=34.28", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "深套持有", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:08", + "updated_at": "2026-07-03 12:08", "type": "持仓策略", "source": "auto", - "sector_context": "行业化学制品下跌-1.0%,适度防御", + "sector_context": "行业化学制品下跌-1.4%,适度防御", "stock_category": "深套", "position_advice": "不补不割", "time_horizon": "长期", "trigger": { - "stop_loss": 14.49, - "entry_zone": "15.08~17.59" + "stop_loss": 14.44, + "entry_zone": "15.02~17.52" }, - "created_at": "2026-07-03 10:16", + "created_at": "2026-07-03 12:08", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:08", "old_action": "", - "new_action": "深套持有 | 深套持有 | 止损14.49 | 目标0 | 买入区15.08~17.59 | 信号:持有", + "new_action": "深套持有 | 深套持有 | 止损14.44 | 目标0 | 买入区15.02~17.52 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } @@ -1460,40 +1119,41 @@ { "code": "688795", "name": "摩尔线程-U", - "price": 642.0, + "price": 654.0, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | 目标696.03 | 止损622.74 | 买入区629.16~652.06 | 信号:观望", - "stop_loss": 622.74, - "entry_low": 629.16, - "entry_high": 652.06, - "tech_snapshot": "形态:锤子线/T字线/bullish 量价:主动卖盘占优 强撑:587.97 弱撑:633.01 弱压:651.0 强压:696.03 | MA5=681.57 MA10=685.62 MA20=656.21 MA60=660.88", + "currency": "CNY", + "action": "盈利持有 | 目标704.53 | 止损634.38 | 买入区640.92~662.44 | 信号:观望", + "stop_loss": 634.38, + "entry_low": 640.92, + "entry_high": 662.44, + "tech_snapshot": "形态:带上影阳线/neutral 量价:主动卖盘占优 强撑:596.47 弱撑:636.51 弱压:667.98 强压:704.53 | MA5=681.57 MA10=685.62 MA20=656.21 MA60=660.88", "timing_signal": "观望", - "rr_ratio": 2.81, + "rr_ratio": 2.58, "status": "updated", "note": "", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:09", + "updated_at": "2026-07-03 12:09", "type": "自选策略", "source": "auto", - "sector_context": "行业半导体-1.0%,中性", + "sector_context": "行业半导体-0.9%,中性", "stock_category": "弱势", "position_advice": "减仓或观望", "time_horizon": "观望", "trigger": { - "stop_loss": 622.74, - "entry_zone": "629.16~652.06", - "take_profit_zone": "0~696.03" + "stop_loss": 634.38, + "entry_zone": "640.92~662.44", + "take_profit_zone": "0~704.53" }, - "created_at": "2026-07-03 10:16", - "take_profit": 696.03, + "created_at": "2026-07-03 12:09", + "take_profit": 704.53, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:09", "old_action": "", - "new_action": "盈利持有 | 目标696.03 | 止损622.74 | 买入区629.16~652.06 | 信号:观望", - "reason": "初始策略创建 | 形态:锤子线/T字线/bullish 量价:主动卖盘占优 强撑:587.97 弱撑:633.01 弱压:651.0 强压", + "new_action": "盈利持有 | 目标704.53 | 止损634.38 | 买入区640.92~662.44 | 信号:观望", + "reason": "初始策略创建 | 形态:带上影阳线/neutral 量价:主动卖盘占优 强撑:596.47 弱撑:636.51 弱压:667.98 强压:", "trigger": "初始创建" } ] @@ -1501,40 +1161,41 @@ { "code": "688802", "name": "沐曦股份-U", - "price": 718.22, + "price": 744.0, "cost": 0, "shares": 0, "avg_price": 0, - "action": "盈利持有 | 目标794.73 | 止损696.67 | 买入区703.86~732.58 | 信号:关注", - "stop_loss": 696.67, - "entry_low": 703.86, - "entry_high": 732.58, - "tech_snapshot": "形态:带下影阴线/neutral 量价:数据不足 强撑:652.73 弱撑:713.46 弱压:734.45 强压:794.73 | MA5=771.28 MA10=769.4 MA20=740.96 MA60=719.77", - "timing_signal": "关注", - "rr_ratio": 3.55, + "currency": "CNY", + "action": "盈利持有 | 目标807.96 | 止损720.04 | 买入区729.12~755.21 | 信号:买入", + "stop_loss": 720.04, + "entry_low": 729.12, + "entry_high": 755.21, + "tech_snapshot": "形态:带下影阳线/bullish 量价:数据不足 强撑:665.96 弱撑:720.04 弱压:760.92 强压:807.96 | MA5=771.28 MA10=769.4 MA20=740.96 MA60=719.77", + "timing_signal": "买入", + "rr_ratio": 2.67, "status": "updated", "note": "", - "timestamp": "2026-07-03 10:16", - "updated_at": "2026-07-03 10:16", + "timestamp": "2026-07-03 12:09", + "updated_at": "2026-07-03 12:09", "type": "自选策略", "source": "auto", - "sector_context": "行业半导体-1.0%,中性", + "sector_context": "行业半导体-0.9%,中性", "stock_category": "中短线", "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 696.67, - "entry_zone": "703.86~732.58", - "take_profit_zone": "0~794.73" + "stop_loss": 720.04, + "entry_zone": "729.12~755.21", + "take_profit_zone": "0~807.96" }, - "created_at": "2026-07-03 10:16", - "take_profit": 794.73, + "created_at": "2026-07-03 12:09", + "take_profit": 807.96, "changelog": [ { - "date": "2026-07-03 10:16", + "date": "2026-07-03 12:09", "old_action": "", - "new_action": "盈利持有 | 目标794.73 | 止损696.67 | 买入区703.86~732.58 | 信号:关注", - "reason": "初始策略创建 | 形态:带下影阴线/neutral 量价:数据不足 强撑:652.73 弱撑:713.46 弱压:734.45 强压:79", + "new_action": "盈利持有 | 目标807.96 | 止损720.04 | 买入区729.12~755.21 | 信号:买入", + "reason": "初始策略创建 | 形态:带下影阳线/bullish 量价:数据不足 强撑:665.96 弱撑:720.04 弱压:760.92 强压:80", "trigger": "初始创建" } ] @@ -1542,44 +1203,45 @@ { "code": "688981", "name": "中芯国际", - "price": 141.08, + "price": 143.65, "cost": 126.07, - "shares": 0, + "shares": 300, "avg_price": 0, - "action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损136.85 | 目标0 | 买入区126.97~148.13 | 信号:持有", - "stop_loss": 136.85, - "entry_low": 126.97, - "entry_high": 148.13, + "currency": "CNY", + "action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损139.34 | 目标0 | 买入区129.28~150.83 | 信号:持有", + "stop_loss": 139.34, + "entry_low": 129.28, + "entry_high": 150.83, "tech_snapshot": "形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None | MA5=151.43 MA10=149.35 MA20=138.9 MA60=125.49", "timing_signal": "持有", "rr_ratio": 0.0, "status": "updated", "note": "⚠️盈亏比极低,关注", - "timestamp": "2026-07-03 10:17", - "updated_at": "2026-07-03 10:17", + "timestamp": "2026-07-03 12:09", + "updated_at": "2026-07-03 12:09", "type": "持仓策略", "source": "auto", - "sector_context": "行业半导体-1.0%,中性", + "sector_context": "行业半导体-0.9%,中性", "stock_category": "中短线", "position_advice": "中等仓位", "time_horizon": "2~6周", "trigger": { - "stop_loss": 136.85, - "entry_zone": "126.97~148.13" + "stop_loss": 139.34, + "entry_zone": "129.28~150.83" }, - "created_at": "2026-07-03 10:17", + "created_at": "2026-07-03 12:09", "take_profit": 0, "changelog": [ { - "date": "2026-07-03 10:17", + "date": "2026-07-03 12:09", "old_action": "", - "new_action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损136.85 | 目标0 | 买入区126.97~148.13 | 信号:持有", + "new_action": "盈利良好 | ⚠️盈亏比极低,关注 | 止损139.34 | 目标0 | 买入区129.28~150.83 | 信号:持有", "reason": "初始策略创建 | 形态:unknown/neutral 量价:数据不足 强撑:None 弱撑:None 弱压:None 强压:None |", "trigger": "初始创建" } ] } ], - "total": 39, - "regenerated_at": "2026-07-03 10:17" + "total": 30, + "regenerated_at": "2026-07-03 12:09" } \ No newline at end of file diff --git a/data/mofin.db-shm b/data/mofin.db-shm index 178b9de..98b1a5d 100644 Binary files a/data/mofin.db-shm and b/data/mofin.db-shm differ diff --git a/data/mofin.db-wal b/data/mofin.db-wal index 687d7f6..382dc1a 100644 Binary files a/data/mofin.db-wal and b/data/mofin.db-wal differ diff --git a/data/multi_tf_cache.json b/data/multi_tf_cache.json index 813acb7..6fe1f90 100644 --- a/data/multi_tf_cache.json +++ b/data/multi_tf_cache.json @@ -1254,7 +1254,7 @@ "volume": 147766189.0 } ], - "updated_at": 1783045022.0597436 + "updated_at": 1783051747.536662 }, "688795": { "daily": [ @@ -2479,7 +2479,7 @@ "volume": 4788252.0 } ], - "updated_at": 1783045017.2558444 + "updated_at": 1783051740.058768 }, "000657": { "daily": [ @@ -3736,7 +3736,7 @@ "volume": 1051508.0 } ], - "updated_at": 1783044767.899178 + "updated_at": 1783051278.4453125 }, "000700": { "daily": [ @@ -4993,7 +4993,7 @@ "volume": 1265397.0 } ], - "updated_at": 1783044853.6724539 + "updated_at": 1783051627.982171 }, "000711": { "daily": [ @@ -6250,7 +6250,7 @@ "volume": 496248.0 } ], - "updated_at": 1783044770.290931 + "updated_at": 1783051282.8847363 }, "001309": { "daily": [ @@ -7507,7 +7507,7 @@ "volume": 216663.0 } ], - "updated_at": 1783044771.251316 + "updated_at": 1783051286.5401492 }, "002594": { "daily": [ @@ -8764,7 +8764,7 @@ "volume": 934285.0 } ], - "updated_at": 1783044773.5265088 + "updated_at": 1783051286.9981902 }, "00700": { "daily": [ @@ -10029,7 +10029,7 @@ "volume": 13032847.0 } ], - "updated_at": 1783044900.4552383 + "updated_at": 1783051633.4890287 }, "00968": { "daily": [ @@ -11294,7 +11294,7 @@ "volume": 19422000.0 } ], - "updated_at": 1783044902.281637 + "updated_at": 1783045247.978355 }, "00981": { "daily": [ @@ -12559,7 +12559,7 @@ "volume": 60114819.0 } ], - "updated_at": 1783044904.170763 + "updated_at": 1783051638.5061805 }, "01070": { "daily": [ @@ -13824,7 +13824,7 @@ "volume": 690000.0 } ], - "updated_at": 1783044904.831619 + "updated_at": 1783045568.5510702 }, "01088": { "daily": [ @@ -15089,7 +15089,7 @@ "volume": 2870057.0 } ], - "updated_at": 1783044905.7026997 + "updated_at": 1783051643.521601 }, "01211": { "daily": [ @@ -16354,7 +16354,7 @@ "volume": 13286402.0 } ], - "updated_at": 1783044907.9523368 + "updated_at": 1783051648.5394585 }, "01478": { "daily": [ @@ -17619,7 +17619,7 @@ "volume": 1618000.0 } ], - "updated_at": 1783044914.9662342 + "updated_at": 1783051653.5516405 }, "01888": { "daily": [ @@ -18884,7 +18884,7 @@ "volume": 29941901.0 } ], - "updated_at": 1783044916.768711 + "updated_at": 1783051658.5684023 }, "02202": { "daily": [ @@ -20149,7 +20149,7 @@ "volume": 19786580.0 } ], - "updated_at": 1783044922.7405882 + "updated_at": 1783051663.581557 }, "02318": { "daily": [ @@ -21414,7 +21414,7 @@ "volume": 11523457.0 } ], - "updated_at": 1783044925.0042746 + "updated_at": 1783045619.0956357 }, "02359": { "daily": [ @@ -22679,7 +22679,7 @@ "volume": 1730975.0 } ], - "updated_at": 1783044933.7568398 + "updated_at": 1783045626.3903263 }, "02388": { "daily": [ @@ -23944,7 +23944,7 @@ "volume": 3823095.0 } ], - "updated_at": 1783044937.2679691 + "updated_at": 1783045637.5225153 }, "02628": { "daily": [ @@ -25209,7 +25209,7 @@ "volume": 23110112.0 } ], - "updated_at": 1783044942.9842496 + "updated_at": 1783045647.7615216 }, "06160": { "daily": [ @@ -26474,7 +26474,7 @@ "volume": 2575221.0 } ], - "updated_at": 1783044948.6934419 + "updated_at": 1783045655.0431929 }, "06869": { "daily": [ @@ -27739,7 +27739,7 @@ "volume": 15066251.0 } ], - "updated_at": 1783044950.480943 + "updated_at": 1783051668.878092 }, "09868": { "daily": [ @@ -29004,7 +29004,7 @@ "volume": 10168056.0 } ], - "updated_at": 1783044951.3377807 + "updated_at": 1783045674.3812273 }, "09988": { "daily": [ @@ -30269,7 +30269,7 @@ "volume": 35148396.0 } ], - "updated_at": 1783044956.074646 + "updated_at": 1783045677.2402296 }, "300035": { "daily": [ @@ -31526,7 +31526,7 @@ "volume": 230937.0 } ], - "updated_at": 1783044957.8285887 + "updated_at": 1783051674.5847087 }, "300124": { "daily": [ @@ -32783,7 +32783,7 @@ "volume": 722493.0 } ], - "updated_at": 1783044958.4761739 + "updated_at": 1783051344.573262 }, "300308": { "daily": [ @@ -34040,7 +34040,7 @@ "volume": 389058.0 } ], - "updated_at": 1783044962.1302354 + "updated_at": 1783051680.1109805 }, "300548": { "daily": [ @@ -35297,7 +35297,7 @@ "volume": 242727.0 } ], - "updated_at": 1783044974.5997705 + "updated_at": 1783051685.6375031 }, "300750": { "daily": [ @@ -36554,7 +36554,7 @@ "volume": 551212.0 } ], - "updated_at": 1783044988.1779277 + "updated_at": 1783051690.893728 }, "301308": { "daily": [ @@ -37811,7 +37811,7 @@ "volume": 296230.0 } ], - "updated_at": 1783044993.181729 + "updated_at": 1783051380.6922731 }, "518880": { "daily": [ @@ -39068,7 +39068,7 @@ "volume": 3915247.0 } ], - "updated_at": 1783045000.125372 + "updated_at": 1783051695.915503 }, "600519": { "daily": [ @@ -40325,7 +40325,7 @@ "volume": 64803.0 } ], - "updated_at": 1783045000.7318592 + "updated_at": 1783051561.7579694 }, "600563": { "daily": [ @@ -41582,7 +41582,7 @@ "volume": 180947.0 } ], - "updated_at": 1783045002.30473 + "updated_at": 1783051700.9725504 }, "601318": { "daily": [ @@ -42839,7 +42839,7 @@ "volume": 1746202.0 } ], - "updated_at": 1783045007.7435973 + "updated_at": 1783051575.149798 }, "601899": { "daily": [ @@ -44096,7 +44096,7 @@ "volume": 4780454.0 } ], - "updated_at": 1783045008.845117 + "updated_at": 1783051705.9927075 }, "688411": { "daily": [ @@ -45353,7 +45353,7 @@ "volume": 13672788.0 } ], - "updated_at": 1783045009.6267552 + "updated_at": 1783051711.208435 }, "688630": { "daily": [ @@ -46610,7 +46610,7 @@ "volume": 9660790.0 } ], - "updated_at": 1783045010.4224615 + "updated_at": 1783051587.7339969 }, "688639": { "daily": [ @@ -47867,7 +47867,7 @@ "volume": 13996588.0 } ], - "updated_at": 1783045016.168726 + "updated_at": 1783051733.2346125 }, "688802": { "daily": [ @@ -49092,6 +49092,6 @@ "volume": 3202146.0 } ], - "updated_at": 1783045018.4521751 + "updated_at": 1783051743.6900556 } } \ No newline at end of file diff --git a/data/portfolio.json b/data/portfolio.json index bd5028c..ea2a9d3 100644 --- a/data/portfolio.json +++ b/data/portfolio.json @@ -5,9 +5,9 @@ "name": "中际旭创", "shares": 100, "cost": 1316.53, - "price": 1145.31, - "market_value": 115899.0, - "change_pct": 0.2, + "price": 1157.97, + "market_value": 115797.0, + "change_pct": 1.31, "currency": "CNY", "position_pct": 15.27, "_currency": "CNY" @@ -16,10 +16,10 @@ "code": "06869", "name": "长飞光纤光缆", "shares": 500, - "cost": 263.72, - "price": 202.6, - "market_value": 87825.0, - "change_pct": 2.34, + "cost": 228.65, + "price": 178.26, + "market_value": 102700.0, + "change_pct": 3.859, "currency": "HKD", "position_pct": 13.47, "_currency": "HKD" @@ -28,10 +28,10 @@ "code": "01478", "name": "丘钛科技", "shares": 11000, - "cost": 13.47, - "price": 6.94, - "market_value": 66110.0, - "change_pct": 3.27, + "cost": 11.68, + "price": 6.11, + "market_value": 77550.0, + "change_pct": 4.911, "currency": "HKD", "position_pct": 7.97, "_currency": "HKD" @@ -41,9 +41,9 @@ "name": "紫金矿业", "shares": 2400, "cost": 39.89, - "price": 28.05, - "market_value": 66528.0, - "change_pct": 6.65, + "price": 28.04, + "market_value": 67296.0, + "change_pct": 6.62, "currency": "CNY", "position_pct": 7.34, "_currency": "CNY" @@ -53,9 +53,9 @@ "name": "海博思创", "shares": 200, "cost": 266.95, - "price": 257.0, - "market_value": 51770.00000000001, - "change_pct": 0.5, + "price": 257.52, + "market_value": 51504.0, + "change_pct": 0.71, "currency": "CNY", "position_pct": 6.31, "_currency": "CNY" @@ -65,9 +65,9 @@ "name": "中芯国际", "shares": 300, "cost": 126.07, - "price": 140.66, - "market_value": 43011.0, - "change_pct": -2.39, + "price": 143.65, + "market_value": 43095.0, + "change_pct": -0.31, "currency": "CNY", "position_pct": 5.44, "_currency": "CNY" @@ -76,10 +76,10 @@ "code": "01888", "name": "建滔积层板", "shares": 500, - "cost": 88.23, - "price": 86.45, - "market_value": 42450.0, - "change_pct": 3.16, + "cost": 76.5, + "price": 74.17, + "market_value": 42600.0, + "change_pct": 2.088, "currency": "HKD", "position_pct": 5.28, "_currency": "HKD" @@ -90,7 +90,7 @@ "shares": 2800, "cost": 21.51, "price": 16.69, - "market_value": 46871.99999999999, + "market_value": 46732.0, "change_pct": -1.53, "currency": "CNY", "position_pct": 5.25, @@ -101,9 +101,9 @@ "name": "宁德时代", "shares": 100, "cost": 401.78, - "price": 386.2, - "market_value": 38427.0, - "change_pct": 1.01, + "price": 384.16, + "market_value": 38416.0, + "change_pct": 0.47, "currency": "CNY", "position_pct": 4.64, "_currency": "CNY" @@ -112,10 +112,10 @@ "code": "01211", "name": "比亚迪股份", "shares": 600, - "cost": 104.87, - "price": 82.5, - "market_value": 42918.0, - "change_pct": 5.36, + "cost": 90.92, + "price": 71.66, + "market_value": 49560.0, + "change_pct": 5.556, "currency": "HKD", "position_pct": 4.62, "_currency": "HKD" @@ -124,10 +124,10 @@ "code": "02202", "name": "万科企业", "shares": 19700, - "cost": 4.67, - "price": 2.34, - "market_value": 39794.0, - "change_pct": 4.93, + "cost": 4.05, + "price": 2.02, + "market_value": 45704.0, + "change_pct": 4.484, "currency": "HKD", "position_pct": 4.6, "_currency": "HKD" @@ -137,9 +137,9 @@ "name": "腾讯", "shares": 100, "cost": null, - "price": 442.2, - "market_value": 38269.0, - "change_pct": 2.79, + "price": 379.05, + "market_value": 43700.0, + "change_pct": 1.627, "currency": "HKD", "position_pct": null, "_currency": "HKD" @@ -148,10 +148,10 @@ "code": "00981", "name": "中芯国际", "shares": 500, - "cost": 75.94, - "price": 79.8, - "market_value": 34570.0, - "change_pct": -0.75, + "cost": 65.84, + "price": 69.1, + "market_value": 39825.0, + "change_pct": -0.871, "currency": "HKD", "position_pct": 4.2, "_currency": "HKD" @@ -161,9 +161,9 @@ "name": "长芯博创", "shares": 100, "cost": 231.46, - "price": 224.15, - "market_value": 22623.0, - "change_pct": 0.97, + "price": 227.0, + "market_value": 22700.0, + "change_pct": 2.25, "currency": "CNY", "position_pct": 3.2, "_currency": "CNY" @@ -173,9 +173,9 @@ "name": "黄金ETF华安", "shares": 2400, "cost": 12.19, - "price": 8.68, - "market_value": 20832.0, - "change_pct": 2.4, + "price": 8.69, + "market_value": 20856.0, + "change_pct": 2.49, "currency": "CNY", "position_pct": 2.45, "_currency": "CNY" @@ -185,9 +185,9 @@ "name": "中科电气", "shares": 1400, "cost": 22.29, - "price": 14.45, - "market_value": 20118.0, - "change_pct": 1.98, + "price": 14.3, + "market_value": 20020.0, + "change_pct": 0.92, "currency": "CNY", "position_pct": 2.42, "_currency": "CNY" @@ -197,9 +197,9 @@ "name": "模塑科技", "shares": 1400, "cost": 14.83, - "price": 17.55, - "market_value": 24304.0, - "change_pct": 4.03, + "price": 17.37, + "market_value": 24318.0, + "change_pct": 2.96, "currency": "CNY", "position_pct": 2.41, "_currency": "CNY" @@ -209,9 +209,9 @@ "name": "法拉电子", "shares": 100, "cost": 147.18, - "price": 157.0, - "market_value": 15900.0, - "change_pct": -4.44, + "price": 161.71, + "market_value": 16171.0, + "change_pct": -1.58, "currency": "CNY", "position_pct": 2.3, "_currency": "CNY" @@ -220,21 +220,21 @@ "code": "01088", "name": "中国神华", "shares": 500, - "cost": 45.89, - "price": 39.7, - "market_value": 17220.0, - "change_pct": 0.25, + "cost": 39.79, + "price": 34.84, + "market_value": 20090.0, + "change_pct": 1.465, "currency": "HKD", "position_pct": 2.14, "_currency": "HKD" } ], - "total_assets": 961185.34, - "total_mv": 829063.41, + "total_assets": 864781.03, + "total_mv": 784305.03, "stock_value": null, - "cash": 132121.93, + "cash": 80476.0, "frozen_cash": 0.0, - "position_pct": 86.25, + "position_pct": 90.69, "currency": "CNY", - "updated_at": "2026-07-03 10:22" + "updated_at": "2026-07-03 12:10" } \ No newline at end of file diff --git a/data/price_events.json b/data/price_events.json index bfd2079..343299c 100644 --- a/data/price_events.json +++ b/data/price_events.json @@ -8309,6 +8309,806 @@ "event_label": "买入区间", "timestamp": "2026-07-03T10:20:24.988282", "date": "2026-07-03" + }, + { + "code": "001309", + "name": "德明利", + "event_type": "entry_zone", + "price": 833.99, + "trigger_value": "818.3~838.69", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:22:24.916521", + "date": "2026-07-03" + }, + { + "code": "600519", + "name": "贵州茅台", + "event_type": "entry_zone", + "price": 1203.0, + "trigger_value": "1179.92~1206.14", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:22:25.114242", + "date": "2026-07-03" + }, + { + "code": "000657", + "name": "中钨高新", + "event_type": "entry_zone", + "price": 87.93, + "trigger_value": "86.73~90.27", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:22:55.561315", + "date": "2026-07-03" + }, + { + "code": "000711", + "name": "ST京蓝", + "event_type": "entry_zone", + "price": 5.24, + "trigger_value": "5.14~5.26", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:22:55.893638", + "date": "2026-07-03" + }, + { + "code": "688630", + "name": "芯碁微装", + "event_type": "entry_zone", + "price": 486.0, + "trigger_value": "475.2~486.17", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:22:57.837686", + "date": "2026-07-03" + }, + { + "code": "000657", + "name": "中钨高新", + "event_type": "entry_zone", + "price": 88.89, + "trigger_value": "86.27~89.79", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:27:25.917807", + "date": "2026-07-03" + }, + { + "code": "000711", + "name": "ST京蓝", + "event_type": "entry_zone", + "price": 5.23, + "trigger_value": "5.14~5.25", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:27:31.525100", + "date": "2026-07-03" + }, + { + "code": "02359", + "name": "药明康德", + "event_type": "entry_zone", + "price": 152.9, + "trigger_value": "149.45~154.86", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:28:16.907063", + "date": "2026-07-03" + }, + { + "code": "06160", + "name": "百济神州", + "event_type": "entry_zone", + "price": 181.6, + "trigger_value": "179.63~183.4", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:28:19.403509", + "date": "2026-07-03" + }, + { + "code": "06869", + "name": "长飞光纤光缆", + "event_type": "entry_zone", + "price": 200.4, + "trigger_value": "182.52~212.94", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:28:19.456833", + "date": "2026-07-03" + }, + { + "code": "09868", + "name": "小鹏汽车-W", + "event_type": "entry_zone", + "price": 50.25, + "trigger_value": "50.18~51.14", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:28:19.522580", + "date": "2026-07-03" + }, + { + "code": "09988", + "name": "阿里巴巴-W", + "event_type": "entry_zone", + "price": 94.1, + "trigger_value": "93.49~94.92", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:28:19.602866", + "date": "2026-07-03" + }, + { + "code": "000657", + "name": "中钨高新", + "event_type": "entry_zone", + "price": 88.67, + "trigger_value": "86.83~90.37", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:30:19.791987", + "date": "2026-07-03" + }, + { + "code": "000711", + "name": "ST京蓝", + "event_type": "entry_zone", + "price": 5.25, + "trigger_value": "5.14~5.25", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:30:25.159354", + "date": "2026-07-03" + }, + { + "code": "000657", + "name": "中钨高新", + "event_type": "entry_zone", + "price": 88.79, + "trigger_value": "86.83~90.37", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:34:15.409862", + "date": "2026-07-03" + }, + { + "code": "000711", + "name": "ST京蓝", + "event_type": "entry_zone", + "price": 5.25, + "trigger_value": "5.14~5.25", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:34:15.537060", + "date": "2026-07-03" + }, + { + "code": "300124", + "name": "汇川技术", + "event_type": "entry_zone", + "price": 71.24, + "trigger_value": "70.48~71.55", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:38:29.251662", + "date": "2026-07-03" + }, + { + "code": "000700", + "name": "模塑科技", + "event_type": "entry_zone", + "price": 17.31, + "trigger_value": "0~18.93", + "event_label": "止盈区间", + "timestamp": "2026-07-03T10:38:39.296028", + "date": "2026-07-03" + }, + { + "code": "300124", + "name": "汇川技术", + "event_type": "entry_zone", + "price": 71.23, + "trigger_value": "70.48~71.55", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:38:42.781286", + "date": "2026-07-03" + }, + { + "code": "600563", + "name": "法拉电子", + "event_type": "entry_zone", + "price": 159.56, + "trigger_value": "0~171.96", + "event_label": "止盈区间", + "timestamp": "2026-07-03T10:38:42.981115", + "date": "2026-07-03" + }, + { + "code": "601899", + "name": "紫金矿业", + "event_type": "entry_zone", + "price": 28.24, + "trigger_value": "0~30.38", + "event_label": "止盈区间", + "timestamp": "2026-07-03T10:38:43.067796", + "date": "2026-07-03" + }, + { + "code": "688981", + "name": "中芯国际", + "event_type": "entry_zone", + "price": 142.5, + "trigger_value": "0~153.58", + "event_label": "止盈区间", + "timestamp": "2026-07-03T10:38:43.142300", + "date": "2026-07-03" + }, + { + "code": "000711", + "name": "ST京蓝", + "event_type": "entry_zone", + "price": 5.26, + "trigger_value": "5.15~5.26", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:44:30.388534", + "date": "2026-07-03" + }, + { + "code": "000711", + "name": "ST京蓝", + "event_type": "entry_zone", + "price": 5.26, + "trigger_value": "5.15~5.26", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:48:28.599391", + "date": "2026-07-03" + }, + { + "code": "000711", + "name": "ST京蓝", + "event_type": "entry_zone", + "price": 5.26, + "trigger_value": "5.15~5.26", + "event_label": "买入区间", + "timestamp": "2026-07-03T10:50:35.989841", + "date": "2026-07-03" + }, + { + "code": "300124", + "name": "汇川技术", + "event_type": "entry_zone", + "price": 71.12, + "trigger_value": "69.93~71.47", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:10:13.492666", + "date": "2026-07-03" + }, + { + "code": "688795", + "name": "摩尔线程-U", + "event_type": "entry_zone", + "price": 656.41, + "trigger_value": "640.4~661.55", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:10:14.388968", + "date": "2026-07-03" + }, + { + "code": "688802", + "name": "沐曦股份-U", + "event_type": "entry_zone", + "price": 747.1, + "trigger_value": "735.17~758.83", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:10:14.569334", + "date": "2026-07-03" + }, + { + "code": "301308", + "name": "江波龙", + "event_type": "entry_zone", + "price": 619.0, + "trigger_value": "613.93~620.18", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:13:01.494442", + "date": "2026-07-03" + }, + { + "code": "00700", + "name": "腾讯", + "event_type": "entry_zone", + "price": 437.0, + "trigger_value": "427.28~444.72", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:14:23.996542", + "date": "2026-07-03" + }, + { + "code": "00700", + "name": "腾讯", + "event_type": "entry_zone", + "price": 437.0, + "trigger_value": "0~470.88", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:14:24.624140", + "date": "2026-07-03" + }, + { + "code": "00981", + "name": "中芯国际", + "event_type": "entry_zone", + "price": 80.7, + "trigger_value": "72.45~84.53", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:14:24.732225", + "date": "2026-07-03" + }, + { + "code": "00981", + "name": "中芯国际", + "event_type": "entry_zone", + "price": 80.7, + "trigger_value": "0~86.94", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:14:25.084481", + "date": "2026-07-03" + }, + { + "code": "01088", + "name": "中国神华", + "event_type": "entry_zone", + "price": 40.16, + "trigger_value": "35.93~41.92", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:14:28.993449", + "date": "2026-07-03" + }, + { + "code": "01088", + "name": "中国神华", + "event_type": "entry_zone", + "price": 40.16, + "trigger_value": "0~43.11", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:14:29.629844", + "date": "2026-07-03" + }, + { + "code": "01211", + "name": "比亚迪股份", + "event_type": "entry_zone", + "price": 82.6, + "trigger_value": "74.03~86.36", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:14:29.965400", + "date": "2026-07-03" + }, + { + "code": "01211", + "name": "比亚迪股份", + "event_type": "entry_zone", + "price": 82.6, + "trigger_value": "0~88.83", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:14:30.114407", + "date": "2026-07-03" + }, + { + "code": "01478", + "name": "丘钛科技", + "event_type": "entry_zone", + "price": 7.05, + "trigger_value": "6.32~7.37", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:14:30.304612", + "date": "2026-07-03" + }, + { + "code": "01478", + "name": "丘钛科技", + "event_type": "entry_zone", + "price": 7.05, + "trigger_value": "0~7.58", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:14:35.358231", + "date": "2026-07-03" + }, + { + "code": "01888", + "name": "建滔积层板", + "event_type": "entry_zone", + "price": 85.6, + "trigger_value": "78.08~91.09", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:14:40.394717", + "date": "2026-07-03" + }, + { + "code": "01888", + "name": "建滔积层板", + "event_type": "entry_zone", + "price": 85.6, + "trigger_value": "0~93.69", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:14:45.434897", + "date": "2026-07-03" + }, + { + "code": "02202", + "name": "万科企业", + "event_type": "entry_zone", + "price": 2.32, + "trigger_value": "2.07~2.42", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:14:50.523069", + "date": "2026-07-03" + }, + { + "code": "02202", + "name": "万科企业", + "event_type": "entry_zone", + "price": 2.32, + "trigger_value": "0~2.48", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:14:59.705931", + "date": "2026-07-03" + }, + { + "code": "06869", + "name": "长飞光纤光缆", + "event_type": "entry_zone", + "price": 206.8, + "trigger_value": "186.12~217.14", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:15:00.287164", + "date": "2026-07-03" + }, + { + "code": "06869", + "name": "长飞光纤光缆", + "event_type": "entry_zone", + "price": 206.8, + "trigger_value": "0~223.34", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:15:00.430498", + "date": "2026-07-03" + }, + { + "code": "301308", + "name": "江波龙", + "event_type": "entry_zone", + "price": 618.02, + "trigger_value": "611.31~619.68", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:22:07.308667", + "date": "2026-07-03" + }, + { + "code": "300124", + "name": "汇川技术", + "event_type": "entry_zone", + "price": 71.11, + "trigger_value": "69.91~71.42", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:22:20.859749", + "date": "2026-07-03" + }, + { + "code": "301308", + "name": "江波龙", + "event_type": "entry_zone", + "price": 617.89, + "trigger_value": "611.31~619.68", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:22:22.199650", + "date": "2026-07-03" + }, + { + "code": "00700", + "name": "腾讯", + "event_type": "entry_zone", + "price": 438.6, + "trigger_value": "427.87~445.33", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:52:11.456641", + "date": "2026-07-03" + }, + { + "code": "00700", + "name": "腾讯", + "event_type": "entry_zone", + "price": 438.6, + "trigger_value": "0~471.53", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:52:18.294805", + "date": "2026-07-03" + }, + { + "code": "00981", + "name": "中芯国际", + "event_type": "entry_zone", + "price": 79.9, + "trigger_value": "72.09~84.11", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:52:26.253956", + "date": "2026-07-03" + }, + { + "code": "00981", + "name": "中芯国际", + "event_type": "entry_zone", + "price": 79.9, + "trigger_value": "0~86.51", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:52:29.577070", + "date": "2026-07-03" + }, + { + "code": "01088", + "name": "中国神华", + "event_type": "entry_zone", + "price": 40.3, + "trigger_value": "36.36~42.42", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:52:30.340343", + "date": "2026-07-03" + }, + { + "code": "01088", + "name": "中国神华", + "event_type": "entry_zone", + "price": 40.3, + "trigger_value": "0~43.63", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:52:32.714618", + "date": "2026-07-03" + }, + { + "code": "01211", + "name": "比亚迪股份", + "event_type": "entry_zone", + "price": 82.6, + "trigger_value": "74.39~86.78", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:52:36.541205", + "date": "2026-07-03" + }, + { + "code": "01211", + "name": "比亚迪股份", + "event_type": "entry_zone", + "price": 82.6, + "trigger_value": "0~89.26", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:52:36.686582", + "date": "2026-07-03" + }, + { + "code": "01478", + "name": "丘钛科技", + "event_type": "entry_zone", + "price": 7.1, + "trigger_value": "6.35~7.41", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:52:37.242356", + "date": "2026-07-03" + }, + { + "code": "01478", + "name": "丘钛科技", + "event_type": "entry_zone", + "price": 7.1, + "trigger_value": "0~7.62", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:52:37.443395", + "date": "2026-07-03" + }, + { + "code": "01888", + "name": "建滔积层板", + "event_type": "entry_zone", + "price": 85.65, + "trigger_value": "76.77~89.56", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:52:38.817321", + "date": "2026-07-03" + }, + { + "code": "01888", + "name": "建滔积层板", + "event_type": "entry_zone", + "price": 85.65, + "trigger_value": "0~92.12", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:52:39.528996", + "date": "2026-07-03" + }, + { + "code": "02202", + "name": "万科企业", + "event_type": "entry_zone", + "price": 2.34, + "trigger_value": "2.1~2.45", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:52:39.720487", + "date": "2026-07-03" + }, + { + "code": "02202", + "name": "万科企业", + "event_type": "entry_zone", + "price": 2.34, + "trigger_value": "0~2.52", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:52:41.301094", + "date": "2026-07-03" + }, + { + "code": "06869", + "name": "长飞光纤光缆", + "event_type": "entry_zone", + "price": 206.6, + "trigger_value": "184.68~215.46", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:52:45.543223", + "date": "2026-07-03" + }, + { + "code": "06869", + "name": "长飞光纤光缆", + "event_type": "entry_zone", + "price": 206.6, + "trigger_value": "0~221.62", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:52:47.160071", + "date": "2026-07-03" + }, + { + "code": "00700", + "name": "腾讯", + "event_type": "entry_zone", + "price": 437.8, + "trigger_value": "427.87~445.33", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:54:14.974895", + "date": "2026-07-03" + }, + { + "code": "00700", + "name": "腾讯", + "event_type": "entry_zone", + "price": 437.8, + "trigger_value": "0~471.53", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:54:17.221621", + "date": "2026-07-03" + }, + { + "code": "00981", + "name": "中芯国际", + "event_type": "entry_zone", + "price": 79.7, + "trigger_value": "72.09~84.11", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:54:22.810159", + "date": "2026-07-03" + }, + { + "code": "00981", + "name": "中芯国际", + "event_type": "entry_zone", + "price": 79.7, + "trigger_value": "0~86.51", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:54:24.193676", + "date": "2026-07-03" + }, + { + "code": "01088", + "name": "中国神华", + "event_type": "entry_zone", + "price": 40.22, + "trigger_value": "36.36~42.42", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:54:24.477745", + "date": "2026-07-03" + }, + { + "code": "01088", + "name": "中国神华", + "event_type": "entry_zone", + "price": 40.22, + "trigger_value": "0~43.63", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:54:24.655213", + "date": "2026-07-03" + }, + { + "code": "01211", + "name": "比亚迪股份", + "event_type": "entry_zone", + "price": 82.8, + "trigger_value": "74.39~86.78", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:54:25.091753", + "date": "2026-07-03" + }, + { + "code": "01211", + "name": "比亚迪股份", + "event_type": "entry_zone", + "price": 82.8, + "trigger_value": "0~89.26", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:54:25.321463", + "date": "2026-07-03" + }, + { + "code": "01478", + "name": "丘钛科技", + "event_type": "entry_zone", + "price": 7.08, + "trigger_value": "6.35~7.41", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:54:26.301603", + "date": "2026-07-03" + }, + { + "code": "01478", + "name": "丘钛科技", + "event_type": "entry_zone", + "price": 7.08, + "trigger_value": "0~7.62", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:54:29.534882", + "date": "2026-07-03" + }, + { + "code": "01888", + "name": "建滔积层板", + "event_type": "entry_zone", + "price": 85.7, + "trigger_value": "76.77~89.56", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:54:29.649302", + "date": "2026-07-03" + }, + { + "code": "01888", + "name": "建滔积层板", + "event_type": "entry_zone", + "price": 85.7, + "trigger_value": "0~92.12", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:54:30.678788", + "date": "2026-07-03" + }, + { + "code": "02202", + "name": "万科企业", + "event_type": "entry_zone", + "price": 2.34, + "trigger_value": "2.1~2.45", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:54:30.768058", + "date": "2026-07-03" + }, + { + "code": "02202", + "name": "万科企业", + "event_type": "entry_zone", + "price": 2.34, + "trigger_value": "0~2.52", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:54:30.829554", + "date": "2026-07-03" + }, + { + "code": "06869", + "name": "长飞光纤光缆", + "event_type": "entry_zone", + "price": 206.2, + "trigger_value": "184.68~215.46", + "event_label": "买入区间", + "timestamp": "2026-07-03T11:54:30.916574", + "date": "2026-07-03" + }, + { + "code": "06869", + "name": "长飞光纤光缆", + "event_type": "entry_zone", + "price": 206.2, + "trigger_value": "0~221.62", + "event_label": "止盈区间", + "timestamp": "2026-07-03T11:54:31.001389", + "date": "2026-07-03" } ] } \ No newline at end of file diff --git a/data/price_history.json b/data/price_history.json index 17b0329..404e7e2 100644 --- a/data/price_history.json +++ b/data/price_history.json @@ -25,8 +25,8 @@ { "date": "2026-07-03", "high": 1215.52, - "low": 1190.51, - "close": 1203.6 + "low": 1190.5, + "close": 1191.31 } ], "02202": [ @@ -48,7 +48,7 @@ "date": "2026-07-03", "high": 53.0, "low": 51.7, - "close": 52.5 + "close": 52.45 } ], "601318": [ @@ -61,8 +61,8 @@ { "date": "2026-07-03", "high": 50.2, - "low": 48.8, - "close": 49.12 + "low": 48.6, + "close": 48.7 } ], "02359": [ @@ -76,7 +76,7 @@ "date": "2026-07-03", "high": 158.7, "low": 151.1, - "close": 153.1 + "close": 152.5 } ], "02388": [ @@ -90,7 +90,7 @@ "date": "2026-07-03", "high": 42.98, "low": 41.94, - "close": 42.66 + "close": 42.68 } ], "02628": [ @@ -104,7 +104,7 @@ "date": "2026-07-03", "high": 29.08, "low": 27.5, - "close": 28.66 + "close": 28.64 } ], "688630": [ @@ -116,9 +116,9 @@ }, { "date": "2026-07-03", - "high": 499.95, + "high": 502.0, "low": 444.55, - "close": 486.69 + "close": 495.95 } ], "06160": [ @@ -132,7 +132,7 @@ "date": "2026-07-03", "high": 183.9, "low": 171.9, - "close": 183.3 + "close": 182.9 } ], "06869": [ @@ -154,7 +154,7 @@ "date": "2026-07-03", "high": 687.04, "low": 633.01, - "close": 642.05 + "close": 654.0 } ], "09868": [ @@ -168,7 +168,7 @@ "date": "2026-07-03", "high": 53.7, "low": 49.3, - "close": 51.1 + "close": 50.9 } ], "09988": [ @@ -182,7 +182,7 @@ "date": "2026-07-03", "high": 97.4, "low": 93.55, - "close": 95.5 + "close": 95.15 } ], "688802": [ @@ -194,9 +194,9 @@ }, { "date": "2026-07-03", - "high": 733.99, + "high": 753.88, "low": 713.0, - "close": 719.01 + "close": 744.0 } ], "300124": [ @@ -208,9 +208,9 @@ }, { "date": "2026-07-03", - "high": 71.79, + "high": 72.38, "low": 67.31, - "close": 71.07 + "close": 72.29 } ], "000657": [ @@ -223,8 +223,8 @@ { "date": "2026-07-03", "high": 101.5, - "low": 87.92, - "close": 88.6 + "low": 87.88, + "close": 91.88 } ], "000711": [ @@ -238,7 +238,7 @@ "date": "2026-07-03", "high": 5.26, "low": 4.87, - "close": 5.25 + "close": 5.26 } ], "001309": [ @@ -250,9 +250,9 @@ }, { "date": "2026-07-03", - "high": 872.83, + "high": 882.5, "low": 795.0, - "close": 835.99 + "close": 874.23 } ], "002594": [ @@ -266,7 +266,7 @@ "date": "2026-07-03", "high": 87.28, "low": 81.9, - "close": 86.88 + "close": 86.56 } ], "00700": [ @@ -288,7 +288,7 @@ "date": "2026-07-03", "high": 2.15, "low": 2.03, - "close": 2.11 + "close": 2.1 } ], "00981": [ @@ -308,9 +308,9 @@ }, { "date": "2026-07-03", - "high": 13.31, + "high": 13.39, "low": 12.63, - "close": 13.31 + "close": 13.36 } ], "01088": [ @@ -332,9 +332,9 @@ "301308": [ { "date": "2026-07-03", - "high": 612.3, + "high": 631.56, "low": 574.1, - "close": 600.8 + "close": 620.0 } ] } \ No newline at end of file diff --git a/mo_data.py b/mo_data.py index 6143c95..d6a4e79 100644 --- a/mo_data.py +++ b/mo_data.py @@ -1,28 +1,23 @@ #!/usr/bin/env python3 """ -mo_data.py — MoFin 统一数据读取层 +mo_data.py — MoFin 统一数据层(纯 DB) -替代 json.load(open(portfolio.json)) 等直接读 JSON 文件的方式。 -所有数据从 DB 读取,币种字段强制存在。 +所有数据从 SQLite 读取。不做 JSON fallback。 +JSON 文件已弃用,仅保留为历史备份。 用法: from mo_data import read_portfolio, read_decisions, read_watchlist - + pf = read_portfolio() # 返回和 portfolio.json 一样的 dict 结构 dec = read_decisions() # 返回和 decisions.json 一样的 dict 结构 wl = read_watchlist() # 返回和 watchlist.json 一样的 dict 结构 """ -import sqlite3, json, os +import sqlite3, json from datetime import datetime DB_PATH = '/home/hmo/web-dashboard/data/mofin.db' -# JSON 文件路径(冷备,仅当 DB 不可用时 fallback) -PORTFOLIO_JSON = '/home/hmo/web-dashboard/data/portfolio.json' -DECISIONS_JSON = '/home/hmo/web-dashboard/data/decisions.json' -WATCHLIST_JSON = '/home/hmo/web-dashboard/data/watchlist.json' - def _get_db(): db = sqlite3.connect(DB_PATH) @@ -30,176 +25,133 @@ def _get_db(): return db -# ── portfolio.json → holdings + portfolio_summary ───────────────── +# ── portfolio ───────────────────────────────────────────────────── def read_portfolio(): - """返回 portfolio.json 等价 dict。DB 优先,JSON 冷备。""" - try: - db = _get_db() - # holdings - rows = db.execute( - "SELECT code, name, shares, cost, price, market_value, " - "change_pct, currency, position_pct " - "FROM holdings WHERE is_active=1" - ).fetchall() - holdings = [] - for r in rows: - h = dict(r) - h['_currency'] = h.get('currency', 'CNY') # 兼容旧代码的 _currency 字段名 - holdings.append(h) - - # summary - sum_row = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone() - summary = dict(sum_row) if sum_row else {} - - db.close() - - return { - "holdings": holdings, - "total_assets": summary.get("total_assets", 0), - "total_mv": summary.get("total_mv", 0), - "stock_value": summary.get("stock_value", summary.get("total_mv", 0)), - "cash": summary.get("cash", 0), - "frozen_cash": summary.get("frozen_cash", 0), - "position_pct": summary.get("position_pct", 0), - "currency": summary.get("currency", "CNY"), - "updated_at": summary.get("updated_at", ""), - } - except Exception: - pass - - # JSON 冷备 - try: - return json.load(open(PORTFOLIO_JSON, encoding='utf-8')) - except: - return {"holdings": [], "total_assets": 0, "cash": 0, "frozen_cash": 0} + """返回 portfolio.json 等价 dict。纯 DB。""" + db = _get_db() + rows = db.execute( + "SELECT code, name, shares, cost, price, market_value, " + "change_pct, currency, position_pct " + "FROM holdings WHERE is_active=1" + ).fetchall() + holdings = [] + for r in rows: + h = dict(r) + h['_currency'] = h.get('currency', 'CNY') + holdings.append(h) + + sum_row = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone() + summary = dict(sum_row) if sum_row else {} + + db.close() + + return { + "holdings": holdings, + "total_assets": summary.get("total_assets", 0), + "total_mv": summary.get("total_mv", 0), + "stock_value": summary.get("stock_value", summary.get("total_mv", 0)), + "cash": summary.get("cash", 0), + "frozen_cash": summary.get("frozen_cash", 0), + "position_pct": summary.get("position_pct", 0), + "currency": summary.get("currency", "CNY"), + "updated_at": summary.get("updated_at", ""), + } -# ── decisions.json → holding_strategies ──────────────────────────── +# ── decisions ───────────────────────────────────────────────────── + +def _parse_json(val, default): + if val: + try: return json.loads(val) + except: pass + return default + def read_decisions(): - """返回 decisions.json 等价 dict。DB 优先,JSON 冷备。""" - try: - db = _get_db() - rows = db.execute( - "SELECT code, name, version, price, cost, shares, " - "stop_loss, take_profit, entry_low, entry_high, " - "currency, strategy_type, action, timing_signal, " - "rr_ratio, tech_snapshot, stock_category, sector_context, " - "status, trigger_json, changelog_json, source, reason, " - "created_at, updated_at " - "FROM holding_strategies WHERE status IN ('active','updated') " - "ORDER BY code" - ).fetchall() - - decisions = [] - for r in rows: - d = dict(r) - # 还原 trigger 字段 - if d.get('trigger_json'): - try: - d['trigger'] = json.loads(d['trigger_json']) - except: - d['trigger'] = {} - else: - d['trigger'] = {} - # 还原 changelog 字段 - if d.get('changelog_json'): - try: - d['changelog'] = json.loads(d['changelog_json']) - except: - d['changelog'] = [] - else: - d['changelog'] = [] - decisions.append(d) - - db.close() - - return { - "decisions": decisions, - "total": len(decisions), - "regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), - } - except Exception: - pass - - try: - return json.load(open(DECISIONS_JSON, encoding='utf-8')) - except: - return {"decisions": [], "total": 0} + """返回 decisions.json 等价 dict。纯 DB。""" + db = _get_db() + rows = db.execute( + "SELECT code, name, version, price, cost, shares, " + "stop_loss, take_profit, entry_low, entry_high, " + "currency, strategy_type, action, timing_signal, " + "rr_ratio, tech_snapshot, stock_category, sector_context, " + "status, trigger_json, changelog_json, source, reason, " + "created_at, updated_at, " + "avg_price, decision_timestamp, note, quality_check, " + "quality_checked_at, quality_issues_json, position_advice, " + "signal_factors_json, time_horizon, decision_type " + "FROM holding_strategies WHERE status IN ('active','updated') " + "ORDER BY code" + ).fetchall() + + decisions = [] + for r in rows: + d = dict(r) + d['trigger'] = _parse_json(r['trigger_json'], {}) + d['changelog'] = _parse_json(r['changelog_json'], []) + d['quality_issues'] = _parse_json(r['quality_issues_json'], {}) + d['signal_factors'] = _parse_json(r['signal_factors_json'], []) + d['timestamp'] = r['decision_timestamp'] or r['created_at'] or '' + d['type'] = r['decision_type'] or r['strategy_type'] or '持仓策略' + decisions.append(d) + + db.close() + + return { + "decisions": decisions, + "total": len(decisions), + "regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), + } -# ── watchlist.json → watchlist_stocks ────────────────────────────── +# ── watchlist ───────────────────────────────────────────────────── def read_watchlist(): - """返回 watchlist.json 等价 dict。DB 优先,JSON 冷备。""" - try: - db = _get_db() - rows = db.execute( - "SELECT code, name, price, entry_low, entry_high, " - "stop_loss, currency, source, source_detail, notes, " - "added_by, added_at, analysis_json " - "FROM watchlist_stocks WHERE is_active=1" - ).fetchall() - - stocks = [] - for r in rows: - s = dict(r) - if s.get('source_detail'): - try: s['source_detail'] = json.loads(s['source_detail']) - except: pass - if s.get('analysis_json'): - try: s['analysis'] = json.loads(s['analysis_json']) - except: s['analysis'] = {} - else: - s['analysis'] = {} - stocks.append(s) - - db.close() - - return { - "stocks": stocks, - "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), - } - except Exception: - pass - - try: - return json.load(open(WATCHLIST_JSON, encoding='utf-8')) - except: - return {"stocks": []} + """返回 watchlist.json 等价 dict。纯 DB。""" + db = _get_db() + rows = db.execute( + "SELECT code, name, price, entry_low, entry_high, " + "stop_loss, currency, source, source_detail, notes, " + "added_by, added_at, analysis_json " + "FROM watchlist_stocks WHERE is_active=1" + ).fetchall() + + stocks = [] + for r in rows: + s = dict(r) + s['source_detail'] = _parse_json(r['source_detail'], None) + if r['analysis_json']: + s['analysis'] = _parse_json(r['analysis_json'], {}) + else: + s['analysis'] = {} + stocks.append(s) + + db.close() + + return { + "stocks": stocks, + "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), + } -# ── 便捷函数 ──────────────────────────────────────────────────────── +# ── 便捷别名 ─────────────────────────────────────────────────────── def read_portfolio_json(): - """别名(兼容旧代码直接 import 后调用)""" return read_portfolio() def read_decisions_json(): - """别名""" return read_decisions() def read_watchlist_json(): - """别名""" return read_watchlist() -# ── cash_log 写入 ───────────────────────────────────────────────────── +# ── cash_log 写入 ────────────────────────────────────────────────── def write_cash_log(cash_before, cash_after, frozen_before, frozen_after, source, note, verified=0): - """记录现金变更到 cash_log 表。 - - 参数: - cash_before/after — 变更前后可用现金 - frozen_before/after — 变更前后冻结资金 - source — 'screenshot'/'manual'/'trade'/'import_xls' - note — 备注(如"卖出法拉电子200股") - verified — 0=未验证 1=Dad已确认 - 返回: - log_id (int) - """ + """记录现金变更到 cash_log 表。""" change_amount = round(cash_after - cash_before, 2) if cash_after is not None and cash_before is not None else 0 db = sqlite3.connect(DB_PATH) try: @@ -217,14 +169,14 @@ def write_cash_log(cash_before, cash_after, frozen_before, frozen_after, db.close() -# ── 自检 ──────────────────────────────────────────────────────────── +# ── 自检 ─────────────────────────────────────────────────────────── if __name__ == "__main__": pf = read_portfolio() print(f"portfolio: {len(pf.get('holdings',[]))} holdings, total_assets={pf.get('total_assets',0)}") - + dec = read_decisions() print(f"decisions: {len(dec.get('decisions',[]))} entries") - + wl = read_watchlist() print(f"watchlist: {len(wl.get('stocks',[]))} stocks") diff --git a/mofin_db.py b/mofin_db.py index f6dde63..f746234 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -141,33 +141,44 @@ def init_all_tables(conn: sqlite3.Connection): -- 持仓策略(对应 decisions.json decisions[]) CREATE TABLE IF NOT EXISTS holding_strategies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL REFERENCES holdings(code), - name TEXT, - version INTEGER DEFAULT 1, - price REAL, -- 当前价格 - cost REAL, -- 成本价 - shares INTEGER DEFAULT 0, - stop_loss REAL, - take_profit REAL, - entry_low REAL, - entry_high REAL, - currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), - strategy_type TEXT DEFAULT 'holding', - action TEXT, -- 买入/持有/卖出/观望 - timing_signal TEXT, -- 时机信号 - rr_ratio REAL, -- 盈亏比 - tech_snapshot TEXT, -- 技术面快照 - stock_category TEXT, -- 股票分类 - sector_context TEXT, -- 板块背景 - status TEXT DEFAULT 'active', -- active/updated/closed - trigger_json TEXT, -- trigger JSON (entry_zone/stop_loss/take_profit_zone) - changelog_json TEXT, -- changelog JSON 数组 - source TEXT, - reason TEXT, - created_at TEXT DEFAULT (datetime('now','localtime')), - updated_at TEXT, - superseded_at TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES holdings(code), + name TEXT, + version INTEGER DEFAULT 1, + price REAL, + cost REAL, + shares INTEGER DEFAULT 0, + stop_loss REAL, + take_profit REAL, + entry_low REAL, + entry_high REAL, + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + strategy_type TEXT DEFAULT 'holding', + action TEXT, + timing_signal TEXT, + rr_ratio REAL, + tech_snapshot TEXT, + stock_category TEXT, + sector_context TEXT, + status TEXT DEFAULT 'active', + trigger_json TEXT, + changelog_json TEXT, + source TEXT, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT, + superseded_at TEXT, + -- 以下为 decisions.json→DB 迁移新增列 + avg_price REAL, + decision_timestamp TEXT, + note TEXT, + quality_check TEXT, + quality_checked_at TEXT, + quality_issues_json TEXT, + position_advice TEXT, + signal_factors_json TEXT, + time_horizon TEXT, + decision_type TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_strategy_code ON holding_strategies(code); CREATE INDEX IF NOT EXISTS idx_strategy_status ON holding_strategies(status); @@ -954,7 +965,14 @@ def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool """写入持仓策略(替代 decisions.json 单条写入)。data 必须包含 currency。""" try: currency = data.get('currency', 'CNY') - # DELETE + INSERT(避免依赖 code 的 UNIQUE 约束) + # Serialize JSON fields + import json as _json + trigger_j = _json.dumps(data.get('trigger', {}), ensure_ascii=False) if isinstance(data.get('trigger'), dict) else str(data.get('trigger', '{}')) + changelog_j = _json.dumps(data.get('changelog', []), ensure_ascii=False) if isinstance(data.get('changelog'), list) else str(data.get('changelog', '[]')) + quality_issues_j = _json.dumps(data.get('quality_issues', {}), ensure_ascii=False) if isinstance(data.get('quality_issues'), dict) else data.get('quality_issues_json', '') + signal_factors_j = _json.dumps(data.get('signal_factors', []), ensure_ascii=False) if isinstance(data.get('signal_factors'), list) else data.get('signal_factors_json', '') + + # DELETE + INSERT conn.execute("DELETE FROM holding_strategies WHERE code=?", (code,)) conn.execute(""" INSERT INTO holding_strategies @@ -962,8 +980,13 @@ def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool entry_low, entry_high, currency, strategy_type, action, timing_signal, rr_ratio, tech_snapshot, stock_category, sector_context, status, trigger_json, changelog_json, - source, reason, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime')) + source, reason, updated_at, + avg_price, decision_timestamp, note, quality_check, + quality_checked_at, quality_issues_json, position_advice, + signal_factors_json, time_horizon, decision_type) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, + datetime('now','localtime'), + ?,?,?,?,?,?,?,?,?) """, ( code, name, data.get('version', 1), data.get('price'), data.get('cost'), @@ -973,8 +996,19 @@ def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool data.get('timing_signal'), data.get('rr_ratio'), data.get('tech_snapshot'), data.get('stock_category'), data.get('sector_context'), data.get('status', 'active'), - data.get('trigger_json'), data.get('changelog_json'), + trigger_j, changelog_j, data.get('source'), data.get('reason'), + # new columns + data.get('avg_price', 0), + data.get('timestamp') or data.get('created_at', ''), + data.get('note', ''), + data.get('quality_check', ''), + data.get('quality_checked_at', ''), + quality_issues_j, + data.get('position_advice', ''), + signal_factors_j, + data.get('time_horizon', ''), + data.get('type', data.get('strategy_type', 'holding')), )) conn.commit() return True, f"策略 {code} 已写入" diff --git a/price_monitor.py b/price_monitor.py index 789ce02..979645e 100644 --- a/price_monitor.py +++ b/price_monitor.py @@ -12,12 +12,9 @@ from datetime import datetime # ── MoFin unified model ────────────────────────────────────────────── from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct -from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock, write_holding_strategy 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" -WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json") EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" @@ -280,14 +277,17 @@ def refresh_data_prices(): import time; time.sleep((attempt+1)*1) else: print(f" [DB写入失败 3次重试后放弃] {e}", flush=True) - # 保留 JSON 副本作为冷备 - json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) elif pf.get('updated_at'): try: last_ts = datetime.strptime(pf['updated_at'], '%Y-%m-%d %H:%M') if (datetime.now() - last_ts).total_seconds() > 600: pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) + try: + conn = get_conn() + write_portfolio_summary(conn, pf) + conn.close() + except Exception: + pass except: pass @@ -314,8 +314,6 @@ def refresh_data_prices(): conn.close() except Exception as e: print(f" [DB watchlist写入失败] {e}", flush=True) - # 保留 JSON 冷备 - json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2) # --- 汇总值重算(使用 mo_models 唯一公式)--- try: @@ -336,8 +334,6 @@ def refresh_data_prices(): conn.close() except Exception as e: print(f" [DB汇总写入失败] {e}", flush=True) - # JSON 冷备 - json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) except Exception as e: print(f" [汇总重算失败] {e}", flush=True) # --- 结束汇总重算 --- @@ -377,7 +373,13 @@ def _record_branch_trigger(code, branch_id, price): b['last_trigger_price'] = round(price, 2) b['last_triggered'] = datetime.now().isoformat() break - json.dump(raw, open(DECISIONS_PATH, 'w'), ensure_ascii=False, indent=2) + try: + conn = get_conn() + for d in raw.get('decisions', []): + write_holding_strategy(conn, d['code'], d.get('name', ''), d) + conn.close() + except Exception: + pass except Exception: pass @@ -504,10 +506,9 @@ def run_once(round_label=""): # === 第二步:检查触发条件 === try: - with open(DECISIONS_PATH) as f: - dec = json.load(f) + dec = read_decisions() except: - print(f"❌{label} 无法读取decisions.json", file=sys.stderr) + print(f"❌{label} 无法读取决策数据", file=sys.stderr) return active = [d for d in dec.get("decisions", []) if d.get("status") in ("active", "updated")] diff --git a/prompt_manager/analytics.py b/prompt_manager/analytics.py index 11551e5..aca9416 100644 --- a/prompt_manager/analytics.py +++ b/prompt_manager/analytics.py @@ -1,186 +1,193 @@ -"""策略→提示词版本分析引擎 - -核心功能:将策略评估结果按提示词版本聚合,计算每个版本的准确率。 -""" - -import json -from pathlib import Path -from datetime import datetime -from typing import Optional - -from .tracking import load_associations, get_associations_for_prompt_version -from .registry import get_prompt, get_version_history - -PROJECT_DIR = Path("/home/hmo/projects/MoFin") -DECISIONS_PATH = PROJECT_DIR / "data" / "decisions.json" -ACCURACY_PATH = PROJECT_DIR / "data" / "accuracy_stats.json" -EVAL_PATH = PROJECT_DIR / "data" / "evaluation.json" - - -def _load_json(path, default=None): - try: - with open(path, encoding="utf-8") as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} if default is None else default - - -def analyze_prompt_version_effectiveness() -> dict: - """按提示词版本聚合策略评估结果 - - 关联 decisions.json 中的 evaluation 字段和 associations.json 中的版本记录, - 计算出每个提示词版本的: - - 生成的策略总数 - - 达到止盈数(成功) - - 跌破止损数(失败) - - 待验证数 - - 盈亏比平均值 - """ - # 加载数据 - decisions = _load_json(DECISIONS_PATH, {"decisions": []}) - associations = load_associations().get("associations", []) - - # 建立 code → 最新关联的映射 - code_to_pv = {} # code -> {prompt_id, version} - for a in associations: - code = a.get("code") - if code and code not in code_to_pv: - code_to_pv[code] = { - "prompt_id": a.get("prompt_id"), - "version": a.get("prompt_version"), - } - - # 按 prompt_id@version 分组统计 - version_stats = {} - - for d in decisions.get("decisions", []): - code = d.get("code") - if not code: - continue - - pv = code_to_pv.get(code) - if not pv: - continue - - key = f"{pv['prompt_id']}@{pv['version']}" - if key not in version_stats: - version_stats[key] = { - "prompt_id": pv["prompt_id"], - "version": pv["version"], - "total": 0, - "take_profit_hit": 0, - "stop_loss_hit": 0, - "in_entry_zone": 0, - "pending": 0, - "avg_rr": 0.0, - "rr_sum": 0.0, - "rr_count": 0, - "stocks": [], - } - - vs = version_stats[key] - vs["total"] += 1 - vs["stocks"].append(code) - - # 从 evaluation 中读取状态 - evals = d.get("evaluation", []) - for ev in evals: - if isinstance(ev, dict) and ev.get("phase") == 1: - theo = ev.get("theoretical", {}) - status = theo.get("status", "") - if status == "take_profit_hit": - vs["take_profit_hit"] += 1 - elif status == "stop_loss_hit": - vs["stop_loss_hit"] += 1 - elif status == "in_entry_zone": - vs["in_entry_zone"] += 1 - else: - vs["pending"] += 1 - - # 盈亏比 - rr = d.get("rr_ratio") - if rr is not None and isinstance(rr, (int, float)): - vs["rr_sum"] += rr - vs["rr_count"] += 1 - - # 计算平均盈亏比和成功率 - for key, vs in version_stats.items(): - if vs["rr_count"] > 0: - vs["avg_rr"] = round(vs["rr_sum"] / vs["rr_count"], 2) - - total_outcome = vs["take_profit_hit"] + vs["stop_loss_hit"] - if total_outcome > 0: - vs["success_rate"] = round(vs["take_profit_hit"] / total_outcome * 100, 1) - else: - vs["success_rate"] = None - - del vs["rr_sum"] - - return version_stats - - -def get_prompt_version_comparison() -> dict: - """生成版本对比报告""" - version_stats = analyze_prompt_version_effectiveness() - - # 补充每个版本的标签信息 - for key, vs in version_stats.items(): - prompt = get_prompt(vs["prompt_id"]) - if prompt: - for v in prompt.versions: - if v.version == vs["version"]: - vs["label"] = v.label - vs["changelog"] = v.changelog - vs["tags"] = v.tags - break - if "label" not in vs: - vs["label"] = vs["version"] - - return version_stats - - -def generate_report() -> str: - """生成版本有效性报告文本""" - comparison = get_prompt_version_comparison() - - lines = [] - lines.append("📊 提示词版本有效性分析 | " + datetime.now().strftime("%Y-%m-%d")) - lines.append("") - - if not comparison: - lines.append("暂无数据 — 请先运行策略生成和评估后重试") - return "\n".join(lines) - - # 按 prompt_id 分组显示 - by_prompt = {} - for key, vs in comparison.items(): - pid = vs["prompt_id"] - by_prompt.setdefault(pid, []).append(vs) - - for pid, versions in sorted(by_prompt.items()): - prompt = get_prompt(pid) - lines.append(f"\n## {prompt.name if prompt else pid}") - lines.append(f" {prompt.description if prompt else ''}") - lines.append("") - - # 按版本号排序 - versions.sort(key=lambda x: x["version"]) - - header = f" {'版本':<10} {'标签':<20} {'策略数':<8} {'止盈':<8} {'止损':<8} {'成功率':<10} {'平均R/R':<10}" - lines.append(header) - lines.append(" " + "-" * len(header)) - - for vs in versions: - sr = f"{vs['success_rate']}%" if vs['success_rate'] is not None else "-" - label = vs.get("label", "")[:18] - lines.append( - f" {vs['version']:<10} {label:<20} " - f"{vs['total']:<8} {vs['take_profit_hit']:<8} " - f"{vs['stop_loss_hit']:<8} {sr:<10} " - f"{vs['avg_rr']:<10}" - ) - - lines.append("") - lines.append("---") - lines.append("注:数据来自 decisions.json evaluation + associations.json") - return "\n".join(lines) +"""策略→提示词版本分析引擎 + +核心功能:将策略评估结果按提示词版本聚合,计算每个版本的准确率。 +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Optional + +from .tracking import load_associations, get_associations_for_prompt_version +from .registry import get_prompt, get_version_history + +PROJECT_DIR = Path("/home/hmo/projects/MoFin") +ACCURACY_PATH = PROJECT_DIR / "data" / "accuracy_stats.json" + + +def _load_json(path, default=None): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} if default is None else default + + +def _load_decisions(): + """从 DB 读取 decisions 数据""" + try: + from mo_data import read_decisions + return read_decisions() + except Exception: + return {"decisions": []} + + +def analyze_prompt_version_effectiveness() -> dict: + """按提示词版本聚合策略评估结果 + + 关联 DB holding_strategies 中的 evaluation 字段和 associations.json 中的版本记录, + 计算出每个提示词版本的: + - 生成的策略总数 + - 达到止盈数(成功) + - 跌破止损数(失败) + - 待验证数 + - 盈亏比平均值 + """ + # 加载数据 + decisions = _load_decisions() + associations = load_associations().get("associations", []) + + # 建立 code → 最新关联的映射 + code_to_pv = {} # code -> {prompt_id, version} + for a in associations: + code = a.get("code") + if code and code not in code_to_pv: + code_to_pv[code] = { + "prompt_id": a.get("prompt_id"), + "version": a.get("prompt_version"), + } + + # 按 prompt_id@version 分组统计 + version_stats = {} + + for d in decisions.get("decisions", []): + code = d.get("code") + if not code: + continue + + pv = code_to_pv.get(code) + if not pv: + continue + + key = f"{pv['prompt_id']}@{pv['version']}" + if key not in version_stats: + version_stats[key] = { + "prompt_id": pv["prompt_id"], + "version": pv["version"], + "total": 0, + "take_profit_hit": 0, + "stop_loss_hit": 0, + "in_entry_zone": 0, + "pending": 0, + "avg_rr": 0.0, + "rr_sum": 0.0, + "rr_count": 0, + "stocks": [], + } + + vs = version_stats[key] + vs["total"] += 1 + vs["stocks"].append(code) + + # 从 evaluation 中读取状态 + evals = d.get("evaluation", []) + for ev in evals: + if isinstance(ev, dict) and ev.get("phase") == 1: + theo = ev.get("theoretical", {}) + status = theo.get("status", "") + if status == "take_profit_hit": + vs["take_profit_hit"] += 1 + elif status == "stop_loss_hit": + vs["stop_loss_hit"] += 1 + elif status == "in_entry_zone": + vs["in_entry_zone"] += 1 + else: + vs["pending"] += 1 + + # 盈亏比 + rr = d.get("rr_ratio") + if rr is not None and isinstance(rr, (int, float)): + vs["rr_sum"] += rr + vs["rr_count"] += 1 + + # 计算平均盈亏比和成功率 + for key, vs in version_stats.items(): + if vs["rr_count"] > 0: + vs["avg_rr"] = round(vs["rr_sum"] / vs["rr_count"], 2) + + total_outcome = vs["take_profit_hit"] + vs["stop_loss_hit"] + if total_outcome > 0: + vs["success_rate"] = round(vs["take_profit_hit"] / total_outcome * 100, 1) + else: + vs["success_rate"] = None + + del vs["rr_sum"] + + return version_stats + + +def get_prompt_version_comparison() -> dict: + """生成版本对比报告""" + version_stats = analyze_prompt_version_effectiveness() + + # 补充每个版本的标签信息 + for key, vs in version_stats.items(): + prompt = get_prompt(vs["prompt_id"]) + if prompt: + for v in prompt.versions: + if v.version == vs["version"]: + vs["label"] = v.label + vs["changelog"] = v.changelog + vs["tags"] = v.tags + break + if "label" not in vs: + vs["label"] = vs["version"] + + return version_stats + + +def generate_report() -> str: + """生成版本有效性报告文本""" + comparison = get_prompt_version_comparison() + + lines = [] + lines.append("📊 提示词版本有效性分析 | " + datetime.now().strftime("%Y-%m-%d")) + lines.append("") + + if not comparison: + lines.append("暂无数据 — 请先运行策略生成和评估后重试") + return "\n".join(lines) + + # 按 prompt_id 分组显示 + by_prompt = {} + for key, vs in comparison.items(): + pid = vs["prompt_id"] + by_prompt.setdefault(pid, []).append(vs) + + for pid, versions in sorted(by_prompt.items()): + prompt = get_prompt(pid) + lines.append(f"\n## {prompt.name if prompt else pid}") + lines.append(f" {prompt.description if prompt else ''}") + lines.append("") + + # 按版本号排序 + versions.sort(key=lambda x: x["version"]) + + header = f" {'版本':<10} {'标签':<20} {'策略数':<8} {'止盈':<8} {'止损':<8} {'成功率':<10} {'平均R/R':<10}" + lines.append(header) + lines.append(" " + "-" * len(header)) + + for vs in versions: + sr = f"{vs['success_rate']}%" if vs['success_rate'] is not None else "-" + label = vs.get("label", "")[:18] + lines.append( + f" {vs['version']:<10} {label:<20} " + f"{vs['total']:<8} {vs['take_profit_hit']:<8} " + f"{vs['stop_loss_hit']:<8} {sr:<10} " + f"{vs['avg_rr']:<10}" + ) + + lines.append("") + lines.append("---") + lines.append("注:数据来自 DB holding_strategies evaluation + associations.json") + return "\n".join(lines) diff --git a/prompt_manager/init_registry.py b/prompt_manager/init_registry.py index 480672e..31a74dc 100644 --- a/prompt_manager/init_registry.py +++ b/prompt_manager/init_registry.py @@ -1,493 +1,493 @@ -#!/usr/bin/env python3 -"""初始化提示词注册表 — 录入所有现存提示词版本""" -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) - -from prompt_manager.registry import add_prompt, add_version, list_prompts -from prompt_manager.models import PromptDef, PromptVersion - -now = "2026-06-12T16:00:00" # 使用最近修改时间 - -# ═══════════════════════════════════════════ -# 1. 策略生成规则 — 最核心的提示词 -# ═══════════════════════════════════════════ -strategy_gen = PromptDef( - id="strategy-generation", - name="策略生成规则", - description="用于生成买入区/止损/止盈的技术面策略规则集,嵌入 strategy_lifecycle.py 的 reassess_strategy() 函数", - category="strategy", - locations=[ - "/home/hmo/web-dashboard/strategy_lifecycle.py", - "/home/hmo/projects/MoFin/src/strategy_lifecycle.py", - "finance/price-range-monitor SKILL.md", - ], - versions=[], - created_at="2026-06-09T08:00:00", - updated_at=now, - current_version="v2.4", -) - -add_prompt(strategy_gen) - -# v1 — 初始机械百分比 -add_version("strategy-generation", PromptVersion( - version="v1", - label="初始机械百分比", - created_at="2026-06-09T08:00:00", - changelog="初始版本,基于固定百分比(±5~10%)计算买入区/止损/止盈,无技术面支撑", - content="""策略生成规则 v1(初始机械百分比) - -止损 = 成本 × 0.85(-15%) -止盈 = 成本 × 1.20(+20%) -买入区 = 现价 × 0.90 ~ 现价 × 1.05 - -无技术面分析,纯百分比计算。 -""", - status="deprecated", - tags=["机械百分比"], -)) - -# v2 — 技术面支撑压力位 v1 -add_version("strategy-generation", PromptVersion( - version="v2", - label="技术面支撑压力位 v1", - created_at="2026-06-11T10:00:00", - changelog="从机械百分比改为基于 technical_analysis.py 的支撑/压力位计算,止损放强支撑,止盈放强压力", - content="""策略生成规则 v2(技术面支撑压力位 v1) - -1. 止损 = 强支撑(strong_support),约5-8%跌幅 -2. 止盈 = 强压力(strong_resist) -3. 买入区 = 弱支撑(ws) ~ 弱压力(wr) -4. 新买入/已持仓统一策略 -5. 无R/R校验 -""", - status="deprecated", - tags=["技术面", "支撑压力"], -)) - -# v2.1 — R/R 校验 + 最小波幅 -add_version("strategy-generation", PromptVersion( - version="v2.1", - label="技术面 + R/R 校验", - created_at="2026-06-12T10:00:00", - changelog="新增R/R≥2.0校验+4%最小波幅保护。修复比亚迪A单日振幅±0.5%导致R/R=0.8的问题。R/R不满足时尝试多级阻力位上调止盈", - content="""策略生成规则 v2.1(技术面 + R/R 校验) - -1. 止损 = 强支撑(新买入用弱支撑) -2. 止盈 = 强压力(多级阻力位尝试满足R/R) -3. 买入区 = 弱支撑~弱支撑×1.05 -4. R/R ≥ 2.0 校验(新买入推荐) -5. 4% 最小波幅保护(单日振幅<2%时) -6. 盈亏比不满足时:弱压→强压 逐级尝试 -""", - status="deprecated", - tags=["技术面", "R/R", "最小波幅"], -)) - -# v2.2 — 止损三级分离 + 移动止损 -add_version("strategy-generation", PromptVersion( - version="v2.2", - label="止损三级分离 + 移动止损", - created_at="2026-06-13T10:00:00", - changelog="止损分三级(新买入/已持仓/深套)。新买入用弱支撑,已持仓用强支撑,深套取强撑/85%最低。盈利>5%启用移动止损保护利润", - content="""策略生成规则 v2.2(止损三级分离 + 移动止损) - -| 场景 | 止损位置 | 逻辑 | -|------|---------|------| -| 新买入(cost=0) | 弱支撑(weak_support) | 入场失败小亏走人 | -| 已持仓(profit≥-20%) | 强支撑(strong_support) | 趋势坏了才走 | -| 深套(profit<-20%) | min(强支撑, 价×0.85) | 不轻易割 | - -盈利>5%:取 max(弱支撑, 成本线, 现价×0.95) 移动止损 - -买入区 R/R 约束:新买入≥1.5,已持仓≥1.0 -买入区宽度收紧:只围绕弱支撑,不扩展到弱压力 -""", - status="deprecated", - tags=["技术面", "R/R", "移动止损", "三级止损"], -)) - -# v2.3 — 买入区 R/R 约束 + 买入时机模型 -add_version("strategy-generation", PromptVersion( - version="v2.3", - label="买入区 R/R 约束 + 时机四象限", - created_at="2026-06-13T16:00:00", - changelog="买入区自身增加R/R约束(entry_high满足1:1.5),新增买入时机四象限模型(放量跌不入/缩量回踩入/放量突破追/缩量反弹不追),趋势位置检测扩展有效区间", - content="""策略生成规则 v2.3 - -买入区 R/R 约束: -- entry_high ≤ (target + min_rr × stop) / (1 + min_rr) -- 新买入 min_rr=1.5,已持仓 min_rr=1.0 -- 坍缩保护:R/R约束导致买入区消失时标记"不建议" - -买入时机四象限: -| 场景 | 操作 | -|------|------| -| ①放量跌入买入区 | ❌ 不买 | -| ②缩量回踩弱支撑+放量反弹 | ✅ 买入 | -| ③放量突破压力位 | ✅ 追买 | -| ④缩量反弹到压力位 | ❌ 警惕 | - -趋势位置检测:股价>80%分位或<20%分位时自动扩展有效区间到价×8% -""", - status="deprecated", - tags=["技术面", "R/R", "买入时机", "趋势位置"], -)) - -# v2.4 — 当前版本(R/R阈值差异化 + 止损最小距离保护) -add_version("strategy-generation", PromptVersion( - version="v2.4", - label="R/R阈值差异化 + 止损最小距离", - created_at="2026-06-13T18:00:00", - changelog="R/R阈值差异化(新买入≥1.5/2.0/2.0三级,已持仓≥0.5/1.5两级),止损最小距离3%保护(正常持仓不被短线波动触发),买入区坍缩逻辑优化", - content="""策略生成规则 v2.4(当前活跃版本) - -R/R阈值差异化: -| 场景 | 阈值 | -|------|------| -| 新买入 <1.5 | ❌ 不建议买入 | -| 新买入 1.5~2.0 | ⚠️ 谨慎买入 | -| 新买入 ≥2.0 | ✅ 正常 | -| 已持仓 <0.5 | ⚠️ 盈亏比极低 | -| 已持仓 0.5~1.5 | ⚠️ 不建议加仓 | -| 已持仓 ≥1.5 | ✅ 无标记 | - -止损最小距离:现价到止损≥3%(非深套场景) -买入区坍缩:R/R约束导致entry_high±3%查新闻 -6. A+H价差正常 -7. 买入时机四象限 -""", - status="active", - tags=["日期纪律", "预检查"], -)) - - -print("✅ 提示词注册表初始化完成!") -print(f" 共注册 {len(list_prompts())} 个提示词") -for p in list_prompts(): - print(f" - {p['id']}: {p['name']} ({len(p['versions'])} 个版本)") +#!/usr/bin/env python3 +"""初始化提示词注册表 — 录入所有现存提示词版本""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from prompt_manager.registry import add_prompt, add_version, list_prompts +from prompt_manager.models import PromptDef, PromptVersion + +now = "2026-06-12T16:00:00" # 使用最近修改时间 + +# ═══════════════════════════════════════════ +# 1. 策略生成规则 — 最核心的提示词 +# ═══════════════════════════════════════════ +strategy_gen = PromptDef( + id="strategy-generation", + name="策略生成规则", + description="用于生成买入区/止损/止盈的技术面策略规则集,嵌入 strategy_lifecycle.py 的 reassess_strategy() 函数", + category="strategy", + locations=[ + "/home/hmo/web-dashboard/strategy_lifecycle.py", + "/home/hmo/projects/MoFin/src/strategy_lifecycle.py", + "finance/price-range-monitor SKILL.md", + ], + versions=[], + created_at="2026-06-09T08:00:00", + updated_at=now, + current_version="v2.4", +) + +add_prompt(strategy_gen) + +# v1 — 初始机械百分比 +add_version("strategy-generation", PromptVersion( + version="v1", + label="初始机械百分比", + created_at="2026-06-09T08:00:00", + changelog="初始版本,基于固定百分比(±5~10%)计算买入区/止损/止盈,无技术面支撑", + content="""策略生成规则 v1(初始机械百分比) + +止损 = 成本 × 0.85(-15%) +止盈 = 成本 × 1.20(+20%) +买入区 = 现价 × 0.90 ~ 现价 × 1.05 + +无技术面分析,纯百分比计算。 +""", + status="deprecated", + tags=["机械百分比"], +)) + +# v2 — 技术面支撑压力位 v1 +add_version("strategy-generation", PromptVersion( + version="v2", + label="技术面支撑压力位 v1", + created_at="2026-06-11T10:00:00", + changelog="从机械百分比改为基于 technical_analysis.py 的支撑/压力位计算,止损放强支撑,止盈放强压力", + content="""策略生成规则 v2(技术面支撑压力位 v1) + +1. 止损 = 强支撑(strong_support),约5-8%跌幅 +2. 止盈 = 强压力(strong_resist) +3. 买入区 = 弱支撑(ws) ~ 弱压力(wr) +4. 新买入/已持仓统一策略 +5. 无R/R校验 +""", + status="deprecated", + tags=["技术面", "支撑压力"], +)) + +# v2.1 — R/R 校验 + 最小波幅 +add_version("strategy-generation", PromptVersion( + version="v2.1", + label="技术面 + R/R 校验", + created_at="2026-06-12T10:00:00", + changelog="新增R/R≥2.0校验+4%最小波幅保护。修复比亚迪A单日振幅±0.5%导致R/R=0.8的问题。R/R不满足时尝试多级阻力位上调止盈", + content="""策略生成规则 v2.1(技术面 + R/R 校验) + +1. 止损 = 强支撑(新买入用弱支撑) +2. 止盈 = 强压力(多级阻力位尝试满足R/R) +3. 买入区 = 弱支撑~弱支撑×1.05 +4. R/R ≥ 2.0 校验(新买入推荐) +5. 4% 最小波幅保护(单日振幅<2%时) +6. 盈亏比不满足时:弱压→强压 逐级尝试 +""", + status="deprecated", + tags=["技术面", "R/R", "最小波幅"], +)) + +# v2.2 — 止损三级分离 + 移动止损 +add_version("strategy-generation", PromptVersion( + version="v2.2", + label="止损三级分离 + 移动止损", + created_at="2026-06-13T10:00:00", + changelog="止损分三级(新买入/已持仓/深套)。新买入用弱支撑,已持仓用强支撑,深套取强撑/85%最低。盈利>5%启用移动止损保护利润", + content="""策略生成规则 v2.2(止损三级分离 + 移动止损) + +| 场景 | 止损位置 | 逻辑 | +|------|---------|------| +| 新买入(cost=0) | 弱支撑(weak_support) | 入场失败小亏走人 | +| 已持仓(profit≥-20%) | 强支撑(strong_support) | 趋势坏了才走 | +| 深套(profit<-20%) | min(强支撑, 价×0.85) | 不轻易割 | + +盈利>5%:取 max(弱支撑, 成本线, 现价×0.95) 移动止损 + +买入区 R/R 约束:新买入≥1.5,已持仓≥1.0 +买入区宽度收紧:只围绕弱支撑,不扩展到弱压力 +""", + status="deprecated", + tags=["技术面", "R/R", "移动止损", "三级止损"], +)) + +# v2.3 — 买入区 R/R 约束 + 买入时机模型 +add_version("strategy-generation", PromptVersion( + version="v2.3", + label="买入区 R/R 约束 + 时机四象限", + created_at="2026-06-13T16:00:00", + changelog="买入区自身增加R/R约束(entry_high满足1:1.5),新增买入时机四象限模型(放量跌不入/缩量回踩入/放量突破追/缩量反弹不追),趋势位置检测扩展有效区间", + content="""策略生成规则 v2.3 + +买入区 R/R 约束: +- entry_high ≤ (target + min_rr × stop) / (1 + min_rr) +- 新买入 min_rr=1.5,已持仓 min_rr=1.0 +- 坍缩保护:R/R约束导致买入区消失时标记"不建议" + +买入时机四象限: +| 场景 | 操作 | +|------|------| +| ①放量跌入买入区 | ❌ 不买 | +| ②缩量回踩弱支撑+放量反弹 | ✅ 买入 | +| ③放量突破压力位 | ✅ 追买 | +| ④缩量反弹到压力位 | ❌ 警惕 | + +趋势位置检测:股价>80%分位或<20%分位时自动扩展有效区间到价×8% +""", + status="deprecated", + tags=["技术面", "R/R", "买入时机", "趋势位置"], +)) + +# v2.4 — 当前版本(R/R阈值差异化 + 止损最小距离保护) +add_version("strategy-generation", PromptVersion( + version="v2.4", + label="R/R阈值差异化 + 止损最小距离", + created_at="2026-06-13T18:00:00", + changelog="R/R阈值差异化(新买入≥1.5/2.0/2.0三级,已持仓≥0.5/1.5两级),止损最小距离3%保护(正常持仓不被短线波动触发),买入区坍缩逻辑优化", + content="""策略生成规则 v2.4(当前活跃版本) + +R/R阈值差异化: +| 场景 | 阈值 | +|------|------| +| 新买入 <1.5 | ❌ 不建议买入 | +| 新买入 1.5~2.0 | ⚠️ 谨慎买入 | +| 新买入 ≥2.0 | ✅ 正常 | +| 已持仓 <0.5 | ⚠️ 盈亏比极低 | +| 已持仓 0.5~1.5 | ⚠️ 不建议加仓 | +| 已持仓 ≥1.5 | ✅ 无标记 | + +止损最小距离:现价到止损≥3%(非深套场景) +买入区坍缩:R/R约束导致entry_high±3%查新闻 +6. A+H价差正常 +7. 买入时机四象限 +""", + status="active", + tags=["日期纪律", "预检查"], +)) + + +print("✅ 提示词注册表初始化完成!") +print(f" 共注册 {len(list_prompts())} 个提示词") +for p in list_prompts(): + print(f" - {p['id']}: {p['name']} ({len(p['versions'])} 个版本)") diff --git a/regenerate_strategies.py b/regenerate_strategies.py index 16c2856..97828cd 100644 --- a/regenerate_strategies.py +++ b/regenerate_strategies.py @@ -1,96 +1,100 @@ -#!/usr/bin/env python3 -"""批量再生所有持仓+自选策略,结合技术面支撑/压力位""" - -import json -import sys -sys.path.insert(0, '/home/hmo/web-dashboard') - -from technical_analysis import full_analysis -from strategy_lifecycle import reassess_strategy - -PF = '/home/hmo/web-dashboard/data/portfolio.json' -WL = '/home/hmo/web-dashboard/data/watchlist.json' - -def main(): - # 持仓 - pf = json.load(open(PF)) - for s in pf['holdings']: - code = s['code'] - name = s['name'] - price = s.get('price', 0) - cost = s.get('cost', 0) - shares = s.get('shares', 0) - - if not price: - continue - - print(f" {name}({code}) 现价{price} 成本{cost}...", end=' ') - - try: - tech = full_analysis(code) - except: - tech = None - - result = reassess_strategy( - code, name, price, cost, shares, - current_action=s.get('analysis', {}).get('action', '') - ) - - if 'analysis' not in s: - s['analysis'] = {} - s['analysis']['stop_loss'] = result['stop_loss'] - s['analysis']['take_profit'] = result['take_profit'] - s['analysis']['entry_low'] = result['entry_low'] - s['analysis']['entry_high'] = result['entry_high'] - s['analysis']['action'] = result['action'] - s['analysis']['status'] = result['status'] - s['analysis']['reassessed_at'] = result['reassessed_at'] - - print(f"损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']}") - - json.dump(pf, open(PF, 'w'), ensure_ascii=False, indent=2) - print(f"\n持仓策略已更新: {len(pf['holdings'])} 条") - - # 自选股 - 简单重新计算买入区 - wl = json.load(open(WL)) - updated = 0 - for s in wl['stocks']: - code = s['code'] - price = s.get('price', 0) - if not price: - continue - tech = None - try: - tech = full_analysis(code) - except: - pass - - # 买入区 = 弱支撑~弱压力 - if tech: - sr = tech.get('support_resistance', {}) - ws = sr.get('weak_support') or price * 0.95 - wr = sr.get('weak_resist') or price * 1.05 - else: - ws = price * 0.92 - wr = price * 1.08 - - if 'analysis' not in s: - s['analysis'] = {} - s['analysis']['buy_low'] = round(ws, 2) - s['analysis']['buy_high'] = round(wr, 2) - if tech: - s['analysis']['tech_levels'] = { - 'strong_support': sr.get('strong_support'), - 'weak_support': sr.get('weak_support'), - 'weak_resist': sr.get('weak_resist'), - 'strong_resist': sr.get('strong_resist'), - } - updated += 1 - print(f" {s['name']}({code}) 买入区={ws:.2f}~{wr:.2f}") - - json.dump(wl, open(WL, 'w'), ensure_ascii=False, indent=2) - print(f"\n自选策略已更新: {updated} 条") - print("\n✅ 全部策略再生完成") - -if __name__ == '__main__': - main() +#!/usr/bin/env python3 +"""批量再生所有持仓+自选策略,结合技术面支撑/压力位""" + +import sys +sys.path.insert(0, '/home/hmo/web-dashboard') + +from technical_analysis import full_analysis +from strategy_lifecycle import reassess_strategy +from mo_data import read_portfolio, read_watchlist +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock + +def main(): + # 持仓 + pf = read_portfolio() + for s in pf['holdings']: + code = s['code'] + name = s['name'] + price = s.get('price', 0) + cost = s.get('cost', 0) + shares = s.get('shares', 0) + + if not price: + continue + + print(f" {name}({code}) 现价{price} 成本{cost}...", end=' ') + + try: + tech = full_analysis(code) + except: + tech = None + + result = reassess_strategy( + code, name, price, cost, shares, + current_action=s.get('analysis', {}).get('action', '') + ) + + if 'analysis' not in s: + s['analysis'] = {} + s['analysis']['stop_loss'] = result['stop_loss'] + s['analysis']['take_profit'] = result['take_profit'] + s['analysis']['entry_low'] = result['entry_low'] + s['analysis']['entry_high'] = result['entry_high'] + s['analysis']['action'] = result['action'] + s['analysis']['status'] = result['status'] + s['analysis']['reassessed_at'] = result['reassessed_at'] + + print(f"损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']}") + + conn = get_conn() + write_holdings_batch(conn, pf['holdings']) + write_portfolio_summary(conn, pf) + conn.close() + print(f"\n持仓策略已更新: {len(pf['holdings'])} 条") + + # 自选股 - 简单重新计算买入区 + wl = read_watchlist() + updated = 0 + for s in wl['stocks']: + code = s['code'] + price = s.get('price', 0) + if not price: + continue + tech = None + try: + tech = full_analysis(code) + except: + pass + + # 买入区 = 弱支撑~弱压力 + if tech: + sr = tech.get('support_resistance', {}) + ws = sr.get('weak_support') or price * 0.95 + wr = sr.get('weak_resist') or price * 1.05 + else: + ws = price * 0.92 + wr = price * 1.08 + + if 'analysis' not in s: + s['analysis'] = {} + s['analysis']['buy_low'] = round(ws, 2) + s['analysis']['buy_high'] = round(wr, 2) + if tech: + s['analysis']['tech_levels'] = { + 'strong_support': sr.get('strong_support'), + 'weak_support': sr.get('weak_support'), + 'weak_resist': sr.get('weak_resist'), + 'strong_resist': sr.get('strong_resist'), + } + updated += 1 + print(f" {s['name']}({code}) 买入区={ws:.2f}~{wr:.2f}") + + conn = get_conn() + for s in wl['stocks']: + write_watchlist_stock(conn, s) + conn.close() + print(f"\n自选策略已更新: {updated} 条") + print("\n✅ 全部策略再生完成") + +if __name__ == '__main__': + main() diff --git a/scripts/branch_evaluator.py b/scripts/branch_evaluator.py index bd17814..a712606 100644 --- a/scripts/branch_evaluator.py +++ b/scripts/branch_evaluator.py @@ -1,148 +1,156 @@ -#!/usr/bin/env python3 -""" -branch_evaluator.py — 分支自成长引擎 - -每30分钟评估所有策略树的当前适用性: - 1. 读取 decisions.json 中所有 strategy_tree.branches - 2. 获取当前宏观情景(detect_scenario) - 3. 对每只股票获取实时价,评估哪些分支条件命中 - 4. 命中的分支 → trigger_count+1, last_triggered=now - 5. 后续跟进:成功/失败取决于该分支被选中后5日盈亏(由price_monitor回填success_rate) - 6. 触发≥3次且成功率<30% → 标记 pruning_candidate - 7. 写回 decisions.json - -设计为 no_agent cron 脚本:非空输出→推送到XMPP,空输出→静默 -""" - -import json, sys, os, re -from datetime import datetime, date - -# 路径 -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" - -# 引入 strategy_tree 模块 -sys.path.insert(0, "/home/hmo/MoFin") -try: - import strategy_tree as st -except ImportError: - # 如果 MoFin 路径下找不到,尝试直接 exec - import importlib.util - spec = importlib.util.spec_from_file_location("st", "/home/hmo/MoFin/strategy_tree.py") - st = importlib.util.module_from_spec(spec) - spec.loader.exec_module(st) - - -def get_live_prices(): - """从 portfolio.json 读取实时价格""" - prices = {} - try: - with open(PORTFOLIO_PATH) as f: - pf = json.load(f) - for h in pf.get("holdings", []): - code = str(h.get("code", "")) - prices[code] = h.get("price", 0) - except Exception: - pass - return prices - - -def evaluate_all(): - """评估所有已触发策略树的分支""" - try: - with open(DECISIONS_PATH) as f: - data = json.load(f) - except Exception as e: - print(f"[错误] 读 decisions.json 失败: {e}", file=sys.stderr) - return - - # 当前情景 - scenario = st.detect_scenario() - scenario_id = scenario.get("id", "") - scenario_label = scenario.get("label", "未知") - - prices = get_live_prices() - decisions = data.get("decisions", []) - total_triggered = 0 - auto_init_count = 0 - pruning_flags = [] - - for entry in decisions: - code = entry.get("code", "") - tree = entry.get("strategy_tree") - if not tree: - # 自初始化:无决策树的股票自动生成默认分支 - try: - branches = st.init_default_branches( - code=code, - name=entry.get("name", ""), - entry_low=entry.get("entry_low", 0), - entry_high=entry.get("entry_high", 0), - stop_loss=entry.get("stop_loss", 0), - take_profit=entry.get("take_profit", 0), - ) - tree = {"branches": branches, "initialized_at": datetime.now().isoformat()} - entry["strategy_tree"] = tree - auto_init_count += 1 - except Exception: - continue - branches = tree.get("branches", []) - if not branches: - continue - - price = prices.get(code, 0) or entry.get("price", 0) - shares = entry.get("shares", 0) - cost = entry.get("cost", 0) - - # 评估所有分支 - results = st.evaluate_branches(code, scenario_id, price, shares, cost) - now_ts = datetime.now().isoformat() - - updated = False - for result in results: - br_id = result.get("branch_id", "") - # 找到对应分支更新trigger_count - for br in branches: - if br.get("id") == br_id: - if result.get("applicable"): - # 分支命中 → 增加触发计数 - br["trigger_count"] = br.get("trigger_count", 0) + 1 - br["last_triggered"] = now_ts - total_triggered += 1 - updated = True - # 检查是否需要标记剪枝候补 - tc = br["trigger_count"] - sr = br.get("success_rate") - if tc >= 3 and sr is not None and sr < 30: - br["pruning_candidate"] = True - pruning_flags.append(f"{code}/{br_id}(触发{tc}次/成功率{sr}%)") - break - - if updated: - # 回写 strategy_tree - entry["strategy_tree"] = tree - # 标记评估时间 - tree["last_evaluated"] = now_ts - - # 写回文件 - with open(DECISIONS_PATH, "w") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - # 输出摘要(空 = 静默) - lines = [] - init_note = f" | 自动初始化{auto_init_count}只" if auto_init_count else "" - lines.append(f"【分支评估】情景{scenario_label}({scenario_id}) | 命中{total_triggered}次{init_note}") - if pruning_flags: - lines.append(f"需剪枝{len(pruning_flags)}个分支:") - for f in pruning_flags: - lines.append(f" ⚠ {f}") - else: - lines.append("无需剪枝的分支") - - out = "\n".join(lines) - print(out) - return out - - -if __name__ == "__main__": - evaluate_all() +#!/usr/bin/env python3 +""" +branch_evaluator.py — 分支自成长引擎 + +每30分钟评估所有策略树的当前适用性: + 1. 读取 decisions.json 中所有 strategy_tree.branches + 2. 获取当前宏观情景(detect_scenario) + 3. 对每只股票获取实时价,评估哪些分支条件命中 + 4. 命中的分支 → trigger_count+1, last_triggered=now + 5. 后续跟进:成功/失败取决于该分支被选中后5日盈亏(由price_monitor回填success_rate) + 6. 触发≥3次且成功率<30% → 标记 pruning_candidate + 7. 写回 decisions.json + +设计为 no_agent cron 脚本:非空输出→推送到XMPP,空输出→静默 +""" + +import json, sys, os, re +from datetime import datetime, date +from mo_data import read_portfolio, read_decisions +from mofin_db import get_conn, write_holding_strategy + +# 路径 +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" + +# 引入 strategy_tree 模块 +sys.path.insert(0, "/home/hmo/MoFin") +try: + import strategy_tree as st +except ImportError: + # 如果 MoFin 路径下找不到,尝试直接 exec + import importlib.util + spec = importlib.util.spec_from_file_location("st", "/home/hmo/MoFin/strategy_tree.py") + st = importlib.util.module_from_spec(spec) + spec.loader.exec_module(st) + + +def get_live_prices(): + """从 portfolio.json 读取实时价格""" + prices = {} + try: + pf = read_portfolio() + for h in pf.get("holdings", []): + code = str(h.get("code", "")) + prices[code] = h.get("price", 0) + except Exception: + pass + return prices + + +def evaluate_all(): + """评估所有已触发策略树的分支""" + try: + data = read_decisions() + except Exception as e: + print(f"[错误] 读 decisions.json 失败: {e}", file=sys.stderr) + return + + # 当前情景 + scenario = st.detect_scenario() + scenario_id = scenario.get("id", "") + scenario_label = scenario.get("label", "未知") + + prices = get_live_prices() + decisions = data.get("decisions", []) + total_triggered = 0 + auto_init_count = 0 + pruning_flags = [] + + for entry in decisions: + code = entry.get("code", "") + tree = entry.get("strategy_tree") + if not tree: + # 自初始化:无决策树的股票自动生成默认分支 + try: + branches = st.init_default_branches( + code=code, + name=entry.get("name", ""), + entry_low=entry.get("entry_low", 0), + entry_high=entry.get("entry_high", 0), + stop_loss=entry.get("stop_loss", 0), + take_profit=entry.get("take_profit", 0), + ) + tree = {"branches": branches, "initialized_at": datetime.now().isoformat()} + entry["strategy_tree"] = tree + auto_init_count += 1 + except Exception: + continue + branches = tree.get("branches", []) + if not branches: + continue + + price = prices.get(code, 0) or entry.get("price", 0) + shares = entry.get("shares", 0) + cost = entry.get("cost", 0) + + # 评估所有分支 + results = st.evaluate_branches(code, scenario_id, price, shares, cost) + now_ts = datetime.now().isoformat() + + updated = False + for result in results: + br_id = result.get("branch_id", "") + # 找到对应分支更新trigger_count + for br in branches: + if br.get("id") == br_id: + if result.get("applicable"): + # 分支命中 → 增加触发计数 + br["trigger_count"] = br.get("trigger_count", 0) + 1 + br["last_triggered"] = now_ts + total_triggered += 1 + updated = True + # 检查是否需要标记剪枝候补 + tc = br["trigger_count"] + sr = br.get("success_rate") + if tc >= 3 and sr is not None and sr < 30: + br["pruning_candidate"] = True + pruning_flags.append(f"{code}/{br_id}(触发{tc}次/成功率{sr}%)") + break + + if updated: + # 回写 strategy_tree + entry["strategy_tree"] = tree + # 标记评估时间 + tree["last_evaluated"] = now_ts + + # 写回 — DB 优先 + try: + conn = get_conn() + for d in data.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(data, f, indent=2, ensure_ascii=False) + + # 输出摘要(空 = 静默) + lines = [] + init_note = f" | 自动初始化{auto_init_count}只" if auto_init_count else "" + lines.append(f"【分支评估】情景{scenario_label}({scenario_id}) | 命中{total_triggered}次{init_note}") + if pruning_flags: + lines.append(f"需剪枝{len(pruning_flags)}个分支:") + for f in pruning_flags: + lines.append(f" ⚠ {f}") + else: + lines.append("无需剪枝的分支") + + out = "\n".join(lines) + print(out) + return out + + +if __name__ == "__main__": + evaluate_all() diff --git a/scripts/branch_scanner.py b/scripts/branch_scanner.py index eef80ca..77f2f9b 100644 --- a/scripts/branch_scanner.py +++ b/scripts/branch_scanner.py @@ -1,126 +1,136 @@ -#!/usr/bin/env python3 -""" -branch_scanner.py — 分支自成长数据采集器(全静默) - -核心功能(三件事,全部后台静默执行): -1. 每轮扫描42只股票,评估当前情景下各分支的适用性 -2. 适用分支 → trigger_count + 1,记录 last_triggered -3. 保存当前状态到 scanner_state.json 供下次对比 - -无输出 → 静默运行。触发数据积累在 decisions.json。 -操作信号由 stale_push_wlin / price_monitor / 开盘收盘简报 另路输出。 - -数据流向(自成长):每15分钟branch_scanner积累trigger_count → - 每日prune_branches评估低效分支 → decisions.json修剪 → 分支越来越有效 -""" - -import json, sys, re -from datetime import datetime -from urllib.request import Request, urlopen - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -SCANNER_STATE = "/home/hmo/web-dashboard/data/scanner_state.json" - - -def get_price(code): - # DB 优先 - try: from mofin_db import get_price_from_db; p, _ = get_price_from_db(code); return p if p else 0 - except: pass - # Fallback: 腾讯 - mkt = "sh" if code.startswith("6") or code.startswith("5") else "sz" - url = f"http://qt.gtimg.cn/q={mkt}{code}" - req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) - try: - resp = urlopen(req, timeout=5).read().decode("gbk") - parts = resp.split("~") - if len(parts) > 3: - return float(parts[3]) - except Exception: - return None - - -def get_scenario(): - try: - sys.path.insert(0, "/home/hmo/MoFin") - from strategy_tree import detect_scenario - return detect_scenario() - except Exception: - return {"id": "unknown", "label": "未知", "confidence": 0} - - -def check_condition(branch, scenario_id, price): - cond = branch.get("condition", {}) - required_scenario = cond.get("scenario", "") - if required_scenario and required_scenario != scenario_id: - return False - price_cond = cond.get("price", "") - if price_cond and price: - ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_cond) - for op, val_str in ops: - val = float(val_str) - if op == "<" and not (price < val): return False - if op == ">" and not (price > val): return False - if op == "<=" and not (price <= val): return False - if op == ">=" and not (price >= val): return False - price_lower = cond.get("price_lower", "") - if price_lower and price: - ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_lower) - for op, val_str in ops: - val = float(val_str) - if op == "<" and not (price < val): return False - if op == ">" and not (price > val): return False - if op == "<=" and not (price <= val): return False - if op == ">=" and not (price >= val): return False - return True - - -def main(): - now = datetime.now() - if now.hour < 9 or now.hour > 16: - return 0 - - scenario = get_scenario() - sid = scenario.get("id", "unknown") - - with open(DECISIONS_PATH) as f: - data = json.load(f) - decisions = data.get("decisions", []) - - for entry in decisions: - code = entry.get("code", "") - tree = entry.get("strategy_tree", {}) - branches = tree.get("branches", []) - if not branches: - continue - price = get_price(code) - if not price: - continue - for br in sorted(branches, key=lambda b: b.get("priority", 999)): - if check_condition(br, sid, price): - br["trigger_count"] = br.get("trigger_count", 0) + 1 - br["last_triggered"] = now.strftime("%Y-%m-%d") - break - - with open(DECISIONS_PATH, "w") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - # 更新状态快照 - state = {"scenario": sid, "updated_at": now.isoformat(), "branches": {}} - for e in decisions: - code = e.get("code", "") - tree = e.get("strategy_tree", {}) - for br in sorted(tree.get("branches", []), key=lambda b: b.get("priority", 999)): - if check_condition(br, sid, get_price(code)): - state["branches"][code] = br.get("id", "") - break - try: - with open(SCANNER_STATE, "w") as f: - json.dump(state, f, indent=2) - except Exception: - pass - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) +#!/usr/bin/env python3 +""" +branch_scanner.py — 分支自成长数据采集器(全静默) + +核心功能(三件事,全部后台静默执行): +1. 每轮扫描42只股票,评估当前情景下各分支的适用性 +2. 适用分支 → trigger_count + 1,记录 last_triggered +3. 保存当前状态到 scanner_state.json 供下次对比 + +无输出 → 静默运行。触发数据积累在 decisions.json。 +操作信号由 stale_push_wlin / price_monitor / 开盘收盘简报 另路输出。 + +数据流向(自成长):每15分钟branch_scanner积累trigger_count → + 每日prune_branches评估低效分支 → decisions.json修剪 → 分支越来越有效 +""" + +import json, sys, re +from datetime import datetime +from urllib.request import Request, urlopen +from mo_data import read_decisions +from mofin_db import get_conn, write_holding_strategy + +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" +SCANNER_STATE = "/home/hmo/web-dashboard/data/scanner_state.json" + + +def get_price(code): + # DB 优先 + try: from mofin_db import get_price_from_db; p, _ = get_price_from_db(code); return p if p else 0 + except: pass + # Fallback: 腾讯 + mkt = "sh" if code.startswith("6") or code.startswith("5") else "sz" + url = f"http://qt.gtimg.cn/q={mkt}{code}" + req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) + try: + resp = urlopen(req, timeout=5).read().decode("gbk") + parts = resp.split("~") + if len(parts) > 3: + return float(parts[3]) + except Exception: + return None + + +def get_scenario(): + try: + sys.path.insert(0, "/home/hmo/MoFin") + from strategy_tree import detect_scenario + return detect_scenario() + except Exception: + return {"id": "unknown", "label": "未知", "confidence": 0} + + +def check_condition(branch, scenario_id, price): + cond = branch.get("condition", {}) + required_scenario = cond.get("scenario", "") + if required_scenario and required_scenario != scenario_id: + return False + price_cond = cond.get("price", "") + if price_cond and price: + ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_cond) + for op, val_str in ops: + val = float(val_str) + if op == "<" and not (price < val): return False + if op == ">" and not (price > val): return False + if op == "<=" and not (price <= val): return False + if op == ">=" and not (price >= val): return False + price_lower = cond.get("price_lower", "") + if price_lower and price: + ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_lower) + for op, val_str in ops: + val = float(val_str) + if op == "<" and not (price < val): return False + if op == ">" and not (price > val): return False + if op == "<=" and not (price <= val): return False + if op == ">=" and not (price >= val): return False + return True + + +def main(): + now = datetime.now() + if now.hour < 9 or now.hour > 16: + return 0 + + scenario = get_scenario() + sid = scenario.get("id", "unknown") + + data = read_decisions() + decisions = data.get("decisions", []) + + for entry in decisions: + code = entry.get("code", "") + tree = entry.get("strategy_tree", {}) + branches = tree.get("branches", []) + if not branches: + continue + price = get_price(code) + if not price: + continue + for br in sorted(branches, key=lambda b: b.get("priority", 999)): + if check_condition(br, sid, price): + br["trigger_count"] = br.get("trigger_count", 0) + 1 + br["last_triggered"] = now.strftime("%Y-%m-%d") + break + + # DB 写入(替代 json.dump) + try: + conn = get_conn() + for d in data.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(data, f, indent=2, ensure_ascii=False) + + # 更新状态快照 + state = {"scenario": sid, "updated_at": now.isoformat(), "branches": {}} + for e in decisions: + code = e.get("code", "") + tree = e.get("strategy_tree", {}) + for br in sorted(tree.get("branches", []), key=lambda b: b.get("priority", 999)): + if check_condition(br, sid, get_price(code)): + state["branches"][code] = br.get("id", "") + break + try: + with open(SCANNER_STATE, "w") as f: + json.dump(state, f, indent=2) + except Exception: + pass + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_schema.py b/scripts/check_schema.py new file mode 100644 index 0000000..25f825c --- /dev/null +++ b/scripts/check_schema.py @@ -0,0 +1,5 @@ +import sqlite3 +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') +sql = db.execute("SELECT sql FROM sqlite_master WHERE name='holding_strategies'").fetchone() +print(sql[0][:600] if sql else "NOT FOUND") +db.close() diff --git a/scripts/chip_factors.py b/scripts/chip_factors.py index 5ecdfd4..cdc7f1b 100644 --- a/scripts/chip_factors.py +++ b/scripts/chip_factors.py @@ -1,262 +1,264 @@ -#!/usr/bin/env python3 -"""chip_factors.py — 筹码因子计算模块 - -基于中信建投《筹码分布因子系统构建》研报,实现四大类因子。 -用日线数据就够了,分钟数据用于当日穿透率增强。 - -用法: - from chip_factors import ChipFactors - cf = ChipFactors() - - # 计算单只股票的筹码乖离率 - result = cf.calc_all("600519") - print(result["bias"], result["ptr"], result["ptr_today"]) - - # 批量计算所有持仓/自选 - results = cf.batch_calc(["600519", "00700", "000700"]) -""" - -import json, os, sqlite3, time, urllib.request -from datetime import datetime, timedelta -from pathlib import Path - -DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") -MOFIN_ROOT = Path("/home/hmo/MoFin") -CACHE_DIR = MOFIN_ROOT / "data" / "chip_cache" - -# ── 分钟数据限流 ── -_last_minute_call = 0 - -def _fetch_quote(code): - """拉腾讯实时价(清代理)""" - for k in list(os.environ.keys()): - if 'proxy' in k.lower(): - os.environ.pop(k) - prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" - try: - url = f"http://qt.gtimg.cn/q={prefix}{code}" - req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) - resp = urllib.request.urlopen(req, timeout=5).read().decode('gbk') - fields = resp.split('=')[1].strip().strip('"').strip(';').split('~') - return float(fields[3]) if len(fields) > 3 else 0 - except: - return 0 - - -def _fetch_minute_kline(code, count=60): - """拉1分钟K线(带限流)""" - global _last_minute_call - now = time.time() - if now - _last_minute_call < 1.0: - time.sleep(1.0 - (now - _last_minute_call)) - secid = f"1.{code}" if code.startswith(('6','5')) else f"0.{code}" - url = (f"https://push2.eastmoney.com/api/qt/stock/kline/get" - f"?secid={secid}&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57" - f"&klt=1&fqt=1&end=20500101&lmt={min(count, 240)}") - try: - req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) - resp = urllib.request.urlopen(req, timeout=8) - data = json.loads(resp.read())["data"]["klines"] - _last_minute_call = time.time() - return [line.split(",") for line in data] - except: - return None - - -class ChipFactors: - """筹码因子计算器""" - - def __init__(self): - CACHE_DIR.mkdir(parents=True, exist_ok=True) - self._cache = {} # code → {last_chip, winner, bias} - self._load_cache() - - def _load_cache(self): - """加载缓存的筹码状态""" - for f in CACHE_DIR.glob("*.json"): - code = f.stem - try: - with open(f) as fp: - self._cache[code] = json.load(fp) - except: - pass - - def _save_cache(self, code): - """保存筹码状态""" - if code in self._cache: - path = CACHE_DIR / f"{code}.json" - with open(path, "w") as fp: - json.dump(self._cache[code], fp, ensure_ascii=False) - - # ── 筹码分布估算(用日线OHLCV) ── - def _build_chip_distribution(self, code): - """从日线K线估算筹码分布。 - - 原理:假设每日成交量在OHLC区间内均匀分布, - 每根K线的成交量按价格区间分配,累积成筹码分布。 - """ - for k in list(os.environ.keys()): - if 'proxy' in k.lower(): - os.environ.pop(k) - # 从腾讯API取60日K线 - prefix = "sh" if code.startswith(('60','68','51')) else "sz" if code.startswith(('00','30','15')) else "hk" - url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,640,qfq" - try: - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) - resp = opener.open(req, timeout=8).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 {} - - # 估算筹码分布:价格区间 → 累积量 - chip_dist = {} # price_level → accumulated_volume - decay = 0.97 # 每日衰减因子(老筹码逐步换手) - - for bar in bars: - try: - if len(bar) < 6: - continue - high = float(bar[3]) # index 3 = high - low = float(bar[4]) # index 4 = low - volume = float(bar[5]) if len(bar) > 5 else 0 # index 5 = volume - if high <= low or volume <= 0: - continue - # 在OHLC区间均匀分配成交量 - step = max(round((high - low) / 5, 2), 0.01) - level = round(low, 2) - vol_per_level = volume / max(int((high - low) / step) + 1, 1) - while level <= high: - chip_dist[level] = chip_dist.get(level, 0) + vol_per_level - level = round(level + step, 2) - except: - continue - - # 衰减老筹码 - total = sum(chip_dist.values()) - if total > 0: - for k in chip_dist: - chip_dist[k] *= decay - - return chip_dist - - # ── 三大因子计算 ── - def calc_all(self, code, name="", price=None): - """计算全部筹码因子,返回dict""" - result = {"code": code, "name": name, "price": price} - - # 获取当前价(如果没传) - if not price: - price = _fetch_quote(code) - if not price: - return result - result["price"] = price - - # 构建筹码分布 - chip_dist = self._build_chip_distribution(code) - if not chip_dist or price <= 0: - return result - - # 计算盈利/亏损筹码占比 - total_vol = sum(chip_dist.values()) - if total_vol <= 0: - return result - - winner_vol = sum(v for k, v in chip_dist.items() if k <= price) # 盈利筹码(cost≤现价) - loser_vol = total_vol - winner_vol # 亏损筹码(cost>现价) - - winner_pct = winner_vol / total_vol - loser_pct = loser_vol / total_vol - - # 获取前日状态 - prev = self._cache.get(code, {}) - prev_winner = prev.get("winner_pct", winner_pct) - prev_bias = prev.get("bias", 0) - - # 估算换手率(近10日均量/总流通股) - turnover = 0.02 # 默认2% - try: - prefix2 = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" - url2 = f"http://ifzq.gtimg.cn/appstock/app/fkline/get?param={prefix2}{code},day,,,10,qfq" - req2 = urllib.request.Request(url2, headers={"User-Agent": "Mozilla/5.0"}) - resp2 = urllib.request.urlopen(req2, timeout=5).read().decode('utf-8') - data2 = json.loads(resp2) - dk = 'qfqday' if prefix2 != 'hk' else 'day' - bars2 = data2.get('data', {}).get(f'{prefix2}{code}', {}).get(dk, []) - if len(bars2) > 5: - avg_vol = sum(float(b[5]) for b in bars2[-10:] if len(b)>5) / min(len(bars2), 10) - # 用近60日最高量估算总流通股 - max_vol = avg_vol * 50 # 估算值 - turnover = min(avg_vol / max(max_vol, 1), 0.3) - except: - pass - - # 1. 筹码穿透率 PTR = (winner_pct - prev_winner) / turnover - ptr = (winner_pct - prev_winner) / max(turnover, 0.001) - - # 2. 当日筹码穿透率(简化版) = 今日量 / 总筹码 / turnover - ptr_today = 0 - minute_data = _fetch_minute_kline(code, count=30) - if minute_data: - today_vol = sum(float(m[5]) for m in minute_data if len(m) > 5) - ptr_today = today_vol / max(total_vol, 1) / max(turnover, 0.001) - - # 3. 筹码乖离率(亏损版本—按文章发现,亏损筹码版反而最强) - # bias = loser_pct * turnover + prev_bias * (1 - turnover) - bias = loser_pct * turnover + prev_bias * (1 - turnover) - - # 更新缓存 - self._cache[code] = { - "winner_pct": winner_pct, - "loser_pct": loser_pct, - "bias": bias, - "updated_at": datetime.now().isoformat() - } - self._save_cache(code) - - return { - "code": code, - "name": name, - "price": price, - "winner_pct": round(winner_pct, 4), - "loser_pct": round(loser_pct, 4), - "ptr": round(ptr, 4), - "ptr_today": round(ptr_today, 4), - "bias": round(bias, 4), - "turnover": round(turnover, 4), - } - - def batch_calc(self, stocks): - """批量计算多只股票""" - results = [] - for i, (code, name) in enumerate(stocks): - if i > 0: - time.sleep(1.5) # 限流 - result = self.calc_all(code, name) - results.append(result) - return results - - -# ── 主入口 ── -if __name__ == "__main__": - import sys - cf = ChipFactors() - - # 从decisions.json获取持仓+自选 - dec = json.loads(open(str(MOFIN_ROOT / "data" / "decisions.json")).read()) - stocks = [(s["code"], s.get("name","")) for s in dec.get("decisions", []) if s.get("status") != "closed"] - - results = cf.batch_calc(stocks) - - # 按bias排序显示(亏损筹码占比最高的排前面) - results.sort(key=lambda r: r.get("bias", 0), reverse=True) - print(f"{'股票':16} {'亏损筹码%':>10} {'PTR':>8} {'乖离率':>8} {'换手率':>8}") - print("-" * 60) - for r in results: - if r.get("price"): - print(f"{r['name']:8}({r['code']:6}) {r['loser_pct']*100:>8.1f}% {r['ptr']:>8.4f} {r['bias']:>8.4f} {r['turnover']*100:>6.1f}%") - - print(f"\n共计算{len(results)}只股票") - print(f"筹码缓存目录: {CACHE_DIR}") +#!/usr/bin/env python3 +"""chip_factors.py — 筹码因子计算模块 + +基于中信建投《筹码分布因子系统构建》研报,实现四大类因子。 +用日线数据就够了,分钟数据用于当日穿透率增强。 + +用法: + from chip_factors import ChipFactors + cf = ChipFactors() + + # 计算单只股票的筹码乖离率 + result = cf.calc_all("600519") + print(result["bias"], result["ptr"], result["ptr_today"]) + + # 批量计算所有持仓/自选 + results = cf.batch_calc(["600519", "00700", "000700"]) +""" + +import json, os, sqlite3, time, urllib.request +from datetime import datetime, timedelta +from mo_data import read_decisions +from mo_data import read_decisions +from pathlib import Path + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +MOFIN_ROOT = Path("/home/hmo/MoFin") +CACHE_DIR = MOFIN_ROOT / "data" / "chip_cache" + +# ── 分钟数据限流 ── +_last_minute_call = 0 + +def _fetch_quote(code): + """拉腾讯实时价(清代理)""" + for k in list(os.environ.keys()): + if 'proxy' in k.lower(): + os.environ.pop(k) + prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" + try: + url = f"http://qt.gtimg.cn/q={prefix}{code}" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + resp = urllib.request.urlopen(req, timeout=5).read().decode('gbk') + fields = resp.split('=')[1].strip().strip('"').strip(';').split('~') + return float(fields[3]) if len(fields) > 3 else 0 + except: + return 0 + + +def _fetch_minute_kline(code, count=60): + """拉1分钟K线(带限流)""" + global _last_minute_call + now = time.time() + if now - _last_minute_call < 1.0: + time.sleep(1.0 - (now - _last_minute_call)) + secid = f"1.{code}" if code.startswith(('6','5')) else f"0.{code}" + url = (f"https://push2.eastmoney.com/api/qt/stock/kline/get" + f"?secid={secid}&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57" + f"&klt=1&fqt=1&end=20500101&lmt={min(count, 240)}") + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + resp = urllib.request.urlopen(req, timeout=8) + data = json.loads(resp.read())["data"]["klines"] + _last_minute_call = time.time() + return [line.split(",") for line in data] + except: + return None + + +class ChipFactors: + """筹码因子计算器""" + + def __init__(self): + CACHE_DIR.mkdir(parents=True, exist_ok=True) + self._cache = {} # code → {last_chip, winner, bias} + self._load_cache() + + def _load_cache(self): + """加载缓存的筹码状态""" + for f in CACHE_DIR.glob("*.json"): + code = f.stem + try: + with open(f) as fp: + self._cache[code] = json.load(fp) + except: + pass + + def _save_cache(self, code): + """保存筹码状态""" + if code in self._cache: + path = CACHE_DIR / f"{code}.json" + with open(path, "w") as fp: + json.dump(self._cache[code], fp, ensure_ascii=False) + + # ── 筹码分布估算(用日线OHLCV) ── + def _build_chip_distribution(self, code): + """从日线K线估算筹码分布。 + + 原理:假设每日成交量在OHLC区间内均匀分布, + 每根K线的成交量按价格区间分配,累积成筹码分布。 + """ + for k in list(os.environ.keys()): + if 'proxy' in k.lower(): + os.environ.pop(k) + # 从腾讯API取60日K线 + prefix = "sh" if code.startswith(('60','68','51')) else "sz" if code.startswith(('00','30','15')) else "hk" + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,640,qfq" + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + resp = opener.open(req, timeout=8).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 {} + + # 估算筹码分布:价格区间 → 累积量 + chip_dist = {} # price_level → accumulated_volume + decay = 0.97 # 每日衰减因子(老筹码逐步换手) + + for bar in bars: + try: + if len(bar) < 6: + continue + high = float(bar[3]) # index 3 = high + low = float(bar[4]) # index 4 = low + volume = float(bar[5]) if len(bar) > 5 else 0 # index 5 = volume + if high <= low or volume <= 0: + continue + # 在OHLC区间均匀分配成交量 + step = max(round((high - low) / 5, 2), 0.01) + level = round(low, 2) + vol_per_level = volume / max(int((high - low) / step) + 1, 1) + while level <= high: + chip_dist[level] = chip_dist.get(level, 0) + vol_per_level + level = round(level + step, 2) + except: + continue + + # 衰减老筹码 + total = sum(chip_dist.values()) + if total > 0: + for k in chip_dist: + chip_dist[k] *= decay + + return chip_dist + + # ── 三大因子计算 ── + def calc_all(self, code, name="", price=None): + """计算全部筹码因子,返回dict""" + result = {"code": code, "name": name, "price": price} + + # 获取当前价(如果没传) + if not price: + price = _fetch_quote(code) + if not price: + return result + result["price"] = price + + # 构建筹码分布 + chip_dist = self._build_chip_distribution(code) + if not chip_dist or price <= 0: + return result + + # 计算盈利/亏损筹码占比 + total_vol = sum(chip_dist.values()) + if total_vol <= 0: + return result + + winner_vol = sum(v for k, v in chip_dist.items() if k <= price) # 盈利筹码(cost≤现价) + loser_vol = total_vol - winner_vol # 亏损筹码(cost>现价) + + winner_pct = winner_vol / total_vol + loser_pct = loser_vol / total_vol + + # 获取前日状态 + prev = self._cache.get(code, {}) + prev_winner = prev.get("winner_pct", winner_pct) + prev_bias = prev.get("bias", 0) + + # 估算换手率(近10日均量/总流通股) + turnover = 0.02 # 默认2% + try: + prefix2 = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" + url2 = f"http://ifzq.gtimg.cn/appstock/app/fkline/get?param={prefix2}{code},day,,,10,qfq" + req2 = urllib.request.Request(url2, headers={"User-Agent": "Mozilla/5.0"}) + resp2 = urllib.request.urlopen(req2, timeout=5).read().decode('utf-8') + data2 = json.loads(resp2) + dk = 'qfqday' if prefix2 != 'hk' else 'day' + bars2 = data2.get('data', {}).get(f'{prefix2}{code}', {}).get(dk, []) + if len(bars2) > 5: + avg_vol = sum(float(b[5]) for b in bars2[-10:] if len(b)>5) / min(len(bars2), 10) + # 用近60日最高量估算总流通股 + max_vol = avg_vol * 50 # 估算值 + turnover = min(avg_vol / max(max_vol, 1), 0.3) + except: + pass + + # 1. 筹码穿透率 PTR = (winner_pct - prev_winner) / turnover + ptr = (winner_pct - prev_winner) / max(turnover, 0.001) + + # 2. 当日筹码穿透率(简化版) = 今日量 / 总筹码 / turnover + ptr_today = 0 + minute_data = _fetch_minute_kline(code, count=30) + if minute_data: + today_vol = sum(float(m[5]) for m in minute_data if len(m) > 5) + ptr_today = today_vol / max(total_vol, 1) / max(turnover, 0.001) + + # 3. 筹码乖离率(亏损版本—按文章发现,亏损筹码版反而最强) + # bias = loser_pct * turnover + prev_bias * (1 - turnover) + bias = loser_pct * turnover + prev_bias * (1 - turnover) + + # 更新缓存 + self._cache[code] = { + "winner_pct": winner_pct, + "loser_pct": loser_pct, + "bias": bias, + "updated_at": datetime.now().isoformat() + } + self._save_cache(code) + + return { + "code": code, + "name": name, + "price": price, + "winner_pct": round(winner_pct, 4), + "loser_pct": round(loser_pct, 4), + "ptr": round(ptr, 4), + "ptr_today": round(ptr_today, 4), + "bias": round(bias, 4), + "turnover": round(turnover, 4), + } + + def batch_calc(self, stocks): + """批量计算多只股票""" + results = [] + for i, (code, name) in enumerate(stocks): + if i > 0: + time.sleep(1.5) # 限流 + result = self.calc_all(code, name) + results.append(result) + return results + + +# ── 主入口 ── +if __name__ == "__main__": + import sys + cf = ChipFactors() + + # 从decisions.json获取持仓+自选 + dec = read_decisions() + stocks = [(s["code"], s.get("name","")) for s in dec.get("decisions", []) if s.get("status") != "closed"] + + results = cf.batch_calc(stocks) + + # 按bias排序显示(亏损筹码占比最高的排前面) + results.sort(key=lambda r: r.get("bias", 0), reverse=True) + print(f"{'股票':16} {'亏损筹码%':>10} {'PTR':>8} {'乖离率':>8} {'换手率':>8}") + print("-" * 60) + for r in results: + if r.get("price"): + print(f"{r['name']:8}({r['code']:6}) {r['loser_pct']*100:>8.1f}% {r['ptr']:>8.4f} {r['bias']:>8.4f} {r['turnover']*100:>6.1f}%") + + print(f"\n共计算{len(results)}只股票") + print(f"筹码缓存目录: {CACHE_DIR}") diff --git a/scripts/clean_watchlist.py b/scripts/clean_watchlist.py index 222f2a0..ab89f13 100644 --- a/scripts/clean_watchlist.py +++ b/scripts/clean_watchlist.py @@ -1,101 +1,122 @@ -#!/usr/bin/env python3 -"""Remove held stocks from watchlist""" - -import json, os -from mo_data import read_portfolio, read_decisions, read_watchlist - -WL = "/home/hmo/web-dashboard/data/watchlist.json" -DEC = "/home/hmo/web-dashboard/data/decisions.json" - -holding_codes = set() -pf = mo_data.read_portfolio() -for h in pf.get("holdings", []): - c = h.get("code", "") - if c: - holding_codes.add(c) - -print(f"持仓 codes: {sorted(holding_codes)}") - -# Load watchlist -wl = json.load(open(WL)) -stocks = wl.get("stocks", []) -before = len(stocks) - -# Remove held stocks -new_stocks = [s for s in stocks if s.get("code") not in holding_codes] -removed = [s for s in stocks if s.get("code") in holding_codes] - -after = len(new_stocks) -wl["stocks"] = new_stocks - -# Backup -os.rename(WL, WL + ".bak2") -json.dump(wl, open(WL, "w"), indent=2, ensure_ascii=False) - -print(f"\n自选: {before} → {after} 只") -print(f"移除 {len(removed)} 只:") -for r in removed: - print(f" {r['code']} {r.get('name','')}") - -# Also update decisions.json - set them to "managed_by_holdings" or remove watchlist-only fields -dec = json.load(open(DEC)) -dec_changed = 0 -for d in dec.get("decisions", []): - code = d.get("code", "") - if code in [r["code"] for r in removed]: - # Remove watchlist-specific tags - if d.get("tag") == "watchlist": - d["tag"] = "managed_by_holdings" - dec_changed += 1 - -if dec_changed: - os.rename(DEC, DEC + ".bak3") - json.dump(dec, open(DEC, "w"), indent=2, ensure_ascii=False) - print(f"\ndecisions.json: {dec_changed} 只更新标签") -else: - print(f"\ndecisions.json: 无需更新") - -# ── 反过程:清仓股自动加回自选 ── -# 找出曾持仓但现已不在 portfolio 的股票 -prev_held = {} # code → last_execution info -for d in dec.get("decisions", []): - code = d.get("code", "") - exec_info = d.get("execution", {}) - if exec_info and exec_info.get("status") in ("executing", "partial_exit"): - # 当前仍持仓但不在 portfolio?说明 portfolio 数据落后,跳过 - pass - elif exec_info and exec_info.get("status") in ("sold", "closed") and code not in holding_codes: - prev_held[code] = { - "name": d.get("name", code), - "entry_low": d.get("entry_low", 0), - "entry_high": d.get("entry_high", 0), - } - -if prev_held: - wl_stock_codes = set(s.get("code", "") for s in new_stocks) - added = 0 - for code, info in sorted(prev_held.items()): - if code not in wl_stock_codes and code not in holding_codes: - # 确保买入区有值 - entry_low = info.get("entry_low", 0) or 0 - entry_high = info.get("entry_high", 0) or 0 - new_stocks.append({ - "code": code, - "name": info["name"], - "entry_low": entry_low, - "entry_high": entry_high, - "stop_loss": 0, - "tag": "recovered_from_sold", - }) - added += 1 - print(f" ← 已清仓→加回自选: {code} {info['name']}") - if added: - wl["stocks"] = new_stocks - json.dump(wl, open(WL, "w"), indent=2, ensure_ascii=False) - print(f"\n反过程: {added} 只清仓股已加回自选") - else: - print("\n反过程: 无清仓股需加回") -else: - print("\n反过程: 无已清仓记录") - -print("\nDONE") +#!/usr/bin/env python3 +"""Remove held stocks from watchlist""" + +import json, os +from mo_data import read_portfolio, read_decisions, read_watchlist +from mofin_db import get_conn, write_watchlist_stock, write_holding_strategy + +WL = "/home/hmo/web-dashboard/data/watchlist.json" +DEC = "/home/hmo/web-dashboard/data/decisions.json" + +holding_codes = set() +pf = read_portfolio() +for h in pf.get("holdings", []): + c = h.get("code", "") + if c: + holding_codes.add(c) + +print(f"持仓 codes: {sorted(holding_codes)}") + +# Load watchlist +wl = read_watchlist() +stocks = wl.get("stocks", []) +before = len(stocks) + +# Remove held stocks +new_stocks = [s for s in stocks if s.get("code") not in holding_codes] +removed = [s for s in stocks if s.get("code") in holding_codes] + +after = len(new_stocks) +wl["stocks"] = new_stocks + +# Backup +os.rename(WL, WL + ".bak2") +# DB 写入 +conn = get_conn() +for s in wl.get("stocks", []): + s.setdefault("currency", "CNY") + write_watchlist_stock(conn, s) +conn.close() +# [migrated to DB] — cold backup removed +# json.dump(wl, open(WL, "w"), indent=2, ensure_ascii=False) + +print(f"\n自选: {before} → {after} 只") +print(f"移除 {len(removed)} 只:") +for r in removed: + print(f" {r['code']} {r.get('name','')}") + +# Also update decisions.json - set them to "managed_by_holdings" or remove watchlist-only fields +dec = read_decisions() +dec_changed = 0 +for d in dec.get("decisions", []): + code = d.get("code", "") + if code in [r["code"] for r in removed]: + # Remove watchlist-specific tags + if d.get("tag") == "watchlist": + d["tag"] = "managed_by_holdings" + dec_changed += 1 + +if dec_changed: + os.rename(DEC, DEC + ".bak3") + # DB 写入 + conn = get_conn() + for d in dec.get("decisions", []): + write_holding_strategy(conn, d.get("code", ""), d.get("name", ""), d) + conn.close() + # [migrated to DB] — cold backup removed + # json.dump(dec, open(DEC, "w"), indent=2, ensure_ascii=False) + print(f"\ndecisions.json: {dec_changed} 只更新标签") +else: + print(f"\ndecisions.json: 无需更新") + +# ── 反过程:清仓股自动加回自选 ── +# 找出曾持仓但现已不在 portfolio 的股票 +prev_held = {} # code → last_execution info +for d in dec.get("decisions", []): + code = d.get("code", "") + exec_info = d.get("execution", {}) + if exec_info and exec_info.get("status") in ("executing", "partial_exit"): + # 当前仍持仓但不在 portfolio?说明 portfolio 数据落后,跳过 + pass + elif exec_info and exec_info.get("status") in ("sold", "closed") and code not in holding_codes: + prev_held[code] = { + "name": d.get("name", code), + "entry_low": d.get("entry_low", 0), + "entry_high": d.get("entry_high", 0), + } + +if prev_held: + wl_stock_codes = set(s.get("code", "") for s in new_stocks) + added = 0 + for code, info in sorted(prev_held.items()): + if code not in wl_stock_codes and code not in holding_codes: + # 确保买入区有值 + entry_low = info.get("entry_low", 0) or 0 + entry_high = info.get("entry_high", 0) or 0 + new_stocks.append({ + "code": code, + "name": info["name"], + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": 0, + "tag": "recovered_from_sold", + }) + added += 1 + print(f" ← 已清仓→加回自选: {code} {info['name']}") + if added: + wl["stocks"] = new_stocks + # DB 写入 + conn = get_conn() + for s in wl.get("stocks", []): + s.setdefault("currency", "CNY") + write_watchlist_stock(conn, s) + conn.close() + # [migrated to DB] — cold backup removed + # json.dump(wl, open(WL, "w"), indent=2, ensure_ascii=False) + print(f"\n反过程: {added} 只清仓股已加回自选") + else: + print("\n反过程: 无清仓股需加回") +else: + print("\n反过程: 无已清仓记录") + +print("\nDONE") diff --git a/scripts/deep_check_01888.py b/scripts/deep_check_01888.py new file mode 100644 index 0000000..b4c9492 --- /dev/null +++ b/scripts/deep_check_01888.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Deep check 01888 decisions""" +import json, sqlite3 + +# Check decisions.json directly +with open('/home/hmo/web-dashboard/data/decisions.json') as f: + raw = json.load(f) + +count = 0 +for d in raw.get('decisions', []): + if d.get('code') == '01888': + count += 1 + print(f"[JSON entry {count}] price={d.get('price')} cost={d.get('cost')} shares={d.get('shares')}") + print(f" curr={d.get('currency','none')} status={d.get('status')} type={d.get('type')}") + print(f" stop_loss={d.get('stop_loss')} take_profit={d.get('trigger',{}).get('take_profit')}") + +print(f"\nTotal 01888 entries in decisions.json: {count}") + +# Also check holding_strategies table +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') +db.row_factory = sqlite3.Row +rows = db.execute("SELECT * FROM holding_strategies WHERE code='01888' ORDER BY updated_at DESC").fetchall() +print(f"\nholding_strategies entries: {len(rows)}") +for r in rows: + print(f" cost={r['cost']} price={r['price']} shares={r['shares']} curr={r['currency']} status={r['status']} updated={r['updated_at']}") +db.close() diff --git a/scripts/fix_and_regenerate.py b/scripts/fix_and_regenerate.py new file mode 100644 index 0000000..8d92199 --- /dev/null +++ b/scripts/fix_and_regenerate.py @@ -0,0 +1,23 @@ +import sqlite3, sys +sys.path.insert(0, '/home/hmo/MoFin') +from strategy_lifecycle import regenerate_all + +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + +# Step 1: Add UNIQUE index if not exists +try: + db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_strategy_code ON holding_strategies(code)") + print("UNIQUE index added") +except sqlite3.OperationalError as e: + print(f"Index already exists or error: {e}") + +# Step 2: Clear old stale data from holding_strategies (regenerate_all will rewrite) +deleted = db.execute("DELETE FROM holding_strategies").rowcount +print(f"Cleared {deleted} old strategy entries") +db.commit() +db.close() + +# Step 3: Full regenerate +print("\n=== Running regenerate_all ===") +regenerate_all(stdout=True) +print("\nDone!") diff --git a/scripts/fix_hk_cost_cny.py b/scripts/fix_hk_cost_cny.py new file mode 100644 index 0000000..5eaca85 --- /dev/null +++ b/scripts/fix_hk_cost_cny.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""一次性修复:将 holdings 表中港股成本从 HKD 转为 CNY。 +运行时机:import_holding_xls.py 修复后,旧数据需要转换。 +""" +import sqlite3, sys, os + +sys.path.insert(0, '/home/hmo/MoFin') +from mo_models import is_hk_stock +from hk_rate import hkd_to_cny + +rate = hkd_to_cny() +DB = '/home/hmo/web-dashboard/data/mofin.db' + +print(f"HK_RATE = {rate}") +print(f"DB = {DB}") + +db = sqlite3.connect(DB) +rows = db.execute( + "SELECT code, name, cost, shares, currency FROM holdings WHERE is_active=1" +).fetchall() + +fixed = 0 +for r in rows: + code = r[0] + name = r[1] + cost = r[2] or 0 + shares = r[3] or 0 + curr = r[4] or 'CNY' + + if not is_hk_stock(str(code)): + continue + if cost <= 0: + continue + + cost_cny = round(cost * rate, 2) + print(f" FIX: {code} {name}: cost {cost} -> {cost_cny} CNY (shares={shares})") + db.execute( + "UPDATE holdings SET cost=?, currency=? WHERE code=?", + (cost_cny, 'CNY', code) + ) + fixed += 1 + +db.commit() +db.close() + +print(f"\nDone. Fixed {fixed} HK stock costs.") diff --git a/scripts/fix_trigger.py b/scripts/fix_trigger.py index 33a3e55..4437ee6 100644 --- a/scripts/fix_trigger.py +++ b/scripts/fix_trigger.py @@ -1,51 +1,57 @@ -#!/usr/bin/env python3 -"""修复decisions.json中所有决策的trigger字段(由regenerate_all负责填充)""" - -import json, sys, os -from mo_data import read_portfolio, read_decisions, read_watchlist - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -DECISIONS_BAK = DECISIONS_PATH + ".bak" - -try: - with open(DECISIONS_PATH) as f: - dec = json.load(f) -except Exception as e: - print(f"读decisions.json失败: {e}") - sys.exit(1) - -count = 0 -for d in dec.get("decisions", []): - trig = d.get("trigger", {}) - if trig: - continue # 已有trigger,跳过 - - sl = d.get("stop_loss", 0) or 0 - tp = d.get("take_profit", 0) or 0 - el = d.get("entry_low", 0) or 0 - eh = d.get("entry_high", 0) or 0 - code = d.get("code", "") - name = d.get("name", "") - - new_trig = {} - if sl and float(sl) > 0: - new_trig["stop_loss"] = float(sl) - if el and eh and float(el) > 0 and float(eh) > 0: - new_trig["entry_zone"] = f"{float(el)}~{float(eh)}" - if tp and float(tp) > 0: - new_trig["take_profit_zone"] = f"0~{float(tp)}" - - d["trigger"] = new_trig - count += 1 - if new_trig: - print(f" {code} {name}: trigger={new_trig}") - -# 备份 -os.makedirs(os.path.dirname(DECISIONS_BAK), exist_ok=True) -with open(DECISIONS_BAK, 'w') as f: - json.dump(mo_data.read_decisions(), f, indent=2, ensure_ascii=False) - -with open(DECISIONS_PATH, 'w') as f: - json.dump(dec, f, indent=2, ensure_ascii=False) - -print(f"\n共{count}只,已更新trigger字段") +#!/usr/bin/env python3 +"""修复decisions.json中所有决策的trigger字段(由regenerate_all负责填充)""" + +import json, sys, os +from mo_data import read_portfolio, read_decisions, read_watchlist +from mofin_db import get_conn, write_holding_strategy + +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" +DECISIONS_BAK = DECISIONS_PATH + ".bak" + +try: + dec = read_decisions() +except Exception as e: + print(f"读decisions.json失败: {e}") + sys.exit(1) + +count = 0 +for d in dec.get("decisions", []): + trig = d.get("trigger", {}) + if trig: + continue # 已有trigger,跳过 + + sl = d.get("stop_loss", 0) or 0 + tp = d.get("take_profit", 0) or 0 + el = d.get("entry_low", 0) or 0 + eh = d.get("entry_high", 0) or 0 + code = d.get("code", "") + name = d.get("name", "") + + new_trig = {} + if sl and float(sl) > 0: + new_trig["stop_loss"] = float(sl) + if el and eh and float(el) > 0 and float(eh) > 0: + new_trig["entry_zone"] = f"{float(el)}~{float(eh)}" + if tp and float(tp) > 0: + new_trig["take_profit_zone"] = f"0~{float(tp)}" + + d["trigger"] = new_trig + count += 1 + if new_trig: + print(f" {code} {name}: trigger={new_trig}") + +# 备份 +os.makedirs(os.path.dirname(DECISIONS_BAK), exist_ok=True) +with open(DECISIONS_BAK, 'w') as f: + json.dump(mo_data.read_decisions(), f, indent=2, ensure_ascii=False) + +# DB 写入(替代 json.dump) +conn = get_conn() +for d in dec.get("decisions", []): + write_holding_strategy(conn, d.get("code", ""), d.get("name", ""), d) +conn.close() +# [migrated to DB] — cold backup removed +# with open(DECISIONS_PATH, 'w') as f: +# json.dump(dec, f, indent=2, ensure_ascii=False) + +print(f"\n共{count}只,已更新trigger字段") diff --git a/scripts/holdings_reconciliation.py b/scripts/holdings_reconciliation.py index 286ce8a..60fa70c 100644 --- a/scripts/holdings_reconciliation.py +++ b/scripts/holdings_reconciliation.py @@ -10,13 +10,15 @@ """ import json, sys from datetime import datetime +from mo_data import read_portfolio, read_decisions +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy DECISIONS = "/home/hmo/web-dashboard/data/decisions.json" PORTFOLIO = "/home/hmo/web-dashboard/data/portfolio.json" def main(): - dec = json.load(open(DECISIONS)) - pf = json.load(open(PORTFOLIO)) + dec = read_decisions() + pf = read_portfolio() # Build maps dmap = {d["code"]: d for d in dec.get("decisions", [])} @@ -120,11 +122,9 @@ def main(): print(f"decisions stock_value: {dec_total:.2f}") print(f"decisions count(shares>0): {len([d for d in dec['decisions'] if d.get('shares',0)>0])}") - # Write — DB 优先(强制币种约束),JSON 冷备 + # Write — DB 优先,JSON 冷备已移除 dec["total"] = len(dec["decisions"]) try: - sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy conn = get_conn() write_holdings_batch(conn, pf.get('holdings', [])) write_portfolio_summary(conn, pf) @@ -133,8 +133,9 @@ def main(): conn.close() except Exception as e: print(f" [DB写入失败] {e}") - json.dump(dec, open(DECISIONS, "w"), ensure_ascii=False, indent=2) - json.dump(pf, open(PORTFOLIO, "w"), ensure_ascii=False, indent=2) + # [migrated to DB] — cold backup removed; DB writes above + # json.dump(dec, open(DECISIONS, "w"), ensure_ascii=False, indent=2) + # json.dump(pf, open(PORTFOLIO, "w"), ensure_ascii=False, indent=2) print(f"done") if __name__ == "__main__": diff --git a/scripts/import_holding_xls.py b/scripts/import_holding_xls.py index 4761d68..cc7f09f 100755 --- a/scripts/import_holding_xls.py +++ b/scripts/import_holding_xls.py @@ -15,6 +15,8 @@ import_holding_xls.py — 从 holding.xls 导入持仓到全系统 """ import csv, json, sys, subprocess, sqlite3, os from datetime import datetime +from mo_data import read_decisions +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy STOCKS_FILE = "/home/hmo/stocks/holding.xls" PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" @@ -149,11 +151,11 @@ def main(): 'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M'), 'source': STOCKS_FILE, } - with open(PORTFOLIO_PATH, 'w') as f: - json.dump(portfolio, f, indent=2, ensure_ascii=False) + # [migrated to DB] — cold backup removed; DB writes below + # with open(PORTFOLIO_PATH, 'w') as f: + # json.dump(portfolio, f, indent=2, ensure_ascii=False) # DB 写入 try: - from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary conn = get_conn() write_holdings_batch(conn, portfolio.get('holdings', [])) write_portfolio_summary(conn, portfolio) @@ -165,8 +167,7 @@ def main(): print("\n→ 重建决策树...") sys.path.insert(0, '/home/hmo/web-dashboard') from strategy_tree import init_default_branches - with open('/home/hmo/web-dashboard/data/decisions.json') as f: - data = json.load(f) + data = read_decisions() ok = 0 for e in data.get('decisions', []): branches = init_default_branches( @@ -175,8 +176,17 @@ def main(): e.get('stop_loss', 0), e.get('take_profit', 0)) e['strategy_tree'] = {'branches': branches, 'created_at': datetime.now().strftime('%Y-%m-%d')} ok += 1 - with open('/home/hmo/web-dashboard/data/decisions.json', 'w') as f: - json.dump(data, f, indent=2, ensure_ascii=False) + # DB 写入(替代 json.dump) + try: + conn = get_conn() + for d in data.get('decisions', []): + _whs(conn, d.get('code', ''), d.get('name', ''), d) + conn.close() + except Exception: + pass + # [migrated to DB] — cold backup removed + # with open('/home/hmo/web-dashboard/data/decisions.json', 'w') as f: + # json.dump(data, f, indent=2, ensure_ascii=False) print(f"\n{'='*50}") print(f"导入完成:{len(holdings)}只持仓") diff --git a/scripts/inspect_decisions.py b/scripts/inspect_decisions.py new file mode 100644 index 0000000..f69b979 --- /dev/null +++ b/scripts/inspect_decisions.py @@ -0,0 +1,11 @@ +import json +with open('/home/hmo/web-dashboard/data/decisions.json') as f: + d = json.load(f) +for i, e in enumerate(d.get('decisions', [])[:3]): + print(f"\n=== Entry {i}: {e.get('code')} {e.get('name')} ===") + for k, v in sorted(e.items()): + t = type(v).__name__ + sample = str(v)[:60] if v is not None else 'None' + print(f" {k}: {t} = {sample}") + if i >= 2: + break diff --git a/scripts/per_stock_reassess.py b/scripts/per_stock_reassess.py index 542feb3..78c2108 100644 --- a/scripts/per_stock_reassess.py +++ b/scripts/per_stock_reassess.py @@ -1,163 +1,167 @@ -#!/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 - -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 - with open(DECISIONS_PATH) as f: - raw = json.load(f) - 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 - with open("/home/hmo/web-dashboard/data/portfolio.json") as _pf: - _pf_data = json.load(_pf) - 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() +#!/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() diff --git a/scripts/phase1_schema.py b/scripts/phase1_schema.py new file mode 100644 index 0000000..5730375 --- /dev/null +++ b/scripts/phase1_schema.py @@ -0,0 +1,31 @@ +"""Phase 1: Add missing columns to holding_strategies for decisions.json→DB migration""" +import sqlite3 + +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + +new_cols = { + 'avg_price': 'REAL', + 'decision_timestamp': 'TEXT', # avoids conflict with SQLite timestamp + 'note': 'TEXT', + 'quality_check': 'TEXT', + 'quality_checked_at': 'TEXT', + 'quality_issues_json': 'TEXT', + 'position_advice': 'TEXT', + 'signal_factors_json': 'TEXT', + 'time_horizon': 'TEXT', + 'decision_type': 'TEXT', +} + +# Get existing columns +existing = {r[1] for r in db.execute("PRAGMA table_info(holding_strategies)")} + +for col, ctype in new_cols.items(): + if col in existing: + print(f"SKIP: {col} already exists") + else: + db.execute(f"ALTER TABLE holding_strategies ADD COLUMN {col} {ctype}") + print(f"ADDED: {col} {ctype}") + +db.commit() +db.close() +print("\nSchema migration done.") diff --git a/scripts/price_monitor.py b/scripts/price_monitor.py index e04080d..99c1854 100644 --- a/scripts/price_monitor.py +++ b/scripts/price_monitor.py @@ -1,789 +1,805 @@ -#!/usr/bin/env python3 -"""price_monitor.py — 高频价格监控脚本(批量版) -规则:进入区间报一次,离开区间报一次,中间不重复。 -每次运行时一次性刷新所有持仓+自选股的实时价。 -""" -import json -import urllib.request -import os -import sys -import time -from datetime import datetime - -# ── MoFin unified model ────────────────────────────────────────────── -sys.path.insert(0, "/home/hmo/MoFin") -from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct -from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock -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" -WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" -BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" -STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json") -EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" - -# 策略重评依赖(技术面驱动,非机械百分比) -sys.path.insert(0, "/home/hmo/web-dashboard") -try: - from strategy_lifecycle import reassess_strategy - HAS_REASSESS = True -except ImportError: - HAS_REASSESS = False - -try: - HK_RATE = get_hk_rate() -except Exception: - HK_RATE = 0.87 # ultimate fallback - -# 分支系统与情景检测 -try: - sys.path.insert(0, '/home/hmo/MoFin') - from strategy_tree import detect_scenario, evaluate_branches - HAS_TREE = True -except Exception: - HAS_TREE = False - def detect_scenario(): return {} - def evaluate_branches(*a, **kw): return [] - -# 情景缓存(每次run_once刷新) -_SCENARIO_CACHE = {} -_BRANCH_CACHE = {} # code -> branches list - -UA = "Mozilla/5.0" - -# ── 批量拉取价格 ────────────────────────────────────────────────────────── - -def fetch_all_prices(codes): - """腾讯批量行情API:仅用于A股(沪市/深市) - A股:sh600110 / sz000001 - 港股已迁移至 fetch_hk_eastmoney()(东方财富实时行情) - 返回 {code: (price, change, change_pct)} - """ - if not codes: - return {} - - # 只处理A股(6位代码),港股走东方财富 - a_codes = [c for c in codes if len(str(c).strip()) == 6] - if not a_codes: - return {} - - symbols = [] - code_map = {} - for code in a_codes: - code_s = str(code).strip() - if code_s.startswith(('5', '6', '9')): - sym = f"sh{code_s}" - else: - sym = f"sz{code_s}" - symbols.append(sym) - code_map[sym] = code_s - - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"⚠️ 腾讯A股拉取失败: {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - try: - raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw_value.split("~") - if len(fields) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig_code = code_map.get(sym) - if not orig_code: - continue - price = float(fields[3]) if fields[3] else 0 - prev_close = float(fields[4]) if fields[4] else 0 - change = price - prev_close if prev_close > 0 else 0 - change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" - results[orig_code] = (price, change, change_pct) - except (ValueError, IndexError): - continue - - return results - - -# ── 港股实时行情(新浪财经批量版,实时,无延迟)───────────────────────────── - -def fetch_hk_sina_batch(codes): - """新浪财经港股批量实时行情 — 一次HTTP请求获取全部港股。 - - 新浪港股API(hq.sinajs.cn)支持批量查询,返回实时数据。 - 对比东财逐股查询(0.2s间隔×17只=3.4s),新浪1次请求搞定。 - - API: https://hq.sinajs.cn/list=hk00700,hk09988 - 格式: hq_str_hk00700="TENCENT,腾讯控股,当前价,昨收,开盘,最高,最低,涨跌额,涨跌幅,..." - - 返回 {code: (price, change, change_pct)} - """ - if not codes: - return {} - - hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5] - if not hk_codes: - return {} - - symbols = [f"hk{c}" for c in hk_codes] - url = f"https://hq.sinajs.cn/list={','.join(symbols)}" - - try: - # 新浪要求有 Referer,且需绕过系统代理(某些环境下东财/新浪走代理会断连) - proxy_handler = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy_handler) - req = urllib.request.Request(url, headers={ - "User-Agent": "Mozilla/5.0", - "Referer": "https://finance.sina.com.cn", - }) - with opener.open(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"⚠️ 新浪港股批量拉取失败: {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - line = line.strip() - if "=" not in line: - continue - try: - code = line.split("=", 1)[0].replace("hq_str_hk", "").replace("var ", "").strip() - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw.split(",") - if len(fields) < 9: - continue - price = float(fields[2]) if fields[2] else 0 - prev_close = float(fields[3]) if fields[3] else 0 - change_amt = float(fields[7]) if fields[7] else 0 - change_pct = fields[8] if fields[8] else "0" - # 新浪 field[2] 可能非实时最新价,用 prev_close + change 计算更准确 - if prev_close > 0 and abs(change_amt) > 0: - price = round(prev_close + change_amt, 2) - change = round(change_amt, 2) - if price > 0: - results[code] = (price, change, change_pct) - except (ValueError, IndexError): - continue - - return results - - -# ── 港股备用通道(东方财富逐股 + 腾讯15min延迟)─────────────────────────── - -def fetch_hk_eastmoney_fallback(codes): - """东方财富港股实时行情(备用通道),逐股查询、间隔1秒避免限流。 - - FTP 说明:港股限流严重,不适合主通道,降级为备用。 - 建议用上面的 fetch_hk_sina_batch() 做主通道。 - - 返回 {code: (price, change, change_pct)} - Fallback: 仍失败时回退到腾讯 qt.gtimg.cn(15分钟延迟) - """ - if not codes: - return {} - - hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5] - if not hk_codes: - return {} - - results = {} - - # 东方财富逐股查询,1秒间隔避免限流 - for code in hk_codes: - try: - url = (f"https://push2.eastmoney.com/api/qt/stock/get" - f"?secid=116.{code}" - f"&fields=f43,f170,f60,f57,f58" - f"&fltt=2") - proxy_handler = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy_handler) - req = urllib.request.Request(url, headers={ - "User-Agent": UA, - "Referer": "https://quote.eastmoney.com/", - }) - with opener.open(req, timeout=5) as r: - resp = json.loads(r.read().decode("utf-8")) - - if resp.get("rc") != 0: - continue - item = resp.get("data", {}) - if not item: - continue - price = float(item.get("f43", 0)) if item.get("f43") else 0 - prev_close = float(item.get("f60", 0)) if item.get("f60") else 0 - change = round(price - prev_close, 2) if prev_close > 0 else 0 - change_pct = str(item.get("f170", "0")) - if price > 0: - results[code] = (price, change, change_pct) - time.sleep(1.0) # 1秒间隔,大幅降低限流概率 - except Exception as e: - print(f" [东财备用 {code}] {e}", file=sys.stderr) - continue - - # Fallback: 腾讯 qt.gtimg.cn(15分钟延迟) - missing = [c for c in hk_codes if c not in results] - if missing: - try: - fallback = _fetch_hk_tencent_fallback(missing) - results.update(fallback) - except Exception: - pass - - return results - - -def _fetch_hk_tencent_fallback(codes): - """腾讯港股行情(15分钟延迟,仅作 fallback)""" - symbols = [f"hk{c}" for c in codes] - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - - code_map = {f"hk{c}": c for c in codes} - results = {} - for line in text.strip().split("\n"): - if "=" not in line: - continue - try: - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw.split("~") - if len(fields) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig = code_map.get(sym) - if not orig: - continue - price = float(fields[3]) if fields[3] else 0 - prev_close = float(fields[4]) if fields[4] else 0 - change = price - prev_close if prev_close > 0 else 0 - change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" - results[orig] = (price, change, change_pct) - except (ValueError, IndexError): - continue - return results - - -def refresh_data_prices(): - """一次性刷新portfolio.json和watchlist.json的所有实时价""" - all_codes = set() - - # 收集所有需要拉取的代码 - try: - pf = read_portfolio() - for s in pf.get('holdings', []): - all_codes.add(s['code']) - except: - pf = {"holdings": []} - - try: - wl = read_watchlist() - for s in wl.get('stocks', []): - all_codes.add(s['code']) - except: - wl = {"stocks": []} - - if not all_codes: - return 0 - - # 分批拉取:A股走腾讯(实时) + 港股走新浪批量(实时,无限流) - all_list = list(all_codes) - prices = fetch_all_prices(all_list) # A股(腾讯,实时) - hk_prices = fetch_hk_sina_batch(all_list) # 港股(新浪批量,实时) - # 新浪未覆盖的走备用通道(东财逐股→腾讯15min延迟) - hk_codes_missing = [c for c in all_list if len(str(c).strip()) <= 5 and c not in hk_prices] - if hk_codes_missing: - fallback = fetch_hk_eastmoney_fallback(hk_codes_missing) - hk_prices.update(fallback) - prices.update(hk_prices) - updated = 0 - - # 保存全量实时价快照(供报告管道消费,确保分析用最新数据) - try: - live = {"updated_at": datetime.now().isoformat(), "prices": {}} - for code in all_codes: - if code in prices: - p, c, chg = prices[code] - live["prices"][code] = {"price": p, "change_pct": chg} - json.dump(live, open("/home/hmo/web-dashboard/data/live_prices.json", "w"), indent=2) - except Exception: - pass - - # 更新portfolio(只在价格变化时写入,避免触发文件变更通知) - changed = False - for s in pf.get('holdings', []): - if s['code'] in prices: - price, _, change_pct = prices[s['code']] - if price > 0: - # 港股:API返回HKD,需转RMB - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) - old = s.get('price', 0) - if abs(old - price) > 0.001: - s['price'] = round(price, 2) - s['change_pct'] = float(change_pct) if change_pct else 0 - updated += 1 - changed = True - if changed: - pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - pf['total_mv'] = calc_total_mv(pf.get('holdings', [])) - pf['total_assets'] = calc_total_assets(pf) - pf['position_pct'] = calc_position_pct(pf) - # DB 写入(替代 json.dump,强制币种约束) - try: - conn = get_conn() - write_holdings_batch(conn, pf['holdings']) - write_portfolio_summary(conn, pf) - conn.close() - except Exception as e: - print(f" [DB写入失败] {e}", flush=True) - # 保留 JSON 副本作为冷备 - json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) +#!/usr/bin/env python3 +"""price_monitor.py — 高频价格监控脚本(批量版) +规则:进入区间报一次,离开区间报一次,中间不重复。 +每次运行时一次性刷新所有持仓+自选股的实时价。 +""" +import json +import urllib.request +import os +import sys +import time +from datetime import datetime + +# ── MoFin unified model ────────────────────────────────────────────── +sys.path.insert(0, "/home/hmo/MoFin") +from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock, write_holding_strategy, write_holding_strategy +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" +WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" +BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" +STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json") +EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" + +# 策略重评依赖(技术面驱动,非机械百分比) +sys.path.insert(0, "/home/hmo/web-dashboard") +try: + from strategy_lifecycle import reassess_strategy + HAS_REASSESS = True +except ImportError: + HAS_REASSESS = False + +try: + HK_RATE = get_hk_rate() +except Exception: + HK_RATE = 0.87 # ultimate fallback + +# 分支系统与情景检测 +try: + sys.path.insert(0, '/home/hmo/MoFin') + from strategy_tree import detect_scenario, evaluate_branches + HAS_TREE = True +except Exception: + HAS_TREE = False + def detect_scenario(): return {} + def evaluate_branches(*a, **kw): return [] + +# 情景缓存(每次run_once刷新) +_SCENARIO_CACHE = {} +_BRANCH_CACHE = {} # code -> branches list + +UA = "Mozilla/5.0" + +# ── 批量拉取价格 ────────────────────────────────────────────────────────── + +def fetch_all_prices(codes): + """腾讯批量行情API:仅用于A股(沪市/深市) + A股:sh600110 / sz000001 + 港股已迁移至 fetch_hk_eastmoney()(东方财富实时行情) + 返回 {code: (price, change, change_pct)} + """ + if not codes: + return {} + + # 只处理A股(6位代码),港股走东方财富 + a_codes = [c for c in codes if len(str(c).strip()) == 6] + if not a_codes: + return {} + + symbols = [] + code_map = {} + for code in a_codes: + code_s = str(code).strip() + if code_s.startswith(('5', '6', '9')): + sym = f"sh{code_s}" + else: + sym = f"sz{code_s}" + symbols.append(sym) + code_map[sym] = code_s + + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=10) as r: + text = r.read().decode("gbk") + except Exception as e: + print(f"⚠️ 腾讯A股拉取失败: {e}", file=sys.stderr) + return {} + + results = {} + for line in text.strip().split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + try: + raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw_value.split("~") + if len(fields) < 6: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + orig_code = code_map.get(sym) + if not orig_code: + continue + price = float(fields[3]) if fields[3] else 0 + prev_close = float(fields[4]) if fields[4] else 0 + change = price - prev_close if prev_close > 0 else 0 + change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" + results[orig_code] = (price, change, change_pct) + except (ValueError, IndexError): + continue + + return results + + +# ── 港股实时行情(新浪财经批量版,实时,无延迟)───────────────────────────── + +def fetch_hk_sina_batch(codes): + """新浪财经港股批量实时行情 — 一次HTTP请求获取全部港股。 + + 新浪港股API(hq.sinajs.cn)支持批量查询,返回实时数据。 + 对比东财逐股查询(0.2s间隔×17只=3.4s),新浪1次请求搞定。 + + API: https://hq.sinajs.cn/list=hk00700,hk09988 + 格式: hq_str_hk00700="TENCENT,腾讯控股,当前价,昨收,开盘,最高,最低,涨跌额,涨跌幅,..." + + 返回 {code: (price, change, change_pct)} + """ + if not codes: + return {} + + hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5] + if not hk_codes: + return {} + + symbols = [f"hk{c}" for c in hk_codes] + url = f"https://hq.sinajs.cn/list={','.join(symbols)}" + + try: + # 新浪要求有 Referer,且需绕过系统代理(某些环境下东财/新浪走代理会断连) + proxy_handler = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy_handler) + req = urllib.request.Request(url, headers={ + "User-Agent": "Mozilla/5.0", + "Referer": "https://finance.sina.com.cn", + }) + with opener.open(req, timeout=10) as r: + text = r.read().decode("gbk") + except Exception as e: + print(f"⚠️ 新浪港股批量拉取失败: {e}", file=sys.stderr) + return {} + + results = {} + for line in text.strip().split("\n"): + line = line.strip() + if "=" not in line: + continue + try: + code = line.split("=", 1)[0].replace("hq_str_hk", "").replace("var ", "").strip() + raw = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw.split(",") + if len(fields) < 9: + continue + price = float(fields[2]) if fields[2] else 0 + prev_close = float(fields[3]) if fields[3] else 0 + change_amt = float(fields[7]) if fields[7] else 0 + change_pct = fields[8] if fields[8] else "0" + # 新浪 field[2] 可能非实时最新价,用 prev_close + change 计算更准确 + if prev_close > 0 and abs(change_amt) > 0: + price = round(prev_close + change_amt, 2) + change = round(change_amt, 2) + if price > 0: + results[code] = (price, change, change_pct) + except (ValueError, IndexError): + continue + + return results + + +# ── 港股备用通道(东方财富逐股 + 腾讯15min延迟)─────────────────────────── + +def fetch_hk_eastmoney_fallback(codes): + """东方财富港股实时行情(备用通道),逐股查询、间隔1秒避免限流。 + + FTP 说明:港股限流严重,不适合主通道,降级为备用。 + 建议用上面的 fetch_hk_sina_batch() 做主通道。 + + 返回 {code: (price, change, change_pct)} + Fallback: 仍失败时回退到腾讯 qt.gtimg.cn(15分钟延迟) + """ + if not codes: + return {} + + hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5] + if not hk_codes: + return {} + + results = {} + + # 东方财富逐股查询,1秒间隔避免限流 + for code in hk_codes: + try: + url = (f"https://push2.eastmoney.com/api/qt/stock/get" + f"?secid=116.{code}" + f"&fields=f43,f170,f60,f57,f58" + f"&fltt=2") + proxy_handler = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy_handler) + req = urllib.request.Request(url, headers={ + "User-Agent": UA, + "Referer": "https://quote.eastmoney.com/", + }) + with opener.open(req, timeout=5) as r: + resp = json.loads(r.read().decode("utf-8")) + + if resp.get("rc") != 0: + continue + item = resp.get("data", {}) + if not item: + continue + price = float(item.get("f43", 0)) if item.get("f43") else 0 + prev_close = float(item.get("f60", 0)) if item.get("f60") else 0 + change = round(price - prev_close, 2) if prev_close > 0 else 0 + change_pct = str(item.get("f170", "0")) + if price > 0: + results[code] = (price, change, change_pct) + time.sleep(1.0) # 1秒间隔,大幅降低限流概率 + except Exception as e: + print(f" [东财备用 {code}] {e}", file=sys.stderr) + continue + + # Fallback: 腾讯 qt.gtimg.cn(15分钟延迟) + missing = [c for c in hk_codes if c not in results] + if missing: + try: + fallback = _fetch_hk_tencent_fallback(missing) + results.update(fallback) + except Exception: + pass + + return results + + +def _fetch_hk_tencent_fallback(codes): + """腾讯港股行情(15分钟延迟,仅作 fallback)""" + symbols = [f"hk{c}" for c in codes] + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=10) as r: + text = r.read().decode("gbk") + + code_map = {f"hk{c}": c for c in codes} + results = {} + for line in text.strip().split("\n"): + if "=" not in line: + continue + try: + raw = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw.split("~") + if len(fields) < 6: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + orig = code_map.get(sym) + if not orig: + continue + price = float(fields[3]) if fields[3] else 0 + prev_close = float(fields[4]) if fields[4] else 0 + change = price - prev_close if prev_close > 0 else 0 + change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" + results[orig] = (price, change, change_pct) + except (ValueError, IndexError): + continue + return results + + +def refresh_data_prices(): + """一次性刷新portfolio.json和watchlist.json的所有实时价""" + all_codes = set() + + # 收集所有需要拉取的代码 + try: + pf = read_portfolio() + for s in pf.get('holdings', []): + all_codes.add(s['code']) + except: + pf = {"holdings": []} + + try: + wl = read_watchlist() + for s in wl.get('stocks', []): + all_codes.add(s['code']) + except: + wl = {"stocks": []} + + if not all_codes: + return 0 + + # 分批拉取:A股走腾讯(实时) + 港股走新浪批量(实时,无限流) + all_list = list(all_codes) + prices = fetch_all_prices(all_list) # A股(腾讯,实时) + hk_prices = fetch_hk_sina_batch(all_list) # 港股(新浪批量,实时) + # 新浪未覆盖的走备用通道(东财逐股→腾讯15min延迟) + hk_codes_missing = [c for c in all_list if len(str(c).strip()) <= 5 and c not in hk_prices] + if hk_codes_missing: + fallback = fetch_hk_eastmoney_fallback(hk_codes_missing) + hk_prices.update(fallback) + prices.update(hk_prices) + updated = 0 + + # 保存全量实时价快照(供报告管道消费,确保分析用最新数据) + try: + live = {"updated_at": datetime.now().isoformat(), "prices": {}} + for code in all_codes: + if code in prices: + p, c, chg = prices[code] + live["prices"][code] = {"price": p, "change_pct": chg} + json.dump(live, open("/home/hmo/web-dashboard/data/live_prices.json", "w"), indent=2) + except Exception: + pass + + # 更新portfolio(只在价格变化时写入,避免触发文件变更通知) + changed = False + for s in pf.get('holdings', []): + if s['code'] in prices: + price, _, change_pct = prices[s['code']] + if price > 0: + # 港股:API返回HKD,需转RMB + if is_hk_stock(s['code']): + price = round(price * HK_RATE, 2) + old = s.get('price', 0) + if abs(old - price) > 0.001: + s['price'] = round(price, 2) + s['change_pct'] = float(change_pct) if change_pct else 0 + updated += 1 + changed = True + if changed: + pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') + pf['total_mv'] = calc_total_mv(pf.get('holdings', [])) + pf['total_assets'] = calc_total_assets(pf) + pf['position_pct'] = calc_position_pct(pf) + # DB 写入(替代 json.dump,强制币种约束) + try: + conn = get_conn() + write_holdings_batch(conn, pf['holdings']) + write_portfolio_summary(conn, pf) + conn.close() + except Exception as e: + print(f" [DB写入失败] {e}", flush=True) + # [migrated to DB] — JSON cold backup removed + # json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) elif pf.get('updated_at'): - try: - last_ts = datetime.strptime(pf['updated_at'], '%Y-%m-%d %H:%M') - if (datetime.now() - last_ts).total_seconds() > 600: - pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) - except: - pass - - # 更新watchlist(只在价格变化时写入) - changed = False - for s in wl.get('stocks', []): - if s['code'] in prices: - price, _, change_pct = prices[s['code']] - if price > 0: - # 港股:API返回HKD,需转RMB - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) - old = s.get('price', 0) - if abs(old - price) > 0.001: - s['price'] = round(price, 2) - s['change_pct'] = float(change_pct) if change_pct else 0 - updated += 1 - changed = True - if changed: - wl['updated_at'] = datetime.now().isoformat() - # DB 写入(替代 json.dump) - try: - conn = get_conn() - for s in wl.get('stocks', []): - s['currency'] = 'CNY' # 自选股价格统一CNY - write_watchlist_stock(conn, s) - conn.close() - except Exception as e: - print(f" [DB watchlist写入失败] {e}", flush=True) - # 保留 JSON 冷备 - json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2) - - # --- 汇总值重算(使用 mo_models 唯一公式)--- - try: - live_market_value = calc_total_mv(pf.get('holdings', [])) - old_mv = pf.get('total_mv', 0) - - if abs(old_mv - live_market_value) > 0.01: - pf['total_mv'] = round(live_market_value, 2) - - pf['total_assets'] = calc_total_assets(pf) - if pf['total_assets'] > 0: - pf['position_pct'] = calc_position_pct(pf) - pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - # DB 写入 - try: - conn = get_conn() - write_portfolio_summary(conn, pf) - conn.close() - except Exception as e: - print(f" [DB汇总写入失败] {e}", flush=True) - # JSON 冷备 - json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) - except Exception as e: - print(f" [汇总重算失败] {e}", flush=True) - # --- 结束汇总重算 --- - - return updated - - -# ── 分支系统辅助函数 ────────────────────────────────────────────────────── - -def _branch_alert_suffix(code, price, shares=0, cost=0): - """返回分支信息后缀:「 | 情景→动作」""" - if not HAS_TREE or not _SCENARIO_CACHE.get('id'): - return "" - try: - sc_id = _SCENARIO_CACHE['id'] - results = evaluate_branches(code, sc_id, price, shares, cost) - for r in results: - if r.get('applicable'): - _record_branch_trigger(code, r.get('branch_id',''), price) - branch_action = r.get('action_type', r.get('action', 'hold')) - return f" | {sc_id}→{branch_action}" - except Exception: - pass - return "" - - -def _record_branch_trigger(code, branch_id, price): - """记录分支触发事件(自成长:trigger_count+1)""" - try: - raw = read_decisions() - for d in raw.get('decisions', []): - if d.get('code') == code and d.get('strategy_tree',{}).get('branches'): - for b in d['strategy_tree']['branches']: - if b['id'] == branch_id: - b.setdefault('trigger_count', 0) - b['trigger_count'] += 1 - b['last_trigger_price'] = round(price, 2) - b['last_triggered'] = datetime.now().isoformat() - break - json.dump(raw, open(DECISIONS_PATH, 'w'), ensure_ascii=False, indent=2) - except Exception: - pass - - -# ── 区间偏离检测 ────────────────────────────────────────────────────────── - -def load_state(): - try: - with open(STATE_PATH) as f: - return json.load(f) - except: - return {} - -def save_state(state): - os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) - with open(STATE_PATH, 'w') as f: - json.dump(state, f, ensure_ascii=False, indent=2) - -def load_breaches(): - try: - with open(BREACH_PATH) as f: - return json.load(f) - except: - return {} - -def save_breaches(data): - os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True) - with open(BREACH_PATH, 'w') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def load_events(): - try: - with open(EVENTS_PATH) as f: - return json.load(f) - except: - return {"events": []} - - -def save_events(events): - os.makedirs(os.path.dirname(EVENTS_PATH), exist_ok=True) - with open(EVENTS_PATH, 'w') as f: - json.dump(events, f, ensure_ascii=False, indent=2) - - -def record_event(code, name, event_type, price, trigger_value, event_label=""): - """记录一次价格触发事件到 price_events.json + SQLite""" - events = load_events() - now = datetime.now().isoformat() - events["events"].append({ - "code": code, - "name": name, - "event_type": event_type, # entry_zone, stop_loss, take_profit, exit_zone - "price": round(price, 2), - "trigger_value": trigger_value, - "event_label": event_label, - "timestamp": now, - "date": datetime.now().strftime("%Y-%m-%d"), - }) - # 保留最近10000条 - events["events"] = events["events"][-10000:] - save_events(events) - - # ── SQLite 双写 ── - try: - from mofin_db import get_conn, init_all_tables, write_price_event - conn = get_conn() - init_all_tables(conn) - write_price_event(conn, code, name, event_type, price, trigger_value, event_label) - conn.close() - except Exception: - pass # SQLite 写入失败不影响主流程 - - -def get_trigger_zones(d): - """返回该decision所有可监控的区间列表,从顶层字段读取""" - zones = [] - is_holding = d.get('shares', 0) > 0 - # 买入区间(自选和持仓都监控) - el = d.get("entry_low", 0) - eh = d.get("entry_high", 0) - if el and eh and float(el) > 0 and float(eh) > 0: - try: - zones.append(("entry_zone", "买入区间", float(el), float(eh))) - except: - pass - # 止损+止盈(只有持仓才监控,自选无意义) - if is_holding: - sl = d.get("stop_loss", 0) - if sl and float(sl) > 0: - try: - zones.append(("stop_loss", "止损", 0, float(sl))) - except: - pass - tp = d.get("take_profit", 0) - if tp and float(tp) > 0: - try: - zones.append(("take_profit_zone", "止盈区间", 0, float(tp))) - except: - pass - return zones - - -def run_once(round_label=""): - """执行一轮完整的监控流程""" - global _SCENARIO_CACHE, _BRANCH_CACHE - label = f" [{round_label}]" if round_label else "" - start = time.time() - - # 刷新情景与分支缓存(每轮更新) - _SCENARIO_CACHE = detect_scenario() if HAS_TREE else {} - _BRANCH_CACHE = {} - try: - raw = read_decisions() - for d in raw.get('decisions', []): - tree = d.get('strategy_tree', {}) - if tree and tree.get('branches'): - _BRANCH_CACHE[d['code']] = tree['branches'] - except Exception: - pass - - # === 第一步:一次性刷新所有价格 === - refreshed = refresh_data_prices() - - # === 第二步:检查触发条件 === - try: - with open(DECISIONS_PATH) as f: - dec = json.load(f) - except: - print(f"❌{label} 无法读取decisions.json", file=sys.stderr) - return - - active = [d for d in dec.get("decisions", []) if d.get("status") in ("active", "updated")] - state = load_state() - outputs = [] - state_updated = False - - # 收集所有需要检查的代码 - check_codes = set() - for d in active: - if get_trigger_zones(d): - check_codes.add(d["code"]) - - # 批量拉取这些股票的价格 - prices = fetch_all_prices(list(check_codes)) - - for d in active: - code = d["code"] - - zones = get_trigger_zones(d) - if not zones: - continue - - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - name = d.get("name", code) - if code not in state: - state[code] = {} - - for key, label, lo, hi in zones: - in_zone = lo <= price <= hi - prev_in_zone = state[code].get(key, None) - - if in_zone and prev_in_zone != True: - if key == "stop_loss": - branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0)) - outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!{branch_sfx}") - record_event(code, name, "stop_loss", price, str(hi)) - else: - extra = "" - if "_price" in key: - batch_shares = d.get(key.replace("_price", "_shares"), "") - action = d.get(key.replace("_price", "_action"), "") - if batch_shares: - extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股" - elif key in ("take_profit_zone",): - act = d.get("take_profit_action", "") - if act: - extra = f"({act})" - branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0)) - outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}{branch_sfx}") - record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label) - state[code][key] = True - state_updated = True - - elif not in_zone and prev_in_zone == True: - if key != "stop_loss": - outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}") - state[code][key] = False - state_updated = True - - # === 第三步:买入区偏离检测 + 自动重评 === - reassesed_codes = [] - for d in active: - code = d["code"] - name = d.get("name", code) - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - # 从 decisions.json 中读取 analysis 的买入区 - entry_low = d.get("entry_low", 0) - entry_high = d.get("entry_high", 0) - if not entry_low or not entry_high: - continue - - in_buy_zone = entry_low <= price <= entry_high - prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None) - - # 状态变化时才触发:True→False离区 或 False→True进区 - # [2026-07-01 fix] prev_in_buy_zone is None(新加自选首次检测) - # 也要触发——否则新自选全程不走重评,timing_signal卡在初始值 - if in_buy_zone and (prev_in_buy_zone == False or prev_in_buy_zone is None): - # 进入买入区 → 触发技术面重评,更新止损/止盈/信号 - outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评") - do_reassess = True - elif not in_buy_zone and prev_in_buy_zone == True: - # 离开买入区 → 立即重评,更新止损/止盈/区间 - outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评") - do_reassess = True - else: - do_reassess = False - - if do_reassess and HAS_REASSESS: - try: - cost = d.get("cost", 0) or 0 - shares = d.get("shares", 0) or 0 - profit_pct = (price - cost) / cost * 100 if cost else 0 - is_deep_loss = profit_pct < -20 - sentiment = "neutral" - if d.get("tech_snapshot"): - if "bearish" in d["tech_snapshot"]: - sentiment = "bearish" - elif "bullish" in d["tech_snapshot"]: - sentiment = "bullish" - - # 调用技术面驱动重评(非机械百分比) - result = reassess_strategy( - code, name, price, cost, shares, - current_action=d.get("action", ""), - volume_signal="中性", sentiment=sentiment, - ) - outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}") - reassesed_codes.append(code) - except Exception as e: - outputs.append(f" ⚠️ 重评失败: {e}") - - # 更新买入区状态 - if "__buy_zone" not in state.get(code, {}): - if code not in state: - state[code] = {} - state[code]["__buy_zone"] = in_buy_zone - state_updated = True - - # 如果有重评过的股票,更新 decisions.json - if reassesed_codes and HAS_REASSESS: - try: - # 重新 regenerate_all 只针对受影响的股票效率太低 - # 直接全量重评(regenerate_all 内部会批量拉价格、做技术分析) - from strategy_lifecycle import regenerate_all - r = regenerate_all(stdout=False) - outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功") - outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}") - except Exception as e: - outputs.append(f" ⚠️ 全量重评失败: {e}") - - # === 3.5 资金流异常检测(2026-06-27 新增)=== - try: - cf = json.load(open("/home/hmo/web-dashboard/data/capital_flow_cache.json")) - # 检查所有 active decision 中的资金流异常 - for d in active: - code = d["code"] - stock_cf = cf.get("stocks", {}).get(code, {}) - analysis = stock_cf.get("analysis", {}) - alerts = analysis.get("alerts", []) - if alerts: - name = d.get("name", code) - for a in alerts: - outputs.append(f" 💰 {name}({code}) {a}") - except Exception: - pass - - # === 第四步:情景变化检测 + 输出 → 直接推XMPP === - now_str = datetime.now().strftime("%H:%M:%S") - elapsed = time.time() - start - - # 情景变化检测(跨轮对比) - if HAS_TREE and _SCENARIO_CACHE.get('id'): - prev_scenario = state.get('_system', {}).get('last_scenario', '') - curr_scenario = _SCENARIO_CACHE['id'] - if prev_scenario and curr_scenario != prev_scenario: - combo = _SCENARIO_CACHE.get('combo_action', '') - outputs.insert(0, f"🌀 情景切换: {prev_scenario}→{curr_scenario} | {combo}") - if outputs: - state.setdefault('_system', {})['last_scenario'] = curr_scenario - state_updated = True - elif not prev_scenario: - state.setdefault('_system', {})['last_scenario'] = curr_scenario - state_updated = True - - if outputs: - # 简短一行一个触发 - for o in outputs: - print(o) - # 推送XMPP(只推关键事件:止损跌破+情景切换+资金流异动,不推买入区进出/重评等操作细节) - critical = [o for o in outputs if o.startswith(("⚠️", "🌀", "💰"))] - if critical: - try: - body = "\n".join([f"{now_str}"] + critical) - payload = json.dumps({ - "to": "hmo@yoin.fun", "body": body, "type": "chat", - }).encode("utf-8") - req = urllib.request.Request( - "http://127.0.0.1:5805/", data=payload, - headers={"Content-Type": "application/json"}, - ) - urllib.request.urlopen(req, timeout=5) - except Exception: - pass - # else: SILENT — 无触发,无输出,不推 - - if state_updated: - save_state(state) - - -def main(): - """每cron触发跑一轮""" - run_once() - - -if __name__ == "__main__": - main() + try: + last_ts = datetime.strptime(pf['updated_at'], '%Y-%m-%d %H:%M') + if (datetime.now() - last_ts).total_seconds() > 600: + pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') + # DB 写入(时间戳更新) + try: + conn2 = get_conn() + write_portfolio_summary(conn2, pf) + conn2.close() + except Exception: + pass + # [migrated to DB] — cold backup removed + # json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) + except: + pass + + # 更新watchlist(只在价格变化时写入) + changed = False + for s in wl.get('stocks', []): + if s['code'] in prices: + price, _, change_pct = prices[s['code']] + if price > 0: + # 港股:API返回HKD,需转RMB + if is_hk_stock(s['code']): + price = round(price * HK_RATE, 2) + old = s.get('price', 0) + if abs(old - price) > 0.001: + s['price'] = round(price, 2) + s['change_pct'] = float(change_pct) if change_pct else 0 + updated += 1 + changed = True + if changed: + wl['updated_at'] = datetime.now().isoformat() + # DB 写入(替代 json.dump) + try: + conn = get_conn() + for s in wl.get('stocks', []): + s['currency'] = 'CNY' # 自选股价格统一CNY + write_watchlist_stock(conn, s) + conn.close() + except Exception as e: + print(f" [DB watchlist写入失败] {e}", flush=True) + # [migrated to DB] — cold backup removed; DB writes above + # json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2) + + # --- 汇总值重算(使用 mo_models 唯一公式)--- + try: + live_market_value = calc_total_mv(pf.get('holdings', [])) + old_mv = pf.get('total_mv', 0) + + if abs(old_mv - live_market_value) > 0.01: + pf['total_mv'] = round(live_market_value, 2) + + pf['total_assets'] = calc_total_assets(pf) + if pf['total_assets'] > 0: + pf['position_pct'] = calc_position_pct(pf) + pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') + # DB 写入 + try: + conn = get_conn() + write_portfolio_summary(conn, pf) + conn.close() + except Exception as e: + print(f" [DB汇总写入失败] {e}", flush=True) + # [migrated to DB] — cold backup removed; DB writes above + # json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) + except Exception as e: + print(f" [汇总重算失败] {e}", flush=True) + # --- 结束汇总重算 --- + + return updated + + +# ── 分支系统辅助函数 ────────────────────────────────────────────────────── + +def _branch_alert_suffix(code, price, shares=0, cost=0): + """返回分支信息后缀:「 | 情景→动作」""" + if not HAS_TREE or not _SCENARIO_CACHE.get('id'): + return "" + try: + sc_id = _SCENARIO_CACHE['id'] + results = evaluate_branches(code, sc_id, price, shares, cost) + for r in results: + if r.get('applicable'): + _record_branch_trigger(code, r.get('branch_id',''), price) + branch_action = r.get('action_type', r.get('action', 'hold')) + return f" | {sc_id}→{branch_action}" + except Exception: + pass + return "" + + +def _record_branch_trigger(code, branch_id, price): + """记录分支触发事件(自成长:trigger_count+1)""" + try: + raw = read_decisions() + for d in raw.get('decisions', []): + if d.get('code') == code and d.get('strategy_tree',{}).get('branches'): + for b in d['strategy_tree']['branches']: + if b['id'] == branch_id: + b.setdefault('trigger_count', 0) + b['trigger_count'] += 1 + b['last_trigger_price'] = round(price, 2) + b['last_triggered'] = datetime.now().isoformat() + break + # DB 写入(替代 json.dump) + try: + conn3 = get_conn() + for d in raw.get('decisions', []): + write_holding_strategy(conn3, d.get('code', ''), d.get('name', ''), d) + conn3.close() + except Exception: + pass + # [migrated to DB] — cold backup removed + # json.dump(raw, open(DECISIONS_PATH, 'w'), ensure_ascii=False, indent=2) + except Exception: + pass + + +# ── 区间偏离检测 ────────────────────────────────────────────────────────── + +def load_state(): + try: + with open(STATE_PATH) as f: + return json.load(f) + except: + return {} + +def save_state(state): + os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) + with open(STATE_PATH, 'w') as f: + json.dump(state, f, ensure_ascii=False, indent=2) + +def load_breaches(): + try: + with open(BREACH_PATH) as f: + return json.load(f) + except: + return {} + +def save_breaches(data): + os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True) + with open(BREACH_PATH, 'w') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def load_events(): + try: + with open(EVENTS_PATH) as f: + return json.load(f) + except: + return {"events": []} + + +def save_events(events): + os.makedirs(os.path.dirname(EVENTS_PATH), exist_ok=True) + with open(EVENTS_PATH, 'w') as f: + json.dump(events, f, ensure_ascii=False, indent=2) + + +def record_event(code, name, event_type, price, trigger_value, event_label=""): + """记录一次价格触发事件到 price_events.json + SQLite""" + events = load_events() + now = datetime.now().isoformat() + events["events"].append({ + "code": code, + "name": name, + "event_type": event_type, # entry_zone, stop_loss, take_profit, exit_zone + "price": round(price, 2), + "trigger_value": trigger_value, + "event_label": event_label, + "timestamp": now, + "date": datetime.now().strftime("%Y-%m-%d"), + }) + # 保留最近10000条 + events["events"] = events["events"][-10000:] + save_events(events) + + # ── SQLite 双写 ── + try: + from mofin_db import get_conn, init_all_tables, write_price_event + conn = get_conn() + init_all_tables(conn) + write_price_event(conn, code, name, event_type, price, trigger_value, event_label) + conn.close() + except Exception: + pass # SQLite 写入失败不影响主流程 + + +def get_trigger_zones(d): + """返回该decision所有可监控的区间列表,从顶层字段读取""" + zones = [] + is_holding = d.get('shares', 0) > 0 + # 买入区间(自选和持仓都监控) + el = d.get("entry_low", 0) + eh = d.get("entry_high", 0) + if el and eh and float(el) > 0 and float(eh) > 0: + try: + zones.append(("entry_zone", "买入区间", float(el), float(eh))) + except: + pass + # 止损+止盈(只有持仓才监控,自选无意义) + if is_holding: + sl = d.get("stop_loss", 0) + if sl and float(sl) > 0: + try: + zones.append(("stop_loss", "止损", 0, float(sl))) + except: + pass + tp = d.get("take_profit", 0) + if tp and float(tp) > 0: + try: + zones.append(("take_profit_zone", "止盈区间", 0, float(tp))) + except: + pass + return zones + + +def run_once(round_label=""): + """执行一轮完整的监控流程""" + global _SCENARIO_CACHE, _BRANCH_CACHE + label = f" [{round_label}]" if round_label else "" + start = time.time() + + # 刷新情景与分支缓存(每轮更新) + _SCENARIO_CACHE = detect_scenario() if HAS_TREE else {} + _BRANCH_CACHE = {} + try: + raw = read_decisions() + for d in raw.get('decisions', []): + tree = d.get('strategy_tree', {}) + if tree and tree.get('branches'): + _BRANCH_CACHE[d['code']] = tree['branches'] + except Exception: + pass + + # === 第一步:一次性刷新所有价格 === + refreshed = refresh_data_prices() + + # === 第二步:检查触发条件 === + try: + dec = read_decisions() + except: + print(f"❌{label} 无法读取decisions.json", file=sys.stderr) + return + + active = [d for d in dec.get("decisions", []) if d.get("status") in ("active", "updated")] + state = load_state() + outputs = [] + state_updated = False + + # 收集所有需要检查的代码 + check_codes = set() + for d in active: + if get_trigger_zones(d): + check_codes.add(d["code"]) + + # 批量拉取这些股票的价格 + prices = fetch_all_prices(list(check_codes)) + + for d in active: + code = d["code"] + + zones = get_trigger_zones(d) + if not zones: + continue + + price_info = prices.get(code) + if not price_info: + continue + price, _, _ = price_info + if price == 0: + continue + + name = d.get("name", code) + if code not in state: + state[code] = {} + + for key, label, lo, hi in zones: + in_zone = lo <= price <= hi + prev_in_zone = state[code].get(key, None) + + if in_zone and prev_in_zone != True: + if key == "stop_loss": + branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0)) + outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!{branch_sfx}") + record_event(code, name, "stop_loss", price, str(hi)) + else: + extra = "" + if "_price" in key: + batch_shares = d.get(key.replace("_price", "_shares"), "") + action = d.get(key.replace("_price", "_action"), "") + if batch_shares: + extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股" + elif key in ("take_profit_zone",): + act = d.get("take_profit_action", "") + if act: + extra = f"({act})" + branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0)) + outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}{branch_sfx}") + record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label) + state[code][key] = True + state_updated = True + + elif not in_zone and prev_in_zone == True: + if key != "stop_loss": + outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}") + state[code][key] = False + state_updated = True + + # === 第三步:买入区偏离检测 + 自动重评 === + reassesed_codes = [] + for d in active: + code = d["code"] + name = d.get("name", code) + price_info = prices.get(code) + if not price_info: + continue + price, _, _ = price_info + if price == 0: + continue + + # 从 decisions.json 中读取 analysis 的买入区 + entry_low = d.get("entry_low", 0) + entry_high = d.get("entry_high", 0) + if not entry_low or not entry_high: + continue + + in_buy_zone = entry_low <= price <= entry_high + prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None) + + # 状态变化时才触发:True→False离区 或 False→True进区 + # [2026-07-01 fix] prev_in_buy_zone is None(新加自选首次检测) + # 也要触发——否则新自选全程不走重评,timing_signal卡在初始值 + if in_buy_zone and (prev_in_buy_zone == False or prev_in_buy_zone is None): + # 进入买入区 → 触发技术面重评,更新止损/止盈/信号 + outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评") + do_reassess = True + elif not in_buy_zone and prev_in_buy_zone == True: + # 离开买入区 → 立即重评,更新止损/止盈/区间 + outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评") + do_reassess = True + else: + do_reassess = False + + if do_reassess and HAS_REASSESS: + try: + cost = d.get("cost", 0) or 0 + shares = d.get("shares", 0) or 0 + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_deep_loss = profit_pct < -20 + sentiment = "neutral" + if d.get("tech_snapshot"): + if "bearish" in d["tech_snapshot"]: + sentiment = "bearish" + elif "bullish" in d["tech_snapshot"]: + sentiment = "bullish" + + # 调用技术面驱动重评(非机械百分比) + result = reassess_strategy( + code, name, price, cost, shares, + current_action=d.get("action", ""), + volume_signal="中性", sentiment=sentiment, + ) + outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}") + reassesed_codes.append(code) + except Exception as e: + outputs.append(f" ⚠️ 重评失败: {e}") + + # 更新买入区状态 + if "__buy_zone" not in state.get(code, {}): + if code not in state: + state[code] = {} + state[code]["__buy_zone"] = in_buy_zone + state_updated = True + + # 如果有重评过的股票,更新 decisions.json + if reassesed_codes and HAS_REASSESS: + try: + # 重新 regenerate_all 只针对受影响的股票效率太低 + # 直接全量重评(regenerate_all 内部会批量拉价格、做技术分析) + from strategy_lifecycle import regenerate_all + r = regenerate_all(stdout=False) + outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功") + outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}") + except Exception as e: + outputs.append(f" ⚠️ 全量重评失败: {e}") + + # === 3.5 资金流异常检测(2026-06-27 新增)=== + try: + cf = json.load(open("/home/hmo/web-dashboard/data/capital_flow_cache.json")) + # 检查所有 active decision 中的资金流异常 + for d in active: + code = d["code"] + stock_cf = cf.get("stocks", {}).get(code, {}) + analysis = stock_cf.get("analysis", {}) + alerts = analysis.get("alerts", []) + if alerts: + name = d.get("name", code) + for a in alerts: + outputs.append(f" 💰 {name}({code}) {a}") + except Exception: + pass + + # === 第四步:情景变化检测 + 输出 → 直接推XMPP === + now_str = datetime.now().strftime("%H:%M:%S") + elapsed = time.time() - start + + # 情景变化检测(跨轮对比) + if HAS_TREE and _SCENARIO_CACHE.get('id'): + prev_scenario = state.get('_system', {}).get('last_scenario', '') + curr_scenario = _SCENARIO_CACHE['id'] + if prev_scenario and curr_scenario != prev_scenario: + combo = _SCENARIO_CACHE.get('combo_action', '') + outputs.insert(0, f"🌀 情景切换: {prev_scenario}→{curr_scenario} | {combo}") + if outputs: + state.setdefault('_system', {})['last_scenario'] = curr_scenario + state_updated = True + elif not prev_scenario: + state.setdefault('_system', {})['last_scenario'] = curr_scenario + state_updated = True + + if outputs: + # 简短一行一个触发 + for o in outputs: + print(o) + # 推送XMPP(只推关键事件:止损跌破+情景切换+资金流异动,不推买入区进出/重评等操作细节) + critical = [o for o in outputs if o.startswith(("⚠️", "🌀", "💰"))] + if critical: + try: + body = "\n".join([f"{now_str}"] + critical) + payload = json.dumps({ + "to": "hmo@yoin.fun", "body": body, "type": "chat", + }).encode("utf-8") + req = urllib.request.Request( + "http://127.0.0.1:5805/", data=payload, + headers={"Content-Type": "application/json"}, + ) + urllib.request.urlopen(req, timeout=5) + except Exception: + pass + # else: SILENT — 无触发,无输出,不推 + + if state_updated: + save_state(state) + + +def main(): + """每cron触发跑一轮""" + run_once() + + +if __name__ == "__main__": + main() diff --git a/scripts/process_trade.py b/scripts/process_trade.py index 0afc2d2..3ff63c8 100644 --- a/scripts/process_trade.py +++ b/scripts/process_trade.py @@ -1,149 +1,148 @@ -#!/usr/bin/env python3 -"""process_trade.py — 处理交易截图,更新 portfolio.json - -Dad 发交易截图后,填入以下信息运行此脚本: - python3 process_trade.py --action buy --code 600563 --shares 100 --price 189.20 - -它会: -1. 更新 holdings(加/减股数,归零则移除) -2. 更新 cash(买入减现金,卖出加现金) -3. 同步更新 decisions.json 的 shares 字段 -4. 记录 changelog - -用法: - python3 process_trade.py --action sell --code 600563 --shares 100 --price 189.20 - python3 process_trade.py --action buy --code 300308 --shares 50 --price 1230.00 -""" -import json, sys, os -from datetime import datetime -from mo_data import read_portfolio, read_decisions, read_watchlist - -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" - -def parse_args(): - args = {} - for i, a in enumerate(sys.argv[1:]): - if a.startswith("--"): - key = a.lstrip("-") - val = sys.argv[i+2] if i+2 < len(sys.argv) and not sys.argv[i+2].startswith("--") else None - args[key] = val - return args - -def main(): - args = parse_args() - action = args.get("action", "") - code = args.get("code", "") - shares = int(float(args.get("shares", 0))) - price = float(args.get("price", 0)) - name = args.get("name", "") - - if not action or not code or not shares or not price: - print("用法: python3 process_trade.py --action sell --code 600563 --shares 100 --price 189.20") - sys.exit(1) - - now = datetime.now().strftime("%Y-%m-%d %H:%M") - cost = shares * price - - # 读数据 - pf = mo_data.read_portfolio() - dec = mo_data.read_decisions() - - if action == "sell": - # 找持仓 - found = None - for h in pf["holdings"]: - if h["code"] == code: - found = h - break - if not found: - print(f"❌ 错误: 代码 {code} 未在持仓中找到") - sys.exit(1) - old_shares = found.get("shares", 0) - if old_shares < shares: - print(f"❌ 错误: 持仓只有 {old_shares} 股,不够卖 {shares} 股") - sys.exit(1) - # 减股数 - found["shares"] = old_shares - shares - found["updated_at"] = now - # 归零则移除 - if found["shares"] <= 0: - pf["holdings"] = [h for h in pf["holdings"] if h["code"] != code] - print(f" 已全部清仓,从持仓移除") - # 加现金 - old_cash = pf.get("cash", 0) or 0 - pf["cash"] = round(old_cash + cost, 2) - print(f" ✅ 卖出 {name}({code}) {shares}股 @{price} = {cost:.2f}") - print(f" 现金: {old_cash} → {pf['cash']}") - - elif action == "buy": - # 找是否已有该股 - found = None - for h in pf["holdings"]: - if h["code"] == code: - found = h - break - if found: - # 加权平均成本 - old_shares = found.get("shares", 0) or 0 - old_cost = found.get("cost", 0) or 0 - total_cost = old_cost * old_shares + cost - new_shares = old_shares + shares - new_avg_cost = round(total_cost / new_shares, 2) if new_shares > 0 else price - found["shares"] = new_shares - found["cost"] = new_avg_cost - found["updated_at"] = now - else: - pf["holdings"].append({ - "code": code, "name": name, "shares": shares, - "cost": price, "price": price, "updated_at": now - }) - # 减现金 - old_cash = pf.get("cash", 0) or 0 - pf["cash"] = round(old_cash - cost, 2) - print(f" ✅ 买入 {name}({code}) {shares}股 @{price} = {cost:.2f}") - print(f" 现金: {old_cash} → {pf['cash']}") - - # 同步 decisions.json 的 shares - for d in dec.get("decisions", []): - if d["code"] == code: - old_dec_shares = d.get("shares", 0) or 0 - d["shares"] = (d.get("shares", 0) or 0) + (shares if action == "buy" else -shares) - if d["shares"] <= 0 and action == "sell": - d["shares"] = 0 - d["type"] = "自选策略" - d.setdefault("changelog", []).append({ - "time": now, - "event": action, - "shares": shares, - "price": price, - "total": cost - }) - break - - # 写入 — DB 优先 - pf["updated_at"] = now - try: - sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy - conn = get_conn() - write_holdings_batch(conn, pf.get('holdings', [])) - write_portfolio_summary(conn, pf) - for d in dec.get('decisions', []): - write_holding_strategy(conn, d.get('code', ''), d.get('name', ''), d) - conn.close() - except Exception: - pass - json.dump(pf, open(PORTFOLIO_PATH, "w"), indent=2, ensure_ascii=False) - json.dump(dec, open(DECISIONS_PATH, "w"), indent=2, ensure_ascii=False) - - # 重算总资产 - total_mv = sum((h.get("shares",0) or 0) * (h.get("price",0) or 0) for h in pf["holdings"]) - total = round(total_mv + (pf.get("cash",0) or 0), 2) - print(f"\n📊 持仓市值: {total_mv:.2f}") - print(f"📊 现金: {pf.get('cash',0):.2f}") - print(f"📊 总资产: {total:.2f}") - print(f"📊 持仓 {len(pf['holdings'])} 只") - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""process_trade.py — 处理交易截图,更新 portfolio.json + +Dad 发交易截图后,填入以下信息运行此脚本: + python3 process_trade.py --action buy --code 600563 --shares 100 --price 189.20 + +它会: +1. 更新 holdings(加/减股数,归零则移除) +2. 更新 cash(买入减现金,卖出加现金) +3. 同步更新 decisions.json 的 shares 字段 +4. 记录 changelog + +用法: + python3 process_trade.py --action sell --code 600563 --shares 100 --price 189.20 + python3 process_trade.py --action buy --code 300308 --shares 50 --price 1230.00 +""" +import json, sys, os +from datetime import datetime +from mo_data import read_portfolio, read_decisions, read_watchlist +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy + +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" + +def parse_args(): + args = {} + for i, a in enumerate(sys.argv[1:]): + if a.startswith("--"): + key = a.lstrip("-") + val = sys.argv[i+2] if i+2 < len(sys.argv) and not sys.argv[i+2].startswith("--") else None + args[key] = val + return args + +def main(): + args = parse_args() + action = args.get("action", "") + code = args.get("code", "") + shares = int(float(args.get("shares", 0))) + price = float(args.get("price", 0)) + name = args.get("name", "") + + if not action or not code or not shares or not price: + print("用法: python3 process_trade.py --action sell --code 600563 --shares 100 --price 189.20") + sys.exit(1) + + now = datetime.now().strftime("%Y-%m-%d %H:%M") + cost = shares * price + + # 读数据 + pf = mo_data.read_portfolio() + dec = mo_data.read_decisions() + + if action == "sell": + # 找持仓 + found = None + for h in pf["holdings"]: + if h["code"] == code: + found = h + break + if not found: + print(f"❌ 错误: 代码 {code} 未在持仓中找到") + sys.exit(1) + old_shares = found.get("shares", 0) + if old_shares < shares: + print(f"❌ 错误: 持仓只有 {old_shares} 股,不够卖 {shares} 股") + sys.exit(1) + # 减股数 + found["shares"] = old_shares - shares + found["updated_at"] = now + # 归零则移除 + if found["shares"] <= 0: + pf["holdings"] = [h for h in pf["holdings"] if h["code"] != code] + print(f" 已全部清仓,从持仓移除") + # 加现金 + old_cash = pf.get("cash", 0) or 0 + pf["cash"] = round(old_cash + cost, 2) + print(f" ✅ 卖出 {name}({code}) {shares}股 @{price} = {cost:.2f}") + print(f" 现金: {old_cash} → {pf['cash']}") + + elif action == "buy": + # 找是否已有该股 + found = None + for h in pf["holdings"]: + if h["code"] == code: + found = h + break + if found: + # 加权平均成本 + old_shares = found.get("shares", 0) or 0 + old_cost = found.get("cost", 0) or 0 + total_cost = old_cost * old_shares + cost + new_shares = old_shares + shares + new_avg_cost = round(total_cost / new_shares, 2) if new_shares > 0 else price + found["shares"] = new_shares + found["cost"] = new_avg_cost + found["updated_at"] = now + else: + pf["holdings"].append({ + "code": code, "name": name, "shares": shares, + "cost": price, "price": price, "updated_at": now + }) + # 减现金 + old_cash = pf.get("cash", 0) or 0 + pf["cash"] = round(old_cash - cost, 2) + print(f" ✅ 买入 {name}({code}) {shares}股 @{price} = {cost:.2f}") + print(f" 现金: {old_cash} → {pf['cash']}") + + # 同步 decisions.json 的 shares + for d in dec.get("decisions", []): + if d["code"] == code: + old_dec_shares = d.get("shares", 0) or 0 + d["shares"] = (d.get("shares", 0) or 0) + (shares if action == "buy" else -shares) + if d["shares"] <= 0 and action == "sell": + d["shares"] = 0 + d["type"] = "自选策略" + d.setdefault("changelog", []).append({ + "time": now, + "event": action, + "shares": shares, + "price": price, + "total": cost + }) + break + + # 写入 — DB(替代 json.dump) + pf["updated_at"] = now + try: + conn = get_conn() + write_holdings_batch(conn, pf.get('holdings', [])) + write_portfolio_summary(conn, pf) + for d in dec.get('decisions', []): + write_holding_strategy(conn, d.get('code', ''), d.get('name', ''), d) + conn.close() + except Exception as e: + print(f" [DB写入失败] {e}", file=sys.stderr) + # [migrated to DB] — JSON cold backup removed + + # 重算总资产 + total_mv = sum((h.get("shares",0) or 0) * (h.get("price",0) or 0) for h in pf["holdings"]) + total = round(total_mv + (pf.get("cash",0) or 0), 2) + print(f"\n📊 持仓市值: {total_mv:.2f}") + print(f"📊 现金: {pf.get('cash',0):.2f}") + print(f"📊 总资产: {total:.2f}") + print(f"📊 持仓 {len(pf['holdings'])} 只") + +if __name__ == "__main__": + main() diff --git a/scripts/prune_branches.py b/scripts/prune_branches.py index cb7b6f7..65fab36 100755 --- a/scripts/prune_branches.py +++ b/scripts/prune_branches.py @@ -1,112 +1,122 @@ -#!/usr/bin/env python3 -""" -prune_branches.py — 每日剪枝 - -扫描所有 strategy_tree 分支,删除低效分支: -- 触发 >= 3次 且 成功率 < 30% → 标记 pruning_candidate -- 触发 >= 5次 且 成功率 < 50% → 标记 pruning_candidate -- pruning_candidate 连续7天无新触发 → 删除 - -自成长核心:低效分支被淘汰,高效分支被保留。 -数据写入 decisions.json 的 strategy_tree.branches[]。 -""" - -import json, sys, os -from datetime import datetime, timedelta - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -PRUNE_LOG = "/home/hmo/MoFin/data/prune_log.json" - - -def load_decisions(): - with open(DECISIONS_PATH) as f: - return json.load(f) - - -def save_decisions(data): - with open(DECISIONS_PATH, "w") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - -def main(): - data = load_decisions() - decisions = data.get("decisions", []) - today = datetime.now().strftime("%Y-%m-%d") - pruned = [] - warnings = [] - - for entry in decisions: - code = entry.get("code", "") - tree = entry.get("strategy_tree", {}) - branches = tree.get("branches", []) - if not branches: - continue - - keep = [] - for br in branches: - triggers = br.get("trigger_count", 0) - success = br.get("success_rate") - last = br.get("last_triggered", "") - priority = br.get("priority", 99) - - # 跳过默认持有分支 - if priority == 99: - keep.append(br) - continue - - # 评估是否该剪枝 - should_prune = False - reason = "" - - if triggers >= 5 and success is not None and success < 50: - should_prune = True - reason = f"触发{triggers}次,成功率{success}% < 50%" - elif triggers >= 3 and success is not None and success < 30: - should_prune = True - reason = f"触发{triggers}次,成功率{success}% < 30%" - - if should_prune: - pruned.append({ - "code": code, - "branch_id": br.get("id", ""), - "action": br.get("action", {}).get("type", ""), - "rationale": br.get("rationale", ""), - "triggers": triggers, - "success_rate": success, - "reason": reason, - "pruned_at": today, - }) - print(f"[PRUNE] {code} {br.get('id','?')}: {reason}") - else: - keep.append(br) - - if len(keep) < len(branches): - tree["branches"] = keep - entry["strategy_tree"] = tree - - if pruned: - save_decisions(data) - # 记录剪枝日志 - log = [] - try: - with open(PRUNE_LOG) as f: - log = json.load(f) - except Exception: - pass - log.append({ - "date": today, - "pruned": pruned, - "total_before": sum(len(e.get("strategy_tree", {}).get("branches", [])) for e in decisions), - }) - os.makedirs(os.path.dirname(PRUNE_LOG), exist_ok=True) - with open(PRUNE_LOG, "w") as f: - json.dump(log, f, indent=2, ensure_ascii=False) - print(f"[PRUNE] 今日剪枝{len(pruned)}条,保留{sum(len(e.get('strategy_tree',{}).get('branches',[])) for e in decisions)}条") - else: - print("[PRUNE] 无需要剪枝的分支") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) +#!/usr/bin/env python3 +""" +prune_branches.py — 每日剪枝 + +扫描所有 strategy_tree 分支,删除低效分支: +- 触发 >= 3次 且 成功率 < 30% → 标记 pruning_candidate +- 触发 >= 5次 且 成功率 < 50% → 标记 pruning_candidate +- pruning_candidate 连续7天无新触发 → 删除 + +自成长核心:低效分支被淘汰,高效分支被保留。 +数据写入 decisions.json 的 strategy_tree.branches[]。 +""" + +import json, sys, os +from datetime import datetime, timedelta +from mo_data import read_decisions +from mofin_db import get_conn, write_holding_strategy + +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" +PRUNE_LOG = "/home/hmo/MoFin/data/prune_log.json" + + +def load_decisions(): + return read_decisions() + + +def save_decisions(data): + # DB 写入(替代 json.dump) + try: + conn = get_conn() + for d in data.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(data, f, indent=2, ensure_ascii=False) + + +def main(): + data = load_decisions() + decisions = data.get("decisions", []) + today = datetime.now().strftime("%Y-%m-%d") + pruned = [] + warnings = [] + + for entry in decisions: + code = entry.get("code", "") + tree = entry.get("strategy_tree", {}) + branches = tree.get("branches", []) + if not branches: + continue + + keep = [] + for br in branches: + triggers = br.get("trigger_count", 0) + success = br.get("success_rate") + last = br.get("last_triggered", "") + priority = br.get("priority", 99) + + # 跳过默认持有分支 + if priority == 99: + keep.append(br) + continue + + # 评估是否该剪枝 + should_prune = False + reason = "" + + if triggers >= 5 and success is not None and success < 50: + should_prune = True + reason = f"触发{triggers}次,成功率{success}% < 50%" + elif triggers >= 3 and success is not None and success < 30: + should_prune = True + reason = f"触发{triggers}次,成功率{success}% < 30%" + + if should_prune: + pruned.append({ + "code": code, + "branch_id": br.get("id", ""), + "action": br.get("action", {}).get("type", ""), + "rationale": br.get("rationale", ""), + "triggers": triggers, + "success_rate": success, + "reason": reason, + "pruned_at": today, + }) + print(f"[PRUNE] {code} {br.get('id','?')}: {reason}") + else: + keep.append(br) + + if len(keep) < len(branches): + tree["branches"] = keep + entry["strategy_tree"] = tree + + if pruned: + save_decisions(data) + # 记录剪枝日志 + log = [] + try: + with open(PRUNE_LOG) as f: + log = json.load(f) + except Exception: + pass + log.append({ + "date": today, + "pruned": pruned, + "total_before": sum(len(e.get("strategy_tree", {}).get("branches", [])) for e in decisions), + }) + os.makedirs(os.path.dirname(PRUNE_LOG), exist_ok=True) + with open(PRUNE_LOG, "w") as f: + json.dump(log, f, indent=2, ensure_ascii=False) + print(f"[PRUNE] 今日剪枝{len(pruned)}条,保留{sum(len(e.get('strategy_tree',{}).get('branches',[])) for e in decisions)}条") + else: + print("[PRUNE] 无需要剪枝的分支") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/review_needed_watchdog.py b/scripts/review_needed_watchdog.py index f8a2afa..82286fd 100644 --- a/scripts/review_needed_watchdog.py +++ b/scripts/review_needed_watchdog.py @@ -1,105 +1,106 @@ -#!/usr/bin/env python3 -""" -review_needed_watchdog.py — review_needed 策略自动跟进 - -每30分钟扫描DB中 status=review_needed 的策略: -1. 对每只策略调用 per_stock_reassess 重评 -2. 重评后 status 变 active → 通过,写入 changelog -3. 还是 review_needed → retry_count+=1 -4. retry_count>=3 → 推 Dad 人工介入 -""" - -import sys, json, os, datetime - -sys.path.insert(0, "/home/hmo/web-dashboard") -os.chdir("/home/hmo/MoFin") - -DEC_PATH = "/home/hmo/web-dashboard/data/decisions.json" -RETRY_FILE = "/home/hmo/web-dashboard/data/review_needed_retry.json" -XMPP_USER = "hmo@yoin.fun" -XMPP_BRIDGE = "http://192.168.1.246:5805/xmpp/send" - -def load_retry(): - try: - return json.load(open(RETRY_FILE)) - except: - return {} - -def save_retry(data): - json.dump(data, open(RETRY_FILE, "w"), indent=2) - -def push_xmpp(text): - try: - from urllib.request import Request, urlopen - payload = json.dumps({"to": XMPP_USER, "body": text.strip()}).encode() - req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) - urlopen(req, timeout=5) - print(f" [XMPP] 已推送") - except Exception as e: - print(f" [XMPP推送失败] {e}") - -def main(): - dec = json.load(open(DEC_PATH)) - review_list = [d for d in dec.get("decisions", []) if d.get("status") == "review_needed"] - retry_data = load_retry() - today = datetime.date.today().isoformat() - - if not review_list: - print("[SILENT] 无待处理策略") - return - - print(f"发现 {len(review_list)} 只 review_needed 策略") - changes = False - for d in review_list: - code = d["code"] - name = d.get("name", code) - retries = retry_data.get(code, {}).get("count", 0) + 1 - retry_data[code] = {"count": retries, "last_attempt": today} - - if retries >= 3: - print(f" ⛔ {name}({code}) 已重试{retries}次,跳过") - continue - - print(f" 🔄 {name}({code}) 第{retries}次重试...") - import subprocess - r = subprocess.run( - [sys.executable, "/home/hmo/MoFin/scripts/per_stock_reassess.py", code], - capture_output=True, text=True, timeout=30 - ) - out = (r.stdout or "") + (r.stderr or "") - print(f" {out[:200]}") - - # 重读决策 - dec2 = json.load(open(DEC_PATH)) - for d2 in dec2.get("decisions", []): - if d2["code"] == code: - if d2.get("status") == "active": - print(f" ✅ {name}({code}) 重评通过!") - retry_data[code] = {"count": 0, "last_attempt": today} - changes = True - elif d2.get("status") == "review_needed": - issues = d2.get("quality_issues", {}).get("critical", []) - print(f" ❌ {name}({code}) 仍 review_needed ({issues})") - break - - # 3次以上失败 → 推 Dad - dead = [code for code, v in retry_data.items() if v.get("count", 0) >= 3] - if dead: - names = [] - for code in dead: - for d in dec.get("decisions", []): - if d["code"] == code: - names.append(f"{d.get('name', code)}({code})") - break - msg = f"【知微】策略质量审核 {today}\n以下策略3次自动重评均失败,需人工介入:\n" - for n in names: - msg += f" - {n}\n" - msg += "\n原因可能是:缺少技术面数据 / 行业信息不完整 / 利润保护目标无法确定。" - push_xmpp(msg) - - save_retry(retry_data) - if not changes: - print("[SILENT] 状态无变化") - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +review_needed_watchdog.py — review_needed 策略自动跟进 + +每30分钟扫描DB中 status=review_needed 的策略: +1. 对每只策略调用 per_stock_reassess 重评 +2. 重评后 status 变 active → 通过,写入 changelog +3. 还是 review_needed → retry_count+=1 +4. retry_count>=3 → 推 Dad 人工介入 +""" + +import sys, json, os, datetime +from mo_data import read_decisions + +sys.path.insert(0, "/home/hmo/web-dashboard") +os.chdir("/home/hmo/MoFin") + +DEC_PATH = "/home/hmo/web-dashboard/data/decisions.json" +RETRY_FILE = "/home/hmo/web-dashboard/data/review_needed_retry.json" +XMPP_USER = "hmo@yoin.fun" +XMPP_BRIDGE = "http://192.168.1.246:5805/xmpp/send" + +def load_retry(): + try: + return json.load(open(RETRY_FILE)) + except: + return {} + +def save_retry(data): + json.dump(data, open(RETRY_FILE, "w"), indent=2) + +def push_xmpp(text): + try: + from urllib.request import Request, urlopen + payload = json.dumps({"to": XMPP_USER, "body": text.strip()}).encode() + req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) + urlopen(req, timeout=5) + print(f" [XMPP] 已推送") + except Exception as e: + print(f" [XMPP推送失败] {e}") + +def main(): + dec = read_decisions() + review_list = [d for d in dec.get("decisions", []) if d.get("status") == "review_needed"] + retry_data = load_retry() + today = datetime.date.today().isoformat() + + if not review_list: + print("[SILENT] 无待处理策略") + return + + print(f"发现 {len(review_list)} 只 review_needed 策略") + changes = False + for d in review_list: + code = d["code"] + name = d.get("name", code) + retries = retry_data.get(code, {}).get("count", 0) + 1 + retry_data[code] = {"count": retries, "last_attempt": today} + + if retries >= 3: + print(f" ⛔ {name}({code}) 已重试{retries}次,跳过") + continue + + print(f" 🔄 {name}({code}) 第{retries}次重试...") + import subprocess + r = subprocess.run( + [sys.executable, "/home/hmo/MoFin/scripts/per_stock_reassess.py", code], + capture_output=True, text=True, timeout=30 + ) + out = (r.stdout or "") + (r.stderr or "") + print(f" {out[:200]}") + + # 重读决策 + dec2 = read_decisions() + for d2 in dec2.get("decisions", []): + if d2["code"] == code: + if d2.get("status") == "active": + print(f" ✅ {name}({code}) 重评通过!") + retry_data[code] = {"count": 0, "last_attempt": today} + changes = True + elif d2.get("status") == "review_needed": + issues = d2.get("quality_issues", {}).get("critical", []) + print(f" ❌ {name}({code}) 仍 review_needed ({issues})") + break + + # 3次以上失败 → 推 Dad + dead = [code for code, v in retry_data.items() if v.get("count", 0) >= 3] + if dead: + names = [] + for code in dead: + for d in dec.get("decisions", []): + if d["code"] == code: + names.append(f"{d.get('name', code)}({code})") + break + msg = f"【知微】策略质量审核 {today}\n以下策略3次自动重评均失败,需人工介入:\n" + for n in names: + msg += f" - {n}\n" + msg += "\n原因可能是:缺少技术面数据 / 行业信息不完整 / 利润保护目标无法确定。" + push_xmpp(msg) + + save_retry(retry_data) + if not changes: + print("[SILENT] 状态无变化") + +if __name__ == "__main__": + main() diff --git a/scripts/stale_detector.py b/scripts/stale_detector.py index bd141c2..675cfca 100644 --- a/scripts/stale_detector.py +++ b/scripts/stale_detector.py @@ -1,287 +1,286 @@ -#!/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: - 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() +#!/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() diff --git a/scripts/stale_push_wlin.py b/scripts/stale_push_wlin.py index 1547302..3d8493f 100644 --- a/scripts/stale_push_wlin.py +++ b/scripts/stale_push_wlin.py @@ -19,6 +19,7 @@ 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__)))) @@ -226,8 +227,7 @@ def trigger_regen_sync(stock_codes=None): def load_cash(): """从 portfolio.json 实时读可用现金(可用 ≈ 实时买力),不硬编码""" try: - with open(PORTFOLIO_PATH) as f: - data = json.load(f) + data = read_portfolio() if isinstance(data, dict): # 先读 cash_available(拆分了可用/冻结),fallback 到 cash return data.get("cash_available", data.get("cash", 0)) @@ -338,8 +338,7 @@ def main(): # 读 decisions.json 获取完整策略数据 code_data = {} try: - with open("/home/hmo/web-dashboard/data/decisions.json") as f: - dec = json.load(f) + dec = read_decisions() for e in dec.get("decisions", []): code_data[e["code"]] = e except Exception: @@ -395,8 +394,7 @@ def main(): # 重评完成,re-read decisions.json 获取最新策略 code_data = {} try: - with open("/home/hmo/web-dashboard/data/decisions.json") as f: - dec = json.load(f) + dec = read_decisions() for e in dec.get("decisions", []): code_data[e["code"]] = e except Exception: @@ -417,8 +415,7 @@ def main(): # 加载portfolio获取持仓信息(A/H去重用) pf = {"holdings": []} try: - with open(PORTFOLIO_PATH) as f: - pf = json.load(f) + pf = read_portfolio() except Exception: pass @@ -492,8 +489,7 @@ def main(): total_assets = 0 available_cash = 0 try: - with open("/home/hmo/web-dashboard/data/portfolio.json") as f: - pf = json.load(f) + pf = read_portfolio() available_cash = pf.get("cash_available", pf.get("cash", 0)) or 0 # 直接取 portfolio.json 的总资产(导入时已做港币→人民币换算) total_assets = pf.get("total_assets", 0) or 0 @@ -845,7 +841,7 @@ def main(): # ── T+2前瞻:扫描近期可能入买区的A股,提前准备现金 ── t2_lines = [] try: - dec_t2 = json.loads(open("/home/hmo/web-dashboard/data/decisions.json").read()) + dec_t2 = read_decisions() for entry in dec_t2.get("decisions", []): if entry.get("status") == "closed" or entry.get("type") != "自选策略": continue diff --git a/scripts/strategy-staleness-check.py b/scripts/strategy-staleness-check.py index f9c31f6..e251881 100644 --- a/scripts/strategy-staleness-check.py +++ b/scripts/strategy-staleness-check.py @@ -1,256 +1,256 @@ -#!/usr/bin/env python3 -""" -策略时效性检查 - Strategy Staleness Checker v1 -============================================== -扫描所有活跃策略,检查三个维度: -1. 时间老化:超过14/21天未更新预警 -2. 价格偏离:当前价偏离买入区中心 >30% 预警 -3. 买入区失效:买入区完全脱离当前价格区间 - -输出两份:JSON报告(给系统) + 人类可读摘要(stdout for cron) -""" - -import json, sys, os, re, urllib.request -from datetime import datetime - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -OUTPUT_PATH = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" - -# Fallback: if new path not found, use old path -FALLBACK_PATH = "/home/hmo/data/decisions.json" - -WARN_DAYS = 14 # 超过14天未更新→警告 -CRITICAL_DAYS = 21 # 超过21天→严重警告 -DIVERGENCE_WARN = 30 # 偏离买入区>30%→警告 -DIVERGENCE_CRIT = 50 # 偏离>50%→严重 - -def get_price(code): - """从腾讯API获取当前价""" - try: - market = "sh" if code.startswith("6") else "sz" if code.startswith("0") or code.startswith("3") else "" - if code.startswith(("00", "30")) or code.startswith("68"): - market = "sh" if code.startswith("6") else "sz" - elif code.startswith(("01", "02", "03")): - market = "sz" - url = f"http://qt.gtimg.cn/q={market}{code}" - req = urllib.request.Request(url, headers={"User-Agent": "curl/7.81"}) - with urllib.request.urlopen(req, timeout=5) as resp: - raw = resp.read().decode("gbk") - parts = raw.split("~") - if len(parts) > 3: - price = float(parts[3]) if parts[3] else 0 - chg = float(parts[32]) if parts[32] else 0 - return price, chg if price > 0 else (None, None) - except: pass - return None, None - -def parse_buy_zone(current): - """从策略current字段提取买入区间最低和最高""" - if not current: - return None, None - m = re.search(r'买入.*?(\d+\.?\d*)\s*[~\-]\s*(\d+\.?\d*)', current) - if m: - return float(m.group(1)), float(m.group(2)) - return None, None - -def main(): - # Try new path first, fall back to old format - path = DECISIONS_PATH if os.path.exists(DECISIONS_PATH) else FALLBACK_PATH - is_new_format = (path == DECISIONS_PATH) - - with open(path) as f: - data = json.load(f) - - # Filter: exclude closed strategies - all_entries = data.get("decisions", []) - active = [e for e in all_entries if e.get("status", "") != "closed"] - flagged = [] - now = datetime.now() - - for entry in active: - code = entry.get("code", "") - name = entry.get("name", "") or code - entry_type = entry.get("type", "") - is_watchlist = "自选" in entry_type - - # --- Age tracking: prefer created_at, fallback to timestamp/updated_at --- - ts = entry.get("created_at") or entry.get("timestamp") or entry.get("updated_at") or "" - age = 0 - if ts: - try: - if "T" in ts: - dt = datetime.fromisoformat(ts) - else: - dt = datetime.strptime(ts, "%Y-%m-%d %H:%M:%S") - age = (now - dt).days - except: - try: - # Try alternative format - dt = datetime.strptime(str(ts)[:19], "%Y-%m-%d %H:%M:%S") - age = (now - dt).days - except: - pass - - # --- Get price: prefer local data, API fallback --- - price = entry.get("price", None) - if not price or price == 0: - price, _ = get_price(code) - else: - price = float(price) if price else None - - # --- Get entry zone from structured fields --- - entry_low = entry.get("entry_low", None) - entry_high = entry.get("entry_high", None) - if (not entry_low) or (not entry_high): - # Fall back to parsing from action/current string - txt = entry.get("action", "") or entry.get("current", "") - entry_low, entry_high = parse_buy_zone(txt) - - # --- Build display info --- - action_text = entry.get("action", "") or entry.get("current", "") - last_update = entry.get("updated_at", "") or entry.get("timestamp", "") - - flags = [] - - # Time-based flagging (different thresholds for 持仓 vs 自选) - time_threshold = WARN_DAYS if not is_watchlist else WARN_DAYS * 2 # 自选给2倍时间 - critical_threshold = CRITICAL_DAYS if not is_watchlist else CRITICAL_DAYS * 2 - - if age >= critical_threshold and not is_watchlist: - flags.append(f"严重过期: {age}天未更新") - elif age >= time_threshold and is_watchlist: - flags.append(f"策略已{age}天未更新(自选)") - elif age >= time_threshold: - flags.append(f"策略已{age}天未更新") - - if price and entry_low and entry_high: - # -- STRATEGY_STALE check for watchlist stocks in buy zone -- - if is_watchlist and entry_low <= price <= entry_high: - timing_signal = entry.get("timing_signal", "") or "" - rr_ratio = entry.get("rr_ratio", 0) or 0 - sl = entry.get("stop_loss", 0) or 0 - tp = entry.get("take_profit", 0) or 0 - # 规则1: timing_signal 含"弱势持有"/"等企稳" → STRATEGY_STALE - if any(kw in timing_signal for kw in ["弱势持有", "等企稳"]): - flags.append("[STRATEGY_STALE] 信号不良(timing_signal含"+str([kw for kw in ["弱势持有", "等企稳"] if kw in timing_signal])+")") - # 规则2: RR<1.5 或无止盈 → STRATEGY_STALE - if tp == 0 or (rr_ratio > 0 and rr_ratio < 1.5): - flags.append("[STRATEGY_STALE] 盈亏比不足(RR="+str(rr_ratio)+")或无止盈") - - if is_watchlist: - # 自选股:检查价格是否进入了买入区 - if entry_low <= price <= entry_high: - flags.append(f"现价{price:.2f}在买入区{entry_low:.0f}~{entry_high:.0f}(是否可买需结合timing_signal判断)") - elif price > entry_high * 1.3: - flags.append(f"现价{price:.2f}远高于买入区{entry_low:.0f}~{entry_high:.0f},需重评") - elif price < entry_low * 0.8: - flags.append(f"现价{price:.2f}远低于买入区{entry_low:.0f}~{entry_high:.0f},买入区需下移") - else: - # 持仓股:检查价格偏离买入区中心 - zone_center = (entry_low + entry_high) / 2 - if zone_center > 0: - divergence = abs(price - zone_center) / zone_center * 100 - if divergence >= DIVERGENCE_CRIT: - flags.append(f"价格距买入区中心{divergence:.0f}% 已严重偏离") - elif divergence >= DIVERGENCE_WARN: - flags.append(f"价格距买入区中心{divergence:.0f}% 需关注") - - # Check if price entirely out of zone - if price > entry_high * 1.5: - flags.append(f"现价{price:.0f}远高于买入区{entry_low:.0f}~{entry_high:.0f}") - elif price < entry_low * 0.5: - flags.append(f"现价{price:.0f}远低于买入区{entry_low:.0f}~{entry_high:.0f}") - # Add type tag for clarity - type_tag = "自选" if is_watchlist else "持仓" - - if flags: - flagged.append({ - "code": code, - "name": name, - "price": round(price, 2) if price else None, - "flags": flags, - "age_days": age, - "last_update": last_update, - "entry_zone": f"{entry_low:.0f}~{entry_high:.0f}" if entry_low else "无买入区", - "current": action_text, - "updated_by": entry.get("source", "auto"), - "updated_reason": "自动生成", - "is_watchlist": is_watchlist, - }) - - # --- Portfolio-level analysis: count weak + deep_loss categories --- - holdings_count = len([e for e in active if "自选" not in e.get("type", "")]) - weak_count = len([e for e in active if e.get("stock_category") in ("弱势", "深套") and "自选" not in e.get("type", "")]) - all_weak_count = len([e for e in active if e.get("stock_category") in ("弱势", "深套")]) - weak_pct = round(weak_count / holdings_count * 100, 1) if holdings_count > 0 else 0 - all_weak_pct = round(all_weak_count / len(active) * 100, 1) if len(active) > 0 else 0 - - # Read portfolio for position% - position_pct = 0 - cash = 0 - try: - with open(PORTFOLIO_PATH) as pf: - pdata = json.load(pf) - position_pct = pdata.get("position_pct", 0) - cash = pdata.get("cash", 0) - except: pass - - portfolio_flags = [] - if weak_pct >= 40: - portfolio_flags.append(f"[PORTFOLIO_WEAK] 组合中弱势+深套分类持仓占比{weak_pct}%>40%,建议系统性减仓") - elif weak_pct >= 30: - portfolio_flags.append(f"[PORTFOLIO_WEAK_MILD] 组合弱势占比{weak_pct}%,需关注") - if position_pct > 80: - portfolio_flags.append(f"[PORTFOLIO_FULL] 总仓位{position_pct}%(现金{cash:.0f}元),买入建议受限") - - # Write report - os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) - report = { - "checked_at": now.strftime("%Y-%m-%dT%H:%M:%S"), - "total_active": len(active), - "flagged_count": len(flagged), - "flagged": flagged, - "portfolio": { - "position_pct": position_pct, - "cash": round(cash, 2), - "weak_position_pct": weak_pct, - "all_weak_pct": all_weak_pct, - "signals": portfolio_flags - }, - "summary": f"扫描{len(active)}个策略,{len(flagged)}个需关注" - } - with open(OUTPUT_PATH, 'w') as f: - json.dump(report, f, indent=2, ensure_ascii=False) - - # Human-readable output - now_str = now.strftime("%m/%d %H:%M") - print(f"【策略重估检查】{now_str}") - print(f"活跃策略{len(active)}个 | 需关注{len(flagged)}个") - - # Portfolio-level signals first (if any) - for pf in portfolio_flags: - print(pf) - - if flagged: - print("") - for s in flagged: - price_str = f"¥{s['price']}" if s['price'] else "N/A" - ttag = "[自选]" if s.get('is_watchlist') else "" - print(f"{ttag}[{s['code']}] {s['name']} {price_str}") - print(f" 上次更新{s['age_days']}天前 | 区间{s['entry_zone']}") - for f in s['flags']: - print(f" ⚠ {f}") - print(f" 策略: {s['current']}") - print("") - - print(f"—END—{len(flagged)}个需关注 | 弱势持仓占比{weak_pct}% | 仓位{position_pct}%") - return len(flagged) - -if __name__ == "__main__": - try: - stale = main() - sys.exit(0) # Always exit 0 — flagged items are in JSON report + stdout; exit code 1 creates misleading "Script Error" alerts - except Exception as e: - print(f"ERROR: {e}") - sys.exit(2) +#!/usr/bin/env python3 +""" +策略时效性检查 - Strategy Staleness Checker v1 +============================================== +扫描所有活跃策略,检查三个维度: +1. 时间老化:超过14/21天未更新预警 +2. 价格偏离:当前价偏离买入区中心 >30% 预警 +3. 买入区失效:买入区完全脱离当前价格区间 + +输出两份:JSON报告(给系统) + 人类可读摘要(stdout for cron) +""" + +import json, sys, os, re, urllib.request +from datetime import datetime +from mo_data import read_decisions, read_portfolio +from mo_data import read_decisions, read_portfolio + +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" +OUTPUT_PATH = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" + +# Fallback: if new path not found, use old path +FALLBACK_PATH = "/home/hmo/data/decisions.json" + +WARN_DAYS = 14 # 超过14天未更新→警告 +CRITICAL_DAYS = 21 # 超过21天→严重警告 +DIVERGENCE_WARN = 30 # 偏离买入区>30%→警告 +DIVERGENCE_CRIT = 50 # 偏离>50%→严重 + +def get_price(code): + """从腾讯API获取当前价""" + try: + market = "sh" if code.startswith("6") else "sz" if code.startswith("0") or code.startswith("3") else "" + if code.startswith(("00", "30")) or code.startswith("68"): + market = "sh" if code.startswith("6") else "sz" + elif code.startswith(("01", "02", "03")): + market = "sz" + url = f"http://qt.gtimg.cn/q={market}{code}" + req = urllib.request.Request(url, headers={"User-Agent": "curl/7.81"}) + with urllib.request.urlopen(req, timeout=5) as resp: + raw = resp.read().decode("gbk") + parts = raw.split("~") + if len(parts) > 3: + price = float(parts[3]) if parts[3] else 0 + chg = float(parts[32]) if parts[32] else 0 + return price, chg if price > 0 else (None, None) + except: pass + return None, None + +def parse_buy_zone(current): + """从策略current字段提取买入区间最低和最高""" + if not current: + return None, None + m = re.search(r'买入.*?(\d+\.?\d*)\s*[~\-]\s*(\d+\.?\d*)', current) + if m: + return float(m.group(1)), float(m.group(2)) + return None, None + +def main(): + # Try new path first, fall back to old format + path = DECISIONS_PATH if os.path.exists(DECISIONS_PATH) else FALLBACK_PATH + is_new_format = (path == DECISIONS_PATH) + + data = read_decisions() + + # Filter: exclude closed strategies + all_entries = data.get("decisions", []) + active = [e for e in all_entries if e.get("status", "") != "closed"] + flagged = [] + now = datetime.now() + + for entry in active: + code = entry.get("code", "") + name = entry.get("name", "") or code + entry_type = entry.get("type", "") + is_watchlist = "自选" in entry_type + + # --- Age tracking: prefer created_at, fallback to timestamp/updated_at --- + ts = entry.get("created_at") or entry.get("timestamp") or entry.get("updated_at") or "" + age = 0 + if ts: + try: + if "T" in ts: + dt = datetime.fromisoformat(ts) + else: + dt = datetime.strptime(ts, "%Y-%m-%d %H:%M:%S") + age = (now - dt).days + except: + try: + # Try alternative format + dt = datetime.strptime(str(ts)[:19], "%Y-%m-%d %H:%M:%S") + age = (now - dt).days + except: + pass + + # --- Get price: prefer local data, API fallback --- + price = entry.get("price", None) + if not price or price == 0: + price, _ = get_price(code) + else: + price = float(price) if price else None + + # --- Get entry zone from structured fields --- + entry_low = entry.get("entry_low", None) + entry_high = entry.get("entry_high", None) + if (not entry_low) or (not entry_high): + # Fall back to parsing from action/current string + txt = entry.get("action", "") or entry.get("current", "") + entry_low, entry_high = parse_buy_zone(txt) + + # --- Build display info --- + action_text = entry.get("action", "") or entry.get("current", "") + last_update = entry.get("updated_at", "") or entry.get("timestamp", "") + + flags = [] + + # Time-based flagging (different thresholds for 持仓 vs 自选) + time_threshold = WARN_DAYS if not is_watchlist else WARN_DAYS * 2 # 自选给2倍时间 + critical_threshold = CRITICAL_DAYS if not is_watchlist else CRITICAL_DAYS * 2 + + if age >= critical_threshold and not is_watchlist: + flags.append(f"严重过期: {age}天未更新") + elif age >= time_threshold and is_watchlist: + flags.append(f"策略已{age}天未更新(自选)") + elif age >= time_threshold: + flags.append(f"策略已{age}天未更新") + + if price and entry_low and entry_high: + # -- STRATEGY_STALE check for watchlist stocks in buy zone -- + if is_watchlist and entry_low <= price <= entry_high: + timing_signal = entry.get("timing_signal", "") or "" + rr_ratio = entry.get("rr_ratio", 0) or 0 + sl = entry.get("stop_loss", 0) or 0 + tp = entry.get("take_profit", 0) or 0 + # 规则1: timing_signal 含"弱势持有"/"等企稳" → STRATEGY_STALE + if any(kw in timing_signal for kw in ["弱势持有", "等企稳"]): + flags.append("[STRATEGY_STALE] 信号不良(timing_signal含"+str([kw for kw in ["弱势持有", "等企稳"] if kw in timing_signal])+")") + # 规则2: RR<1.5 或无止盈 → STRATEGY_STALE + if tp == 0 or (rr_ratio > 0 and rr_ratio < 1.5): + flags.append("[STRATEGY_STALE] 盈亏比不足(RR="+str(rr_ratio)+")或无止盈") + + if is_watchlist: + # 自选股:检查价格是否进入了买入区 + if entry_low <= price <= entry_high: + flags.append(f"现价{price:.2f}在买入区{entry_low:.0f}~{entry_high:.0f}(是否可买需结合timing_signal判断)") + elif price > entry_high * 1.3: + flags.append(f"现价{price:.2f}远高于买入区{entry_low:.0f}~{entry_high:.0f},需重评") + elif price < entry_low * 0.8: + flags.append(f"现价{price:.2f}远低于买入区{entry_low:.0f}~{entry_high:.0f},买入区需下移") + else: + # 持仓股:检查价格偏离买入区中心 + zone_center = (entry_low + entry_high) / 2 + if zone_center > 0: + divergence = abs(price - zone_center) / zone_center * 100 + if divergence >= DIVERGENCE_CRIT: + flags.append(f"价格距买入区中心{divergence:.0f}% 已严重偏离") + elif divergence >= DIVERGENCE_WARN: + flags.append(f"价格距买入区中心{divergence:.0f}% 需关注") + + # Check if price entirely out of zone + if price > entry_high * 1.5: + flags.append(f"现价{price:.0f}远高于买入区{entry_low:.0f}~{entry_high:.0f}") + elif price < entry_low * 0.5: + flags.append(f"现价{price:.0f}远低于买入区{entry_low:.0f}~{entry_high:.0f}") + # Add type tag for clarity + type_tag = "自选" if is_watchlist else "持仓" + + if flags: + flagged.append({ + "code": code, + "name": name, + "price": round(price, 2) if price else None, + "flags": flags, + "age_days": age, + "last_update": last_update, + "entry_zone": f"{entry_low:.0f}~{entry_high:.0f}" if entry_low else "无买入区", + "current": action_text, + "updated_by": entry.get("source", "auto"), + "updated_reason": "自动生成", + "is_watchlist": is_watchlist, + }) + + # --- Portfolio-level analysis: count weak + deep_loss categories --- + holdings_count = len([e for e in active if "自选" not in e.get("type", "")]) + weak_count = len([e for e in active if e.get("stock_category") in ("弱势", "深套") and "自选" not in e.get("type", "")]) + all_weak_count = len([e for e in active if e.get("stock_category") in ("弱势", "深套")]) + weak_pct = round(weak_count / holdings_count * 100, 1) if holdings_count > 0 else 0 + all_weak_pct = round(all_weak_count / len(active) * 100, 1) if len(active) > 0 else 0 + + # Read portfolio for position% + position_pct = 0 + cash = 0 + try: + pdata = read_portfolio() + position_pct = pdata.get("position_pct", 0) + cash = pdata.get("cash", 0) + except: pass + + portfolio_flags = [] + if weak_pct >= 40: + portfolio_flags.append(f"[PORTFOLIO_WEAK] 组合中弱势+深套分类持仓占比{weak_pct}%>40%,建议系统性减仓") + elif weak_pct >= 30: + portfolio_flags.append(f"[PORTFOLIO_WEAK_MILD] 组合弱势占比{weak_pct}%,需关注") + if position_pct > 80: + portfolio_flags.append(f"[PORTFOLIO_FULL] 总仓位{position_pct}%(现金{cash:.0f}元),买入建议受限") + + # Write report + os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) + report = { + "checked_at": now.strftime("%Y-%m-%dT%H:%M:%S"), + "total_active": len(active), + "flagged_count": len(flagged), + "flagged": flagged, + "portfolio": { + "position_pct": position_pct, + "cash": round(cash, 2), + "weak_position_pct": weak_pct, + "all_weak_pct": all_weak_pct, + "signals": portfolio_flags + }, + "summary": f"扫描{len(active)}个策略,{len(flagged)}个需关注" + } + with open(OUTPUT_PATH, 'w') as f: + json.dump(report, f, indent=2, ensure_ascii=False) + + # Human-readable output + now_str = now.strftime("%m/%d %H:%M") + print(f"【策略重估检查】{now_str}") + print(f"活跃策略{len(active)}个 | 需关注{len(flagged)}个") + + # Portfolio-level signals first (if any) + for pf in portfolio_flags: + print(pf) + + if flagged: + print("") + for s in flagged: + price_str = f"¥{s['price']}" if s['price'] else "N/A" + ttag = "[自选]" if s.get('is_watchlist') else "" + print(f"{ttag}[{s['code']}] {s['name']} {price_str}") + print(f" 上次更新{s['age_days']}天前 | 区间{s['entry_zone']}") + for f in s['flags']: + print(f" ⚠ {f}") + print(f" 策略: {s['current']}") + print("") + + print(f"—END—{len(flagged)}个需关注 | 弱势持仓占比{weak_pct}% | 仓位{position_pct}%") + return len(flagged) + +if __name__ == "__main__": + try: + stale = main() + sys.exit(0) # Always exit 0 — flagged items are in JSON report + stdout; exit code 1 creates misleading "Script Error" alerts + except Exception as e: + print(f"ERROR: {e}") + sys.exit(2) diff --git a/scripts/verify_01888.py b/scripts/verify_01888.py new file mode 100644 index 0000000..b7a9a05 --- /dev/null +++ b/scripts/verify_01888.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Verify 01888 data after fix""" +import sys +sys.path.insert(0, '/home/hmo/MoFin') +from mo_data import read_portfolio, read_decisions +import sqlite3 + +# Check DB +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') +r = db.execute("SELECT code, name, cost, shares, currency FROM holdings WHERE code='01888'").fetchone() +if r: + print(f"[DB] {r[0]} {r[1]}: cost={r[2]} shares={r[3]} curr={r[4]}") + +# Check decisions +dec = read_decisions() +for d in dec.get('decisions', []): + if d.get('code') == '01888': + print(f"[DEC] price={d.get('price')} cost={d.get('cost')} shares={d.get('shares')}") + print(f" curr={d.get('currency')} status={d.get('status')}") + c = d.get('cost', 0) + p = d.get('price', 0) + if c > 0 and p > 0: + print(f" PnL={(p-c)/c*100:.1f}%") + +# Check portfolio +pf = read_portfolio() +for h in pf.get('holdings', []): + if h.get('code') == '01888': + print(f"[PF] {h.get('code')} cost={h.get('cost')} price={h.get('price')} shares={h.get('shares')}") + +db.close() diff --git a/scripts/xiaoguo_signal_consumer.py b/scripts/xiaoguo_signal_consumer.py index 63ee5d6..dd5a898 100644 --- a/scripts/xiaoguo_signal_consumer.py +++ b/scripts/xiaoguo_signal_consumer.py @@ -1,278 +1,290 @@ -#!/usr/bin/env python3 -"""xiaoguo_signal_consumer.py — 知微消费小果扫描信号 - -盘中每30分钟运行,读取 signal_news 表中未处理的 xiaoguo 信号, -做五维快速评估后决定:加自选 / 关注 / 跳过。 - -管道位置: - xiaoguo_scanner (每5分) → signal_news → 本脚本 → 知微分析报告 - -no_agent模式:有发现→输出,无→静默 -""" - -import json, os, sqlite3, sys, time, urllib.request -from pathlib import Path -from datetime import datetime - -BASE = Path("/home/hmo/MoFin") -DATA = BASE / "data" -DB_PATH = DATA / "mofin.db" - -# 自选池和决策文件 -WATCHLIST_PATH = DATA / "watchlist.json" -DECISIONS_PATH = DATA / "decisions.json" -SIGNAL_MAX_AGE_HOURS = 4 # 只处理4小时内产生的信号 - - -def clean_proxy(): - for k in ['http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY']: - os.environ.pop(k, None) - - -def fetch_quote(code): - """拉行情。DB 优先,腾讯 fallback""" - # DB 优先 - try: from mofin_db import get_price_from_db; p, chg = get_price_from_db(code); return {"name":"", "code":code, "price":p, "change_pct":chg or 0} if p else None - except: pass - # Fallback: 腾讯 - try: - prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" - url = f"http://qt.gtimg.cn/q={prefix}{code}" - req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) - resp = urllib.request.urlopen(req, timeout=5).read().decode('gbk') - fld = resp.split('=')[1].strip().strip('"').strip(';').split('~') - return { - "name": fld[1] if len(fld) > 1 else "", - "code": code, - "price": float(fld[3]) if len(fld) > 3 else 0, - "change_pct": float(fld[32]) if len(fld) > 32 else 0, - "pe": float(fld[39]) if len(fld) > 39 and fld[39] else 0, - "turnover": float(fld[38]) if len(fld) > 38 and fld[38] else 0, - } - except Exception as e: - return {"code": code, "error": str(e)[:60]} - - -def is_in_portfolio(conn, code): - """检查是否已在持仓或自选中""" - code_stripped = code.lstrip("0") - cur = conn.execute("SELECT COUNT(*) FROM holdings WHERE code=? AND is_active=1", (code_stripped,)) - if cur.fetchone()[0] > 0: - return "holdings" - cur = conn.execute("SELECT COUNT(*) FROM watchlist_stocks WHERE code=?", (code,)) - if cur.fetchone()[0] > 0: - return "watchlist" - # 也检查 watchlist.json - try: - wl = json.loads(WATCHLIST_PATH.read_text()) - for s in wl.get("stocks", []): - if s.get("code") == code or s.get("code", "").lstrip("0") == code_stripped: - return "watchlist" - except: - pass - return None - - -def quick_assess(quote): - """五维快速评估(自动版)""" - score = 0 - reasons = [] - - # 大盘环境,从DB读(回退JSON) - try: - import sqlite3 - conn = sqlite3.connect(str(DB_PATH)) - row = conn.execute( - "SELECT indices FROM macro_context_log WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" - ).fetchone() - conn.close() - if row and row[0]: - mc = json.loads(row[0]) - else: - raise ValueError - sh = 0 - for k, v in mc.items(): - if "上证" in k: - sh = v.get("change_pct", 0) - break - if sh > 0.5: - score += 1 - reasons.append(f"大盘+{sh:.1f}%偏强") - elif sh < -0.5: - score -= 1 - reasons.append(f"大盘{sh:.1f}%偏弱") - except Exception: - try: - mc = json.loads((DATA / "macro_context.json").read_text()) - sh = mc.get("shanghai", {}).get("change_pct", 0) - if sh > 0.5: - score += 1 - reasons.append(f"大盘+{sh:.1f}%偏强") - elif sh < -0.5: - score -= 1 - reasons.append(f"大盘{sh:.1f}%偏弱") - except: - pass - - # 技术面:涨跌幅 - chg = quote.get("change_pct", 0) - if chg > 3: - score += 1 - reasons.append(f"涨幅+{chg:.1f}%偏强") - elif chg < -3: - score -= 1 - reasons.append(f"跌幅{chg:.1f}%偏弱") - else: - score += 0.5 - reasons.append(f"走势平稳{chg:+.1f}%") - - # 基本面:PE - pe = quote.get("pe", 0) - if 5 < pe < 40: - score += 1 - reasons.append(f"PE={pe:.0f}合理") - elif pe <= 0: - score -= 0.5 - reasons.append("PE为负") - elif pe > 100: - score -= 0.5 - reasons.append(f"PE={pe:.0f}偏高") - - # 量能 - turn = quote.get("turnover", 0) - if turn > 5: - score += 0.5 - reasons.append(f"换手{turn:.1f}%活跃") - elif turn < 0.5: - score -= 0.3 - reasons.append(f"换手{turn:.1f}%偏低") - - return score, reasons - - -def evaluate_and_act(signal, quote): - """评估信号并决定操作""" - status_in = is_in_portfolio(get_conn(), signal.get("code", "")) - if status_in: - return f"已在{status_in}中,跳过", None - - score, reasons = quick_assess(quote) - - if score >= 1.5: - action = "watchlist" - summary = f"加自选: {' | '.join(reasons)}" - elif score >= 0: - action = "monitor" - summary = f"关注: {' | '.join(reasons)}" - else: - action = "skip" - summary = f"跳过(评分{score:.1f}): {' | '.join(reasons)}" - - return summary, action - - -def get_conn(): - import sqlite3 - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - return conn - - -def mark_processed(conn, signal_id): - conn.execute("UPDATE signal_news SET processed=1 WHERE id=?", (signal_id,)) - conn.commit() - - -def main(): - clean_proxy() - start = time.time() - today = datetime.now().strftime("%Y-%m-%d") - - conn = get_conn() - - # 读未处理 xiaoguo 信号(今日) - rows = conn.execute( - "SELECT id, sector, overall_sentiment, summary, key_articles, searched_stocks, source " - "FROM signal_news " - "WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL) " - "AND date(created_at) = ? " - "ORDER BY created_at DESC LIMIT 20", - (today,) - ).fetchall() - - if not rows: - conn.close() - print("[SILENT] 今日无未处理小果信号") - return - - # 尝试从 searched_stocks 提取股票代码 - results = [] - for r in rows: - try: - searched = json.loads(r["searched_stocks"]) if r["searched_stocks"] else [] - except: - searched = [] - - # 从 sector 字段取股票名 - sector_name = r["sector"] or "" - - # 尝试提取代码 - codes_found = [] - for s in searched: - # searched_stocks 存的是股票名称列表 - # 尝试从 summary 里找代码 - import re - codes = re.findall(r'\d{6}', r["summary"] or "") - codes_found.extend(codes) - - if not codes_found: - # 没有直接代码,用名称去查 - mark_processed(conn, r["id"]) - continue - - code = codes_found[0] - quote = fetch_quote(code) - - summary, action = evaluate_and_act(dict(r), quote) - - if action == "watchlist": - # 加自选 - results.append(f"✅ {sector_name}({code}): {summary}") - # 写入 watchlist.json - try: - wl = json.loads(WATCHLIST_PATH.read_text()) - wl.setdefault("stocks", []) - # 检查是否已在 - existing = [s for s in wl["stocks"] if s.get("code") == code] - if not existing: - wl["stocks"].append({ - "code": code, - "name": quote.get("name", sector_name), - "price": quote.get("price", 0), - "status": "watching", - "source": "xiaoguo_scanner", - "added_at": today, - }) - WATCHLIST_PATH.write_text(json.dumps(wl, ensure_ascii=False, indent=2)) - except: - pass - elif action == "monitor": - results.append(f"🔄 {sector_name}({code}): {summary}") - else: - results.append(f"⏭️ {sector_name}({code}): {summary}") - - mark_processed(conn, r["id"]) - - conn.close() - - elapsed = time.time() - start - if results: - print(f"小果信号消费 | {today} | {len(results)}条处理 ({elapsed:.0f}s)") - for r in results: - print(f" {r}") - else: - print("[SILENT] 小果信号消费结束") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""xiaoguo_signal_consumer.py — 知微消费小果扫描信号 + +盘中每30分钟运行,读取 signal_news 表中未处理的 xiaoguo 信号, +做五维快速评估后决定:加自选 / 关注 / 跳过。 + +管道位置: + xiaoguo_scanner (每5分) → signal_news → 本脚本 → 知微分析报告 + +no_agent模式:有发现→输出,无→静默 +""" + +import json, os, sqlite3, sys, time, urllib.request +from pathlib import Path +from datetime import datetime +from mo_data import read_watchlist +from mofin_db import write_watchlist_stock + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" + +# 自选池和决策文件 +WATCHLIST_PATH = DATA / "watchlist.json" +DECISIONS_PATH = DATA / "decisions.json" +SIGNAL_MAX_AGE_HOURS = 4 # 只处理4小时内产生的信号 + + +def clean_proxy(): + for k in ['http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY']: + os.environ.pop(k, None) + + +def fetch_quote(code): + """拉行情。DB 优先,腾讯 fallback""" + # DB 优先 + try: from mofin_db import get_price_from_db; p, chg = get_price_from_db(code); return {"name":"", "code":code, "price":p, "change_pct":chg or 0} if p else None + except: pass + # Fallback: 腾讯 + try: + prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" + url = f"http://qt.gtimg.cn/q={prefix}{code}" + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + resp = urllib.request.urlopen(req, timeout=5).read().decode('gbk') + fld = resp.split('=')[1].strip().strip('"').strip(';').split('~') + return { + "name": fld[1] if len(fld) > 1 else "", + "code": code, + "price": float(fld[3]) if len(fld) > 3 else 0, + "change_pct": float(fld[32]) if len(fld) > 32 else 0, + "pe": float(fld[39]) if len(fld) > 39 and fld[39] else 0, + "turnover": float(fld[38]) if len(fld) > 38 and fld[38] else 0, + } + except Exception as e: + return {"code": code, "error": str(e)[:60]} + + +def is_in_portfolio(conn, code): + """检查是否已在持仓或自选中""" + code_stripped = code.lstrip("0") + cur = conn.execute("SELECT COUNT(*) FROM holdings WHERE code=? AND is_active=1", (code_stripped,)) + if cur.fetchone()[0] > 0: + return "holdings" + cur = conn.execute("SELECT COUNT(*) FROM watchlist_stocks WHERE code=?", (code,)) + if cur.fetchone()[0] > 0: + return "watchlist" + # 也检查 watchlist.json + try: + wl = read_watchlist() + for s in wl.get("stocks", []): + if s.get("code") == code or s.get("code", "").lstrip("0") == code_stripped: + return "watchlist" + except: + pass + return None + + +def quick_assess(quote): + """五维快速评估(自动版)""" + score = 0 + reasons = [] + + # 大盘环境,从DB读(回退JSON) + try: + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + row = conn.execute( + "SELECT indices FROM macro_context_log WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + if row and row[0]: + mc = json.loads(row[0]) + else: + raise ValueError + sh = 0 + for k, v in mc.items(): + if "上证" in k: + sh = v.get("change_pct", 0) + break + if sh > 0.5: + score += 1 + reasons.append(f"大盘+{sh:.1f}%偏强") + elif sh < -0.5: + score -= 1 + reasons.append(f"大盘{sh:.1f}%偏弱") + except Exception: + try: + mc = json.loads((DATA / "macro_context.json").read_text()) + sh = mc.get("shanghai", {}).get("change_pct", 0) + if sh > 0.5: + score += 1 + reasons.append(f"大盘+{sh:.1f}%偏强") + elif sh < -0.5: + score -= 1 + reasons.append(f"大盘{sh:.1f}%偏弱") + except: + pass + + # 技术面:涨跌幅 + chg = quote.get("change_pct", 0) + if chg > 3: + score += 1 + reasons.append(f"涨幅+{chg:.1f}%偏强") + elif chg < -3: + score -= 1 + reasons.append(f"跌幅{chg:.1f}%偏弱") + else: + score += 0.5 + reasons.append(f"走势平稳{chg:+.1f}%") + + # 基本面:PE + pe = quote.get("pe", 0) + if 5 < pe < 40: + score += 1 + reasons.append(f"PE={pe:.0f}合理") + elif pe <= 0: + score -= 0.5 + reasons.append("PE为负") + elif pe > 100: + score -= 0.5 + reasons.append(f"PE={pe:.0f}偏高") + + # 量能 + turn = quote.get("turnover", 0) + if turn > 5: + score += 0.5 + reasons.append(f"换手{turn:.1f}%活跃") + elif turn < 0.5: + score -= 0.3 + reasons.append(f"换手{turn:.1f}%偏低") + + return score, reasons + + +def evaluate_and_act(signal, quote): + """评估信号并决定操作""" + status_in = is_in_portfolio(get_conn(), signal.get("code", "")) + if status_in: + return f"已在{status_in}中,跳过", None + + score, reasons = quick_assess(quote) + + if score >= 1.5: + action = "watchlist" + summary = f"加自选: {' | '.join(reasons)}" + elif score >= 0: + action = "monitor" + summary = f"关注: {' | '.join(reasons)}" + else: + action = "skip" + summary = f"跳过(评分{score:.1f}): {' | '.join(reasons)}" + + return summary, action + + +def get_conn(): + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def mark_processed(conn, signal_id): + conn.execute("UPDATE signal_news SET processed=1 WHERE id=?", (signal_id,)) + conn.commit() + + +def main(): + clean_proxy() + start = time.time() + today = datetime.now().strftime("%Y-%m-%d") + + conn = get_conn() + + # 读未处理 xiaoguo 信号(今日) + rows = conn.execute( + "SELECT id, sector, overall_sentiment, summary, key_articles, searched_stocks, source " + "FROM signal_news " + "WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL) " + "AND date(created_at) = ? " + "ORDER BY created_at DESC LIMIT 20", + (today,) + ).fetchall() + + if not rows: + conn.close() + print("[SILENT] 今日无未处理小果信号") + return + + # 尝试从 searched_stocks 提取股票代码 + results = [] + for r in rows: + try: + searched = json.loads(r["searched_stocks"]) if r["searched_stocks"] else [] + except: + searched = [] + + # 从 sector 字段取股票名 + sector_name = r["sector"] or "" + + # 尝试提取代码 + codes_found = [] + for s in searched: + # searched_stocks 存的是股票名称列表 + # 尝试从 summary 里找代码 + import re + codes = re.findall(r'\d{6}', r["summary"] or "") + codes_found.extend(codes) + + if not codes_found: + # 没有直接代码,用名称去查 + mark_processed(conn, r["id"]) + continue + + code = codes_found[0] + quote = fetch_quote(code) + + summary, action = evaluate_and_act(dict(r), quote) + + if action == "watchlist": + # 加自选 + results.append(f"✅ {sector_name}({code}): {summary}") + # 写入 watchlist.json + try: + wl = read_watchlist() + wl.setdefault("stocks", []) + # 检查是否已在 + existing = [s for s in wl["stocks"] if s.get("code") == code] + if not existing: + new_stock = { + "code": code, + "name": quote.get("name", sector_name), + "price": quote.get("price", 0), + "status": "watching", + "source": "xiaoguo_scanner", + "added_at": today, + } + wl["stocks"].append(new_stock) + # DB 写入(watchlist_stocks) + try: + conn2 = get_conn() + new_stock["currency"] = "CNY" + write_watchlist_stock(conn2, new_stock) + conn2.close() + except Exception: + pass + # [migrated to DB] — JSON cold backup removed + # WATCHLIST_PATH.write_text(json.dumps(wl, ensure_ascii=False, indent=2)) + except: + pass + elif action == "monitor": + results.append(f"🔄 {sector_name}({code}): {summary}") + else: + results.append(f"⏭️ {sector_name}({code}): {summary}") + + mark_processed(conn, r["id"]) + + conn.close() + + elapsed = time.time() - start + if results: + print(f"小果信号消费 | {today} | {len(results)}条处理 ({elapsed:.0f}s)") + for r in results: + print(f" {r}") + else: + print("[SILENT] 小果信号消费结束") + + +if __name__ == "__main__": + main() diff --git a/server.py b/server.py index 1acbfef..ed73c9d 100644 --- a/server.py +++ b/server.py @@ -1,1069 +1,1089 @@ -#!/usr/bin/env python3 -"""MoFin Dashboard - 莫荷持仓情报可视化系统""" - -import base64 -import json -import os -import re -import uuid -import urllib.request -from datetime import datetime -from pathlib import Path - -from flask import Flask, jsonify, send_from_directory, request - -# 提示词管理模块 -from prompt_manager.dashboard_views import register_routes - -app = Flask(__name__, static_folder="static", static_url_path="") - -DATA_DIR = Path(__file__).parent / "data" -UPLOAD_DIR = Path(__file__).parent / "uploads" - -# Hermes Gateway -GATEWAY = "http://localhost:8642/v1/chat/completions" -API_KEY = "hermes123" - - -def _load_json(path, default=None): - try: - with open(path, encoding="utf-8") as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} if default is None else default - - -def _load_from_db(query_func, json_path, default=None): - """优先从 SQLite 读取,失败回退 JSON""" - try: - from mofin_db import get_conn - conn = get_conn() - result = query_func(conn) - conn.close() - if result: - return result - except Exception: - pass - return _load_json(json_path, default) - - -def _save_json(path, data): - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -# ── API 路由 ────────────────────────────────────────── - -@app.route("/") -def index(): - return send_from_directory(app.static_folder, "index.html") - - -@app.route("/api/portfolio") -def api_portfolio(): - """持仓列表""" - try: - from mofin_db import get_conn, query_holdings, query_portfolio_summary - conn = get_conn() - holdings = query_holdings(conn) - summary = query_portfolio_summary(conn) - conn.close() - if holdings: - data = dict(summary) - data["holdings"] = holdings - return jsonify(data) - except Exception: - pass - return jsonify(_load_json(DATA_DIR / "portfolio.json")) - - -@app.route("/api/watchlist") -def api_watchlist(): - """自选列表""" - try: - from mofin_db import get_conn, query_watchlist - conn = get_conn() - stocks = query_watchlist(conn) - conn.close() - if stocks: - return jsonify({"stocks": stocks}) - except Exception: - pass - return jsonify(_load_json(DATA_DIR / "watchlist.json")) - - -@app.route("/api/overview") -def api_overview(): - """概览数据""" - try: - from mofin_db import get_conn, query_holdings, query_portfolio_summary, query_latest_market - conn = get_conn() - holdings = query_holdings(conn) - summary = query_portfolio_summary(conn) - market = query_latest_market(conn) - conn.close() - if holdings: - total_assets = summary.get("total_assets", 0) or 0 - stock_value = summary.get("stock_value", 0) or 0 - cash = summary.get("cash", 0) or 0 - position_pct = summary.get("position_pct", 0) or 0 - total_pnl = summary.get("total_pnl", 0) or 0 - top_movers = sorted( - [h for h in holdings if abs(h.get("change_pct", 0) or 0) >= 3], - key=lambda x: abs(x.get("change_pct", 0) or 0), reverse=True)[:5] - return jsonify({ - "total_assets": total_assets, "stock_value": stock_value, - "cash": cash, "position_pct": position_pct, "total_pnl": total_pnl, - "top_movers": top_movers, "market": market, - "alerts": _load_json(DATA_DIR / "alerts.json", [])[:10], - "updated_at": summary.get("updated_at", ""), - }) - except Exception: - pass - portfolio = _load_json(DATA_DIR / "portfolio.json", []) - market = _load_json(DATA_DIR / "market.json", {}) - alerts = _load_json(DATA_DIR / "alerts.json", []) - total_assets = portfolio.get("total_assets", 0) - stock_value = portfolio.get("stock_value", 0) - cash = portfolio.get("cash", 0) - position_pct = portfolio.get("position_pct", 0) - total_pnl = portfolio.get("total_pnl", 0) - holdings = portfolio.get("holdings", []) - top_movers = sorted( - [h for h in holdings if abs(h.get("change_pct", 0)) >= 3], - key=lambda x: abs(x.get("change_pct", 0)), reverse=True)[:5] - return jsonify({ - "total_assets": total_assets, "stock_value": stock_value, - "cash": cash, "position_pct": position_pct, "total_pnl": total_pnl, - "top_movers": top_movers, "market": market, "alerts": alerts[:10], - "updated_at": portfolio.get("updated_at", ""), - }) - - -@app.route("/api/reports") -def api_reports(): - """历史报告列表""" - reports_dir = DATA_DIR / "reports" - reports = [] - if reports_dir.exists(): - for f in sorted(reports_dir.iterdir(), reverse=True)[:100]: - if f.suffix == ".json": - data = _load_json(f) - reports.append({ - "id": f.stem, - "title": data.get("title", f.stem), - "type": data.get("type", "未知"), - "created_at": data.get("created_at", ""), - "summary": data.get("summary", ""), - }) - return jsonify(reports) - - -@app.route("/api/report/") -def api_report(report_id): - """单个报告详情""" - # Try exact file first - path = DATA_DIR / "reports" / f"{report_id}.json" - if path.exists(): - return jsonify(_load_json(path)) - # Try prefix match - reports_dir = DATA_DIR / "reports" - if reports_dir.exists(): - for f in reports_dir.iterdir(): - if f.stem.startswith(report_id) and f.suffix == ".json": - return jsonify(_load_json(f)) - return jsonify({"error": "report not found"}), 404 - - -@app.route("/api/stock/") -def api_stock(code): - """个股详情 + 操作建议历史""" - stock_data = _load_json(DATA_DIR / "stocks" / f"{code}.json", {}) - return jsonify(stock_data) - - -@app.route("/api/market") -def api_market(): - """市场观察""" - try: - from mofin_db import get_conn, query_latest_market - conn = get_conn() - data = query_latest_market(conn) - conn.close() - if data and data.get("sectors"): - return jsonify(data) - except Exception: - pass - return jsonify(_load_json(DATA_DIR / "market.json", {})) - - -# ── 信号API(新增) ───────────────────────────────────── - - -@app.route("/api/signals") -def api_signals(): - """最近信号 + 小果分析""" - try: - from mofin_db import get_conn - conn = get_conn() - signals = conn.execute(""" - SELECT sn.id, sn.sector, sn.overall_sentiment, - sn.summary, sn.source, sn.created_at, - ss.signal_type, ss.severity - FROM signal_news sn - LEFT JOIN sector_signals ss ON sn.signal_id = ss.id - ORDER BY sn.id DESC LIMIT 20 - """).fetchall() - conn.close() - return jsonify([dict(r) for r in signals]) - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -@app.route("/api/xiaoguo-scan") -def api_xiaoguo_scan(): - """小果扫描统计""" - try: - from mofin_db import get_conn - conn = get_conn() - total = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker").fetchone()[0] - found = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker WHERE found_count>0").fetchone()[0] - recent = conn.execute(""" - SELECT code, name, last_scanned_at, found_count - FROM xiaoguo_scan_tracker - ORDER BY last_scanned_at DESC LIMIT 20 - """).fetchall() - source_count = conn.execute(""" - SELECT source, COUNT(*) as cnt FROM signal_news - WHERE datetime(created_at) > datetime('now', '-1 day') - GROUP BY source - """).fetchall() - conn.close() - return jsonify({ - "total_scanned": total, - "found_signals": found, - "recent": [dict(r) for r in recent], - "source_today": {r["source"]: r["cnt"] for r in source_count} - }) - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -# ── 数据写入API ── - -@app.route("/api/update/portfolio", methods=["POST"]) -def update_portfolio(): - data = request.get_json(force=True) - _save_json(DATA_DIR / "portfolio.json", data) - return jsonify({"status": "ok"}) - - -@app.route("/api/update/watchlist", methods=["POST"]) -def update_watchlist(): - data = request.get_json(force=True) - _save_json(DATA_DIR / "watchlist.json", data) - return jsonify({"status": "ok"}) - - -@app.route("/api/update/report", methods=["POST"]) -def update_report(): - data = request.get_json(force=True) - report_id = data.pop("_id", datetime.now().strftime("%Y%m%d_%H%M%S")) - data["created_at"] = data.get("created_at", datetime.now().isoformat()) - _save_json(DATA_DIR / "reports" / f"{report_id}.json", data) - return jsonify({"status": "ok", "id": report_id}) - - -@app.route("/api/update/stock/", methods=["POST"]) -def update_stock(code): - data = request.get_json(force=True) - existing = _load_json(DATA_DIR / "stocks" / f"{code}.json", {}) - history = existing.get("history", []) - if data.get("entry"): - history.append({ - "time": datetime.now().isoformat(), - "price": data.get("price"), - "recommendation": data.get("recommendation"), - "stop_loss": data.get("stop_loss"), - "take_profit": data.get("take_profit"), - "reason": data.get("reason"), - }) - existing.update(data) - existing["history"] = history[-50:] - _save_json(DATA_DIR / "stocks" / f"{code}.json", existing) - return jsonify({"status": "ok"}) - - -@app.route("/api/update/market", methods=["POST"]) -def update_market(): - data = request.get_json(force=True) or {} - _save_json(DATA_DIR / "market.json", data) - return jsonify({"status": "ok"}) - - -# ── 知微分析结果写入API ── -@app.route("/api/analysis/batch", methods=["POST"]) -def analysis_batch(): - """接收知微cron的分析结果,写回持仓/自选JSON的analysis字段""" - data = request.get_json(force=True) or {} - - # 更新持仓 - if "holdings" in data: - pf = _load_json(DATA_DIR / "portfolio.json", {}) - idx = {h["code"]: i for i, h in enumerate(pf.get("holdings", []))} - for item in data["holdings"]: - code = item.get("code", "") - if code not in idx: - continue - h = pf["holdings"][idx[code]] - h["analysis"] = { - "suggestion": item.get("suggestion"), - "stop_loss": item.get("stop_loss"), - "take_profit": item.get("take_profit"), - "buy_zone_low": item.get("buy_zone_low"), - "buy_zone_high": item.get("buy_zone_high"), - "position_suggested": item.get("position_suggested"), - "reason": item.get("reason"), - "updated_at": datetime.now().isoformat(), - } - _save_json(DATA_DIR / "portfolio.json", pf) - - # 更新自选 - if "watchlist" in data: - wl = _load_json(DATA_DIR / "watchlist.json", {}) - idx = {s["code"]: i for i, s in enumerate(wl.get("stocks", []))} - for item in data["watchlist"]: - code = item.get("code", "") - if code not in idx: - continue - s = wl["stocks"][idx[code]] - s["analysis"] = { - "buy_low": item.get("buy_low"), - "buy_high": item.get("buy_high"), - "position_recommend": item.get("position_recommend"), - "reason": item.get("reason"), - "updated_at": datetime.now().isoformat(), - } - _save_json(DATA_DIR / "watchlist.json", wl) - - return jsonify({"status": "ok", "updated_at": datetime.now().isoformat()}) - - -# ── 操作决策库API ── -@app.route("/api/decisions", methods=["GET"]) -def get_decisions(): - """返回决策库数据,统一新旧格式""" - raw = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) - decisions = raw.get("decisions", []) - if not decisions and isinstance(raw, list): - decisions = raw - - # portfolio 用来判断是持仓还是自选 - portfolio = _load_json(DATA_DIR / "portfolio.json", {"holdings": []}) - watchlist = _load_json(DATA_DIR / "watchlist.json", {"stocks": []}) - holding_codes = {h.get("code","") for h in portfolio.get("holdings",[])} - watch_codes = {s.get("code","") for s in watchlist.get("stocks",[])} - - normalized = [] - for d in decisions: - if not isinstance(d, dict): - continue - - # 检测新旧格式:新格式有 stop_loss 顶层字段,旧格式有 trigger 对象 - is_new = "stop_loss" in d and "trigger" not in d - - if is_new: - code = d.get("code", "") - name = d.get("name", "") - price = d.get("price", 0) - sl = d.get("stop_loss") - tp = d.get("take_profit") - el = d.get("entry_low") - eh = d.get("entry_high") - ts = d.get("tech_snapshot", "") - - # type: 持仓还是自选 - if code in holding_codes: - dtype = "持仓策略" - elif code in watch_codes: - dtype = "自选策略" - else: - dtype = "—" - - # 判断 active - status_raw = d.get("status", "") - status = "active" if status_raw in ("active", "updated", "") else "superseded" - - # trigger 对象 - entry_zone_str = "" - if el and eh: - entry_zone_str = f"¥{el}~¥{eh}" - elif el: - entry_zone_str = f"≥¥{el}" - - trigger = {} - if sl: - trigger["stop_loss"] = f"¥{sl}" if isinstance(sl, (int,float)) else str(sl) - if tp: - trigger["take_profit"] = f"¥{tp}" if isinstance(tp, (int,float)) else str(tp) - if entry_zone_str: - trigger["entry_zone"] = entry_zone_str - - # current - current = "" - if price: - current = f"现价¥{price}" if code and not code.startswith(("0","1")) else f"¥{price}" - - # zone_breach - zone_breach = d.get("zone_breach", "") - - # updated_reason - note = d.get("note", "") - timing = d.get("timing_signal", "") - reason_parts = [] - if note: - reason_parts.append(note) - if timing and timing != "neutral": - reason_parts.append(f"时机:{timing}") - if d.get("rr_ratio"): - reason_parts.append(f"盈亏比:{d['rr_ratio']}") - - # advice_timeline - 从新格式重建 - timeline = [] - - entry = { - "code": code, - "name": name, - "type": dtype, - "status": status, - "tag": d.get("tag", ""), - "action": d.get("action", ""), - "trigger": trigger, - "current": current, - "zone_breach": zone_breach, - "updated_reason": " | ".join(reason_parts) if reason_parts else "", - "advice_timeline": timeline, - "changelog": d.get("changelog", []), - "execution": d.get("execution", {}), - "analysis": d.get("analysis", {}), - "tech_snapshot": ts, - "timestamp": d.get("timestamp", ""), - "updated_by": "知微", - } - # 保留原始数据供前端扩展 - entry["_raw_action"] = d.get("action", "") - normalized.append(entry) - else: - # 旧格式:已有 trigger 等字段,直接保留 - entry = dict(d) - # 确保 status 正确 - if entry.get("status") not in ("active", "superseded"): - entry["status"] = "active" - if not entry.get("type"): - code = entry.get("code", "") - if code in holding_codes: - entry["type"] = "持仓策略" - elif code in watch_codes: - entry["type"] = "自选策略" - else: - entry["type"] = "—" - normalized.append(entry) - - # 添加 execution 和 analysis 信息,按执行状态排序 - for n in normalized: - code = n.get("code", "") - # 从原始数据中找到 execution 和 analysis - raw_entry = next((d for d in decisions if isinstance(d, dict) and d.get("code") == code), {}) - n["execution"] = raw_entry.get("execution", {"status": "none"}) - n["analysis"] = raw_entry.get("analysis", {}) - - # 排序规则:推荐>执行中>观察>无标签 - def sort_key(x): - tag = x.get("tag", "") - exec_status = x.get("execution", {}).get("status", "none") - # 标签优先级(current_recommend才靠前,active_manual只是记录不升序) - tag_order = {"current_recommend": 0} - tag_priority = tag_order.get(tag, 50) - # 执行状态优先级 - exec_order = {"partial_exit": 0, "executing": 1, "observing": 2, "none": 99} - exec_priority = exec_order.get(exec_status, 99) - # 组合:先按标签排,再按执行状态排 - return (tag_priority, exec_priority, x.get("code", "")) - - normalized.sort(key=sort_key) - - return jsonify({ - "decisions": normalized, - "total": len(normalized), - "regenerated_at": raw.get("regenerated_at", ""), - }) - - -@app.route("/api/decisions/add", methods=["POST"]) -def add_decision(): - """新增/更新一条决策(新格式)""" - data = request.get_json(force=True) or {} - code = data.get("code", "") - if not code: - return jsonify({"status": "error", "message": "code required"}), 400 - - d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) - - # 同一股票旧决策标记为superseded - for e in d["decisions"]: - if e["code"] == code and e.get("status") in ("active", "updated"): - e["status"] = "superseded" - - entry = { - "code": code, - "name": data.get("name", ""), - "price": data.get("price", 0), - "action": data.get("action", ""), - "stop_loss": data.get("stop_loss"), - "take_profit": data.get("take_profit"), - "entry_low": data.get("entry_low"), - "entry_high": data.get("entry_high"), - "tech_snapshot": data.get("tech_snapshot", ""), - "timing_signal": data.get("timing_signal", ""), - "rr_ratio": data.get("rr_ratio"), - "tag": data.get("tag", ""), - "note": data.get("note", ""), - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), - "updated_reason": data.get("updated_reason", ""), - "status": "updated", - "changelog": data.get("changelog", []), - "execution": data.get("execution", {"status": "none"}), - "analysis": data.get("analysis", {}), - } - d["decisions"].append(entry) - _save_json(DATA_DIR / "decisions.json", d) - return jsonify({"status": "ok", "entry": entry}) - - -@app.route("/api/decisions/tag", methods=["POST"]) -def set_decision_tag(): - """设置/清除某只股票的推荐标签""" - data = request.get_json(force=True) or {} - code = data.get("code", "") - tag = data.get("tag", "") # 'current_recommend', 'active_manual', or '' to clear - if not code: - return jsonify({"status": "error", "message": "code required"}), 400 - - d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) - found = False - for e in d.get("decisions", []): - if e.get("code") == code: - e["tag"] = tag - e["tag_updated"] = datetime.now().isoformat() - found = True - break - - if not found: - return jsonify({"status": "error", "message": f"stock {code} not found"}), 404 - - _save_json(DATA_DIR / "decisions.json", d) - return jsonify({"status": "ok", "code": code, "tag": tag}) - - -@app.route("/api/decisions/pending") -def get_pending_decisions(): - """返回所有有未确认建议的条目""" - d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) - pending = [] - for entry in d["decisions"]: - timeline = entry.get("advice_timeline", []) - unconfirmed = [a for a in timeline if a.get("status") in (None, "pending")] - if unconfirmed: - pending.append({ - "code": entry["code"], - "name": entry["name"], - "current": entry.get("current", ""), - "pending_advice": unconfirmed, - }) - return jsonify(pending) - - -@app.route("/api/advice/record", methods=["POST"]) -def record_advice(): - """记录一条分析建议,自动去重(相同code+同天+同方向=跳过)""" - data = request.get_json(force=True) or {} - code = data.get("code", "") - if not code: - return jsonify({"status": "error", "message": "code required"}), 400 - - direction = data.get("direction", "持有") - today = datetime.now().strftime("%Y-%m-%d") - - d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) - - entry = None - for e in d["decisions"]: - if e["code"] == code and e["status"] in ("active", "updated"): - entry = e - break - - if not entry: - return jsonify({"status": "error", "message": f"no active decision for {code}"}), 404 - - timeline = entry.setdefault("advice_timeline", []) - - # 去重:同一天+同方向+摘要前40字相似 → 跳过 - summary_short = (data.get("summary", "") or "")[:40] - for a in timeline: - a_date = a.get("date", "")[:10] - a_dir = a.get("direction", "") - a_summary = (a.get("summary", "") or "")[:40] - if a_date == today and a_dir == direction and a_summary == summary_short: - return jsonify({"status": "skipped", "reason": "duplicate", "advice": a}) - - advice = { - "date": datetime.now().strftime("%Y-%m-%d %H:%M"), - "direction": direction, - "price": data.get("price", ""), - "summary": data.get("summary", ""), - "status": "pending", - } - timeline.append(advice) - _save_json(DATA_DIR / "decisions.json", d) - return jsonify({"status": "ok", "advice": advice}) - - -@app.route("/api/advice/confirm", methods=["POST"]) -def confirm_advice(): - """确认/忽略/标记已执行""" - data = request.get_json(force=True) or {} - code = data.get("code", "") - idx = data.get("index", -1) - action = data.get("action", "confirmed") # confirmed | ignored | executed - result = data.get("result", "") - - d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) - for e in d["decisions"]: - if e["code"] == code and e["status"] == "active": - timeline = e.get("advice_timeline", []) - if 0 <= idx < len(timeline): - timeline[idx]["status"] = action - if action == "executed": - timeline[idx]["evaluated"] = True - timeline[idx]["evaluated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") - if result: - timeline[idx]["result"] = result - _save_json(DATA_DIR / "decisions.json", d) - return jsonify({"status": "ok"}) - return jsonify({"status": "error", "message": "not found"}), 404 - - -# ── 准确率统计API ── -@app.route("/api/stats/accuracy") -def get_accuracy_stats(): - data = _load_json(DATA_DIR / "accuracy_stats.json", {}) - return jsonify(data) - - -# ── 策略评估API ── -@app.route("/api/evaluation") -def get_evaluation(): - """返回所有策略的双维度评估结果""" - # 主数据源:evaluation.json - eval_data = _load_json(DATA_DIR / "evaluation.json", {}) - strategies = eval_data.get("strategies", []) - if strategies: - return jsonify(strategies) - - # 备选:从 decisions.json 的 evaluation 字段读取(尚未反写时的兼容) - decisions = _load_json(DECISIONS_PATH if 'DECISIONS_PATH' in dir() else DATA_DIR / "decisions.json", {"decisions": []}) - evals = [] - for d in decisions.get("decisions", []): - e = d.get("evaluation", []) - if e: - evals.append({ - "code": d["code"], - "name": d["name"], - "type": d.get("type", ""), - "current": d.get("current", ""), - "evaluations": e, - }) - return jsonify(evals) - - -@app.route("/api/evaluation/trigger", methods=["POST"]) -def trigger_evaluation(): - """手动触发策略评估""" - import subprocess - try: - r = subprocess.run( - ["python3", str(DATA_DIR.parent / "strategy_evaluator.py")], - capture_output=True, timeout=60, text=True, - ) - return jsonify({"status": "ok", "output": r.stdout, "error": r.stderr}) - except Exception as e: - return jsonify({"status": "error", "message": str(e)}), 500 - - -# ── 策略反馈API ── -@app.route("/api/feedback") -def get_feedback(): - data = _load_json(DATA_DIR / "strategy_feedback.json", {}) - return jsonify(data) - - -# ── 持仓截图上传与解析 ──────────────────────────────── - - -@app.route("/upload") -def upload_page(): - return send_from_directory(app.static_folder, "upload.html") - - -def _ocr_image(image_path): - """优先用小果GLM-OCR-8bit识别,失败则降级到pytesseract""" - import sys - from PIL import Image, ImageEnhance, ImageFilter - import pytesseract - - # 尝试小果OCR(GLM-OCR-8bit) - try: - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "scripts")) - from ocr_client import ocr_image as xg_ocr - result = xg_ocr(image_path, "请识别这张图片中所有文字,包括股票名称、代码、价格、持股数、金额、百分比等。输出完整内容。") - if result.get("success") and len(result.get("text", "")) > 20: - return result["text"].strip() - except Exception: - pass # 降级到tesseract - - # 降级:Tesseract(预处理优化中文表格识别) - img = Image.open(image_path) - - # 预处理:放大 + 锐化 + 二值化,提升小字识别率 - w, h = img.size - if w < 2000 or h < 2000: - scale = max(2, 2000 // min(w, h)) - img = img.resize((w * scale, h * scale), Image.LANCZOS) - - # 转灰度 - img = img.convert("L") - - # 增强对比度 - enhancer = ImageEnhance.Contrast(img) - img = enhancer.enhance(2.0) - - # 锐化 - img = img.filter(ImageFilter.SHARPEN) - - # 二值化(自适应阈值) - threshold = 128 - img = img.point(lambda x: 255 if x > threshold else 0) - - # OCR:chip_sim+eng,PSM 6(统一文本块) - text = pytesseract.image_to_string( - img, - lang="chi_sim+eng", - config="--psm 6 --oem 3", - ) - return text.strip() - - -ANALYZE_PROMPT = """你是股票持仓数据分析助手。以下是用户上传的持仓/自选截图经过OCR提取的文字,请从中提取所有股票信息。 - -判断这是「持仓截图」还是「自选截图」: -- 持仓截图:每支股票有"证券数量"(持股数)、成本价、盈亏 -- 自选截图:只有股票列表和价格,没有持股数/成本 - -股票代码格式: -- A股:6位数字(如 600519, 000858, 300750) -- 港股:纯数字代码(如 0700, 3690, 1211),不带HK前缀 - -⚠️ 重要:截图顶部通常有汇总数据,如总资产、股票市值、可用资金、当日盈亏等。 -如果OCR文字中有这些汇总数字,请一并提取到JSON的summary字段中。 -不要自己计算汇总值,直接从OCR原文中提取。 - -请严格按照以下JSON格式回复,只输出JSON: - -```json -{ - "type": "portfolio" 或 "watchlist", - "summary": { - "total_assets": "总资产数字(可选,从截图中提取)", - "stock_value": "股票市值/持仓市值数字(可选,从截图中提取)", - "cash": "可用资金/现金数字(可选,从截图中提取)", - "day_pnl": "当日盈亏金额(可选,从截图中提取)" - }, - "stocks": [ - { - "code": "股票代码", - "name": "股票名称(中文)", - "price": "现价(数字)", - "shares": "持股数量(数字,持仓截图才有)", - "cost": "成本价(数字,持仓截图才有)", - "pnl": "盈亏百分比如+15.1%(持仓截图才有)", - "position_pct": "仓位占比数字如12.5(可选)" - } - ] -} -``` - -OCR原文: -""" - - -@app.route("/api/upload/analyze", methods=["POST"]) -def upload_analyze(): - """接收图片,OCR提取文字 → LLM解析结构化数据""" - if "image" not in request.files: - return jsonify({"error": "请上传图片"}), 400 - - f = request.files["image"] - if not f.filename: - return jsonify({"error": "空文件"}), 400 - - # 保存到临时目录 - UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - ext = Path(f.filename).suffix or ".png" - save_path = UPLOAD_DIR / f"{uuid.uuid4().hex}{ext}" - f.save(str(save_path)) - - try: - # 第一步:OCR提取文字 - raw_text = _ocr_image(str(save_path)) - if not raw_text: - return jsonify({"error": "OCR未识别到文字,请确认图片清晰"}), 400 - except Exception as e: - os.unlink(str(save_path)) - return jsonify({"error": f"OCR失败: {e}"}), 500 - - # 第二步:LLM解析结构化数据(走文本API,不走视觉) - llm_text = _llm_parse(raw_text, ANALYZE_PROMPT) - - os.unlink(str(save_path)) - - # 从LLM回复中提取JSON - json_match = re.search(r"```(?:json)?\s*({.*?})\s*```", llm_text, re.DOTALL) - if json_match: - try: - parsed = json.loads(json_match.group(1)) - except json.JSONDecodeError: - return jsonify({"error": f"LLM解析JSON失败: {llm_text[:500]}"}), 500 - else: - # 尝试直接找JSON(没被代码块包裹) - try: - parsed = json.loads(llm_text) - except json.JSONDecodeError: - return jsonify({"error": f"未提取到结构化数据: {raw_text[:300]}...\n\nLLM回复: {llm_text[:500]}"}), 500 - - return jsonify(parsed) - - -def _llm_parse(text, prompt_template): - """发送OCR文本到Hermes LLM解析,返回JSON字符串""" - payload = json.dumps({ - "model": "hermes-agent", - "messages": [ - {"role": "system", "content": "你是一个数据提取助手。从OCR文字中提取结构化JSON数据。"}, - {"role": "user", "content": prompt_template + "\n" + text}, - ], - "max_tokens": 4096, - }).encode() - - req = urllib.request.Request(GATEWAY, data=payload, method="POST") - req.add_header("Content-Type", "application/json") - req.add_header("Authorization", f"Bearer {API_KEY}") - req.add_header("X-Hermes-Session-Id", "upload-ocr-parse") - - try: - resp = urllib.request.urlopen(req, timeout=120) - data = json.loads(resp.read()) - return data.get("choices", [{}])[0].get("message", {}).get("content", "") - except Exception as e: - return f"ERROR: {e}" - - -@app.route("/api/upload/confirm", methods=["POST"]) -def upload_confirm(): - """确认解析结果,更新数据文件""" - data = request.get_json(force=True) - stocks = data.get("stocks", []) - doc_type = data.get("type", "portfolio") - - # 尝试获取实时行情补充数据 - try: - codes = [s["code"] for s in stocks if s.get("code")] - if codes: - # DB 优先(price_monitor 维护的实时价) - db_prices = {} - try: - import sqlite3 - db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') - db.row_factory = sqlite3.Row - for code in codes: - row = db.execute("SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() - if row and row['price']: - db_prices[code] = (row['price'], row['change_pct'] or 0) - db.close() - except Exception: - pass - - # Fallback: 腾讯 API - need_tencent = [c for c in codes if c not in db_prices] - if need_tencent: - qs = " ".join( - f"hk{c}" if len(c) == 5 - else f"sz{c}" if c.startswith("0") or c.startswith("3") - else f"sh{c}" if c.startswith("6") - else f"hk{c}" - for c in need_tencent - ) - url = f"https://qt.gtimg.cn/q={qs}" - req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) - resp = urllib.request.urlopen(req, timeout=10) - qt_text = resp.read().decode("gbk", errors="replace") - # 优先 DB 价格,再补腾讯 - for stock in stocks: - code = stock.get("code", "") - if code in db_prices: - if not stock.get("price"): - stock["price"] = db_prices[code][0] - elif need_tencent and code in need_tencent: - prefix = "hk" if len(code) == 5 else "sz" if code.startswith(("0","3")) else "sh" if code.startswith("6") else "hk" - m = re.search(rf'{prefix}{code}="([^"]+)"', qt_text) - if m: - fields = m.group(1).split('~') - if not stock.get("name"): - stock["name"] = fields[1] - if not stock.get("price"): - stock["price"] = fields[3] - except: - pass # 行情获取失败不影响主流程 - - # 更新对应数据文件 - if doc_type == "portfolio": - existing = _load_json(DATA_DIR / "portfolio.json", {}) - old_holdings = {h["code"]: h for h in existing.get("holdings", []) if h.get("code")} - new_holdings = [] - for s in stocks: - code = s.get("code", "") - old = old_holdings.get(code, {}) - new_shares = int(s["shares"]) if str(s.get("shares", "")).lstrip('-').isdigit() else old.get("shares", 0) - old_shares = old.get("shares", 0) - # 股数突变检测:旧200→新0是合理卖出,但旧0→新200可能是OCR错读 - if old_shares > 0 and new_shares == 0 and old_shares != new_shares: - print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}→{new_shares} (卖出清仓)") - elif abs(new_shares - old_shares) > max(old_shares * 0.5, 100) and old_shares > 0: - print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}→{new_shares} (变动较大)") - new_holdings.append({ - "code": code, - "name": s.get("name") or old.get("name", ""), - "shares": new_shares, - "price": float(s.get("price", 0)) or old.get("price", 0), - "cost": float(s.get("cost", 0)) if s.get("cost") else old.get("cost", 0), - "pnl": s.get("pnl") or old.get("pnl", ""), - "position_pct": float(s.get("position_pct", 0)) if s.get("position_pct") else old.get("position_pct", 0), - "change_pct": old.get("change_pct", 0), - }) - existing["holdings"] = new_holdings - - # 使用截图中的汇总数据(优先),没有则用旧数据 - summary = data.get("summary", {}) - if summary.get("stock_value"): - existing["stock_value"] = float(summary["stock_value"]) - else: - existing["stock_value"] = round( - sum(h["shares"] * h["price"] for h in existing["holdings"]), 2 - ) - if summary.get("cash"): - existing["cash"] = float(summary["cash"]) - if summary.get("total_assets"): - existing["total_assets"] = float(summary["total_assets"]) - else: - # Use unified formula (includes frozen_cash) - from mo_models import calc_total_assets - existing["total_assets"] = calc_total_assets(existing) - if summary.get("day_pnl"): - existing["day_pnl"] = float(summary["day_pnl"]) - existing["updated_at"] = datetime.now().isoformat() - # 计算仓位% - if existing["total_assets"] > 0: - existing["position_pct"] = round(existing["stock_value"] / existing["total_assets"] * 100, 2) - _save_json(DATA_DIR / "portfolio.json", existing) - msg = f"更新了 {len(stocks)} 只持仓股" - - elif doc_type == "watchlist": - existing = _load_json(DATA_DIR / "watchlist.json", {}) - existing["stocks"] = [ - { - "code": s.get("code", ""), - "name": s.get("name", ""), - "price": float(s.get("price", 0)) if s.get("price") else 0, - } - for s in stocks - ] - existing["updated_at"] = datetime.now().isoformat() - _save_json(DATA_DIR / "watchlist.json", existing) - msg = f"更新了 {len(stocks)} 只自选股" - - else: - return jsonify({"error": f"未知类型: {doc_type}"}), 400 - - return jsonify({"status": "ok", "message": msg}) - - -# ── TDX中继实时行情接收API ── -@app.route("/api/update/realtime", methods=["POST"]) -def update_realtime(): - """接收小小莫中继的实时行情数据""" - data = request.get_json(force=True) or {} - stocks = data.get("stocks", []) - source = data.get("source", "unknown") - - if not stocks: - return jsonify({"status": "error", "message": "没有股票数据"}), 400 - - # 更新 portfolio.json 中的实时价格(change_pct字段) - pf = _load_json(DATA_DIR / "portfolio.json", {"holdings": []}) - pf_holdings = {h["code"]: h for h in pf.get("holdings", [])} - - updated = 0 - for s in stocks: - code = s.get("code", "") - if code in pf_holdings: - pf_holdings[code]["price"] = float(s.get("price", pf_holdings[code].get("price", 0))) - pf_holdings[code]["change_pct"] = float(s.get("change_pct", 0)) - pf_holdings[code]["high"] = float(s.get("high", 0)) - pf_holdings[code]["low"] = float(s.get("low", 0)) - pf_holdings[code]["open"] = float(s.get("open", 0)) - pf_holdings[code]["volume"] = int(s.get("volume", 0)) - pf_holdings[code]["data_source"] = source - pf_holdings[code]["updated_at"] = datetime.now().isoformat() - updated += 1 - - # 也更新 watchlist.json - wl = _load_json(DATA_DIR / "watchlist.json", {"stocks": []}) +#!/usr/bin/env python3 +"""MoFin Dashboard - 莫荷持仓情报可视化系统""" + +import base64 +import json +import os +import re +import uuid +import urllib.request +from datetime import datetime +from pathlib import Path + +from flask import Flask, jsonify, send_from_directory, request + +# 提示词管理模块 +from prompt_manager.dashboard_views import register_routes + +# MoFin 数据层(纯 DB,不再读 JSON) +from mo_data import read_portfolio, read_decisions, read_watchlist +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock, write_holding_strategy + +app = Flask(__name__, static_folder="static", static_url_path="") + +DATA_DIR = Path(__file__).parent / "data" +UPLOAD_DIR = Path(__file__).parent / "uploads" + +# Hermes Gateway +GATEWAY = "http://localhost:8642/v1/chat/completions" +API_KEY = "hermes123" + + +def _load_json(path, default=None): + """仅用于非核心文件(reports, stocks, market 等)。portfolio/decisions/watchlist 已迁移到 DB。""" + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} if default is None else default + + +def _save_json(path, data): + """仅用于非核心文件(reports, stocks, market 等)。portfolio/decisions/watchlist 已迁移到 DB。""" + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def _save_portfolio(data): + """写入持仓数据到 DB。data 必须包含 holdings[] 和顶层 summary 字段。""" + conn = get_conn() + try: + write_holdings_batch(conn, data.get('holdings', [])) + write_portfolio_summary(conn, data) + finally: + conn.close() + + +def _save_decision(code, name, data): + """写入单条决策到 DB。""" + conn = get_conn() + try: + write_holding_strategy(conn, code, name, data) + finally: + conn.close() + + +def _save_watchlist(data): + """写入自选股列表到 DB。""" + conn = get_conn() + for s in data.get('stocks', []): + s.setdefault('currency', 'CNY') + write_watchlist_stock(conn, s) + conn.close() + + +# ── API 路由 ────────────────────────────────────────── + +@app.route("/") +def index(): + return send_from_directory(app.static_folder, "index.html") + + +@app.route("/api/portfolio") +def api_portfolio(): + """持仓列表""" + try: + from mofin_db import get_conn, query_holdings, query_portfolio_summary + conn = get_conn() + holdings = query_holdings(conn) + summary = query_portfolio_summary(conn) + conn.close() + if holdings: + data = dict(summary) + data["holdings"] = holdings + return jsonify(data) + except Exception: + pass + return jsonify({"error": "数据库查询失败"}), 500 + + +@app.route("/api/watchlist") +def api_watchlist(): + """自选列表""" + try: + from mofin_db import get_conn, query_watchlist + conn = get_conn() + stocks = query_watchlist(conn) + conn.close() + if stocks: + return jsonify({"stocks": stocks}) + except Exception: + pass + return jsonify({"error": "数据库查询失败"}), 500 + + +@app.route("/api/overview") +def api_overview(): + """概览数据""" + try: + from mofin_db import get_conn, query_holdings, query_portfolio_summary, query_latest_market + conn = get_conn() + holdings = query_holdings(conn) + summary = query_portfolio_summary(conn) + market = query_latest_market(conn) + conn.close() + if holdings: + total_assets = summary.get("total_assets", 0) or 0 + stock_value = summary.get("stock_value", 0) or 0 + cash = summary.get("cash", 0) or 0 + position_pct = summary.get("position_pct", 0) or 0 + total_pnl = summary.get("total_pnl", 0) or 0 + top_movers = sorted( + [h for h in holdings if abs(h.get("change_pct", 0) or 0) >= 3], + key=lambda x: abs(x.get("change_pct", 0) or 0), reverse=True)[:5] + return jsonify({ + "total_assets": total_assets, "stock_value": stock_value, + "cash": cash, "position_pct": position_pct, "total_pnl": total_pnl, + "top_movers": top_movers, "market": market, + "alerts": _load_json(DATA_DIR / "alerts.json", [])[:10], + "updated_at": summary.get("updated_at", ""), + }) + except Exception: + pass + portfolio = read_portfolio() + market = _load_json(DATA_DIR / "market.json", {}) + alerts = _load_json(DATA_DIR / "alerts.json", []) + total_assets = portfolio.get("total_assets", 0) + stock_value = portfolio.get("stock_value", 0) + cash = portfolio.get("cash", 0) + position_pct = portfolio.get("position_pct", 0) + total_pnl = portfolio.get("total_pnl", 0) + holdings = portfolio.get("holdings", []) + top_movers = sorted( + [h for h in holdings if abs(h.get("change_pct", 0)) >= 3], + key=lambda x: abs(x.get("change_pct", 0)), reverse=True)[:5] + return jsonify({ + "total_assets": total_assets, "stock_value": stock_value, + "cash": cash, "position_pct": position_pct, "total_pnl": total_pnl, + "top_movers": top_movers, "market": market, "alerts": alerts[:10], + "updated_at": portfolio.get("updated_at", ""), + }) + + +@app.route("/api/reports") +def api_reports(): + """历史报告列表""" + reports_dir = DATA_DIR / "reports" + reports = [] + if reports_dir.exists(): + for f in sorted(reports_dir.iterdir(), reverse=True)[:100]: + if f.suffix == ".json": + data = _load_json(f) + reports.append({ + "id": f.stem, + "title": data.get("title", f.stem), + "type": data.get("type", "未知"), + "created_at": data.get("created_at", ""), + "summary": data.get("summary", ""), + }) + return jsonify(reports) + + +@app.route("/api/report/") +def api_report(report_id): + """单个报告详情""" + # Try exact file first + path = DATA_DIR / "reports" / f"{report_id}.json" + if path.exists(): + return jsonify(_load_json(path)) + # Try prefix match + reports_dir = DATA_DIR / "reports" + if reports_dir.exists(): + for f in reports_dir.iterdir(): + if f.stem.startswith(report_id) and f.suffix == ".json": + return jsonify(_load_json(f)) + return jsonify({"error": "report not found"}), 404 + + +@app.route("/api/stock/") +def api_stock(code): + """个股详情 + 操作建议历史""" + stock_data = _load_json(DATA_DIR / "stocks" / f"{code}.json", {}) + return jsonify(stock_data) + + +@app.route("/api/market") +def api_market(): + """市场观察""" + try: + from mofin_db import get_conn, query_latest_market + conn = get_conn() + data = query_latest_market(conn) + conn.close() + if data and data.get("sectors"): + return jsonify(data) + except Exception: + pass + return jsonify(_load_json(DATA_DIR / "market.json", {})) + + +# ── 信号API(新增) ───────────────────────────────────── + + +@app.route("/api/signals") +def api_signals(): + """最近信号 + 小果分析""" + try: + from mofin_db import get_conn + conn = get_conn() + signals = conn.execute(""" + SELECT sn.id, sn.sector, sn.overall_sentiment, + sn.summary, sn.source, sn.created_at, + ss.signal_type, ss.severity + FROM signal_news sn + LEFT JOIN sector_signals ss ON sn.signal_id = ss.id + ORDER BY sn.id DESC LIMIT 20 + """).fetchall() + conn.close() + return jsonify([dict(r) for r in signals]) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/xiaoguo-scan") +def api_xiaoguo_scan(): + """小果扫描统计""" + try: + from mofin_db import get_conn + conn = get_conn() + total = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker").fetchone()[0] + found = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker WHERE found_count>0").fetchone()[0] + recent = conn.execute(""" + SELECT code, name, last_scanned_at, found_count + FROM xiaoguo_scan_tracker + ORDER BY last_scanned_at DESC LIMIT 20 + """).fetchall() + source_count = conn.execute(""" + SELECT source, COUNT(*) as cnt FROM signal_news + WHERE datetime(created_at) > datetime('now', '-1 day') + GROUP BY source + """).fetchall() + conn.close() + return jsonify({ + "total_scanned": total, + "found_signals": found, + "recent": [dict(r) for r in recent], + "source_today": {r["source"]: r["cnt"] for r in source_count} + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +# ── 数据写入API ── + +@app.route("/api/update/portfolio", methods=["POST"]) +def update_portfolio(): + data = request.get_json(force=True) + _save_portfolio(data) + return jsonify({"status": "ok"}) + + +@app.route("/api/update/watchlist", methods=["POST"]) +def update_watchlist(): + data = request.get_json(force=True) + _save_watchlist(data) + return jsonify({"status": "ok"}) + + +@app.route("/api/update/report", methods=["POST"]) +def update_report(): + data = request.get_json(force=True) + report_id = data.pop("_id", datetime.now().strftime("%Y%m%d_%H%M%S")) + data["created_at"] = data.get("created_at", datetime.now().isoformat()) + _save_json(DATA_DIR / "reports" / f"{report_id}.json", data) + return jsonify({"status": "ok", "id": report_id}) + + +@app.route("/api/update/stock/", methods=["POST"]) +def update_stock(code): + data = request.get_json(force=True) + existing = _load_json(DATA_DIR / "stocks" / f"{code}.json", {}) + history = existing.get("history", []) + if data.get("entry"): + history.append({ + "time": datetime.now().isoformat(), + "price": data.get("price"), + "recommendation": data.get("recommendation"), + "stop_loss": data.get("stop_loss"), + "take_profit": data.get("take_profit"), + "reason": data.get("reason"), + }) + existing.update(data) + existing["history"] = history[-50:] + _save_json(DATA_DIR / "stocks" / f"{code}.json", existing) + return jsonify({"status": "ok"}) + + +@app.route("/api/update/market", methods=["POST"]) +def update_market(): + data = request.get_json(force=True) or {} + _save_json(DATA_DIR / "market.json", data) + return jsonify({"status": "ok"}) + + +# ── 知微分析结果写入API ── +@app.route("/api/analysis/batch", methods=["POST"]) +def analysis_batch(): + """接收知微cron的分析结果,写回持仓/自选JSON的analysis字段""" + data = request.get_json(force=True) or {} + + # 更新持仓 + if "holdings" in data: + pf = read_portfolio() + idx = {h["code"]: i for i, h in enumerate(pf.get("holdings", []))} + for item in data["holdings"]: + code = item.get("code", "") + if code not in idx: + continue + h = pf["holdings"][idx[code]] + h["analysis"] = { + "suggestion": item.get("suggestion"), + "stop_loss": item.get("stop_loss"), + "take_profit": item.get("take_profit"), + "buy_zone_low": item.get("buy_zone_low"), + "buy_zone_high": item.get("buy_zone_high"), + "position_suggested": item.get("position_suggested"), + "reason": item.get("reason"), + "updated_at": datetime.now().isoformat(), + } + _save_portfolio(pf) + + # 更新自选 + if "watchlist" in data: + wl = read_watchlist() + idx = {s["code"]: i for i, s in enumerate(wl.get("stocks", []))} + for item in data["watchlist"]: + code = item.get("code", "") + if code not in idx: + continue + s = wl["stocks"][idx[code]] + s["analysis"] = { + "buy_low": item.get("buy_low"), + "buy_high": item.get("buy_high"), + "position_recommend": item.get("position_recommend"), + "reason": item.get("reason"), + "updated_at": datetime.now().isoformat(), + } + _save_watchlist(wl) + + return jsonify({"status": "ok", "updated_at": datetime.now().isoformat()}) + + +# ── 操作决策库API ── +@app.route("/api/decisions", methods=["GET"]) +def get_decisions(): + """返回决策库数据,统一新旧格式""" + raw = read_decisions() + decisions = raw.get("decisions", []) + if not decisions and isinstance(raw, list): + decisions = raw + + # portfolio 用来判断是持仓还是自选 + portfolio = read_portfolio() + watchlist = read_watchlist() + holding_codes = {h.get("code","") for h in portfolio.get("holdings",[])} + watch_codes = {s.get("code","") for s in watchlist.get("stocks",[])} + + normalized = [] + for d in decisions: + if not isinstance(d, dict): + continue + + # 检测新旧格式:新格式有 stop_loss 顶层字段,旧格式有 trigger 对象 + is_new = "stop_loss" in d and "trigger" not in d + + if is_new: + code = d.get("code", "") + name = d.get("name", "") + price = d.get("price", 0) + sl = d.get("stop_loss") + tp = d.get("take_profit") + el = d.get("entry_low") + eh = d.get("entry_high") + ts = d.get("tech_snapshot", "") + + # type: 持仓还是自选 + if code in holding_codes: + dtype = "持仓策略" + elif code in watch_codes: + dtype = "自选策略" + else: + dtype = "—" + + # 判断 active + status_raw = d.get("status", "") + status = "active" if status_raw in ("active", "updated", "") else "superseded" + + # trigger 对象 + entry_zone_str = "" + if el and eh: + entry_zone_str = f"¥{el}~¥{eh}" + elif el: + entry_zone_str = f"≥¥{el}" + + trigger = {} + if sl: + trigger["stop_loss"] = f"¥{sl}" if isinstance(sl, (int,float)) else str(sl) + if tp: + trigger["take_profit"] = f"¥{tp}" if isinstance(tp, (int,float)) else str(tp) + if entry_zone_str: + trigger["entry_zone"] = entry_zone_str + + # current + current = "" + if price: + current = f"现价¥{price}" if code and not code.startswith(("0","1")) else f"¥{price}" + + # zone_breach + zone_breach = d.get("zone_breach", "") + + # updated_reason + note = d.get("note", "") + timing = d.get("timing_signal", "") + reason_parts = [] + if note: + reason_parts.append(note) + if timing and timing != "neutral": + reason_parts.append(f"时机:{timing}") + if d.get("rr_ratio"): + reason_parts.append(f"盈亏比:{d['rr_ratio']}") + + # advice_timeline - 从新格式重建 + timeline = [] + + entry = { + "code": code, + "name": name, + "type": dtype, + "status": status, + "tag": d.get("tag", ""), + "action": d.get("action", ""), + "trigger": trigger, + "current": current, + "zone_breach": zone_breach, + "updated_reason": " | ".join(reason_parts) if reason_parts else "", + "advice_timeline": timeline, + "changelog": d.get("changelog", []), + "execution": d.get("execution", {}), + "analysis": d.get("analysis", {}), + "tech_snapshot": ts, + "timestamp": d.get("timestamp", ""), + "updated_by": "知微", + } + # 保留原始数据供前端扩展 + entry["_raw_action"] = d.get("action", "") + normalized.append(entry) + else: + # 旧格式:已有 trigger 等字段,直接保留 + entry = dict(d) + # 确保 status 正确 + if entry.get("status") not in ("active", "superseded"): + entry["status"] = "active" + if not entry.get("type"): + code = entry.get("code", "") + if code in holding_codes: + entry["type"] = "持仓策略" + elif code in watch_codes: + entry["type"] = "自选策略" + else: + entry["type"] = "—" + normalized.append(entry) + + # 添加 execution 和 analysis 信息,按执行状态排序 + for n in normalized: + code = n.get("code", "") + # 从原始数据中找到 execution 和 analysis + raw_entry = next((d for d in decisions if isinstance(d, dict) and d.get("code") == code), {}) + n["execution"] = raw_entry.get("execution", {"status": "none"}) + n["analysis"] = raw_entry.get("analysis", {}) + + # 排序规则:推荐>执行中>观察>无标签 + def sort_key(x): + tag = x.get("tag", "") + exec_status = x.get("execution", {}).get("status", "none") + # 标签优先级(current_recommend才靠前,active_manual只是记录不升序) + tag_order = {"current_recommend": 0} + tag_priority = tag_order.get(tag, 50) + # 执行状态优先级 + exec_order = {"partial_exit": 0, "executing": 1, "observing": 2, "none": 99} + exec_priority = exec_order.get(exec_status, 99) + # 组合:先按标签排,再按执行状态排 + return (tag_priority, exec_priority, x.get("code", "")) + + normalized.sort(key=sort_key) + + return jsonify({ + "decisions": normalized, + "total": len(normalized), + "regenerated_at": raw.get("regenerated_at", ""), + }) + + +@app.route("/api/decisions/add", methods=["POST"]) +def add_decision(): + """新增/更新一条决策(新格式)""" + data = request.get_json(force=True) or {} + code = data.get("code", "") + if not code: + return jsonify({"status": "error", "message": "code required"}), 400 + + d = read_decisions() + + # 同一股票旧决策标记为superseded + for e in d["decisions"]: + if e["code"] == code and e.get("status") in ("active", "updated"): + e["status"] = "superseded" + + entry = { + "code": code, + "name": data.get("name", ""), + "price": data.get("price", 0), + "action": data.get("action", ""), + "stop_loss": data.get("stop_loss"), + "take_profit": data.get("take_profit"), + "entry_low": data.get("entry_low"), + "entry_high": data.get("entry_high"), + "tech_snapshot": data.get("tech_snapshot", ""), + "timing_signal": data.get("timing_signal", ""), + "rr_ratio": data.get("rr_ratio"), + "tag": data.get("tag", ""), + "note": data.get("note", ""), + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + "updated_reason": data.get("updated_reason", ""), + "status": "updated", + "changelog": data.get("changelog", []), + "execution": data.get("execution", {"status": "none"}), + "analysis": data.get("analysis", {}), + } + d["decisions"].append(entry) + _save_decision(code, entry.get('name',''), entry) + return jsonify({"status": "ok", "entry": entry}) + + +@app.route("/api/decisions/tag", methods=["POST"]) +def set_decision_tag(): + """设置/清除某只股票的推荐标签""" + data = request.get_json(force=True) or {} + code = data.get("code", "") + tag = data.get("tag", "") # 'current_recommend', 'active_manual', or '' to clear + if not code: + return jsonify({"status": "error", "message": "code required"}), 400 + + d = read_decisions() + found = False + for e in d.get("decisions", []): + if e.get("code") == code: + e["tag"] = tag + e["tag_updated"] = datetime.now().isoformat() + found = True + break + + if not found: + return jsonify({"status": "error", "message": f"stock {code} not found"}), 404 + + _save_decision(code, e.get('name',''), e) + return jsonify({"status": "ok", "code": code, "tag": tag}) + + +@app.route("/api/decisions/pending") +def get_pending_decisions(): + """返回所有有未确认建议的条目""" + d = read_decisions() + pending = [] + for entry in d["decisions"]: + timeline = entry.get("advice_timeline", []) + unconfirmed = [a for a in timeline if a.get("status") in (None, "pending")] + if unconfirmed: + pending.append({ + "code": entry["code"], + "name": entry["name"], + "current": entry.get("current", ""), + "pending_advice": unconfirmed, + }) + return jsonify(pending) + + +@app.route("/api/advice/record", methods=["POST"]) +def record_advice(): + """记录一条分析建议,自动去重(相同code+同天+同方向=跳过)""" + data = request.get_json(force=True) or {} + code = data.get("code", "") + if not code: + return jsonify({"status": "error", "message": "code required"}), 400 + + direction = data.get("direction", "持有") + today = datetime.now().strftime("%Y-%m-%d") + + d = read_decisions() + + entry = None + for e in d["decisions"]: + if e["code"] == code and e["status"] in ("active", "updated"): + entry = e + break + + if not entry: + return jsonify({"status": "error", "message": f"no active decision for {code}"}), 404 + + timeline = entry.setdefault("advice_timeline", []) + + # 去重:同一天+同方向+摘要前40字相似 → 跳过 + summary_short = (data.get("summary", "") or "")[:40] + for a in timeline: + a_date = a.get("date", "")[:10] + a_dir = a.get("direction", "") + a_summary = (a.get("summary", "") or "")[:40] + if a_date == today and a_dir == direction and a_summary == summary_short: + return jsonify({"status": "skipped", "reason": "duplicate", "advice": a}) + + advice = { + "date": datetime.now().strftime("%Y-%m-%d %H:%M"), + "direction": direction, + "price": data.get("price", ""), + "summary": data.get("summary", ""), + "status": "pending", + } + timeline.append(advice) + _save_decision(code, entry.get('name',''), entry) + return jsonify({"status": "ok", "advice": advice}) + + +@app.route("/api/advice/confirm", methods=["POST"]) +def confirm_advice(): + """确认/忽略/标记已执行""" + data = request.get_json(force=True) or {} + code = data.get("code", "") + idx = data.get("index", -1) + action = data.get("action", "confirmed") # confirmed | ignored | executed + result = data.get("result", "") + + d = read_decisions() + for e in d["decisions"]: + if e["code"] == code and e["status"] == "active": + timeline = e.get("advice_timeline", []) + if 0 <= idx < len(timeline): + timeline[idx]["status"] = action + if action == "executed": + timeline[idx]["evaluated"] = True + timeline[idx]["evaluated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + if result: + timeline[idx]["result"] = result + _save_decision(code, e.get('name',''), e) + return jsonify({"status": "ok"}) + return jsonify({"status": "error", "message": "not found"}), 404 + + +# ── 准确率统计API ── +@app.route("/api/stats/accuracy") +def get_accuracy_stats(): + data = _load_json(DATA_DIR / "accuracy_stats.json", {}) + return jsonify(data) + + +# ── 策略评估API ── +@app.route("/api/evaluation") +def get_evaluation(): + """返回所有策略的双维度评估结果""" + # 主数据源:evaluation.json + eval_data = _load_json(DATA_DIR / "evaluation.json", {}) + strategies = eval_data.get("strategies", []) + if strategies: + return jsonify(strategies) + + # 备选:从 decisions.json 的 evaluation 字段读取(尚未反写时的兼容) + decisions = read_decisions() + evals = [] + for d in decisions.get("decisions", []): + e = d.get("evaluation", []) + if e: + evals.append({ + "code": d["code"], + "name": d["name"], + "type": d.get("type", ""), + "current": d.get("current", ""), + "evaluations": e, + }) + return jsonify(evals) + + +@app.route("/api/evaluation/trigger", methods=["POST"]) +def trigger_evaluation(): + """手动触发策略评估""" + import subprocess + try: + r = subprocess.run( + ["python3", str(DATA_DIR.parent / "strategy_evaluator.py")], + capture_output=True, timeout=60, text=True, + ) + return jsonify({"status": "ok", "output": r.stdout, "error": r.stderr}) + except Exception as e: + return jsonify({"status": "error", "message": str(e)}), 500 + + +# ── 策略反馈API ── +@app.route("/api/feedback") +def get_feedback(): + data = _load_json(DATA_DIR / "strategy_feedback.json", {}) + return jsonify(data) + + +# ── 持仓截图上传与解析 ──────────────────────────────── + + +@app.route("/upload") +def upload_page(): + return send_from_directory(app.static_folder, "upload.html") + + +def _ocr_image(image_path): + """优先用小果GLM-OCR-8bit识别,失败则降级到pytesseract""" + import sys + from PIL import Image, ImageEnhance, ImageFilter + import pytesseract + + # 尝试小果OCR(GLM-OCR-8bit) + try: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "scripts")) + from ocr_client import ocr_image as xg_ocr + result = xg_ocr(image_path, "请识别这张图片中所有文字,包括股票名称、代码、价格、持股数、金额、百分比等。输出完整内容。") + if result.get("success") and len(result.get("text", "")) > 20: + return result["text"].strip() + except Exception: + pass # 降级到tesseract + + # 降级:Tesseract(预处理优化中文表格识别) + img = Image.open(image_path) + + # 预处理:放大 + 锐化 + 二值化,提升小字识别率 + w, h = img.size + if w < 2000 or h < 2000: + scale = max(2, 2000 // min(w, h)) + img = img.resize((w * scale, h * scale), Image.LANCZOS) + + # 转灰度 + img = img.convert("L") + + # 增强对比度 + enhancer = ImageEnhance.Contrast(img) + img = enhancer.enhance(2.0) + + # 锐化 + img = img.filter(ImageFilter.SHARPEN) + + # 二值化(自适应阈值) + threshold = 128 + img = img.point(lambda x: 255 if x > threshold else 0) + + # OCR:chip_sim+eng,PSM 6(统一文本块) + text = pytesseract.image_to_string( + img, + lang="chi_sim+eng", + config="--psm 6 --oem 3", + ) + return text.strip() + + +ANALYZE_PROMPT = """你是股票持仓数据分析助手。以下是用户上传的持仓/自选截图经过OCR提取的文字,请从中提取所有股票信息。 + +判断这是「持仓截图」还是「自选截图」: +- 持仓截图:每支股票有"证券数量"(持股数)、成本价、盈亏 +- 自选截图:只有股票列表和价格,没有持股数/成本 + +股票代码格式: +- A股:6位数字(如 600519, 000858, 300750) +- 港股:纯数字代码(如 0700, 3690, 1211),不带HK前缀 + +⚠️ 重要:截图顶部通常有汇总数据,如总资产、股票市值、可用资金、当日盈亏等。 +如果OCR文字中有这些汇总数字,请一并提取到JSON的summary字段中。 +不要自己计算汇总值,直接从OCR原文中提取。 + +请严格按照以下JSON格式回复,只输出JSON: + +```json +{ + "type": "portfolio" 或 "watchlist", + "summary": { + "total_assets": "总资产数字(可选,从截图中提取)", + "stock_value": "股票市值/持仓市值数字(可选,从截图中提取)", + "cash": "可用资金/现金数字(可选,从截图中提取)", + "day_pnl": "当日盈亏金额(可选,从截图中提取)" + }, + "stocks": [ + { + "code": "股票代码", + "name": "股票名称(中文)", + "price": "现价(数字)", + "shares": "持股数量(数字,持仓截图才有)", + "cost": "成本价(数字,持仓截图才有)", + "pnl": "盈亏百分比如+15.1%(持仓截图才有)", + "position_pct": "仓位占比数字如12.5(可选)" + } + ] +} +``` + +OCR原文: +""" + + +@app.route("/api/upload/analyze", methods=["POST"]) +def upload_analyze(): + """接收图片,OCR提取文字 → LLM解析结构化数据""" + if "image" not in request.files: + return jsonify({"error": "请上传图片"}), 400 + + f = request.files["image"] + if not f.filename: + return jsonify({"error": "空文件"}), 400 + + # 保存到临时目录 + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + ext = Path(f.filename).suffix or ".png" + save_path = UPLOAD_DIR / f"{uuid.uuid4().hex}{ext}" + f.save(str(save_path)) + + try: + # 第一步:OCR提取文字 + raw_text = _ocr_image(str(save_path)) + if not raw_text: + return jsonify({"error": "OCR未识别到文字,请确认图片清晰"}), 400 + except Exception as e: + os.unlink(str(save_path)) + return jsonify({"error": f"OCR失败: {e}"}), 500 + + # 第二步:LLM解析结构化数据(走文本API,不走视觉) + llm_text = _llm_parse(raw_text, ANALYZE_PROMPT) + + os.unlink(str(save_path)) + + # 从LLM回复中提取JSON + json_match = re.search(r"```(?:json)?\s*({.*?})\s*```", llm_text, re.DOTALL) + if json_match: + try: + parsed = json.loads(json_match.group(1)) + except json.JSONDecodeError: + return jsonify({"error": f"LLM解析JSON失败: {llm_text[:500]}"}), 500 + else: + # 尝试直接找JSON(没被代码块包裹) + try: + parsed = json.loads(llm_text) + except json.JSONDecodeError: + return jsonify({"error": f"未提取到结构化数据: {raw_text[:300]}...\n\nLLM回复: {llm_text[:500]}"}), 500 + + return jsonify(parsed) + + +def _llm_parse(text, prompt_template): + """发送OCR文本到Hermes LLM解析,返回JSON字符串""" + payload = json.dumps({ + "model": "hermes-agent", + "messages": [ + {"role": "system", "content": "你是一个数据提取助手。从OCR文字中提取结构化JSON数据。"}, + {"role": "user", "content": prompt_template + "\n" + text}, + ], + "max_tokens": 4096, + }).encode() + + req = urllib.request.Request(GATEWAY, data=payload, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Authorization", f"Bearer {API_KEY}") + req.add_header("X-Hermes-Session-Id", "upload-ocr-parse") + + try: + resp = urllib.request.urlopen(req, timeout=120) + data = json.loads(resp.read()) + return data.get("choices", [{}])[0].get("message", {}).get("content", "") + except Exception as e: + return f"ERROR: {e}" + + +@app.route("/api/upload/confirm", methods=["POST"]) +def upload_confirm(): + """确认解析结果,更新数据文件""" + data = request.get_json(force=True) + stocks = data.get("stocks", []) + doc_type = data.get("type", "portfolio") + + # 尝试获取实时行情补充数据 + try: + codes = [s["code"] for s in stocks if s.get("code")] + if codes: + # DB 优先(price_monitor 维护的实时价) + db_prices = {} + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + for code in codes: + row = db.execute("SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() + if row and row['price']: + db_prices[code] = (row['price'], row['change_pct'] or 0) + db.close() + except Exception: + pass + + # Fallback: 腾讯 API + need_tencent = [c for c in codes if c not in db_prices] + if need_tencent: + qs = " ".join( + f"hk{c}" if len(c) == 5 + else f"sz{c}" if c.startswith("0") or c.startswith("3") + else f"sh{c}" if c.startswith("6") + else f"hk{c}" + for c in need_tencent + ) + url = f"https://qt.gtimg.cn/q={qs}" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + resp = urllib.request.urlopen(req, timeout=10) + qt_text = resp.read().decode("gbk", errors="replace") + # 优先 DB 价格,再补腾讯 + for stock in stocks: + code = stock.get("code", "") + if code in db_prices: + if not stock.get("price"): + stock["price"] = db_prices[code][0] + elif need_tencent and code in need_tencent: + prefix = "hk" if len(code) == 5 else "sz" if code.startswith(("0","3")) else "sh" if code.startswith("6") else "hk" + m = re.search(rf'{prefix}{code}="([^"]+)"', qt_text) + if m: + fields = m.group(1).split('~') + if not stock.get("name"): + stock["name"] = fields[1] + if not stock.get("price"): + stock["price"] = fields[3] + except: + pass # 行情获取失败不影响主流程 + + # 更新对应数据文件 + if doc_type == "portfolio": + existing = read_portfolio() + old_holdings = {h["code"]: h for h in existing.get("holdings", []) if h.get("code")} + new_holdings = [] + for s in stocks: + code = s.get("code", "") + old = old_holdings.get(code, {}) + new_shares = int(s["shares"]) if str(s.get("shares", "")).lstrip('-').isdigit() else old.get("shares", 0) + old_shares = old.get("shares", 0) + # 股数突变检测:旧200→新0是合理卖出,但旧0→新200可能是OCR错读 + if old_shares > 0 and new_shares == 0 and old_shares != new_shares: + print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}→{new_shares} (卖出清仓)") + elif abs(new_shares - old_shares) > max(old_shares * 0.5, 100) and old_shares > 0: + print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}→{new_shares} (变动较大)") + new_holdings.append({ + "code": code, + "name": s.get("name") or old.get("name", ""), + "shares": new_shares, + "price": float(s.get("price", 0)) or old.get("price", 0), + "cost": float(s.get("cost", 0)) if s.get("cost") else old.get("cost", 0), + "pnl": s.get("pnl") or old.get("pnl", ""), + "position_pct": float(s.get("position_pct", 0)) if s.get("position_pct") else old.get("position_pct", 0), + "change_pct": old.get("change_pct", 0), + }) + existing["holdings"] = new_holdings + + # 使用截图中的汇总数据(优先),没有则用旧数据 + summary = data.get("summary", {}) + if summary.get("stock_value"): + existing["stock_value"] = float(summary["stock_value"]) + else: + existing["stock_value"] = round( + sum(h["shares"] * h["price"] for h in existing["holdings"]), 2 + ) + if summary.get("cash"): + existing["cash"] = float(summary["cash"]) + if summary.get("total_assets"): + existing["total_assets"] = float(summary["total_assets"]) + else: + # Use unified formula (includes frozen_cash) + from mo_models import calc_total_assets + existing["total_assets"] = calc_total_assets(existing) + if summary.get("day_pnl"): + existing["day_pnl"] = float(summary["day_pnl"]) + existing["updated_at"] = datetime.now().isoformat() + # 计算仓位% + if existing["total_assets"] > 0: + existing["position_pct"] = round(existing["stock_value"] / existing["total_assets"] * 100, 2) + _save_portfolio(existing) + msg = f"更新了 {len(stocks)} 只持仓股" + + elif doc_type == "watchlist": + existing = read_watchlist() + existing["stocks"] = [ + { + "code": s.get("code", ""), + "name": s.get("name", ""), + "price": float(s.get("price", 0)) if s.get("price") else 0, + } + for s in stocks + ] + existing["updated_at"] = datetime.now().isoformat() + _save_watchlist(existing) + msg = f"更新了 {len(stocks)} 只自选股" + + else: + return jsonify({"error": f"未知类型: {doc_type}"}), 400 + + return jsonify({"status": "ok", "message": msg}) + + +# ── TDX中继实时行情接收API ── +@app.route("/api/update/realtime", methods=["POST"]) +def update_realtime(): + """接收小小莫中继的实时行情数据""" + data = request.get_json(force=True) or {} + stocks = data.get("stocks", []) + source = data.get("source", "unknown") + + if not stocks: + return jsonify({"status": "error", "message": "没有股票数据"}), 400 + + # 更新 portfolio.json 中的实时价格(change_pct字段) + pf = read_portfolio() + pf_holdings = {h["code"]: h for h in pf.get("holdings", [])} + + updated = 0 + for s in stocks: + code = s.get("code", "") + if code in pf_holdings: + pf_holdings[code]["price"] = float(s.get("price", pf_holdings[code].get("price", 0))) + pf_holdings[code]["change_pct"] = float(s.get("change_pct", 0)) + pf_holdings[code]["high"] = float(s.get("high", 0)) + pf_holdings[code]["low"] = float(s.get("low", 0)) + pf_holdings[code]["open"] = float(s.get("open", 0)) + pf_holdings[code]["volume"] = int(s.get("volume", 0)) + pf_holdings[code]["data_source"] = source + pf_holdings[code]["updated_at"] = datetime.now().isoformat() + updated += 1 + + # 也更新 watchlist.json + wl = read_watchlist() wl_stocks = {s["code"]: s for s in wl.get("stocks", [])} - - for s in stocks: - code = s.get("code", "") - if code in wl_stocks: - wl_stocks[code]["price"] = float(s.get("price", wl_stocks[code].get("price", 0))) - wl_stocks[code]["change_pct"] = float(s.get("change_pct", 0)) - - pf["updated_at"] = datetime.now().isoformat() - wl["updated_at"] = datetime.now().isoformat() - _save_json(DATA_DIR / "portfolio.json", pf) - _save_json(DATA_DIR / "watchlist.json", wl) - - return jsonify({ - "status": "ok", - "updated": updated, - "source": source, - "timestamp": datetime.now().isoformat(), - }) - - -# 注册提示词管理路由 -register_routes(app) - - -if __name__ == "__main__": - port = int(os.environ.get("PORT", 8899)) - print(f"🚀 MoFin Dashboard → http://0.0.0.0:{port}") + + for s in stocks: + code = s.get("code", "") + if code in wl_stocks: + wl_stocks[code]["price"] = float(s.get("price", wl_stocks[code].get("price", 0))) + wl_stocks[code]["change_pct"] = float(s.get("change_pct", 0)) + + pf["updated_at"] = datetime.now().isoformat() + wl["updated_at"] = datetime.now().isoformat() + _save_portfolio(pf) + _save_watchlist(wl) + + return jsonify({ + "status": "ok", + "updated": updated, + "source": source, + "timestamp": datetime.now().isoformat(), + }) + + +# 注册提示词管理路由 +register_routes(app) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 8899)) + print(f"🚀 MoFin Dashboard → http://0.0.0.0:{port}") app.run(host="0.0.0.0", port=port, debug=False) \ No newline at end of file diff --git a/strategy_evaluator.py b/strategy_evaluator.py index fe49b07..055c19e 100644 --- a/strategy_evaluator.py +++ b/strategy_evaluator.py @@ -1,563 +1,566 @@ -#!/usr/bin/env python3 -"""strategy_evaluator.py — 策略双维度评估引擎 - -两阶段评估模型: - -阶段一(策略制定→价格达标): - 理论:策略设定的买入区/止损/止盈 → 股价是否达到过这些价位 → 理论盈亏 - 实际:老爸是否按策略执行 → 实际买入/卖出价格 → 实际盈亏 - -阶段二(价格回落后→新止损验证): - 理论:价格未按预期走 → 给出新止损 → 股价是否继续下跌验证止损正确性 - 实际:老爸实际卖出价格 → 对比新止损 → 验证止损有效性 - -输出:写入 decisions.json 的 evaluation 字段 + accuracy_stats.json -""" -import json -import urllib.request -import os -import sys -import re -from datetime import datetime, timedelta -from pathlib import Path - -DATA_DIR = Path(__file__).parent / "data" -DECISIONS_PATH = DATA_DIR / "decisions.json" -PORTFOLIO_PATH = DATA_DIR / "portfolio.json" -ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" - -UA = "Mozilla/5.0" - - -def load_json(path, default=None): - try: - with open(path, encoding="utf-8") as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} if default is None else default - - -def save_json(path, data): - Path(path).parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def fetch_prices(codes): - """批量拉价格。DB 优先(price_monitor 维护),腾讯 API fallback""" - if not codes: - return {} - - # 主通道: DB - try: - from mofin_db import get_prices_batch_from_db - db_results = get_prices_batch_from_db(codes) - if db_results: - return {code: {"name": "", "price": p, "prev_close": 0, "change_pct": chg or 0, - "high": 0, "low": 0} for code, (p, chg) in db_results.items()} - except Exception: - pass - - # Fallback: 腾讯 API - symbols = [] - code_map = {} - for c in codes: - sym = f"hk{c}" if len(c) == 5 else f"sh{c}" if c.startswith(("5", "6", "9")) else f"sz{c}" - symbols.append(sym) - code_map[sym] = c - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - resp = urllib.request.urlopen(req, timeout=10) - text = resp.read().decode("gbk") - except Exception as e: - print(f"行情拉取失败: {e}", file=sys.stderr) - return {} - prices = {} - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw.split("~") - if len(fields) < 33: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig = code_map.get(sym) - if not orig: - continue - prices[orig] = { - "name": fields[1], - "price": float(fields[3]) if fields[3] else 0, - "prev_close": float(fields[4]) if fields[4] else 0, - "change_pct": float(fields[32]) if fields[32] else 0, - "high": float(fields[33]) if fields[33] else 0, - "low": float(fields[34]) if fields[34] else 0, - } - return prices - - -def parse_tech_snapshot(decision): - """ - 从 decision 的 tech_snapshot 中提取技术面参数。 - 返回 dict: { 'pattern', 'volume', '强撑', '弱撑', '弱压', '强压' } - """ - trig = decision.get("trigger", {}) - raw = trig.get("tech_snapshot", decision.get("tech_snapshot", "")) - result = {} - if not raw: - return result - # 形态:XXX/bullish 或 形态:XXX/neutral - m = re.search(r'形态:([^\s]+)', raw) - if m: - result["pattern"] = m.group(1) - # 量价:XXX - m = re.search(r'量价:([^\s]+)', raw) - if m: - result["volume"] = m.group(1) - # 强撑:N / 弱撑:N / 弱压:N / 强压:N - for key in ["强撑", "弱撑", "弱压", "强压"]: - m = re.search(rf'{key}:([\d.]+)', raw) - if m: - result[key] = float(m.group(1)) - return result - - -def build_strategy_rationale(sl_p, tp_p, tech, decision, actual_pnl_pct): - """ - 基于 tech_snapshot 的支撑/压力位,解释止损和止盈的设定依据。 - 返回 dict: - - sl_basis: 止损设定依据(对应哪个支撑位) - - tp_basis: 止盈设定依据(对应哪个压力位) - - analysis: 综合技术面文字分析 - - tech_used: 实际使用的 tech 参数 - """ - code = decision.get("code", "") - name = decision.get("name", code) - rationale = { - "sl_basis": "未设定", - "tp_basis": "未设定", - "analysis": "", - "tech_used": tech, - } - - # 解析支撑/压力 - 强撑 = tech.get("强撑") - 弱撑 = tech.get("弱撑") - 弱压 = tech.get("弱压") - 强压 = tech.get("强压") - 形态 = tech.get("pattern", "无记录") - 量价 = tech.get("volume", "数据不足") - - # ---- 止损依据 ---- - if sl_p and 强撑 and 弱撑: - # 止损在强撑附近 (±1%) - if abs(sl_p - 强撑) / 强撑 < 0.02: - rationale["sl_basis"] = f"技术面强支撑{强撑}(距{abs(sl_p-强撑)/强撑*100:.1f}%)" - # 止损在弱撑附近 - elif abs(sl_p - 弱撑) / 弱撑 < 0.02: - rationale["sl_basis"] = f"技术面弱支撑{弱撑}(距{abs(sl_p-弱撑)/弱撑*100:.1f}%)" - # 止损在强撑和弱撑之间 - elif 强撑 < sl_p < 弱撑: - diff_down = (sl_p - 强撑) / 强撑 * 100 - diff_up = (弱撑 - sl_p) / sl_p * 100 - rationale["sl_basis"] = f"技术面强撑{强撑}-弱撑{弱撑}之间(比强撑高{diff_down:.1f}%,比弱撑低{diff_up:.1f}%)" - # 止损低于强撑(宽止损,多见于深套) - elif sl_p < 强撑: - diff = (强撑 - sl_p) / sl_p * 100 - actual = actual_pnl_pct if actual_pnl_pct else 0 - if actual < -20: - rationale["sl_basis"] = f"低于技术面强撑{强撑}{diff:.1f}%(深套宽止损)" - else: - rationale["sl_basis"] = f"低于技术面强撑{强撑}{diff:.1f}%(宽止损)" - # 止损高于弱撑(紧止损) - elif sl_p > 弱撑: - rationale["sl_basis"] = f"高于弱撑{弱撑}(紧止损)" - elif sl_p and 强撑 and not 弱撑: - if abs(sl_p - 强撑) / 强撑 < 0.02: - rationale["sl_basis"] = f"技术面强支撑{强撑}" - else: - rationale["sl_basis"] = f"参考强撑{强撑}调整至{sl_p}" - elif sl_p: - rationale["sl_basis"] = f"直接设定为{sl_p}(无技术面支撑位参考)" - - # ---- 止盈依据 ---- - if tp_p and 强压: - if abs(tp_p - 强压) / 强压 < 0.03: - rationale["tp_basis"] = f"技术面强压力{强压}(距{abs(tp_p-强压)/强压*100:.1f}%)" - elif tp_p > 强压: - diff = (tp_p - 强压) / 强压 * 100 - rationale["tp_basis"] = f"技术面强压{强压}上方{diff:.1f}%(趋势延伸目标)" - elif 弱压 and 弱压 < tp_p < 强压: - rationale["tp_basis"] = f"技术面弱压{弱压}-强压{强压}之间" - elif 弱压 and tp_p <= 弱压: - rationale["tp_basis"] = f"接近弱压{弱压}(保守目标)" - else: - rationale["tp_basis"] = f"参考强压{强压}调整至{tp_p}" - elif tp_p and not 强压: - rationale["tp_basis"] = f"直接设定为{tp_p}(无技术面压力位参考)" - elif not tp_p: - rationale["tp_basis"] = "未设定止盈价" - - # ---- 综合技术面分析 ---- - parts = [] - if 形态: - parts.append(f"K线形态:{形态}") - if 量价: - parts.append(f"量价:{量价}") - if 强撑 or 弱撑 or 弱压 or 强压: - levels = [] - if 强撑: levels.append(f"强撑{强撑}") - if 弱撑: levels.append(f"弱撑{弱撑}") - if 弱压: levels.append(f"弱压{弱压}") - if 强压: levels.append(f"强压{强压}") - parts.append("技术位:" + "/".join(levels)) - rationale["analysis"] = " | ".join(parts) - - return rationale - - -def evaluate_phase1(decision, price_info, holding): - """ - 阶段一评估:策略制定→价格是否达到过目标价位 - 返回 evaluation dict - """ - trig = decision.get("trigger", {}) - code = decision["code"] - name = decision.get("name", code) - price = price_info.get("price", 0) - change = price_info.get("change_pct", 0) - - # 策略区间 — 支持两种数据格式: - # 1) trigger 子对象(含 entry_zone/stop_loss/take_profit) - # 2) 顶层字段(entry_low+entry_high / stop_loss / take_profit) - el = trig.get("entry_zone", "") - sl = trig.get("stop_loss", "") - tp = trig.get("take_profit", "") - if not el and decision.get("entry_low") is not None: - el_low = decision.get("entry_low") - el_high = decision.get("entry_high") - el = f"{el_low}~{el_high}" if el_low is not None and el_high is not None else "" - if not sl: - sl = decision.get("stop_loss", "") - if not tp: - tp = decision.get("take_profit", "") - - el_low = el_high = None - if el and "~" in str(el): - try: - parts = str(el).split("~") - el_low, el_high = float(parts[0]), float(parts[1]) - except: - pass - sl_p = float(sl) if sl else None - tp_p = float(tp) if tp else None - - # 持仓信息 - cost = holding.get("cost", 0) if holding else 0 - shares = holding.get("shares", 0) if holding else 0 - position_pct = holding.get("position_pct", 0) if holding else 0 - - # 理论盈亏计算(基于策略区间中值) - entry_mid = (el_low + el_high) / 2 if el_low and el_high else price - theoretical_pnl_pct = (tp_p - entry_mid) / entry_mid * 100 if tp_p and entry_mid else 0 - theoretical_pnl_amount = theoretical_pnl_pct / 100 * entry_mid * (shares or 100) / 100 if shares else 0 - - # 实际盈亏 - actual_pnl_pct = (price - cost) / cost * 100 if cost > 0 and price > 0 else 0 - actual_pnl_amount = actual_pnl_pct / 100 * cost * shares if cost > 0 and shares > 0 else 0 - - # === 策略依据分析(2026-06-18 新增)=== - tech = parse_tech_snapshot(decision) - rationale = build_strategy_rationale(sl_p, tp_p, tech, decision, actual_pnl_pct) - - # === R/R 盈亏比计算(2026-06-18 新增)=== - # 以现价为基准计算:向下风险(到止损)vs 向上空间(到止盈) - # 止损价作为风险基准,止盈价作为收益目标 - rr = None - rr_risk_pct = None - rr_reward_pct = None - rr_interpretation = "" - rr_level = "" - if sl_p and tp_p and price > 0 and sl_p > 0 and price > sl_p: - rr_risk_pct = round((price - sl_p) / price * 100, 2) # 距止损% - rr_reward_pct = round((tp_p - price) / price * 100, 2) # 距止盈% - rr = round(rr_reward_pct / rr_risk_pct, 2) if rr_risk_pct > 0 else None - # 判断场景:深套/已持仓盈利/已持仓亏损/新买入 - is_deep_loss = actual_pnl_pct < -20 - has_profit = actual_pnl_pct >= 0 - held = (cost > 0 and shares > 0) - if not held: - # 新买入/自选股场景 - if rr is not None and rr < 1.5: - rr_level = "⚠️盈亏比不足" - rr_interpretation = f"新买入要求R/R≥1.5,现{rr}每亏1元仅赚{rr}元,不建议买入" - elif rr is not None and rr < 2.0: - rr_level = "⚠️盈亏比偏低" - rr_interpretation = f"新买入要求R/R≥1.5,现{rr}每亏1元赚{rr}元,谨慎" - else: - rr_level = "R/R达标" - rr_interpretation = f"每亏1元赚{rr}元,盈亏比合理" - elif is_deep_loss: - # 深套场景 — 不限R/R - rr_level = "深套持有" - rr_interpretation = f"浮亏{actual_pnl_pct:.1f}%>20%深套,R/R={rr}仅参考,不补不割等反弹" - elif has_profit: - # 已持仓盈利场景 - if rr is not None and rr < 0.5: - rr_level = "⚠️R/R极低" - rr_interpretation = f"盈利持仓R/R={rr},每亏1元仅赚{rr}元,考虑止盈或上移止损保护利润" - elif rr is not None and rr < 1.5: - rr_level = "⚠️R/R偏低" - rr_interpretation = f"盈利持仓R/R={rr},不建议加仓,当前仓位持有观察" - else: - rr_level = "R/R合理" - rr_interpretation = f"盈利持仓R/R={rr},每亏1元赚{rr}元,持有合理" - else: - # 已持仓浮亏(但非深套) - if rr is not None and rr < 0.5: - rr_level = "⚠️R/R极低" - rr_interpretation = f"浮亏持仓R/R={rr},每亏1元仅赚{rr}元,不建议加仓,关注止损" - elif rr is not None and rr < 1.0: - rr_level = "⚠️R/R不足" - rr_interpretation = f"浮亏持仓要求加仓R/R≥1.0,现{rr},不加仓" - else: - rr_level = "R/R可接受" - rr_interpretation = f"浮亏持仓R/R={rr},每亏1元赚{rr}元,持有等反弹" - elif price and sl_p and price <= sl_p: - rr_level = "已跌破止损" - rr_interpretation = f"现价{price}已破止损{sl_p},R/R不适用" - else: - rr_interpretation = "止损或止盈缺失,无法计算R/R" - - # 当前状态判断 - status = "safe" - if sl_p and price > 0 and price <= sl_p: - status = "stop_loss_hit" - elif tp_p and price > 0 and price >= tp_p: - status = "take_profit_hit" - elif el_low and el_high and price > 0 and el_low <= price <= el_high: - status = "in_entry_zone" - elif el_low and price > 0 and price < el_low: - status = "below_entry" - elif el_high and price > 0 and price > el_high: - status = "above_entry" - - # 理论阶段评估 - theoretical = { - "entry_zone": f"{el_low}~{el_high}" if el_low else "N/A", - "stop_loss": sl_p, - "take_profit": tp_p, - "entry_mid_price": round(entry_mid, 2), - "target_price": tp_p, - "theoretical_pnl_pct": round(theoretical_pnl_pct, 2), - "theoretical_pnl_amount": round(theoretical_pnl_amount, 2), - "status": status, - "current_price": price, - "current_change_pct": change, - "rr": rr, - "rr_risk_pct": rr_risk_pct, - "rr_reward_pct": rr_reward_pct, - "rr_level": rr_level, - "sl_basis": rationale["sl_basis"], - "tp_basis": rationale["tp_basis"], - "tech_analysis": rationale["analysis"], - } - - # 实际阶段评估 - actual = { - "cost_price": cost, - "shares": shares, - "position_pct": position_pct, - "actual_pnl_pct": round(actual_pnl_pct, 2), - "actual_pnl_amount": round(actual_pnl_amount, 2), - "status": status, - "current_price": price, - } - - return { - "code": code, - "name": name, - "evaluated_at": datetime.now().isoformat(), - "phase": 1, - "theoretical": theoretical, - "actual": actual, - "rr_level": rr_level, - "rr_interpretation": rr_interpretation, - "strategy_rationale": { - "sl_basis": rationale["sl_basis"], - "tp_basis": rationale["tp_basis"], - "tech_analysis": rationale["analysis"], - }, - "summary": f"{name}({code}) | 损{sl_p}({rationale['sl_basis']})/" - f"盈{tp_p}({rationale['tp_basis']}) | " - f"现价{price}({change:+.2f}%) | " - f"距损{rr_risk_pct}%/距盈{rr_reward_pct}% | RR={rr} | " - f"{rr_level} | 理{theoretical_pnl_pct:+.1f}%实{actual_pnl_pct:+.1f}%", - } - - -def evaluate_phase2(decision, price_info, holding, prev_eval): - """ - 阶段二评估:价格回落后→新止损验证 - 需要 prev_eval 中记录了之前的目标价和新止损价 - """ - trig = decision.get("trigger", {}) - code = decision["code"] - name = decision.get("name", code) - price = price_info.get("price", 0) - - sl = trig.get("stop_loss", "") - if not sl: - sl = decision.get("stop_loss", "") - sl_p = float(sl) if sl else None - - # 从 prev_eval 中获取阶段一的止损 - prev_sl = None - if prev_eval: - prev_sl = prev_eval.get("theoretical", {}).get("stop_loss") - - # 检查新止损是否被跌破 - new_sl_hit = False - days_to_hit = None - if sl_p and price > 0 and price <= sl_p: - new_sl_hit = True - # 无法精确知道多少天跌破,标记为当前 - days_to_hit = 0 - - result = { - "code": code, - "name": name, - "evaluated_at": datetime.now().isoformat(), - "phase": 2, - "new_stop_loss": sl_p, - "previous_stop_loss": prev_sl, - "current_price": price, - "new_sl_hit": new_sl_hit, - "days_to_hit": days_to_hit, - "summary": f"{name}({code}) 新止损{sl_p} {'已跌破' if new_sl_hit else '未触及'} 现价{price}", - } - return result - - -def run(): - decisions = load_json(DECISIONS_PATH, {"decisions": []}) - portfolio = load_json(PORTFOLIO_PATH, {"holdings": []}) - holdings_map = {h["code"]: h for h in portfolio.get("holdings", [])} - - # 收集所有代码 - all_codes = [d["code"] for d in decisions["decisions"]] - prices = fetch_prices(all_codes) - - results = [] - stats = { - "phase1_correct": 0, "phase1_wrong": 0, "phase1_pending": 0, - "phase2_correct": 0, "phase2_wrong": 0, "phase2_pending": 0, - } - - for d in decisions["decisions"]: - code = d["code"] - pi = prices.get(code, {}) - h = holdings_map.get(code) - - # 获取已有的 evaluation 记录 - existing_eval = d.get("evaluation", []) - - # 阶段一评估 - eval1 = evaluate_phase1(d, pi, h) - results.append(eval1) - - # 阶段二评估(如果有前次止损记录) - prev_eval = existing_eval[-1] if existing_eval else None - if prev_eval and prev_eval.get("phase") == 1: - eval2 = evaluate_phase2(d, pi, h, prev_eval) - results.append(eval2) - - # 更新 decisions.json 的 evaluation 字段 - d["evaluation"] = [e for e in [eval1] + ([eval2] if prev_eval and prev_eval.get("phase") == 1 else [])] - - # 保存更新 - save_json(DECISIONS_PATH, decisions) - - # 汇总统计 - for r in results: - phase = r["phase"] - if phase == 1: - status = r["theoretical"]["status"] - if status in ("take_profit_hit",): - stats["phase1_correct"] += 1 - elif status in ("stop_loss_hit",): - stats["phase1_wrong"] += 1 - else: - stats["phase1_pending"] += 1 - elif phase == 2: - if r.get("new_sl_hit"): - stats["phase2_correct"] += 1 - else: - stats["phase2_pending"] += 1 - - # 写入 accuracy_stats - accuracy = { - "updated_at": datetime.now().isoformat(), - "phase1": { - "correct": stats["phase1_correct"], - "wrong": stats["phase1_wrong"], - "pending": stats["phase1_pending"], - "accuracy_pct": round(stats["phase1_correct"] / max(stats["phase1_correct"] + stats["phase1_wrong"], 1) * 100, 1), - }, - "phase2": { - "correct": stats["phase2_correct"], - "wrong": stats["phase2_wrong"], - "pending": stats["phase2_pending"], - "accuracy_pct": round(stats["phase2_correct"] / max(stats["phase2_correct"] + stats["phase2_wrong"], 1) * 100, 1), - }, - "total_evaluated": len(results), - "details": [r["summary"] for r in results], - } - save_json(ACCURACY_PATH, accuracy) - - # 输出报告 - print("=" * 70) - print(f"策略双维度评估报告 | {datetime.now().strftime('%Y-%m-%d %H:%M')}") - print("=" * 70) - - print(f"\n📊 阶段一(策略制定→价格达标)") - print(f" 正确(达到止盈): {stats['phase1_correct']}") - print(f" 错误(跌破止损): {stats['phase1_wrong']}") - print(f" 待验证: {stats['phase1_pending']}") - print(f" 准确率: {accuracy['phase1']['accuracy_pct']}%") - - print(f"\n📊 阶段二(价格回落→新止损验证)") - print(f" 正确(新止损验证有效): {stats['phase2_correct']}") - print(f" 错误: {stats['phase2_wrong']}") - print(f" 待验证: {stats['phase2_pending']}") - - print(f"\n📋 逐股评估:") - for r in results: - if r["phase"] == 1: - print(f" {r['summary']}") - if r.get("rr_interpretation"): - print(f" RR: {r['rr_interpretation']}") - sr = r.get("strategy_rationale", {}) - if sr.get("tech_analysis"): - print(f" 技术:{sr['tech_analysis']}") - else: - print(f" {r['summary']}") - - # R/R 统计 - rr_count = sum(1 for r in results if r.get("rr_level") and r["phase"] == 1) - rr_warn = sum(1 for r in results if "⚠️" in r.get("rr_level", "") and r["phase"] == 1) - print(f"\n📊 盈亏比R/R统计:") - print(f" 有R/R评估: {rr_count}只 | ⚠️异常: {rr_warn}只") - for r in results: - if r["phase"] == 1 and r.get("rr_level"): - level = r["rr_level"] - if "⚠️" in level: - name_code = r['summary'].split('|')[0].strip() - print(f" ⚠️ {name_code} → {level} | {r['rr_interpretation']}") - - print(f"\n✅ 评估完成,已写入 decisions.json 和 accuracy_stats.json") - - -if __name__ == "__main__": - run() +#!/usr/bin/env python3 +"""strategy_evaluator.py — 策略双维度评估引擎 + +两阶段评估模型: + +阶段一(策略制定→价格达标): + 理论:策略设定的买入区/止损/止盈 → 股价是否达到过这些价位 → 理论盈亏 + 实际:老爸是否按策略执行 → 实际买入/卖出价格 → 实际盈亏 + +阶段二(价格回落后→新止损验证): + 理论:价格未按预期走 → 给出新止损 → 股价是否继续下跌验证止损正确性 + 实际:老爸实际卖出价格 → 对比新止损 → 验证止损有效性 + +输出:写入 decisions.json 的 evaluation 字段 + accuracy_stats.json +""" +import json +import urllib.request +import os +import sys +import re +from datetime import datetime, timedelta +from pathlib import Path +from mo_data import read_decisions, read_portfolio +from mofin_db import get_conn, write_holding_strategy + +DATA_DIR = Path(__file__).parent / "data" +ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" + +UA = "Mozilla/5.0" + + +def load_json(path, default=None): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} if default is None else default + + +def save_json(path, data): + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def fetch_prices(codes): + """批量拉价格。DB 优先(price_monitor 维护),腾讯 API fallback""" + if not codes: + return {} + + # 主通道: DB + try: + from mofin_db import get_prices_batch_from_db + db_results = get_prices_batch_from_db(codes) + if db_results: + return {code: {"name": "", "price": p, "prev_close": 0, "change_pct": chg or 0, + "high": 0, "low": 0} for code, (p, chg) in db_results.items()} + except Exception: + pass + + # Fallback: 腾讯 API + symbols = [] + code_map = {} + for c in codes: + sym = f"hk{c}" if len(c) == 5 else f"sh{c}" if c.startswith(("5", "6", "9")) else f"sz{c}" + symbols.append(sym) + code_map[sym] = c + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + resp = urllib.request.urlopen(req, timeout=10) + text = resp.read().decode("gbk") + except Exception as e: + print(f"行情拉取失败: {e}", file=sys.stderr) + return {} + prices = {} + for line in text.strip().split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + raw = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw.split("~") + if len(fields) < 33: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + orig = code_map.get(sym) + if not orig: + continue + prices[orig] = { + "name": fields[1], + "price": float(fields[3]) if fields[3] else 0, + "prev_close": float(fields[4]) if fields[4] else 0, + "change_pct": float(fields[32]) if fields[32] else 0, + "high": float(fields[33]) if fields[33] else 0, + "low": float(fields[34]) if fields[34] else 0, + } + return prices + + +def parse_tech_snapshot(decision): + """ + 从 decision 的 tech_snapshot 中提取技术面参数。 + 返回 dict: { 'pattern', 'volume', '强撑', '弱撑', '弱压', '强压' } + """ + trig = decision.get("trigger", {}) + raw = trig.get("tech_snapshot", decision.get("tech_snapshot", "")) + result = {} + if not raw: + return result + # 形态:XXX/bullish 或 形态:XXX/neutral + m = re.search(r'形态:([^\s]+)', raw) + if m: + result["pattern"] = m.group(1) + # 量价:XXX + m = re.search(r'量价:([^\s]+)', raw) + if m: + result["volume"] = m.group(1) + # 强撑:N / 弱撑:N / 弱压:N / 强压:N + for key in ["强撑", "弱撑", "弱压", "强压"]: + m = re.search(rf'{key}:([\d.]+)', raw) + if m: + result[key] = float(m.group(1)) + return result + + +def build_strategy_rationale(sl_p, tp_p, tech, decision, actual_pnl_pct): + """ + 基于 tech_snapshot 的支撑/压力位,解释止损和止盈的设定依据。 + 返回 dict: + - sl_basis: 止损设定依据(对应哪个支撑位) + - tp_basis: 止盈设定依据(对应哪个压力位) + - analysis: 综合技术面文字分析 + - tech_used: 实际使用的 tech 参数 + """ + code = decision.get("code", "") + name = decision.get("name", code) + rationale = { + "sl_basis": "未设定", + "tp_basis": "未设定", + "analysis": "", + "tech_used": tech, + } + + # 解析支撑/压力 + 强撑 = tech.get("强撑") + 弱撑 = tech.get("弱撑") + 弱压 = tech.get("弱压") + 强压 = tech.get("强压") + 形态 = tech.get("pattern", "无记录") + 量价 = tech.get("volume", "数据不足") + + # ---- 止损依据 ---- + if sl_p and 强撑 and 弱撑: + # 止损在强撑附近 (±1%) + if abs(sl_p - 强撑) / 强撑 < 0.02: + rationale["sl_basis"] = f"技术面强支撑{强撑}(距{abs(sl_p-强撑)/强撑*100:.1f}%)" + # 止损在弱撑附近 + elif abs(sl_p - 弱撑) / 弱撑 < 0.02: + rationale["sl_basis"] = f"技术面弱支撑{弱撑}(距{abs(sl_p-弱撑)/弱撑*100:.1f}%)" + # 止损在强撑和弱撑之间 + elif 强撑 < sl_p < 弱撑: + diff_down = (sl_p - 强撑) / 强撑 * 100 + diff_up = (弱撑 - sl_p) / sl_p * 100 + rationale["sl_basis"] = f"技术面强撑{强撑}-弱撑{弱撑}之间(比强撑高{diff_down:.1f}%,比弱撑低{diff_up:.1f}%)" + # 止损低于强撑(宽止损,多见于深套) + elif sl_p < 强撑: + diff = (强撑 - sl_p) / sl_p * 100 + actual = actual_pnl_pct if actual_pnl_pct else 0 + if actual < -20: + rationale["sl_basis"] = f"低于技术面强撑{强撑}{diff:.1f}%(深套宽止损)" + else: + rationale["sl_basis"] = f"低于技术面强撑{强撑}{diff:.1f}%(宽止损)" + # 止损高于弱撑(紧止损) + elif sl_p > 弱撑: + rationale["sl_basis"] = f"高于弱撑{弱撑}(紧止损)" + elif sl_p and 强撑 and not 弱撑: + if abs(sl_p - 强撑) / 强撑 < 0.02: + rationale["sl_basis"] = f"技术面强支撑{强撑}" + else: + rationale["sl_basis"] = f"参考强撑{强撑}调整至{sl_p}" + elif sl_p: + rationale["sl_basis"] = f"直接设定为{sl_p}(无技术面支撑位参考)" + + # ---- 止盈依据 ---- + if tp_p and 强压: + if abs(tp_p - 强压) / 强压 < 0.03: + rationale["tp_basis"] = f"技术面强压力{强压}(距{abs(tp_p-强压)/强压*100:.1f}%)" + elif tp_p > 强压: + diff = (tp_p - 强压) / 强压 * 100 + rationale["tp_basis"] = f"技术面强压{强压}上方{diff:.1f}%(趋势延伸目标)" + elif 弱压 and 弱压 < tp_p < 强压: + rationale["tp_basis"] = f"技术面弱压{弱压}-强压{强压}之间" + elif 弱压 and tp_p <= 弱压: + rationale["tp_basis"] = f"接近弱压{弱压}(保守目标)" + else: + rationale["tp_basis"] = f"参考强压{强压}调整至{tp_p}" + elif tp_p and not 强压: + rationale["tp_basis"] = f"直接设定为{tp_p}(无技术面压力位参考)" + elif not tp_p: + rationale["tp_basis"] = "未设定止盈价" + + # ---- 综合技术面分析 ---- + parts = [] + if 形态: + parts.append(f"K线形态:{形态}") + if 量价: + parts.append(f"量价:{量价}") + if 强撑 or 弱撑 or 弱压 or 强压: + levels = [] + if 强撑: levels.append(f"强撑{强撑}") + if 弱撑: levels.append(f"弱撑{弱撑}") + if 弱压: levels.append(f"弱压{弱压}") + if 强压: levels.append(f"强压{强压}") + parts.append("技术位:" + "/".join(levels)) + rationale["analysis"] = " | ".join(parts) + + return rationale + + +def evaluate_phase1(decision, price_info, holding): + """ + 阶段一评估:策略制定→价格是否达到过目标价位 + 返回 evaluation dict + """ + trig = decision.get("trigger", {}) + code = decision["code"] + name = decision.get("name", code) + price = price_info.get("price", 0) + change = price_info.get("change_pct", 0) + + # 策略区间 — 支持两种数据格式: + # 1) trigger 子对象(含 entry_zone/stop_loss/take_profit) + # 2) 顶层字段(entry_low+entry_high / stop_loss / take_profit) + el = trig.get("entry_zone", "") + sl = trig.get("stop_loss", "") + tp = trig.get("take_profit", "") + if not el and decision.get("entry_low") is not None: + el_low = decision.get("entry_low") + el_high = decision.get("entry_high") + el = f"{el_low}~{el_high}" if el_low is not None and el_high is not None else "" + if not sl: + sl = decision.get("stop_loss", "") + if not tp: + tp = decision.get("take_profit", "") + + el_low = el_high = None + if el and "~" in str(el): + try: + parts = str(el).split("~") + el_low, el_high = float(parts[0]), float(parts[1]) + except: + pass + sl_p = float(sl) if sl else None + tp_p = float(tp) if tp else None + + # 持仓信息 + cost = holding.get("cost", 0) if holding else 0 + shares = holding.get("shares", 0) if holding else 0 + position_pct = holding.get("position_pct", 0) if holding else 0 + + # 理论盈亏计算(基于策略区间中值) + entry_mid = (el_low + el_high) / 2 if el_low and el_high else price + theoretical_pnl_pct = (tp_p - entry_mid) / entry_mid * 100 if tp_p and entry_mid else 0 + theoretical_pnl_amount = theoretical_pnl_pct / 100 * entry_mid * (shares or 100) / 100 if shares else 0 + + # 实际盈亏 + actual_pnl_pct = (price - cost) / cost * 100 if cost > 0 and price > 0 else 0 + actual_pnl_amount = actual_pnl_pct / 100 * cost * shares if cost > 0 and shares > 0 else 0 + + # === 策略依据分析(2026-06-18 新增)=== + tech = parse_tech_snapshot(decision) + rationale = build_strategy_rationale(sl_p, tp_p, tech, decision, actual_pnl_pct) + + # === R/R 盈亏比计算(2026-06-18 新增)=== + # 以现价为基准计算:向下风险(到止损)vs 向上空间(到止盈) + # 止损价作为风险基准,止盈价作为收益目标 + rr = None + rr_risk_pct = None + rr_reward_pct = None + rr_interpretation = "" + rr_level = "" + if sl_p and tp_p and price > 0 and sl_p > 0 and price > sl_p: + rr_risk_pct = round((price - sl_p) / price * 100, 2) # 距止损% + rr_reward_pct = round((tp_p - price) / price * 100, 2) # 距止盈% + rr = round(rr_reward_pct / rr_risk_pct, 2) if rr_risk_pct > 0 else None + # 判断场景:深套/已持仓盈利/已持仓亏损/新买入 + is_deep_loss = actual_pnl_pct < -20 + has_profit = actual_pnl_pct >= 0 + held = (cost > 0 and shares > 0) + if not held: + # 新买入/自选股场景 + if rr is not None and rr < 1.5: + rr_level = "⚠️盈亏比不足" + rr_interpretation = f"新买入要求R/R≥1.5,现{rr}每亏1元仅赚{rr}元,不建议买入" + elif rr is not None and rr < 2.0: + rr_level = "⚠️盈亏比偏低" + rr_interpretation = f"新买入要求R/R≥1.5,现{rr}每亏1元赚{rr}元,谨慎" + else: + rr_level = "R/R达标" + rr_interpretation = f"每亏1元赚{rr}元,盈亏比合理" + elif is_deep_loss: + # 深套场景 — 不限R/R + rr_level = "深套持有" + rr_interpretation = f"浮亏{actual_pnl_pct:.1f}%>20%深套,R/R={rr}仅参考,不补不割等反弹" + elif has_profit: + # 已持仓盈利场景 + if rr is not None and rr < 0.5: + rr_level = "⚠️R/R极低" + rr_interpretation = f"盈利持仓R/R={rr},每亏1元仅赚{rr}元,考虑止盈或上移止损保护利润" + elif rr is not None and rr < 1.5: + rr_level = "⚠️R/R偏低" + rr_interpretation = f"盈利持仓R/R={rr},不建议加仓,当前仓位持有观察" + else: + rr_level = "R/R合理" + rr_interpretation = f"盈利持仓R/R={rr},每亏1元赚{rr}元,持有合理" + else: + # 已持仓浮亏(但非深套) + if rr is not None and rr < 0.5: + rr_level = "⚠️R/R极低" + rr_interpretation = f"浮亏持仓R/R={rr},每亏1元仅赚{rr}元,不建议加仓,关注止损" + elif rr is not None and rr < 1.0: + rr_level = "⚠️R/R不足" + rr_interpretation = f"浮亏持仓要求加仓R/R≥1.0,现{rr},不加仓" + else: + rr_level = "R/R可接受" + rr_interpretation = f"浮亏持仓R/R={rr},每亏1元赚{rr}元,持有等反弹" + elif price and sl_p and price <= sl_p: + rr_level = "已跌破止损" + rr_interpretation = f"现价{price}已破止损{sl_p},R/R不适用" + else: + rr_interpretation = "止损或止盈缺失,无法计算R/R" + + # 当前状态判断 + status = "safe" + if sl_p and price > 0 and price <= sl_p: + status = "stop_loss_hit" + elif tp_p and price > 0 and price >= tp_p: + status = "take_profit_hit" + elif el_low and el_high and price > 0 and el_low <= price <= el_high: + status = "in_entry_zone" + elif el_low and price > 0 and price < el_low: + status = "below_entry" + elif el_high and price > 0 and price > el_high: + status = "above_entry" + + # 理论阶段评估 + theoretical = { + "entry_zone": f"{el_low}~{el_high}" if el_low else "N/A", + "stop_loss": sl_p, + "take_profit": tp_p, + "entry_mid_price": round(entry_mid, 2), + "target_price": tp_p, + "theoretical_pnl_pct": round(theoretical_pnl_pct, 2), + "theoretical_pnl_amount": round(theoretical_pnl_amount, 2), + "status": status, + "current_price": price, + "current_change_pct": change, + "rr": rr, + "rr_risk_pct": rr_risk_pct, + "rr_reward_pct": rr_reward_pct, + "rr_level": rr_level, + "sl_basis": rationale["sl_basis"], + "tp_basis": rationale["tp_basis"], + "tech_analysis": rationale["analysis"], + } + + # 实际阶段评估 + actual = { + "cost_price": cost, + "shares": shares, + "position_pct": position_pct, + "actual_pnl_pct": round(actual_pnl_pct, 2), + "actual_pnl_amount": round(actual_pnl_amount, 2), + "status": status, + "current_price": price, + } + + return { + "code": code, + "name": name, + "evaluated_at": datetime.now().isoformat(), + "phase": 1, + "theoretical": theoretical, + "actual": actual, + "rr_level": rr_level, + "rr_interpretation": rr_interpretation, + "strategy_rationale": { + "sl_basis": rationale["sl_basis"], + "tp_basis": rationale["tp_basis"], + "tech_analysis": rationale["analysis"], + }, + "summary": f"{name}({code}) | 损{sl_p}({rationale['sl_basis']})/" + f"盈{tp_p}({rationale['tp_basis']}) | " + f"现价{price}({change:+.2f}%) | " + f"距损{rr_risk_pct}%/距盈{rr_reward_pct}% | RR={rr} | " + f"{rr_level} | 理{theoretical_pnl_pct:+.1f}%实{actual_pnl_pct:+.1f}%", + } + + +def evaluate_phase2(decision, price_info, holding, prev_eval): + """ + 阶段二评估:价格回落后→新止损验证 + 需要 prev_eval 中记录了之前的目标价和新止损价 + """ + trig = decision.get("trigger", {}) + code = decision["code"] + name = decision.get("name", code) + price = price_info.get("price", 0) + + sl = trig.get("stop_loss", "") + if not sl: + sl = decision.get("stop_loss", "") + sl_p = float(sl) if sl else None + + # 从 prev_eval 中获取阶段一的止损 + prev_sl = None + if prev_eval: + prev_sl = prev_eval.get("theoretical", {}).get("stop_loss") + + # 检查新止损是否被跌破 + new_sl_hit = False + days_to_hit = None + if sl_p and price > 0 and price <= sl_p: + new_sl_hit = True + # 无法精确知道多少天跌破,标记为当前 + days_to_hit = 0 + + result = { + "code": code, + "name": name, + "evaluated_at": datetime.now().isoformat(), + "phase": 2, + "new_stop_loss": sl_p, + "previous_stop_loss": prev_sl, + "current_price": price, + "new_sl_hit": new_sl_hit, + "days_to_hit": days_to_hit, + "summary": f"{name}({code}) 新止损{sl_p} {'已跌破' if new_sl_hit else '未触及'} 现价{price}", + } + return result + + +def run(): + decisions = read_decisions() + portfolio = read_portfolio() + holdings_map = {h["code"]: h for h in portfolio.get("holdings", [])} + + # 收集所有代码 + all_codes = [d["code"] for d in decisions["decisions"]] + prices = fetch_prices(all_codes) + + results = [] + stats = { + "phase1_correct": 0, "phase1_wrong": 0, "phase1_pending": 0, + "phase2_correct": 0, "phase2_wrong": 0, "phase2_pending": 0, + } + + for d in decisions["decisions"]: + code = d["code"] + pi = prices.get(code, {}) + h = holdings_map.get(code) + + # 获取已有的 evaluation 记录 + existing_eval = d.get("evaluation", []) + + # 阶段一评估 + eval1 = evaluate_phase1(d, pi, h) + results.append(eval1) + + # 阶段二评估(如果有前次止损记录) + prev_eval = existing_eval[-1] if existing_eval else None + if prev_eval and prev_eval.get("phase") == 1: + eval2 = evaluate_phase2(d, pi, h, prev_eval) + results.append(eval2) + + # 更新 decisions.json 的 evaluation 字段 + d["evaluation"] = [e for e in [eval1] + ([eval2] if prev_eval and prev_eval.get("phase") == 1 else [])] + + # 保存更新到 DB + conn = get_conn() + for d in decisions.get("decisions", []): + write_holding_strategy(conn, d["code"], d.get("name", ""), d) + conn.close() + + # 汇总统计 + for r in results: + phase = r["phase"] + if phase == 1: + status = r["theoretical"]["status"] + if status in ("take_profit_hit",): + stats["phase1_correct"] += 1 + elif status in ("stop_loss_hit",): + stats["phase1_wrong"] += 1 + else: + stats["phase1_pending"] += 1 + elif phase == 2: + if r.get("new_sl_hit"): + stats["phase2_correct"] += 1 + else: + stats["phase2_pending"] += 1 + + # 写入 accuracy_stats + accuracy = { + "updated_at": datetime.now().isoformat(), + "phase1": { + "correct": stats["phase1_correct"], + "wrong": stats["phase1_wrong"], + "pending": stats["phase1_pending"], + "accuracy_pct": round(stats["phase1_correct"] / max(stats["phase1_correct"] + stats["phase1_wrong"], 1) * 100, 1), + }, + "phase2": { + "correct": stats["phase2_correct"], + "wrong": stats["phase2_wrong"], + "pending": stats["phase2_pending"], + "accuracy_pct": round(stats["phase2_correct"] / max(stats["phase2_correct"] + stats["phase2_wrong"], 1) * 100, 1), + }, + "total_evaluated": len(results), + "details": [r["summary"] for r in results], + } + save_json(ACCURACY_PATH, accuracy) + + # 输出报告 + print("=" * 70) + print(f"策略双维度评估报告 | {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print("=" * 70) + + print(f"\n📊 阶段一(策略制定→价格达标)") + print(f" 正确(达到止盈): {stats['phase1_correct']}") + print(f" 错误(跌破止损): {stats['phase1_wrong']}") + print(f" 待验证: {stats['phase1_pending']}") + print(f" 准确率: {accuracy['phase1']['accuracy_pct']}%") + + print(f"\n📊 阶段二(价格回落→新止损验证)") + print(f" 正确(新止损验证有效): {stats['phase2_correct']}") + print(f" 错误: {stats['phase2_wrong']}") + print(f" 待验证: {stats['phase2_pending']}") + + print(f"\n📋 逐股评估:") + for r in results: + if r["phase"] == 1: + print(f" {r['summary']}") + if r.get("rr_interpretation"): + print(f" RR: {r['rr_interpretation']}") + sr = r.get("strategy_rationale", {}) + if sr.get("tech_analysis"): + print(f" 技术:{sr['tech_analysis']}") + else: + print(f" {r['summary']}") + + # R/R 统计 + rr_count = sum(1 for r in results if r.get("rr_level") and r["phase"] == 1) + rr_warn = sum(1 for r in results if "⚠️" in r.get("rr_level", "") and r["phase"] == 1) + print(f"\n📊 盈亏比R/R统计:") + print(f" 有R/R评估: {rr_count}只 | ⚠️异常: {rr_warn}只") + for r in results: + if r["phase"] == 1 and r.get("rr_level"): + level = r["rr_level"] + if "⚠️" in level: + name_code = r['summary'].split('|')[0].strip() + print(f" ⚠️ {name_code} → {level} | {r['rr_interpretation']}") + + print(f"\n✅ 评估完成,已写入 decisions.json 和 accuracy_stats.json") + + +if __name__ == "__main__": + run() diff --git a/strategy_feedback.py b/strategy_feedback.py index 7a10b9b..69a9463 100644 --- a/strategy_feedback.py +++ b/strategy_feedback.py @@ -1,259 +1,258 @@ -#!/usr/bin/env python3 -"""strategy_feedback.py — 策略评估反馈引擎 - -从评估结果自动推导策略调整建议: -1. 阶段一完成(达到止盈)→ 标记成功,萃取经验 -2. 阶段一失败(跌破止损)→ 标记失败,分析原因 -3. 阶段二验证(新止损被跌破)→ 标记止损正确 -4. 长期未触及任何区间 → 建议重新评估策略区间 -5. 准确率趋势 → 调整策略参数(宽度、区间计算方法) - -输出:写入 decisions.json + 生成调整建议报告 -""" -import json -import sys -from datetime import datetime, timedelta -from pathlib import Path - -DATA_DIR = Path(__file__).parent / "data" -DECISIONS_PATH = DATA_DIR / "decisions.json" -PORTFOLIO_PATH = DATA_DIR / "portfolio.json" -ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" -EVENTS_PATH = DATA_DIR / "price_events.json" -FEEDBACK_PATH = DATA_DIR / "strategy_feedback.json" - - -def load_json(path, default=None): - try: - with open(path, encoding="utf-8") as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} if default is None else default - - -def save_json(path, data): - Path(path).parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def check_phase_completion(decision, events): - """检查某条策略是否有阶段完成事件""" - code = decision["code"] - trig = decision.get("trigger", {}) - stock_events = [e for e in events.get("events", []) if e["code"] == code] - - el = trig.get("entry_zone", "") - sl = trig.get("stop_loss", "") - tp = trig.get("take_profit", "") - - el_low = el_high = None - if el and "~" in str(el): - try: - parts = str(el).split("~") - el_low, el_high = float(parts[0]), float(parts[1]) - except: - pass - sl_p = float(sl) if sl else None - tp_p = float(tp) if tp else None - - result = { - "phase1_completed": False, - "phase1_result": None, # "success" or "failure" - "phase1_completed_at": None, - "phase1_price_at_completion": None, - "phase2_completed": False, - "phase2_result": None, - "phase2_completed_at": None, - "days_in_phase1": None, - } - - # 检查价格事件中是否有止盈/止损触发 - for ev in stock_events: - ev_type = ev.get("event_type", "") - ev_price = ev.get("price", 0) - ev_time = ev.get("timestamp", "") - - if ev_type == "stop_loss" and sl_p and ev_price <= sl_p: - result["phase1_completed"] = True - result["phase1_result"] = "failure" - result["phase1_completed_at"] = ev_time - result["phase1_price_at_completion"] = ev_price - - # 止盈:检查是否达到或超过止盈价 - if tp_p and ev_price >= tp_p: - result["phase1_completed"] = True - result["phase1_result"] = "success" - result["phase1_completed_at"] = ev_time - result["phase1_price_at_completion"] = ev_price - - return result - - -def compute_accuracy_trend(accuracy_stats): - """计算准确率趋势,用于调整策略参数""" - details = accuracy_stats.get("details", []) - if not details: - return {"trend": "stable", "phase1_accuracy": 0, "phase2_accuracy": 0} - - phase1 = accuracy_stats.get("phase1", {}) - phase2 = accuracy_stats.get("phase2", {}) - - p1_acc = phase1.get("accuracy_pct", 0) - p2_acc = phase2.get("accuracy_pct", 0) - - if p1_acc >= 80: - trend = "improving" - elif p1_acc <= 50 and phase1.get("correct", 0) + phase1.get("wrong", 0) >= 3: - trend = "declining" - else: - trend = "stable" - - return { - "trend": trend, - "phase1_accuracy": p1_acc, - "phase2_accuracy": p2_acc, - "phase1_evaluated": phase1.get("correct", 0) + phase1.get("wrong", 0), - "phase2_evaluated": phase2.get("correct", 0) + phase2.get("wrong", 0), - } - - -def generate_adjustment(decision, phase_check, accuracy_trend): - """根据评估结果生成策略调整建议""" - code = decision["code"] - name = decision.get("name", code) - trig = decision.get("trigger", {}) - adjustments = [] - - el = trig.get("entry_zone", "") - sl = trig.get("stop_loss", "") - tp = trig.get("take_profit", "") - - # 1. 阶段一完成 → 萃取经验 - if phase_check["phase1_completed"]: - if phase_check["phase1_result"] == "success": - adjustments.append({ - "type": "phase1_success", - "message": f"{name}({code}) 阶段一成功:达到止盈{tp},价格{phase_check['phase1_price_at_completion']}", - "action": "mark_completed", - "knowledge": f"策略区间{el}有效,价格达到止盈位{tp}", - }) - elif phase_check["phase1_result"] == "failure": - adjustments.append({ - "type": "phase1_failure", - "message": f"{name}({code}) 阶段一失败:跌破止损{sl},价格{phase_check['phase1_price_at_completion']}", - "action": "reassess", - "knowledge": f"策略区间{el}失效,止损{sl}被跌破,需重新评估", - }) - - # 2. 长期未触发 → 建议重新评估 - # 检查策略创建时间 - created_at = decision.get("timestamp", "") - if created_at: - try: - created = datetime.fromisoformat(created_at) - days_since = (datetime.now() - created).days - if days_since >= 14 and not phase_check["phase1_completed"]: - adjustments.append({ - "type": "stale_strategy", - "message": f"{name}({code}) 策略已{days_since}天未触发任何区间,建议重新评估", - "action": "reassess", - }) - except: - pass - - # 3. 准确率趋势 → 调整策略宽度 - if accuracy_trend["trend"] == "declining" and accuracy_trend["phase1_evaluated"] >= 3: - adjustments.append({ - "type": "accuracy_declining", - "message": f"准确率下降至{accuracy_trend['phase1_accuracy']}%,建议收紧策略区间宽度", - "action": "tighten", - }) - - return adjustments - - -def run(): - decisions = load_json(DECISIONS_PATH, {"decisions": []}) - # 优先从 SQLite 读取价格事件 - try: - from mofin_db import get_conn, query_price_events - conn = get_conn() - pe_rows = query_price_events(conn, limit=50000) - conn.close() - events = {"events": pe_rows} - except Exception: - events = load_json(EVENTS_PATH, {"events": []}) - accuracy_stats = load_json(ACCURACY_PATH, {}) - - accuracy_trend = compute_accuracy_trend(accuracy_stats) - - all_feedback = [] - phase1_completed = [] - reassess_needed = [] - - for d in decisions["decisions"]: - code = d["code"] - name = d.get("name", code) - - # 检查阶段完成情况 - phase_check = check_phase_completion(d, events) - - # 生成调整建议 - adjustments = generate_adjustment(d, phase_check, accuracy_trend) - - feedback_entry = { - "code": code, - "name": name, - "evaluated_at": datetime.now().isoformat(), - "phase_check": phase_check, - "adjustments": adjustments, - } - all_feedback.append(feedback_entry) - - # 收集需要处理的策略 - if phase_check["phase1_completed"]: - phase1_completed.append(feedback_entry) - - if any(a["action"] in ("reassess", "tighten") for a in adjustments): - reassess_needed.append(feedback_entry) - - # 保存反馈结果 - feedback_data = { - "updated_at": datetime.now().isoformat(), - "accuracy_trend": accuracy_trend, - "total_strategies": len(decisions["decisions"]), - "phase1_completed_count": len(phase1_completed), - "reassess_needed_count": len(reassess_needed), - "feedback": all_feedback, - } - save_json(FEEDBACK_PATH, feedback_data) - - # 输出报告 - print("=" * 70) - print(f"策略反馈引擎报告 | {datetime.now().strftime('%Y-%m-%d %H:%M')}") - print("=" * 70) - - print(f"\n📈 准确率趋势: {accuracy_trend['trend']}") - print(f" 阶段一准确率: {accuracy_trend['phase1_accuracy']}% ({accuracy_trend['phase1_evaluated']}次评估)") - print(f" 阶段二准确率: {accuracy_trend['phase2_accuracy']}% ({accuracy_trend['phase2_evaluated']}次评估)") - - print(f"\n✅ 阶段一已完成: {len(phase1_completed)}只") - for fb in phase1_completed: - pc = fb["phase_check"] - icon = "🟢" if pc["phase1_result"] == "success" else "🔴" - print(f" {icon} {fb['name']}({fb['code']}) {pc['phase1_result']} 于{pc['phase1_completed_at'][:19]} 价格{pc['phase1_price_at_completion']}") - - print(f"\n🔄 需重新评估: {len(reassess_needed)}只") - for fb in reassess_needed: - for adj in fb["adjustments"]: - print(f" {adj['type']}: {adj['message']}") - - if not phase1_completed and not reassess_needed: - print(f"\n 无策略需要调整,所有策略正常运行中") - - print(f"\n✅ 反馈完成,已写入 {FEEDBACK_PATH}") - - -if __name__ == "__main__": - run() +#!/usr/bin/env python3 +"""strategy_feedback.py — 策略评估反馈引擎 + +从评估结果自动推导策略调整建议: +1. 阶段一完成(达到止盈)→ 标记成功,萃取经验 +2. 阶段一失败(跌破止损)→ 标记失败,分析原因 +3. 阶段二验证(新止损被跌破)→ 标记止损正确 +4. 长期未触及任何区间 → 建议重新评估策略区间 +5. 准确率趋势 → 调整策略参数(宽度、区间计算方法) + +输出:写入 decisions.json + 生成调整建议报告 +""" +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path +from mo_data import read_decisions + +DATA_DIR = Path(__file__).parent / "data" +ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" +EVENTS_PATH = DATA_DIR / "price_events.json" +FEEDBACK_PATH = DATA_DIR / "strategy_feedback.json" + + +def load_json(path, default=None): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} if default is None else default + + +def save_json(path, data): + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def check_phase_completion(decision, events): + """检查某条策略是否有阶段完成事件""" + code = decision["code"] + trig = decision.get("trigger", {}) + stock_events = [e for e in events.get("events", []) if e["code"] == code] + + el = trig.get("entry_zone", "") + sl = trig.get("stop_loss", "") + tp = trig.get("take_profit", "") + + el_low = el_high = None + if el and "~" in str(el): + try: + parts = str(el).split("~") + el_low, el_high = float(parts[0]), float(parts[1]) + except: + pass + sl_p = float(sl) if sl else None + tp_p = float(tp) if tp else None + + result = { + "phase1_completed": False, + "phase1_result": None, # "success" or "failure" + "phase1_completed_at": None, + "phase1_price_at_completion": None, + "phase2_completed": False, + "phase2_result": None, + "phase2_completed_at": None, + "days_in_phase1": None, + } + + # 检查价格事件中是否有止盈/止损触发 + for ev in stock_events: + ev_type = ev.get("event_type", "") + ev_price = ev.get("price", 0) + ev_time = ev.get("timestamp", "") + + if ev_type == "stop_loss" and sl_p and ev_price <= sl_p: + result["phase1_completed"] = True + result["phase1_result"] = "failure" + result["phase1_completed_at"] = ev_time + result["phase1_price_at_completion"] = ev_price + + # 止盈:检查是否达到或超过止盈价 + if tp_p and ev_price >= tp_p: + result["phase1_completed"] = True + result["phase1_result"] = "success" + result["phase1_completed_at"] = ev_time + result["phase1_price_at_completion"] = ev_price + + return result + + +def compute_accuracy_trend(accuracy_stats): + """计算准确率趋势,用于调整策略参数""" + details = accuracy_stats.get("details", []) + if not details: + return {"trend": "stable", "phase1_accuracy": 0, "phase2_accuracy": 0} + + phase1 = accuracy_stats.get("phase1", {}) + phase2 = accuracy_stats.get("phase2", {}) + + p1_acc = phase1.get("accuracy_pct", 0) + p2_acc = phase2.get("accuracy_pct", 0) + + if p1_acc >= 80: + trend = "improving" + elif p1_acc <= 50 and phase1.get("correct", 0) + phase1.get("wrong", 0) >= 3: + trend = "declining" + else: + trend = "stable" + + return { + "trend": trend, + "phase1_accuracy": p1_acc, + "phase2_accuracy": p2_acc, + "phase1_evaluated": phase1.get("correct", 0) + phase1.get("wrong", 0), + "phase2_evaluated": phase2.get("correct", 0) + phase2.get("wrong", 0), + } + + +def generate_adjustment(decision, phase_check, accuracy_trend): + """根据评估结果生成策略调整建议""" + code = decision["code"] + name = decision.get("name", code) + trig = decision.get("trigger", {}) + adjustments = [] + + el = trig.get("entry_zone", "") + sl = trig.get("stop_loss", "") + tp = trig.get("take_profit", "") + + # 1. 阶段一完成 → 萃取经验 + if phase_check["phase1_completed"]: + if phase_check["phase1_result"] == "success": + adjustments.append({ + "type": "phase1_success", + "message": f"{name}({code}) 阶段一成功:达到止盈{tp},价格{phase_check['phase1_price_at_completion']}", + "action": "mark_completed", + "knowledge": f"策略区间{el}有效,价格达到止盈位{tp}", + }) + elif phase_check["phase1_result"] == "failure": + adjustments.append({ + "type": "phase1_failure", + "message": f"{name}({code}) 阶段一失败:跌破止损{sl},价格{phase_check['phase1_price_at_completion']}", + "action": "reassess", + "knowledge": f"策略区间{el}失效,止损{sl}被跌破,需重新评估", + }) + + # 2. 长期未触发 → 建议重新评估 + # 检查策略创建时间 + created_at = decision.get("timestamp", "") + if created_at: + try: + created = datetime.fromisoformat(created_at) + days_since = (datetime.now() - created).days + if days_since >= 14 and not phase_check["phase1_completed"]: + adjustments.append({ + "type": "stale_strategy", + "message": f"{name}({code}) 策略已{days_since}天未触发任何区间,建议重新评估", + "action": "reassess", + }) + except: + pass + + # 3. 准确率趋势 → 调整策略宽度 + if accuracy_trend["trend"] == "declining" and accuracy_trend["phase1_evaluated"] >= 3: + adjustments.append({ + "type": "accuracy_declining", + "message": f"准确率下降至{accuracy_trend['phase1_accuracy']}%,建议收紧策略区间宽度", + "action": "tighten", + }) + + return adjustments + + +def run(): + decisions = read_decisions() + # 优先从 SQLite 读取价格事件 + try: + from mofin_db import get_conn, query_price_events + conn = get_conn() + pe_rows = query_price_events(conn, limit=50000) + conn.close() + events = {"events": pe_rows} + except Exception: + events = load_json(EVENTS_PATH, {"events": []}) + accuracy_stats = load_json(ACCURACY_PATH, {}) + + accuracy_trend = compute_accuracy_trend(accuracy_stats) + + all_feedback = [] + phase1_completed = [] + reassess_needed = [] + + for d in decisions["decisions"]: + code = d["code"] + name = d.get("name", code) + + # 检查阶段完成情况 + phase_check = check_phase_completion(d, events) + + # 生成调整建议 + adjustments = generate_adjustment(d, phase_check, accuracy_trend) + + feedback_entry = { + "code": code, + "name": name, + "evaluated_at": datetime.now().isoformat(), + "phase_check": phase_check, + "adjustments": adjustments, + } + all_feedback.append(feedback_entry) + + # 收集需要处理的策略 + if phase_check["phase1_completed"]: + phase1_completed.append(feedback_entry) + + if any(a["action"] in ("reassess", "tighten") for a in adjustments): + reassess_needed.append(feedback_entry) + + # 保存反馈结果 + feedback_data = { + "updated_at": datetime.now().isoformat(), + "accuracy_trend": accuracy_trend, + "total_strategies": len(decisions["decisions"]), + "phase1_completed_count": len(phase1_completed), + "reassess_needed_count": len(reassess_needed), + "feedback": all_feedback, + } + save_json(FEEDBACK_PATH, feedback_data) + + # 输出报告 + print("=" * 70) + print(f"策略反馈引擎报告 | {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print("=" * 70) + + print(f"\n📈 准确率趋势: {accuracy_trend['trend']}") + print(f" 阶段一准确率: {accuracy_trend['phase1_accuracy']}% ({accuracy_trend['phase1_evaluated']}次评估)") + print(f" 阶段二准确率: {accuracy_trend['phase2_accuracy']}% ({accuracy_trend['phase2_evaluated']}次评估)") + + print(f"\n✅ 阶段一已完成: {len(phase1_completed)}只") + for fb in phase1_completed: + pc = fb["phase_check"] + icon = "🟢" if pc["phase1_result"] == "success" else "🔴" + print(f" {icon} {fb['name']}({fb['code']}) {pc['phase1_result']} 于{pc['phase1_completed_at'][:19]} 价格{pc['phase1_price_at_completion']}") + + print(f"\n🔄 需重新评估: {len(reassess_needed)}只") + for fb in reassess_needed: + for adj in fb["adjustments"]: + print(f" {adj['type']}: {adj['message']}") + + if not phase1_completed and not reassess_needed: + print(f"\n 无策略需要调整,所有策略正常运行中") + + print(f"\n✅ 反馈完成,已写入 {FEEDBACK_PATH}") + + +if __name__ == "__main__": + run() diff --git a/strategy_tree.py b/strategy_tree.py index 99ec864..3a8edfc 100644 --- a/strategy_tree.py +++ b/strategy_tree.py @@ -1,434 +1,443 @@ -#!/usr/bin/env python3 -""" -strategy_tree.py — 情景化多分支策略决策引擎 - -核心理念: - 每只股票不再只有一个买入区+止损,而是有一棵决策树。 - 每个分支 = {条件, 动作, 优先级, 触发统计} - 当前宏观情景决定走哪个分支。 - -自成长: - → 每次分支被触发,记录 trigger_count + 后续5日盈亏 - → success_rate < 30% 且触发≥5次 → 自动标记 pruning_candidate - → 每周 pruning 时剪掉低效分支 - -数据存在 decisions.json 的 strategy_tree 字段。 -""" - -import json, os, sys, re -from datetime import datetime, date, timedelta -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" -MACRO_PATH = "/home/hmo/web-dashboard/data/macro_context.json" -MARKET_PATH = "/home/hmo/web-dashboard/data/market.json" -TREND_PATH = "/home/hmo/web-dashboard/data/trend_signals.json" - -# ── 情景定义 ────────────────────────────────────────────────────────────── - -SCENARIOS = [ - { - "id": "sharp_decline", - "label": "急跌防御", - "desc": "大盘放量下跌,多板块共振杀跌", - "rules": {"mood": "bearish", "sector_crash": True}, - "portfolio_action": "减仓至80%以下,优先出弱势深套", - }, - { - "id": "weak_consolidation", - "label": "弱势震荡", - "desc": "大盘缩量阴跌,结构分化", - "rules": {"mood": "neutral", "breadth": "weak"}, - "portfolio_action": "保持仓位90%以内,调结构", - }, - { - "id": "sector_rotation", - "label": "板块轮动", - "desc": "大盘窄幅,强势板块切换", - "rules": {"mood": "neutral", "rotation": True}, - "portfolio_action": "跟随板块切换,减旧加新", - }, - { - "id": "bullish_recovery", - "label": "反弹上行", - "desc": "大盘放量上涨,情绪回暖", - "rules": {"mood": "bullish"}, - "portfolio_action": "加仓至95%,追随趋势", - }, -] - - -# ── 情景判定 ────────────────────────────────────────────────────────────── - -def detect_scenario(): - """从宏观+市场数据判断当前情景 - - 返回: - {"id": str, "label": str, "confidence": float, "portfolio_action": str} - """ - scenario_id = "weak_consolidation" # 默认 - confidence = 0.5 - - try: - # 优先 DB - import sqlite3 - from pathlib import Path - db = sqlite3.connect(str(Path(__file__).parent / "data" / "mofin.db")) - mrow = db.execute( - "SELECT indices, structure, sector_mood FROM macro_context_log " - "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" - ).fetchone() - db.close() - if mrow: - structure = json.loads(mrow[1]) if mrow[1] else {} - overall = structure.get("overall", "").lower() - mood = (mrow[2] or "").lower() if len(mrow) > 2 else "" - else: - raise ValueError("no db data") - except Exception: - try: - macro = json.load(open(MACRO_PATH)) - market = json.load(open(MARKET_PATH)) - mood = market.get("mood", "").lower() - structure = macro.get("structure", {}) - overall = structure.get("overall", "").lower() - except Exception: - return {"id": "weak_consolidation", "label": "默认-弱势震荡", "confidence": 0.3, "portfolio_action": "观望"} - trend_desc = structure.get("description", "").lower() - - # Check for sharp decline - if "bearish" in mood or "bearish" in overall: - if "crash" in trend_desc or "跌幅" in trend_desc or "恐慌" in trend_desc: - scenario_id = "sharp_decline" - confidence = 0.7 - elif "弱势" in trend_desc or "疲弱" in trend_desc: - scenario_id = "weak_consolidation" - confidence = 0.6 - else: - scenario_id = "weak_consolidation" - confidence = 0.5 - elif "bullish" in mood or "bullish" in overall: - scenario_id = "bullish_recovery" - confidence = 0.6 - elif "neutral" in mood: - # Check for rotation signals - try: - trend = json.load(open(TREND_PATH)) - if trend.get("rotation_detected"): - scenario_id = "sector_rotation" - confidence = 0.5 - except Exception: - pass - scenario_id = "weak_consolidation" - confidence = 0.4 - - sc = next((s for s in SCENARIOS if s["id"] == scenario_id), SCENARIOS[0]) - return { - "id": scenario_id, - "label": sc["label"], - "desc": sc["desc"], - "confidence": round(confidence, 2), - "portfolio_action": sc["portfolio_action"], - } - - -# ── 分支评估 ────────────────────────────────────────────────────────────── - -def evaluate_branches(code, scenario_id, price, shares, cost): - """评估某只股票在当前情景下的所有分支 - - 从 decisions.json 读取 strategy_tree.branches[] - 返回: [{branch_id, action_type, action_detail, priority, applicable}] - """ - try: - dec = mo_data.read_decisions() - except Exception: - return [] - - entry = None - for e in dec.get("decisions", []): - if e.get("code") == code: - entry = e - break - if not entry: - return [] - - branches = entry.get("strategy_tree", {}).get("branches", []) - if not branches: - return [] - - results = [] - for br in sorted(branches, key=lambda b: b.get("priority", 999)): - applicable = _check_branch_condition(br, scenario_id, price, shares, cost) - results.append({ - "branch_id": br.get("id"), - "action_type": br.get("action", {}).get("type", "hold"), - "action_detail": br.get("action", {}), - "priority": br.get("priority", 999), - "rationale": br.get("rationale", ""), - "applicable": applicable, - }) - - return results - - -def _check_branch_condition(branch, scenario_id, price, shares, cost): - """检查分支条件是否满足""" - cond = branch.get("condition", {}) - required_scenario = cond.get("scenario", "") - if required_scenario and required_scenario != scenario_id: - return False - - # Price conditions - price_cond = cond.get("price", "") - if price_cond: - ops = re.findall(r'([<>=!]+)\s*([\d.]+)', price_cond) - for op, val_str in ops: - val = float(val_str) - op = op.strip() - if op == "<" and not (price < val): - return False - if op == ">" and not (price > val): - return False - if op == "<=" and not (price <= val): - return False - if op == ">=" and not (price >= val): - return False - if op == "==" and not (abs(price - val) < 0.01): - return False - - # Price lower bound (separate field) - price_lower = cond.get("price_lower", "") - if price_lower: - ops = re.findall(r'([<>=!]+)\s*([\d.]+)', price_lower) - for op, val_str in ops: - val = float(val_str) - op = op.strip() - if op == "<" and not (price < val): - return False - if op == ">" and not (price > val): - return False - if op == "<=" and not (price <= val): - return False - if op == ">=" and not (price >= val): - return False - if op == "==" and not (abs(price - val) < 0.01): - return False - - # Trend condition - trend = cond.get("trend", "") - if trend and trend == "uptrend": - pass # TODO: check multi_timeframe - - # Loss condition - loss_pct = cond.get("loss_pct", "") - if loss_pct and cost > 0: - actual_loss = (price - cost) / cost * 100 - if "<" in str(loss_pct): - limit = float(str(loss_pct).replace("<", "").replace("%", "")) - if not (actual_loss < limit): - return False - - return True - - -# ── 分支触发记录 ────────────────────────────────────────────────────────── - -def record_branch_trigger(code, branch_id): - """记录分支被触发了一次,用于自成长统计""" - try: - dec = mo_data.read_decisions() - for e in dec.get("decisions", []): - if e.get("code") == code: - st = e.setdefault("strategy_tree", {}) - for br in st.get("branches", []): - if br.get("id") == branch_id: - br["trigger_count"] = br.get("trigger_count", 0) + 1 - br["last_triggered"] = datetime.now().isoformat() - break - break - json.dump(dec, open(DECISIONS_PATH, "w"), ensure_ascii=False, indent=2) - except Exception: - pass - - -# ── 分支剪枝(自成长核心)───────────────────────────────────────────────── - -def prune_low_performance_branches(min_triggers=5, min_success_rate=0.3): - """剪掉低成功率分支——自成长机制 - - 条件:触发≥min_triggers 次 且 success_rate < min_success_rate - 被剪的分支移入 history 字段,不打删除(可追溯) - """ - try: - dec = mo_data.read_decisions() - except Exception: - return [] - - pruned = [] - for e in dec.get("decisions", []): - st = e.setdefault("strategy_tree", {}) - branches = st.get("branches", []) - kept = [] - for br in branches: - tc = br.get("trigger_count", 0) - sr = br.get("success_rate") - if sr is not None and tc >= min_triggers and sr < min_success_rate: - # 移入 history - history = st.setdefault("pruned_branches", []) - br["pruned_at"] = datetime.now().isoformat() - br["prune_reason"] = f"低成功率: {sr:.0%} (触发{tc}次)" - history.append(br) - pruned.append(f'{e.get("code")}:{br.get("id")} ({sr:.0%} < {min_success_rate:.0%})') - else: - kept.append(br) - st["branches"] = kept - - if pruned: - json.dump(dec, open(DECISIONS_PATH, "w"), ensure_ascii=False, indent=2) - - return pruned - - -# ── 初始化策略树(为一只票创建默认分支)───────────────────────────────────── - -def init_default_branches(code, name, entry_low, entry_high, stop_loss, take_profit): - """为 stock 创建默认多分支策略——由 per_stock_reassess 调用""" - base_price = (entry_low + entry_high) / 2 if entry_low and entry_high else 0 - - branches = [] - - # 分支0:止损(始终有效) - if stop_loss: - branches.append({ - "id": f"{code}_stop_loss", - "condition": {"price": f"<{stop_loss}"}, - "action": {"type": "sell", "amount": "all", "reason": "止损"}, - "priority": 0, - "rationale": "止损保护本金", - "trigger_count": 0, - "success_rate": None, - "last_triggered": None, - }) - - # 分支1:回调买入(弱势情景适用) - if entry_low: - branches.append({ - "id": f"{code}_buy_dip", - "condition": {"scenario": "weak_consolidation", "price": f"<={entry_high}", "price_lower": f">={entry_low}"}, - "action": {"type": "buy", "amount": "normal", "limit": entry_low, "reason": "回调支撑买入"}, - "priority": 1, - "rationale": "价格回调到支撑区,弱势市场低吸", - "trigger_count": 0, - "success_rate": None, - "last_triggered": None, - }) - - # 分支2:突破追涨(强势情景适用) - if take_profit: - branches.append({ - "id": f"{code}_breakout_chase", - "condition": {"scenario": "bullish_recovery", "price": f">={take_profit}"}, - "action": {"type": "buy", "amount": "normal", "limit": "market", "reason": "突破确认追涨"}, - "priority": 2, - "rationale": "价格突破阻力,确认上升趋势后买入", - "trigger_count": 0, - "success_rate": None, - "last_triggered": None, - }) - - # 分支3:减仓(急跌情景适用) - branches.append({ - "id": f"{code}_trim", - "condition": {"scenario": "sharp_decline", "loss_pct": "<-15%"}, - "action": {"type": "sell", "amount": "half", "reason": "急跌降风险"}, - "priority": 3, - "rationale": "急跌市场,深套股减半仓减少敞口", - "trigger_count": 0, - "success_rate": None, - "last_triggered": None, - }) - - # 分支4:止盈(浮盈较大) - if take_profit and entry_low: - branches.append({ - "id": f"{code}_take_profit", - "condition": {"price": f">={take_profit}"}, - "action": {"type": "sell", "amount": "half", "reason": "止盈锁利"}, - "priority": 4, - "rationale": "达到目标价,减半仓锁定利润", - "trigger_count": 0, - "success_rate": None, - "last_triggered": None, - }) - - # 分支5:持有(默认) - branches.append({ - "id": f"{code}_hold", - "condition": {}, - "action": {"type": "hold", "reason": "无明确信号,继续持有"}, - "priority": 99, - "rationale": "没有分支匹配时的默认动作", - "trigger_count": 0, - "success_rate": None, - "last_triggered": None, - }) - - return branches - - -# ── 组合约束检查 ────────────────────────────────────────────────────────── - -def check_portfolio_constraint(action_type, amount, cash_remain=None): - """组合约束检查:现金够不够?仓位上限?""" - try: - pf = mo_data.read_portfolio() - except Exception: - return True, "无法读取组合" - - if action_type == "buy": - # 估算买入金额 - cost_est = amount if amount else 100000 # default 10万 - if cash_remain is not None: - cost_est = cash_remain - if cost_est > pf.get("cash", 0): - return False, f"现金不足: 需要~{cost_est:.0f},可用{pf['cash']:.0f}" - - return True, "OK" - - -# ── CLI 入口 ────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser(description="多分支策略决策引擎") - parser.add_argument("--detect", action="store_true", help="检测当前情景") - parser.add_argument("--evaluate", type=str, help="评估指定股票的分支") - parser.add_argument("--prune", action="store_true", help="剪枝低效分支") - args = parser.parse_args() - - if args.detect: - sc = detect_scenario() - print(f"情景: {sc['id']} ({sc['label']})") - print(f"置信度: {sc['confidence']}") - print(f"组合动作: {sc['portfolio_action']}") - - if args.evaluate: - code = args.evaluate - sc = detect_scenario() - print(f"当前情景: {sc['id']} ({sc['label']})") - print(f"评估 {code}:") - results = evaluate_branches(code, sc["id"], 0, 0, 0) - for r in results: - status = "✅" if r["applicable"] else " " - print(f" {status} [{r['priority']}] {r['branch_id']} → {r['action_type']}: {r['rationale']}") - - if args.prune: - pruned = prune_low_performance_branches() - if pruned: - print(f"已剪枝: {len(pruned)} 条") - for p in pruned: - print(f" - {p}") - else: - print("无需要剪枝的分支") +#!/usr/bin/env python3 +""" +strategy_tree.py — 情景化多分支策略决策引擎 + +核心理念: + 每只股票不再只有一个买入区+止损,而是有一棵决策树。 + 每个分支 = {条件, 动作, 优先级, 触发统计} + 当前宏观情景决定走哪个分支。 + +自成长: + → 每次分支被触发,记录 trigger_count + 后续5日盈亏 + → success_rate < 30% 且触发≥5次 → 自动标记 pruning_candidate + → 每周 pruning 时剪掉低效分支 + +数据存在 decisions.json 的 strategy_tree 字段。 +""" + +import json, os, sys, re +from datetime import datetime, date, timedelta +from mo_data import read_portfolio, read_decisions, read_watchlist +from mofin_db import get_conn, write_holding_strategy +from mofin_db import get_conn, write_holding_strategy + +MACRO_PATH = "/home/hmo/web-dashboard/data/macro_context.json" +MARKET_PATH = "/home/hmo/web-dashboard/data/market.json" +TREND_PATH = "/home/hmo/web-dashboard/data/trend_signals.json" + +# ── 情景定义 ────────────────────────────────────────────────────────────── + +SCENARIOS = [ + { + "id": "sharp_decline", + "label": "急跌防御", + "desc": "大盘放量下跌,多板块共振杀跌", + "rules": {"mood": "bearish", "sector_crash": True}, + "portfolio_action": "减仓至80%以下,优先出弱势深套", + }, + { + "id": "weak_consolidation", + "label": "弱势震荡", + "desc": "大盘缩量阴跌,结构分化", + "rules": {"mood": "neutral", "breadth": "weak"}, + "portfolio_action": "保持仓位90%以内,调结构", + }, + { + "id": "sector_rotation", + "label": "板块轮动", + "desc": "大盘窄幅,强势板块切换", + "rules": {"mood": "neutral", "rotation": True}, + "portfolio_action": "跟随板块切换,减旧加新", + }, + { + "id": "bullish_recovery", + "label": "反弹上行", + "desc": "大盘放量上涨,情绪回暖", + "rules": {"mood": "bullish"}, + "portfolio_action": "加仓至95%,追随趋势", + }, +] + + +# ── 情景判定 ────────────────────────────────────────────────────────────── + +def detect_scenario(): + """从宏观+市场数据判断当前情景 + + 返回: + {"id": str, "label": str, "confidence": float, "portfolio_action": str} + """ + scenario_id = "weak_consolidation" # 默认 + confidence = 0.5 + + try: + # 优先 DB + import sqlite3 + from pathlib import Path + db = sqlite3.connect(str(Path(__file__).parent / "data" / "mofin.db")) + mrow = db.execute( + "SELECT indices, structure, sector_mood FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + db.close() + if mrow: + structure = json.loads(mrow[1]) if mrow[1] else {} + overall = structure.get("overall", "").lower() + mood = (mrow[2] or "").lower() if len(mrow) > 2 else "" + else: + raise ValueError("no db data") + except Exception: + try: + macro = json.load(open(MACRO_PATH)) + market = json.load(open(MARKET_PATH)) + mood = market.get("mood", "").lower() + structure = macro.get("structure", {}) + overall = structure.get("overall", "").lower() + except Exception: + return {"id": "weak_consolidation", "label": "默认-弱势震荡", "confidence": 0.3, "portfolio_action": "观望"} + trend_desc = structure.get("description", "").lower() + + # Check for sharp decline + if "bearish" in mood or "bearish" in overall: + if "crash" in trend_desc or "跌幅" in trend_desc or "恐慌" in trend_desc: + scenario_id = "sharp_decline" + confidence = 0.7 + elif "弱势" in trend_desc or "疲弱" in trend_desc: + scenario_id = "weak_consolidation" + confidence = 0.6 + else: + scenario_id = "weak_consolidation" + confidence = 0.5 + elif "bullish" in mood or "bullish" in overall: + scenario_id = "bullish_recovery" + confidence = 0.6 + elif "neutral" in mood: + # Check for rotation signals + try: + trend = json.load(open(TREND_PATH)) + if trend.get("rotation_detected"): + scenario_id = "sector_rotation" + confidence = 0.5 + except Exception: + pass + scenario_id = "weak_consolidation" + confidence = 0.4 + + sc = next((s for s in SCENARIOS if s["id"] == scenario_id), SCENARIOS[0]) + return { + "id": scenario_id, + "label": sc["label"], + "desc": sc["desc"], + "confidence": round(confidence, 2), + "portfolio_action": sc["portfolio_action"], + } + + +# ── 分支评估 ────────────────────────────────────────────────────────────── + +def evaluate_branches(code, scenario_id, price, shares, cost): + """评估某只股票在当前情景下的所有分支 + + 从 decisions.json 读取 strategy_tree.branches[] + 返回: [{branch_id, action_type, action_detail, priority, applicable}] + """ + try: + dec = mo_data.read_decisions() + except Exception: + return [] + + entry = None + for e in dec.get("decisions", []): + if e.get("code") == code: + entry = e + break + if not entry: + return [] + + branches = entry.get("strategy_tree", {}).get("branches", []) + if not branches: + return [] + + results = [] + for br in sorted(branches, key=lambda b: b.get("priority", 999)): + applicable = _check_branch_condition(br, scenario_id, price, shares, cost) + results.append({ + "branch_id": br.get("id"), + "action_type": br.get("action", {}).get("type", "hold"), + "action_detail": br.get("action", {}), + "priority": br.get("priority", 999), + "rationale": br.get("rationale", ""), + "applicable": applicable, + }) + + return results + + +def _check_branch_condition(branch, scenario_id, price, shares, cost): + """检查分支条件是否满足""" + cond = branch.get("condition", {}) + required_scenario = cond.get("scenario", "") + if required_scenario and required_scenario != scenario_id: + return False + + # Price conditions + price_cond = cond.get("price", "") + if price_cond: + ops = re.findall(r'([<>=!]+)\s*([\d.]+)', price_cond) + for op, val_str in ops: + val = float(val_str) + op = op.strip() + if op == "<" and not (price < val): + return False + if op == ">" and not (price > val): + return False + if op == "<=" and not (price <= val): + return False + if op == ">=" and not (price >= val): + return False + if op == "==" and not (abs(price - val) < 0.01): + return False + + # Price lower bound (separate field) + price_lower = cond.get("price_lower", "") + if price_lower: + ops = re.findall(r'([<>=!]+)\s*([\d.]+)', price_lower) + for op, val_str in ops: + val = float(val_str) + op = op.strip() + if op == "<" and not (price < val): + return False + if op == ">" and not (price > val): + return False + if op == "<=" and not (price <= val): + return False + if op == ">=" and not (price >= val): + return False + if op == "==" and not (abs(price - val) < 0.01): + return False + + # Trend condition + trend = cond.get("trend", "") + if trend and trend == "uptrend": + pass # TODO: check multi_timeframe + + # Loss condition + loss_pct = cond.get("loss_pct", "") + if loss_pct and cost > 0: + actual_loss = (price - cost) / cost * 100 + if "<" in str(loss_pct): + limit = float(str(loss_pct).replace("<", "").replace("%", "")) + if not (actual_loss < limit): + return False + + return True + + +# ── 分支触发记录 ────────────────────────────────────────────────────────── + +def record_branch_trigger(code, branch_id): + """记录分支被触发了一次,用于自成长统计""" + try: + dec = mo_data.read_decisions() + for e in dec.get("decisions", []): + if e.get("code") == code: + st = e.setdefault("strategy_tree", {}) + for br in st.get("branches", []): + if br.get("id") == branch_id: + br["trigger_count"] = br.get("trigger_count", 0) + 1 + br["last_triggered"] = datetime.now().isoformat() + break + break + conn = get_conn() + for e in dec.get("decisions", []): + if e.get("code") == code: + write_holding_strategy(conn, code, e.get('name', ''), e) + break + conn.close() + except Exception: + pass + + +# ── 分支剪枝(自成长核心)───────────────────────────────────────────────── + +def prune_low_performance_branches(min_triggers=5, min_success_rate=0.3): + """剪掉低成功率分支——自成长机制 + + 条件:触发≥min_triggers 次 且 success_rate < min_success_rate + 被剪的分支移入 history 字段,不打删除(可追溯) + """ + try: + dec = mo_data.read_decisions() + except Exception: + return [] + + pruned = [] + for e in dec.get("decisions", []): + st = e.setdefault("strategy_tree", {}) + branches = st.get("branches", []) + kept = [] + for br in branches: + tc = br.get("trigger_count", 0) + sr = br.get("success_rate") + if sr is not None and tc >= min_triggers and sr < min_success_rate: + # 移入 history + history = st.setdefault("pruned_branches", []) + br["pruned_at"] = datetime.now().isoformat() + br["prune_reason"] = f"低成功率: {sr:.0%} (触发{tc}次)" + history.append(br) + pruned.append(f'{e.get("code")}:{br.get("id")} ({sr:.0%} < {min_success_rate:.0%})') + else: + kept.append(br) + st["branches"] = kept + + if pruned: + conn = get_conn() + for e in dec.get("decisions", []): + if e.get("strategy_tree", {}).get("branches") is not None: + write_holding_strategy(conn, e.get("code"), e.get('name', ''), e) + conn.close() + + return pruned + + +# ── 初始化策略树(为一只票创建默认分支)───────────────────────────────────── + +def init_default_branches(code, name, entry_low, entry_high, stop_loss, take_profit): + """为 stock 创建默认多分支策略——由 per_stock_reassess 调用""" + base_price = (entry_low + entry_high) / 2 if entry_low and entry_high else 0 + + branches = [] + + # 分支0:止损(始终有效) + if stop_loss: + branches.append({ + "id": f"{code}_stop_loss", + "condition": {"price": f"<{stop_loss}"}, + "action": {"type": "sell", "amount": "all", "reason": "止损"}, + "priority": 0, + "rationale": "止损保护本金", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支1:回调买入(弱势情景适用) + if entry_low: + branches.append({ + "id": f"{code}_buy_dip", + "condition": {"scenario": "weak_consolidation", "price": f"<={entry_high}", "price_lower": f">={entry_low}"}, + "action": {"type": "buy", "amount": "normal", "limit": entry_low, "reason": "回调支撑买入"}, + "priority": 1, + "rationale": "价格回调到支撑区,弱势市场低吸", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支2:突破追涨(强势情景适用) + if take_profit: + branches.append({ + "id": f"{code}_breakout_chase", + "condition": {"scenario": "bullish_recovery", "price": f">={take_profit}"}, + "action": {"type": "buy", "amount": "normal", "limit": "market", "reason": "突破确认追涨"}, + "priority": 2, + "rationale": "价格突破阻力,确认上升趋势后买入", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支3:减仓(急跌情景适用) + branches.append({ + "id": f"{code}_trim", + "condition": {"scenario": "sharp_decline", "loss_pct": "<-15%"}, + "action": {"type": "sell", "amount": "half", "reason": "急跌降风险"}, + "priority": 3, + "rationale": "急跌市场,深套股减半仓减少敞口", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支4:止盈(浮盈较大) + if take_profit and entry_low: + branches.append({ + "id": f"{code}_take_profit", + "condition": {"price": f">={take_profit}"}, + "action": {"type": "sell", "amount": "half", "reason": "止盈锁利"}, + "priority": 4, + "rationale": "达到目标价,减半仓锁定利润", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支5:持有(默认) + branches.append({ + "id": f"{code}_hold", + "condition": {}, + "action": {"type": "hold", "reason": "无明确信号,继续持有"}, + "priority": 99, + "rationale": "没有分支匹配时的默认动作", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + return branches + + +# ── 组合约束检查 ────────────────────────────────────────────────────────── + +def check_portfolio_constraint(action_type, amount, cash_remain=None): + """组合约束检查:现金够不够?仓位上限?""" + try: + pf = mo_data.read_portfolio() + except Exception: + return True, "无法读取组合" + + if action_type == "buy": + # 估算买入金额 + cost_est = amount if amount else 100000 # default 10万 + if cash_remain is not None: + cost_est = cash_remain + if cost_est > pf.get("cash", 0): + return False, f"现金不足: 需要~{cost_est:.0f},可用{pf['cash']:.0f}" + + return True, "OK" + + +# ── CLI 入口 ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="多分支策略决策引擎") + parser.add_argument("--detect", action="store_true", help="检测当前情景") + parser.add_argument("--evaluate", type=str, help="评估指定股票的分支") + parser.add_argument("--prune", action="store_true", help="剪枝低效分支") + args = parser.parse_args() + + if args.detect: + sc = detect_scenario() + print(f"情景: {sc['id']} ({sc['label']})") + print(f"置信度: {sc['confidence']}") + print(f"组合动作: {sc['portfolio_action']}") + + if args.evaluate: + code = args.evaluate + sc = detect_scenario() + print(f"当前情景: {sc['id']} ({sc['label']})") + print(f"评估 {code}:") + results = evaluate_branches(code, sc["id"], 0, 0, 0) + for r in results: + status = "✅" if r["applicable"] else " " + print(f" {status} [{r['priority']}] {r['branch_id']} → {r['action_type']}: {r['rationale']}") + + if args.prune: + pruned = prune_low_performance_branches() + if pruned: + print(f"已剪枝: {len(pruned)} 条") + for p in pruned: + print(f" - {p}") + else: + print("无需要剪枝的分支") diff --git a/update_data.py b/update_data.py index a0014d7..97eb910 100644 --- a/update_data.py +++ b/update_data.py @@ -1,317 +1,317 @@ -#!/usr/bin/env python3 -"""update_data.py — 解析cron输出,更新dashboard数据层""" - -import json -import os -import re -from datetime import datetime -from pathlib import Path - -DATA_DIR = Path(__file__).parent / "data" - - -def _save(name, data): - path = DATA_DIR / name - os.makedirs(path.parent, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def parse_report(markdown_text, source_file=None): - """解析cron输出的markdown报告,提取结构化数据""" - report = { - "title": "", - "type": "未知", - "created_at": datetime.now().isoformat(), - "summary": "", - "content": markdown_text, - "stocks_mentioned": [], - "structured": None, # 结构化数据优先 - } - - lines = markdown_text.split("\n") - - # 提取标题 - for line in lines: - m = re.match(r"^#\s+(.+)", line) - if m: - report["title"] = m.group(1).strip() - break - m = re.match(r"^📊\s+(.+)", line) - if m: - report["title"] = m.group(1).strip() - break - - # 判断类型 - if "盘中" in report["title"]: - report["type"] = "盘中" - elif "盘后" in report["title"] or "复盘" in report["title"]: - report["type"] = "盘后" - elif "盯盘" in report["title"]: - report["type"] = "盯盘" - elif "扫描" in report["title"]: - report["type"] = "盘前" - - # ★ 优先提取结构化JSON(如果知微输出了的话) - struct_match = re.search(r'\s*(\{.*?\})\s*', markdown_text, re.DOTALL) - if struct_match: - try: - parsed = json.loads(struct_match.group(1)) - report["structured"] = parsed - # 从结构化数据中直接取stock codes - codes = set() - for h in parsed.get("holdings", []): - c = h.get("code", "") - if c: - codes.add(c) - report["stocks_mentioned"] = sorted(codes) - except (json.JSONDecodeError, Exception) as e: - pass # JSON解析失败→走NLP兜底 - - # 摘要(前3非空行) - body_lines = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("##")] - report["summary"] = "\n".join(body_lines[:5])[:200] - - # NLP兜底(仅当结构化数据没取到code时) - if not report["stocks_mentioned"]: - codes = set(re.findall(r'\b\d{6}\b', markdown_text)) - hk_codes = set(re.findall(r'\b\d{5}\b', markdown_text)) - report["stocks_mentioned"] = sorted(codes | hk_codes) - - return report - - -def import_cron_outputs(): - """从cron输出目录导入最新报告""" - cron_dir = Path.home() / ".hermes" / "cron" / "output" - reports_dir = DATA_DIR / "reports" - os.makedirs(reports_dir, exist_ok=True) - - count = 0 - if not cron_dir.exists(): - return count - - for job_dir in sorted(cron_dir.iterdir()): - if not job_dir.is_dir(): - continue - for f in sorted(job_dir.iterdir(), reverse=True)[:5]: # 每个job最近5个 - if f.suffix != ".md": - continue - # Skip if already imported - export_name = f"cron_{job_dir.name}_{f.stem}.json" - if (reports_dir / export_name).exists(): - continue - - content = f.read_text(encoding="utf-8", errors="replace") - report = parse_report(content, source_file=str(f)) - - # Extract response section - resp_match = re.search(r"## Response\n+(.*)", content, re.DOTALL) - if resp_match: - resp = resp_match.group(1).strip() - if resp == "[SILENT]": - continue # Skip SILENT reports - - report["_id"] = export_name.replace(".json", "") - _save(f"reports/{export_name}", report) - count += 1 - - return count - - -def extract_stock_mentions(): - """从报告中提取个股操作建议""" - reports_dir = DATA_DIR / "reports" - stocks_dir = DATA_DIR / "stocks" - os.makedirs(stocks_dir, exist_ok=True) - - stock_data = {} - - for f in sorted(reports_dir.iterdir()): - if f.suffix != ".json": - continue - try: - report = json.loads(f.read_text(encoding="utf-8")) - except: - continue - - content = report.get("content", "") - codes = report.get("stocks_mentioned", []) - - for code in codes: - if code not in stock_data: - stock_data[code] = {"code": code, "history": []} - - # Try to extract recommendation from content - # Look for patterns like "建议|止盈|止损|补仓|持有" - pattern = re.compile( - rf'.*?({code}).*?(建议|止盈|止损|补仓|持有|减仓|加仓|卖出|买入).*?(?:\n|$)', - re.IGNORECASE, - ) - for m in pattern.finditer(content): - stock_data[code]["history"].append({ - "time": report.get("created_at", ""), - "content": m.group(0).strip()[:100], - "report_id": report.get("_id", ""), - }) - - for code, data in stock_data.items(): - _save(f"stocks/{code}.json", data) - - return len(stock_data) - - -def sync_to_decisions(): - """将个股建议同步到决策库(advice_timeline),自动去重""" - decisions_path = DATA_DIR / "decisions.json" - if not decisions_path.exists(): - return 0 - - decisions = json.loads(decisions_path.read_text(encoding="utf-8")) - stocks_dir = DATA_DIR / "stocks" - synced = 0 - - for f in sorted(stocks_dir.iterdir()): - if f.suffix != ".json": - continue - try: - stock = json.loads(f.read_text(encoding="utf-8")) - except: - continue - - code = stock.get("code", "") - history = stock.get("history", []) - if not code or not history: - continue - - # 找决策库中是否有此股 - existing = None - for d in decisions["decisions"]: - if d["code"] == code: - existing = d - break - - if not existing: - # 无决策记录→生成inactive记录 - existing = { - "code": code, - "name": stock.get("name", ""), - "timestamp": datetime.now().isoformat(), - "type": "历史建议汇总", - "current": "自动从update_data同步", - "status": "inactive", - "updated_by": "system(update_data)", - "advice_timeline": [] - } - decisions["decisions"].append(existing) - - # 去重合并 - timeline = existing.setdefault("advice_timeline", []) - existing_keys = {(e["date"], e["direction"], e["summary"]) for e in timeline - if "date" in e and "direction" in e and "summary" in e} - - new_count = 0 - for entry in history: - content = entry.get("content", "") - # 判断方向 - direction = "其他" - if any(w in content for w in ["买入", "加仓", "入场", "🟢", "可加", "可入"]): - direction = "买入" - elif any(w in content for w in ["卖出", "止盈", "减仓", "止损", "清仓", "🔴", "锁定利润"]): - direction = "卖出" - elif any(w in content for w in ["持有", "观望", "👀", "🤝", "暂持", "继续持有"]): - direction = "持有" - - if direction == "其他": - continue - - # 提取日期 - rid = entry.get("report_id", "") - m = re.search(r'(\d{4}-\d{2}-\d{2})', rid) - date = m.group(1) if m else "unknown" - - key = (date, direction, content.strip()[:80]) - if key not in existing_keys: - existing_keys.add(key) - timeline.append({ - "date": date, - "direction": direction, - "summary": content.strip()[:120], - "report_id": rid - }) - new_count += 1 - - if new_count > 0: - # 按日期排序 - timeline.sort(key=lambda e: e.get("date", "")) - synced += new_count - - decisions_path.write_text( - json.dumps(decisions, ensure_ascii=False, indent=2), encoding="utf-8" - ) - return synced - - -def build_portfolio_from_obsidian(): - """读取Obsidian持仓数据,生成portfolio.json""" - import subprocess - # Attempt to read from Obsidian - obsidian_path = Path.home() / "Obsidian" / "knowledge" / "finance" - portfolio_file = obsidian_path / "dad-portfolio.md" - - holdings = [] - total_assets = 0 - stock_value = 0 - cash = 0 - - if portfolio_file.exists(): - content = portfolio_file.read_text(encoding="utf-8", errors="replace") - lines = content.split("\n") - - for line in lines: - m = re.match(r'\|.*?\|.*?(\d+)@(\d+\.?\d*)@.*?\|(\d+\.?\d*)%?\|', line) - if m: - # Parse holding lines from markdown table - parts = [p.strip() for p in line.split("|")] - if len(parts) >= 8: - name = parts[1] if len(parts) > 1 else "" - code = parts[2] if len(parts) > 2 else "" - if code: - holdings.append({ - "code": code, - "name": name, - "position_pct": 0, - "cost": 0, - "shares": 0, - "price": 0, - "change_pct": 0, - }) - - return { - "holdings": holdings, - "total_assets": total_assets, - "stock_value": stock_value, - "cash": cash, - "position_pct": 0, - "total_pnl": 0, - "updated_at": datetime.now().isoformat(), - } - - -if __name__ == "__main__": - count = import_cron_outputs() - if count > 0: - print(f"📥 新增报告: {count}篇") - - stocks = extract_stock_mentions() - if stocks > 0: - print(f"📊 个股数据: {stocks}条") - - synced = sync_to_decisions() - if synced > 0: - print(f"📋 决策库: 新增{synced}条建议") - - # 有实质更新才发汇总,否则安静 - if count > 0 or stocks > 0 or synced > 0: - print(f"✅ {datetime.now().strftime('%m/%d %H:%M')} 数据同步完成") +#!/usr/bin/env python3 +"""update_data.py — 解析cron输出,更新dashboard数据层""" + +import json +import os +import re +from datetime import datetime +from pathlib import Path +from mo_data import read_decisions +from mofin_db import get_conn, write_holding_strategy + +DATA_DIR = Path(__file__).parent / "data" + + +def _save(name, data): + path = DATA_DIR / name + os.makedirs(path.parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def parse_report(markdown_text, source_file=None): + """解析cron输出的markdown报告,提取结构化数据""" + report = { + "title": "", + "type": "未知", + "created_at": datetime.now().isoformat(), + "summary": "", + "content": markdown_text, + "stocks_mentioned": [], + "structured": None, # 结构化数据优先 + } + + lines = markdown_text.split("\n") + + # 提取标题 + for line in lines: + m = re.match(r"^#\s+(.+)", line) + if m: + report["title"] = m.group(1).strip() + break + m = re.match(r"^📊\s+(.+)", line) + if m: + report["title"] = m.group(1).strip() + break + + # 判断类型 + if "盘中" in report["title"]: + report["type"] = "盘中" + elif "盘后" in report["title"] or "复盘" in report["title"]: + report["type"] = "盘后" + elif "盯盘" in report["title"]: + report["type"] = "盯盘" + elif "扫描" in report["title"]: + report["type"] = "盘前" + + # ★ 优先提取结构化JSON(如果知微输出了的话) + struct_match = re.search(r'\s*(\{.*?\})\s*', markdown_text, re.DOTALL) + if struct_match: + try: + parsed = json.loads(struct_match.group(1)) + report["structured"] = parsed + # 从结构化数据中直接取stock codes + codes = set() + for h in parsed.get("holdings", []): + c = h.get("code", "") + if c: + codes.add(c) + report["stocks_mentioned"] = sorted(codes) + except (json.JSONDecodeError, Exception) as e: + pass # JSON解析失败→走NLP兜底 + + # 摘要(前3非空行) + body_lines = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("##")] + report["summary"] = "\n".join(body_lines[:5])[:200] + + # NLP兜底(仅当结构化数据没取到code时) + if not report["stocks_mentioned"]: + codes = set(re.findall(r'\b\d{6}\b', markdown_text)) + hk_codes = set(re.findall(r'\b\d{5}\b', markdown_text)) + report["stocks_mentioned"] = sorted(codes | hk_codes) + + return report + + +def import_cron_outputs(): + """从cron输出目录导入最新报告""" + cron_dir = Path.home() / ".hermes" / "cron" / "output" + reports_dir = DATA_DIR / "reports" + os.makedirs(reports_dir, exist_ok=True) + + count = 0 + if not cron_dir.exists(): + return count + + for job_dir in sorted(cron_dir.iterdir()): + if not job_dir.is_dir(): + continue + for f in sorted(job_dir.iterdir(), reverse=True)[:5]: # 每个job最近5个 + if f.suffix != ".md": + continue + # Skip if already imported + export_name = f"cron_{job_dir.name}_{f.stem}.json" + if (reports_dir / export_name).exists(): + continue + + content = f.read_text(encoding="utf-8", errors="replace") + report = parse_report(content, source_file=str(f)) + + # Extract response section + resp_match = re.search(r"## Response\n+(.*)", content, re.DOTALL) + if resp_match: + resp = resp_match.group(1).strip() + if resp == "[SILENT]": + continue # Skip SILENT reports + + report["_id"] = export_name.replace(".json", "") + _save(f"reports/{export_name}", report) + count += 1 + + return count + + +def extract_stock_mentions(): + """从报告中提取个股操作建议""" + reports_dir = DATA_DIR / "reports" + stocks_dir = DATA_DIR / "stocks" + os.makedirs(stocks_dir, exist_ok=True) + + stock_data = {} + + for f in sorted(reports_dir.iterdir()): + if f.suffix != ".json": + continue + try: + report = json.loads(f.read_text(encoding="utf-8")) + except: + continue + + content = report.get("content", "") + codes = report.get("stocks_mentioned", []) + + for code in codes: + if code not in stock_data: + stock_data[code] = {"code": code, "history": []} + + # Try to extract recommendation from content + # Look for patterns like "建议|止盈|止损|补仓|持有" + pattern = re.compile( + rf'.*?({code}).*?(建议|止盈|止损|补仓|持有|减仓|加仓|卖出|买入).*?(?:\n|$)', + re.IGNORECASE, + ) + for m in pattern.finditer(content): + stock_data[code]["history"].append({ + "time": report.get("created_at", ""), + "content": m.group(0).strip()[:100], + "report_id": report.get("_id", ""), + }) + + for code, data in stock_data.items(): + _save(f"stocks/{code}.json", data) + + return len(stock_data) + + +def sync_to_decisions(): + """将个股建议同步到决策库(advice_timeline),自动去重""" + decisions = read_decisions() + stocks_dir = DATA_DIR / "stocks" + synced = 0 + conn = get_conn() + + for f in sorted(stocks_dir.iterdir()): + if f.suffix != ".json": + continue + try: + stock = json.loads(f.read_text(encoding="utf-8")) + except: + continue + + code = stock.get("code", "") + history = stock.get("history", []) + if not code or not history: + continue + + # 找决策库中是否有此股 + existing = None + for d in decisions.get("decisions", []): + if d["code"] == code: + existing = d + break + + if not existing: + # 无决策记录→生成inactive记录,写入 DB + existing = { + "code": code, + "name": stock.get("name", ""), + "timestamp": datetime.now().isoformat(), + "type": "历史建议汇总", + "current": "自动从update_data同步", + "status": "inactive", + "updated_by": "system(update_data)", + "advice_timeline": [], + "source": "update_data", + } + decisions["decisions"].append(existing) + write_holding_strategy(conn, code, stock.get("name", ""), existing) + + # 去重合并 + timeline = existing.setdefault("advice_timeline", []) + existing_keys = {(e["date"], e["direction"], e["summary"]) for e in timeline + if "date" in e and "direction" in e and "summary" in e} + + new_count = 0 + for entry in history: + content = entry.get("content", "") + # 判断方向 + direction = "其他" + if any(w in content for w in ["买入", "加仓", "入场", "🟢", "可加", "可入"]): + direction = "买入" + elif any(w in content for w in ["卖出", "止盈", "减仓", "止损", "清仓", "🔴", "锁定利润"]): + direction = "卖出" + elif any(w in content for w in ["持有", "观望", "👀", "🤝", "暂持", "继续持有"]): + direction = "持有" + + if direction == "其他": + continue + + # 提取日期 + rid = entry.get("report_id", "") + m = re.search(r'(\d{4}-\d{2}-\d{2})', rid) + date = m.group(1) if m else "unknown" + + key = (date, direction, content.strip()[:80]) + if key not in existing_keys: + existing_keys.add(key) + timeline.append({ + "date": date, + "direction": direction, + "summary": content.strip()[:120], + "report_id": rid + }) + new_count += 1 + + if new_count > 0: + # 按日期排序 + timeline.sort(key=lambda e: e.get("date", "")) + write_holding_strategy(conn, code, existing.get("name", ""), existing) + synced += new_count + + conn.close() + return synced + + +def build_portfolio_from_obsidian(): + """读取Obsidian持仓数据,生成portfolio.json""" + import subprocess + # Attempt to read from Obsidian + obsidian_path = Path.home() / "Obsidian" / "knowledge" / "finance" + portfolio_file = obsidian_path / "dad-portfolio.md" + + holdings = [] + total_assets = 0 + stock_value = 0 + cash = 0 + + if portfolio_file.exists(): + content = portfolio_file.read_text(encoding="utf-8", errors="replace") + lines = content.split("\n") + + for line in lines: + m = re.match(r'\|.*?\|.*?(\d+)@(\d+\.?\d*)@.*?\|(\d+\.?\d*)%?\|', line) + if m: + # Parse holding lines from markdown table + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 8: + name = parts[1] if len(parts) > 1 else "" + code = parts[2] if len(parts) > 2 else "" + if code: + holdings.append({ + "code": code, + "name": name, + "position_pct": 0, + "cost": 0, + "shares": 0, + "price": 0, + "change_pct": 0, + }) + + return { + "holdings": holdings, + "total_assets": total_assets, + "stock_value": stock_value, + "cash": cash, + "position_pct": 0, + "total_pnl": 0, + "updated_at": datetime.now().isoformat(), + } + + +if __name__ == "__main__": + count = import_cron_outputs() + if count > 0: + print(f"📥 新增报告: {count}篇") + + stocks = extract_stock_mentions() + if stocks > 0: + print(f"📊 个股数据: {stocks}条") + + synced = sync_to_decisions() + if synced > 0: + print(f"📋 决策库: 新增{synced}条建议") + + # 有实质更新才发汇总,否则安静 + if count > 0 or stocks > 0 or synced > 0: + print(f"✅ {datetime.now().strftime('%m/%d %H:%M')} 数据同步完成") # 什么新数据都没有→安静,不输出任何内容 \ No newline at end of file