From 66962ae1905e2b57bce292c7f66f3dfaee6cd7b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E5=BE=AE?= Date: Mon, 6 Jul 2026 14:01:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=A8=E9=9D=A2=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20delivery=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20+=20=E8=84=9A=E6=9C=AC=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=20+=20refresh=5Fmtf=5Fcache=20import=E4=BF=AE=E5=A4=8D=20+=20c?= =?UTF-8?q?lean=5Fwatchlist=E7=A1=AC=E7=BC=96=E7=A0=81=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复清单: - cron delivery修正:10个job从deliver=origin/all改为local,消除推送错误 - refresh_mtf_cache.py:替换strategy_lifecycle.PORTFOLIO_PATH导入为mofin_db直接读DB - clean_watchlist.py:修复mo_data导入名错误和JSON硬编码路径 - MoFin/scripts/与profile scripts互相补全,双向sync到一致 - price_monitor.py:现金源从portfolio_summary表改为cash_log(Dad确认的现金权威) - 更新watchlist.json加入000850华茂股份 --- data/candidate_pool.json | 288 +- data/portfolio.json | 182 +- data/price_history.json | 12 +- market.db | 0 price_monitor.py | 21 +- scripts/_watchdog_report.py | 35 + scripts/advice_reconciliation.py | 245 + scripts/bulk_strategy_regenerate.py | 10 + scripts/check-prompt-deps.py | 194 + scripts/check_key_levels.py | 70 + scripts/collect_evaluation_data.py | 373 ++ scripts/cron_to_xmpp.py | 356 ++ scripts/cron_to_xmpp.py.disabled | 351 ++ scripts/data/accuracy_stats.json | 17 + scripts/data/decisions.json | 3 + scripts/data/evaluation_input.json | 55 + scripts/data/mofin.db | 1 + scripts/data/stock_name_code_cache.json | 5566 +++++++++++++++++++++++ scripts/data_freshness.py | 63 + scripts/fix_portfolio_prices.py | 156 + scripts/get_realtime_prices.py | 94 + scripts/hk_rate.py | 124 + scripts/inject_xiaoguo_insight.py | 65 + scripts/market_insight.py | 200 + scripts/market_screener.py | 283 ++ scripts/market_watch.py | 221 + scripts/migrate_all.py | 700 +++ scripts/mo_alphasift_bridge.py | 252 + scripts/mo_bridge.py | 323 ++ scripts/mo_config.py | 226 + scripts/mo_data.py | 182 + scripts/mo_dsa_opinion.py | 75 + scripts/mo_models.py | 227 + scripts/mo_provider.py | 375 ++ scripts/mofin_db.py | 1282 ++++++ scripts/mofin_news.py | 129 + scripts/mofin_query.py | 169 + scripts/multi_timeframe.py | 642 +++ scripts/post_mortem.py | 121 + scripts/pre-flight-check.py | 225 + scripts/price_data_inject.py | 275 ++ scripts/price_monitor.py | 477 ++ scripts/price_monitor.py.bk | 827 ++++ scripts/refresh_mtf_cache.py | 31 +- scripts/regenerate_strategies.py | 100 + scripts/server.py | 1071 +++++ scripts/session_to_cron_bridge.py | 216 + scripts/stock_profile.py | 493 ++ scripts/stock_sector_enrich.py | 189 + scripts/strategy_evaluator.py | 566 +++ scripts/strategy_feedback.py | 258 ++ scripts/strategy_lifecycle.py | 2568 +++++++++++ scripts/strategy_tree.py | 443 ++ scripts/sync_dashboard.py | 78 + scripts/system_audit.py | 239 + scripts/system_health_check.py | 321 ++ scripts/technical_analysis.py | 422 ++ scripts/trend_detector.py | 303 ++ scripts/update_data.py | 317 ++ scripts/xiaoguo_news_processor.py | 266 ++ scripts/xiaoguo_sentiment_bridge.py | 74 + stock_analysis.db | 0 62 files changed, 23364 insertions(+), 83 deletions(-) create mode 100644 market.db create mode 100644 scripts/_watchdog_report.py create mode 100644 scripts/advice_reconciliation.py create mode 100644 scripts/bulk_strategy_regenerate.py create mode 100755 scripts/check-prompt-deps.py create mode 100644 scripts/check_key_levels.py create mode 100644 scripts/collect_evaluation_data.py create mode 100644 scripts/cron_to_xmpp.py create mode 100644 scripts/cron_to_xmpp.py.disabled create mode 100644 scripts/data/accuracy_stats.json create mode 100644 scripts/data/decisions.json create mode 100644 scripts/data/evaluation_input.json create mode 120000 scripts/data/mofin.db create mode 100644 scripts/data/stock_name_code_cache.json create mode 100644 scripts/data_freshness.py create mode 100644 scripts/fix_portfolio_prices.py create mode 100644 scripts/get_realtime_prices.py create mode 100644 scripts/hk_rate.py create mode 100644 scripts/inject_xiaoguo_insight.py create mode 100644 scripts/market_insight.py create mode 100644 scripts/market_screener.py create mode 100644 scripts/market_watch.py create mode 100644 scripts/migrate_all.py create mode 100644 scripts/mo_alphasift_bridge.py create mode 100644 scripts/mo_bridge.py create mode 100644 scripts/mo_config.py create mode 100644 scripts/mo_data.py create mode 100644 scripts/mo_dsa_opinion.py create mode 100644 scripts/mo_models.py create mode 100644 scripts/mo_provider.py create mode 100644 scripts/mofin_db.py create mode 100644 scripts/mofin_news.py create mode 100644 scripts/mofin_query.py create mode 100644 scripts/multi_timeframe.py create mode 100644 scripts/post_mortem.py create mode 100644 scripts/pre-flight-check.py create mode 100755 scripts/price_data_inject.py create mode 100644 scripts/price_monitor.py create mode 100644 scripts/price_monitor.py.bk create mode 100644 scripts/regenerate_strategies.py create mode 100644 scripts/server.py create mode 100644 scripts/session_to_cron_bridge.py create mode 100644 scripts/stock_profile.py create mode 100644 scripts/stock_sector_enrich.py create mode 100644 scripts/strategy_evaluator.py create mode 100644 scripts/strategy_feedback.py create mode 100644 scripts/strategy_lifecycle.py create mode 100644 scripts/strategy_tree.py create mode 100644 scripts/sync_dashboard.py create mode 100644 scripts/system_audit.py create mode 100644 scripts/system_health_check.py create mode 100644 scripts/technical_analysis.py create mode 100644 scripts/trend_detector.py create mode 100644 scripts/update_data.py create mode 100644 scripts/xiaoguo_news_processor.py create mode 100644 scripts/xiaoguo_sentiment_bridge.py create mode 100644 stock_analysis.db diff --git a/data/candidate_pool.json b/data/candidate_pool.json index 2337737..ce875d1 100644 --- a/data/candidate_pool.json +++ b/data/candidate_pool.json @@ -1,6 +1,6 @@ { - "last_updated": "2026-07-03 15:40", - "total_candidates": 15, + "last_updated": "2026-07-06 13:59", + "total_candidates": 23, "sectors_analyzed_today": [ "半导体", "金属新材料", @@ -831,6 +831,290 @@ "drop_reason": null, "trend_warning": false, "trend_note": "" + }, + { + "code": "300015", + "name": "爱尔眼科", + "sector": "医疗服务", + "xiaoguo_score": 8, + "xiaoguo_reason": "眼科连锁龙头,门店网络与品牌壁垒深厚,业绩增速稳健,估值处于历史中低位,资金关注度回升。", + "xiaoguo_strategy": { + "entry_range": "19.5-20.5元", + "stop_loss": "18.8元", + "target": "23.5元" + }, + "verified_price": 8.48, + "verified_change": -0.82, + "added_at": "2026-07-06 12:12", + "last_updated": "2026-07-06 13:22", + "num_observations": 3, + "score_history": [ + { + "date": "2026-07-06 12:12", + "score": 8 + }, + { + "date": "2026-07-06 12:37", + "score": 8 + }, + { + "date": "2026-07-06 13:22", + "score": 8 + } + ], + "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": "600763", + "name": "通策医疗", + "sector": "医疗服务", + "xiaoguo_score": 7, + "xiaoguo_reason": "口腔赛道核心标的,受宏观消费节奏影响短期承压,但区域扩张与单店盈利模型优化提供修复弹性,技术面呈筑底形态。", + "xiaoguo_strategy": { + "entry_range": "48.0-50.0元", + "stop_loss": "45.5元", + "target": "58.0元" + }, + "verified_price": 35.82, + "verified_change": 0.39, + "added_at": "2026-07-06 12:12", + "last_updated": "2026-07-06 12:12", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-06 12:12", + "score": 7 + } + ], + "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": "002044", + "name": "美年健康", + "sector": "医疗服务", + "xiaoguo_score": 7, + "xiaoguo_reason": "体检服务龙头,规模效应显著,疫后需求复苏+数字化运营降本增效,弹性较大但需跟踪单客价值修复进度。", + "xiaoguo_strategy": { + "entry_range": "10.5-11.2元", + "stop_loss": "9.8元", + "target": "13.0元" + }, + "verified_price": 4.84, + "verified_change": -1.63, + "added_at": "2026-07-06 12:12", + "last_updated": "2026-07-06 13:22", + "num_observations": 3, + "score_history": [ + { + "date": "2026-07-06 12:12", + "score": 7.5 + }, + { + "date": "2026-07-06 12:37", + "score": 7 + }, + { + "date": "2026-07-06 13:22", + "score": 7 + } + ], + "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": "600036", + "name": "招商银行", + "sector": "银行", + "xiaoguo_score": 8.5, + "xiaoguo_reason": "零售业务护城河深,资产质量与ROE居同业前列,股息率稳定在5%以上,机构资金持续回流龙头,估值修复空间明确。", + "xiaoguo_strategy": { + "entry_range": "32.50-33.50元", + "stop_loss": "31.20元", + "target": "36.00-37.50元" + }, + "verified_price": 37.48, + "verified_change": 1.76, + "added_at": "2026-07-06 12:17", + "last_updated": "2026-07-06 13:27", + "num_observations": 2, + "score_history": [ + { + "date": "2026-07-06 12:17", + "score": 8 + }, + { + "date": "2026-07-06 13:27", + "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": "002142", + "name": "宁波银行", + "sector": "银行", + "xiaoguo_score": 8.0, + "xiaoguo_reason": "城商行基本面标杆,不良率极低且拨备充足,财富管理与小微业务增长强劲,业绩弹性大,适合成长型配置。", + "xiaoguo_strategy": { + "entry_range": "24.00-25.00元", + "stop_loss": "22.80元", + "target": "28.50-30.00元" + }, + "verified_price": 30.53, + "verified_change": 0.79, + "added_at": "2026-07-06 12:17", + "last_updated": "2026-07-06 13:27", + "num_observations": 2, + "score_history": [ + { + "date": "2026-07-06 12:17", + "score": 7.5 + }, + { + "date": "2026-07-06 13:27", + "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": "" + }, + { + "code": "601288", + "name": "农业银行", + "sector": "银行", + "xiaoguo_score": 8.2, + "xiaoguo_reason": "国有大行中股息率领先(约6%+),PB处于历史低位,避险属性强,直接受益于化债推进与稳增长政策,资金承接力佳。", + "xiaoguo_strategy": { + "entry_range": "4.10-4.25元", + "stop_loss": "3.95元", + "target": "4.60-4.80元" + }, + "verified_price": 5.92, + "verified_change": 0.34, + "added_at": "2026-07-06 12:17", + "last_updated": "2026-07-06 13:27", + "num_observations": 2, + "score_history": [ + { + "date": "2026-07-06 12:17", + "score": 8.5 + }, + { + "date": "2026-07-06 13:27", + "score": 8.2 + } + ], + "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": "000516", + "name": "国际医学", + "sector": "医疗服务", + "xiaoguo_score": 6.5, + "xiaoguo_reason": "区域综合医院龙头,DRG/DIP改革下运营效率持续改善,但业绩波动性较高,适合技术面配合的波段操作。", + "xiaoguo_strategy": { + "entry_range": "5.80-6.10元", + "stop_loss": "5.40元", + "target": "7.20元" + }, + "verified_price": 3.99, + "verified_change": -0.25, + "added_at": "2026-07-06 12:37", + "last_updated": "2026-07-06 12:37", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-06 12:37", + "score": 6.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": "300244", + "name": "迪安诊断", + "sector": "医疗服务", + "xiaoguo_score": 7.5, + "xiaoguo_reason": "第三方医学检测龙头,受益于基层医疗扩容与ICL行业整合,现金流改善,估值具备安全边际。", + "xiaoguo_strategy": { + "entry_range": "10.8-11.5元", + "stop_loss": "10.2元", + "target": "13.8元" + }, + "verified_price": 19.52, + "verified_change": -3.46, + "added_at": "2026-07-06 13:22", + "last_updated": "2026-07-06 13:22", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-06 13:22", + "score": 7.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": "" } ] } \ No newline at end of file diff --git a/data/portfolio.json b/data/portfolio.json index 6791eb8..46620ea 100644 --- a/data/portfolio.json +++ b/data/portfolio.json @@ -5,162 +5,220 @@ "name": "腾讯", "shares": 100, "cost": 443.13, - "price": 446.4, + "position_pct": null, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 447.4, "market_value": 39063.0, - "change_pct": 3.53, - "currency": "HKD", - "position_pct": null + "change_pct": 3.76, + "currency": "HKD" }, { "code": "01088", "name": "中国神华", "shares": 500, "cost": 45.89, - "price": 40.72, + "position_pct": 2.14, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 40.66, "market_value": 17575.0, - "change_pct": 1.8, - "currency": "HKD", - "position_pct": 2.14 + "change_pct": 1.65, + "currency": "HKD" }, { "code": "01211", "name": "比亚迪股份", "shares": 600, "cost": 104.87, - "price": 84.25, + "position_pct": 4.62, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 84.15, "market_value": 44376.0, - "change_pct": 0.18, - "currency": "HKD", - "position_pct": 4.62 + "change_pct": 0.06, + "currency": "HKD" }, { "code": "01478", "name": "丘钛科技", "shares": 11000, "cost": 13.47, - "price": 6.75, + "position_pct": 7.97, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 6.74, "market_value": 64460.0, - "change_pct": -3.43, - "currency": "HKD", - "position_pct": 7.97 + "change_pct": -3.58, + "currency": "HKD" }, { "code": "02202", "name": "万科企业", "shares": 19700, "cost": 4.67, - "price": 2.33, + "position_pct": 4.6, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 2.32, "market_value": 40385.0, - "change_pct": -0.43, - "currency": "HKD", - "position_pct": 4.6 + "change_pct": -0.85, + "currency": "HKD" }, { "code": "300035", "name": "中科电气", "shares": 1400, "cost": 22.29, - "price": 14.02, + "position_pct": 2.42, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 14.03, "market_value": 19712.0, - "change_pct": -1.89, - "currency": "CNY", - "position_pct": 2.42 + "change_pct": -1.82, + "currency": "CNY" }, { "code": "300308", "name": "中际旭创", "shares": 100, "cost": 1316.53, - "price": 1116.03, + "position_pct": 15.27, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 1117.49, "market_value": 106181.0, - "change_pct": 0.0, - "currency": "CNY", - "position_pct": 15.27 + "change_pct": 0.13, + "currency": "CNY" }, { "code": "300750", "name": "宁德时代", "shares": 100, "cost": 401.78, - "price": 377.6, + "position_pct": 4.64, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 378.07, "market_value": 37726.0, - "change_pct": -0.63, - "currency": "CNY", - "position_pct": 4.64 + "change_pct": -0.51, + "currency": "CNY" }, { "code": "518880", "name": "黄金ETF华安", "shares": 2400, "cost": 12.19, - "price": 8.66, + "position_pct": 2.45, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 8.63, "market_value": 20832.0, - "change_pct": -0.08, - "currency": "CNY", - "position_pct": 2.45 + "change_pct": -0.45, + "currency": "CNY" }, { "code": "600563", "name": "法拉电子", "shares": 100, "cost": 147.18, - "price": 153.42, + "position_pct": 2.3, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 154.64, "market_value": 15021.0, - "change_pct": -2.32, - "currency": "CNY", - "position_pct": 2.3 + "change_pct": -1.54, + "currency": "CNY" }, { "code": "601899", "name": "紫金矿业", "shares": 2400, "cost": 39.89, - "price": 28.41, + "position_pct": 7.34, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 28.42, "market_value": 68784.0, - "change_pct": 2.12, - "currency": "CNY", - "position_pct": 7.34 + "change_pct": 2.16, + "currency": "CNY" }, { "code": "688411", "name": "海博思创", "shares": 200, "cost": 266.95, - "price": 262.98, + "position_pct": 6.31, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 263.0, "market_value": 52064.0, - "change_pct": 4.71, - "currency": "CNY", - "position_pct": 6.31 + "change_pct": 4.72, + "currency": "CNY" }, { "code": "688639", "name": "华恒生物", "shares": 2800, "cost": 21.51, - "price": 16.61, + "position_pct": 5.25, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 16.57, "market_value": 46480.0, - "change_pct": -0.3, - "currency": "CNY", - "position_pct": 5.25 + "change_pct": -0.54, + "currency": "CNY" }, { "code": "688981", "name": "中芯国际", "shares": 300, "cost": 126.07, - "price": 147.18, + "position_pct": 5.44, + "added_at": null, + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 147.6, "market_value": 40599.0, - "change_pct": 4.9, - "currency": "CNY", - "position_pct": 5.44 + "change_pct": 5.2, + "currency": "CNY" } ], - "cash": 289196.0, + "cash": 321271.0, "frozen_cash": 0.0, - "total_mv": 619713.76, - "total_assets": 908909.76, - "position_pct": 68.18, + "total_mv": 620482.46, + "total_assets": 941753.46, + "stock_value": 620482.46, + "position_pct": 65.89, "currency": "CNY", - "updated_at": "2026-07-06 11:35:19", - "stock_value": 619713.76 + "updated_at": "2026-07-06 13:06:24", + "total_pnl": null, + "cash_history": [] } \ No newline at end of file diff --git a/data/price_history.json b/data/price_history.json index 7d1311c..d828039 100644 --- a/data/price_history.json +++ b/data/price_history.json @@ -266,7 +266,7 @@ "date": "2026-07-06", "high": 90.58, "low": 80.46, - "close": 81.83 + "close": 81.34 } ], "000711": [ @@ -286,7 +286,7 @@ "date": "2026-07-06", "high": 5.65, "low": 5.0, - "close": 5.29 + "close": 5.17 } ], "001309": [ @@ -402,5 +402,13 @@ "low": 659.16, "close": 695.26 } + ], + "000700": [ + { + "date": "2026-07-06", + "high": 17.18, + "low": 16.3, + "close": 16.88 + } ] } \ No newline at end of file diff --git a/market.db b/market.db new file mode 100644 index 0000000..e69de29 diff --git a/price_monitor.py b/price_monitor.py index 9ced72e..017a4e1 100644 --- a/price_monitor.py +++ b/price_monitor.py @@ -153,14 +153,23 @@ def refresh_data_prices(): print(f"❌ DB写持仓失败(3次重试耗尽): {msg}", file=sys.stderr) break - # 重新计算市值(不变现金——DB的cash是权威) + # 重新计算市值(现金从cash_log取最新Dad确认值——price_monitor不再持有现金权威) mv = calc_total_mv(db_holdings) - # 读取DB当前的现金和冻结,不覆盖 - existing = conn.execute( - 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1' + # 从cash_log读取最新verified现金(Dad确认的才是权威),不读portfolio_summary + latest = conn.execute( + 'SELECT cash_after, frozen_after FROM cash_log ' + 'WHERE verified=1 ORDER BY id DESC LIMIT 1' ).fetchone() - db_cash = existing['cash'] if existing else 0.0 - db_frozen = existing['frozen_cash'] if existing else 0.0 + if latest: + db_cash = latest['cash_after'] or 0.0 + db_frozen = latest['frozen_after'] or 0.0 + else: + # 首次运行/无cash_log记录时回退到summary现存值 + existing = conn.execute( + 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1' + ).fetchone() + db_cash = existing['cash'] if existing else 0.0 + db_frozen = existing['frozen_cash'] if existing else 0.0 assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen}) position_pct = round(mv / assets * 100, 2) if assets > 0 else 0 write_portfolio_summary(conn, { diff --git a/scripts/_watchdog_report.py b/scripts/_watchdog_report.py new file mode 100644 index 0000000..f825265 --- /dev/null +++ b/scripts/_watchdog_report.py @@ -0,0 +1,35 @@ +import sys; sys.path.insert(0, '/home/hmo/MoFin') +from mo_data import * + +# Portfolio +pf = read_portfolio() +ta = pf.get('total_assets',0) +ca = pf.get('cash',0) +pp = pf.get('position_pct',0) +print(f"总资产: {ta:.0f} 现金: {ca:.0f} 仓位: {pp:.1f}%") +for h in pf.get('holdings', []): + c = h.get('currency','CNY') + price = h['price'] + cost = h['cost'] + profit_pct = (price/cost - 1)*100 if cost and cost else 0 + ps = f"{price:.2f}{' HKD' if c=='HKD' else ''}" + pp_h = h.get('position_pct') + if pp_h is None: pp_h = 0 + print(f" {h['code']} {h['name']} 价{ps} 仓{pp_h:.1f}% 盈{profit_pct:.1f}%") + +# Watchlist +print() +wl = read_watchlist() +for s in wl.get('stocks',[]): + try: + c = s.get('currency','CNY') + price = s.get('price') + if price is None: price = 0 + ps = f"{price:.2f}{' HKD' if c=='HKD' else ''}" + el = s.get('entry_low') + eh = s.get('entry_high') + enl = f"{el:.2f}" if el is not None else '?' + enh = f"{eh:.2f}" if eh is not None else '?' + print(f" 自选 {s['code']} {s.get('name','')} 价{ps} 入{enl}~{enh}") + except Exception as e: + print(f" ERROR {s.get('code','?')}: {e}") diff --git a/scripts/advice_reconciliation.py b/scripts/advice_reconciliation.py new file mode 100644 index 0000000..93f4fe7 --- /dev/null +++ b/scripts/advice_reconciliation.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""advice_reconciliation.py — 建议对账脚本 + +每周运行一次,对比 decisions.json 的 advice_timeline 与 portfolio.json +的实际持仓变化,统计准确率。 + +用法: + python3 advice_reconciliation.py # 正常对账 + python3 advice_reconciliation.py --force # 强制重新对账所有建议 +""" +import json +import sys +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 + +ACCURACY_PATH = Path(__file__).parent / "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 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 get_holding_change(portfolio, code): + """获取某只股票的当前持仓信息""" + holdings = portfolio.get("holdings", []) + for h in holdings: + if h["code"] == code: + return { + "code": code, + "name": h.get("name", ""), + "shares": h.get("shares", 0), + "cost": h.get("cost", 0), + "price": h.get("price", 0), + "position_pct": h.get("position_pct", 0), + } + return None # 已清仓 + +def evaluate_advice(advice, current_holding): + """评估一条建议是否正确 + + Returns: 'correct', 'partial', 'wrong', 'pending', 'unknown' + """ + direction = advice.get("direction", "") + status = advice.get("status", "pending") + + if status == "ignored": + return "ignored" + if status == "pending": + return "pending" + + if not current_holding: + # 股票已清仓 + if direction in ("卖出", "清仓", "减仓"): + return "correct" + elif direction in ("买入", "加仓", "补仓"): + return "wrong" + else: + return "unknown" + + shares = current_holding.get("shares", 0) + cost = current_holding.get("cost", 0) + price = current_holding.get("price", 0) + pnl_pct = (price - cost) / cost * 100 if cost > 0 else 0 + + if direction in ("买入", "加仓", "补仓"): + # 如果建议买入时价格低于现价,或浮盈为正 → 正确 + try: + advised_price = float(advice.get("price", 0)) + if advised_price > 0 and price > 0: + if price >= advised_price * 0.95: # 允许5%误差 + return "correct" + else: + return "wrong" + else: + return "unknown" + except: + return "unknown" + + elif direction in ("卖出", "清仓", "减仓"): + # 如果建议卖出时价格高于现价 → 正确(规避了下跌) + try: + advised_price = float(advice.get("price", 0)) + if advised_price > 0 and price > 0: + if price <= advised_price * 1.05: + return "correct" + else: + return "wrong" + else: + return "unknown" + except: + return "unknown" + + elif direction in ("持有", "观望"): + # 持有建议 → 看后续是否涨 + try: + advised_price = float(advice.get("price", 0)) + if advised_price > 0 and price > 0: + change = (price - advised_price) / advised_price * 100 + if change > -5: # 没跌超过5% + return "correct" + else: + return "wrong" + else: + return "unknown" + except: + return "unknown" + + elif direction == "自选": + # 自选建议无法直接对账 + return "unknown" + + return "unknown" + + +def run(): + force = "--force" in sys.argv + + decisions = read_decisions() + portfolio = read_portfolio() + old_stats = load_json(ACCURACY_PATH, {}) + + results = [] + total = {"correct": 0, "wrong": 0, "partial": 0, "unknown": 0, "pending": 0, "ignored": 0} + + for entry in decisions.get("decisions", []): + code = entry["code"] + name = entry.get("name", code) + timeline = entry.get("advice_timeline", []) + + if not timeline: + continue + + current_holding = get_holding_change(portfolio, code) + + for i, advice in enumerate(timeline): + # 跳过已评估过的(除非 --force) + if not force and advice.get("evaluated"): + # 计数已有结果 + result = advice.get("result", "unknown") + total[result] = total.get(result, 0) + 1 + continue + + result = evaluate_advice(advice, current_holding) + advice["evaluated"] = True + advice["result"] = result + advice["evaluated_at"] = datetime.now().isoformat() + total[result] = total.get(result, 0) + 1 + + results.append({ + "code": code, + "name": name, + "date": advice.get("date", ""), + "direction": advice.get("direction", ""), + "summary": advice.get("summary", ""), + "result": result, + }) + + # 保存更新后的 decisions 到 DB(含评估标记) + conn = get_conn() + for entry in decisions.get("decisions", []): + code = entry.get("code", "") + name = entry.get("name", code) + write_holding_strategy(conn, code, name, entry) + # 写入 advice_timeline 评估标记 + for adv in entry.get("advice_timeline", []): + conn.execute( + """INSERT OR REPLACE INTO advice_timeline + (id, code, date, direction, price, summary, status, + evaluated, result, evaluated_at, report_id) + VALUES ( + (SELECT id FROM advice_timeline WHERE code=? AND date=? AND direction=? AND summary=?), + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + )""", + (code, adv.get("date", ""), adv.get("direction", ""), adv.get("summary", ""), + code, adv.get("date", ""), adv.get("direction", ""), + adv.get("price"), adv.get("summary", ""), adv.get("status", ""), + 1 if adv.get("evaluated") else 0, + adv.get("result", ""), adv.get("evaluated_at", ""), + adv.get("report_id", ""))) + conn.commit() + conn.close() + + # 计算准确率 + evaluated = total["correct"] + total["wrong"] + total["partial"] + accuracy = round(total["correct"] / evaluated * 100, 1) if evaluated > 0 else 0 + + stats = { + "updated_at": datetime.now().isoformat(), + "period_start": old_stats.get("period_start", (datetime.now() - timedelta(days=7)).isoformat()), + "period_end": datetime.now().isoformat(), + "total_advice": sum(total.values()), + "correct": total["correct"], + "wrong": total["wrong"], + "partial": total["partial"], + "unknown": total["unknown"], + "pending": total["pending"], + "ignored": total["ignored"], + "evaluated": evaluated, + "accuracy_pct": accuracy, + "details": results, + # 累计统计 + "cumulative": { + "total": old_stats.get("cumulative", {}).get("total", 0) + evaluated, + "correct": old_stats.get("cumulative", {}).get("correct", 0) + total["correct"], + "wrong": old_stats.get("cumulative", {}).get("wrong", 0) + total["wrong"], + }, + } + + cum = stats["cumulative"] + cum_accuracy = round(cum["correct"] / cum["total"] * 100, 1) if cum["total"] > 0 else 0 + stats["cumulative_accuracy_pct"] = cum_accuracy + + save_json(ACCURACY_PATH, stats) + + # 输出摘要 + print(f"📊 建议对账报告") + print(f" 周期: {stats['period_start'][:10]} ~ {stats['period_end'][:10]}") + print(f" 总建议: {stats['total_advice']}") + print(f" ✅ 正确: {stats['correct']}") + print(f" ❌ 错误: {stats['wrong']}") + print(f" ⏳ 待确认: {stats['pending']}") + print(f" ✗ 已忽略: {stats['ignored']}") + print(f" ❓ 无法判断: {stats['unknown']}") + print(f" 📈 本期准确率: {accuracy}%") + print(f" 📈 累计准确率: {cum_accuracy}%") + + if results: + print(f"\n 详情:") + for r in results[:20]: + icon = {"correct": "✅", "wrong": "❌", "partial": "🟡", "unknown": "❓", "pending": "⏳", "ignored": "✗"} + print(f" {icon.get(r['result'], '?')} {r['name']}({r['code']}) {r['direction']} → {r['result']}") + + +if __name__ == "__main__": + run() diff --git a/scripts/bulk_strategy_regenerate.py b/scripts/bulk_strategy_regenerate.py new file mode 100644 index 0000000..58a24c6 --- /dev/null +++ b/scripts/bulk_strategy_regenerate.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""收盘后全量策略重评(no_agent 脚本)""" +import sys, json +sys.path.insert(0, "/home/hmo/web-dashboard") +from strategy_lifecycle import regenerate_all, load_macro_context + +bias, desc = load_macro_context() +print(f"宏观参考: {desc} (bias={bias})") +r = regenerate_all(stdout=False) +print(f"{json.dumps(r, ensure_ascii=False, indent=2)}") diff --git a/scripts/check-prompt-deps.py b/scripts/check-prompt-deps.py new file mode 100755 index 0000000..1861dcc --- /dev/null +++ b/scripts/check-prompt-deps.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +检查 MoFin prompt-manager 的跨提示词依赖一致性。 + +功能: +1. 对每个有 depends_on 的 prompt,验证上游 prompt 的 current_version 匹配 +2. 对每个有 impacts 的 prompt,验证下游 prompt 的 depends_on.version 是否已正确更新 +3. 输出状态:✅ 全部一致 / ⚠️ 有差异(列出差异详情) + +用法: + python3 check-prompt-deps.py + python3 check-prompt-deps.py --registry /path/to/registry.json + +集成方式: + 每次做策略分析前,或每次更新提示词后,运行一次确认一致性。 +""" + +import json +import sys +import os +from pathlib import Path + +DEFAULT_REGISTRY = "/home/hmo/projects/MoFin/data/prompts/registry.json" + +def load_registry(path: str) -> dict: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + +def prompt_map(registry: dict) -> dict: + """将 prompts 数组转为 id -> prompt 的字典""" + return {p["id"]: p for p in registry.get("prompts", [])} + +def check_depends_on(pm: dict) -> list: + """检查所有 depends_on 声明是否匹配上游的 current_version""" + issues = [] + for p in pm.values(): + deps = p.get("depends_on") + if not deps: + continue + target_id = deps.get("prompt") + expected_ver = deps.get("version") + target = pm.get(target_id) + if not target: + issues.append({ + "type": "MISSING_UPSTREAM", + "prompt": p["id"], + "detail": f"依赖的上游提示词 '{target_id}' 在 registry 中不存在" + }) + continue + actual_ver = target.get("current_version") + if actual_ver != expected_ver: + issues.append({ + "type": "VERSION_MISMATCH", + "prompt": p["id"], + "detail": ( + f"{p['id']}.depends_on → {target_id}@{expected_ver}, " + f"但 {target_id}.current_version = {actual_ver}" + ) + }) + return issues + +def check_impacts_fulfilled(pm: dict) -> list: + """检查有 impacts 声明的 prompt,其下游是否已更新 depends_on 到当前版本""" + issues = [] + for p in pm.values(): + impacted = p.get("impacts") + if not impacted: + continue + my_id = p["id"] + my_ver = p.get("current_version") + for downstream_id in impacted: + downstream = pm.get(downstream_id) + if not downstream: + issues.append({ + "type": "MISSING_DOWNSTREAM", + "prompt": my_id, + "detail": f"impacts 中列出的 '{downstream_id}' 在 registry 中不存在" + }) + continue + dd = downstream.get("depends_on", {}) + expected_ver = dd.get("version") if dd.get("prompt") == my_id else None + if expected_ver is None: + issues.append({ + "type": "MISSING_DEPENDS_ON", + "prompt": my_id, + "detail": ( + f"{my_id}.impacts 声明了 {downstream_id}, " + f"但 {downstream_id} 没有 depends_on → {my_id}" + ) + }) + elif expected_ver != my_ver: + issues.append({ + "type": "STALE_DEPENDENCY", + "prompt": my_id, + "detail": ( + f"{my_id}.current_version = {my_ver}, " + f"但 {downstream_id}.depends_on.version = {expected_ver}" + ) + }) + return issues + +def check_integrity(pm: dict) -> list: + """基本格式校验""" + issues = [] + for p in pm.values(): + deps = p.get("depends_on") + if deps: + if not deps.get("prompt") or not deps.get("version"): + issues.append({ + "type": "INVALID_DEPENDS_ON", + "prompt": p["id"], + "detail": "depends_on 结构不完整,需包含 prompt 和 version" + }) + if not deps.get("reason"): + issues.append({ + "type": "MISSING_REASON", + "prompt": p["id"], + "detail": "depends_on 缺少 reason 字段(说明为什么依赖)" + }) + impacts = p.get("impacts") + if impacts and not isinstance(impacts, list): + issues.append({ + "type": "INVALID_IMPACTS", + "prompt": p["id"], + "detail": "impacts 必须是数组" + }) + return issues + +def main(): + reg_path = DEFAULT_REGISTRY + if len(sys.argv) > 2 and sys.argv[1] == "--registry": + reg_path = sys.argv[2] + if not os.path.exists(reg_path): + print(f"❌ 找不到 registry 文件: {reg_path}") + sys.exit(1) + + registry = load_registry(reg_path) + pm = prompt_map(registry) + + all_issues = [] + all_issues.extend(check_integrity(pm)) + all_issues.extend(check_depends_on(pm)) + all_issues.extend(check_impacts_fulfilled(pm)) + + print(f"📋 MoFin 提示词版本依赖检查 ({registry.get('updated_at', '?')})") + print(f" 共 {len(pm)} 个提示词") + print() + + if not all_issues: + print("✅ 全部一致,无依赖问题") + # 打印当前依赖状态一览 + print() + for p in pm.values(): + deps = p.get("depends_on") + impacts = p.get("impacts") + status = p["id"] + if deps: + status += f" ← 依赖 {deps['prompt']}@{deps['version']}" + if impacts: + status += f" → 影响 {', '.join(impacts)}" + print(f" {status}") + sys.exit(0) + + # 按类型分组输出 + categories = { + "MISSING_UPSTREAM": "上游提示词缺失", + "VERSION_MISMATCH": "依赖版本不匹配", + "MISSING_DOWNSTREAM": "下游提示词缺失", + "MISSING_DEPENDS_ON": "下游缺少 depends_on 声明", + "STALE_DEPENDENCY": "下游依赖版本未同步更新", + "INVALID_DEPENDS_ON": "depends_on 结构不完整", + "MISSING_REASON": "缺少依赖原因说明", + "INVALID_IMPACTS": "impacts 格式错误", + } + + has_error = False + for issue in all_issues: + cat = categories.get(issue["type"], issue["type"]) + marker = "❌" if issue["type"] in ("VERSION_MISMATCH", "STALE_DEPENDENCY", "MISSING_UPSTREAM") else "⚠️" + if marker == "❌": + has_error = True + print(f"{marker} [{issue['prompt']}] {cat}") + print(f" {issue['detail']}") + + print() + if has_error: + print("❗ 存在必须修复的依赖问题,请在更新提示词前先处理。") + else: + print("💡 无版本冲突,但上面有信息缺失/规范问题,建议补充。") + + sys.exit(1 if has_error else 0) + +if __name__ == "__main__": + main() diff --git a/scripts/check_key_levels.py b/scripts/check_key_levels.py new file mode 100644 index 0000000..3d3801b --- /dev/null +++ b/scripts/check_key_levels.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +import json +import sys + +# 读取实时数据 +with open('data/temp_realtime.json', 'r') as f: + data = json.load(f) + +prices = data['prices'] +holdings = data['holdings'] + +print("=== 关键价位检查 ===") + +# 检查每个持仓的关键价位 +for holding in holdings: + code = holding['code'] + name = holding['name'] + current_price = holding['price'] + analysis = holding.get('analysis', {}) + + stop_loss = analysis.get('stop_loss') + take_profit = analysis.get('take_profit') + + if stop_loss and stop_loss != '': + try: + stop_loss_val = float(stop_loss) + distance_pct = (current_price - stop_loss_val) / stop_loss_val * 100 + if distance_pct < 5: # 距离止损不到5% + print(f"⚠️ {name}({code}) 现价{current_price} 距止损{stop_loss_val}仅{abs(distance_pct):.1f}%") + except: + pass + + if take_profit and take_profit != '': + try: + take_profit_val = float(take_profit) + distance_pct = (take_profit_val - current_price) / current_price * 100 + if distance_pct < 5: # 距离止盈不到5% + print(f"🎯 {name}({code}) 现价{current_price} 距止盈{take_profit_val}仅{abs(distance_pct):.1f}%") + except: + pass + +# 检查涨跌幅超过5%的股票 +print("\n=== 异动股票检查(涨跌幅>5%) ===") +for code, price_data in prices.items(): + change_pct = price_data['change_pct'] + if abs(change_pct) >= 5: + name = price_data['name'] + price = price_data['price'] + print(f"{'📈' if change_pct > 0 else '📉'} {name}({code}) 现价{price} {change_pct:+.2f}%") + +# 检查决策库中的操作区间 +print("\n=== 决策库操作区间检查 ===") +# 这里需要读取决策库,但数据太大,我们只检查几个关键股票 +key_stocks = ['06160', '600110', '688411', '01478'] + +for code in key_stocks: + if code in prices: + price_data = prices[code] + name = price_data['name'] + price = price_data['price'] + + # 根据历史回顾判断 + if code == '06160': + print(f"🔵 {name}({code}) 现价{price} → 两批试仓已完成,止损160安全,目标175/185") + elif code == '600110': + print(f"🔵 {name}({code}) 现价{price} → 6月4日已按11.5~11.8加仓,现价仍在区间内") + elif code == '688411': + print(f"⚠️ {name}({code}) 现价{price} → 大涨10.87%,追踪止盈290接近,注意风险") + elif code == '01478': + print(f"⚠️ {name}({code}) 现价{price} → 反弹7.44%,深套股反弹至13~14可减仓") \ No newline at end of file diff --git a/scripts/collect_evaluation_data.py b/scripts/collect_evaluation_data.py new file mode 100644 index 0000000..f27d4bf --- /dev/null +++ b/scripts/collect_evaluation_data.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""collect_evaluation_data.py — 六维评估原始数据采集 + +纯数据收集脚本(no_agent),不做任何评估/判断/RR计算。 +输出:data/evaluation_input.json — 供 21:00 LLM cron 使用。 + +采集内容: +D1 宏观环境 — 五大指数(上证/深证/恒生/恒科/A50) +D2 行业表现 — 持仓+自选按行业分组 +D3 技术面(当前) — 今开/今高/今低/昨收/现价/成交量 +D4 基本面 — PE/PB/总市值/52周高/52周低 +D5 消息面 — (此脚本不采集,LLM cron web_search) +D6 资金面 — 成交额/换手率/量比 + +日期:2026-06-18 v1 — 初始版本 +""" + +import json +import urllib.request +import os +import sys +import re +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" +PROFILES_PATH = DATA_DIR / "stock_profiles.json" +OUTPUT_PATH = DATA_DIR / "evaluation_input.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_tencent_data(symbols): + """批量拉行情。DB 优先,腾讯 API fallback""" + if not symbols: + return {} + # DB 优先 + try: + from mofin_db import get_prices_batch_from_db + db = get_prices_batch_from_db(symbols) + if db: + return {code: {"name": "", "price": p, "prev_close": 0, "change_pct": chg or 0, + "high": 0, "low": 0} for code, (p, chg) in db.items()} + except: pass + # Fallback: 腾讯 + code_map = {} + query_symbols = [] + for c in symbols: + sym = f"hk{c}" if len(c) == 5 else f"sh{c}" if c.startswith(("5", "6", "9")) else f"sz{c}" + query_symbols.append(sym) + code_map[sym] = c + url = f"http://qt.gtimg.cn/q={','.join(query_symbols)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + resp = urllib.request.urlopen(req, timeout=15) + text = resp.read().decode("gbk") + except Exception as e: + print(f"行情拉取失败: {e}", file=sys.stderr) + return {} + result = {} + 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) < 35: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + orig = code_map.get(sym) + if not orig: + continue + # 统一格式(A股和港股字段长度不同) + result[orig] = fields + return result + + +def fetch_indices(): + """拉五大指数""" + index_codes = { + "sh000001": "上证指数", + "sz399001": "深证成指", + "sz399006": "创业板指", + "hkHSI": "恒生指数", + "hkHSTECH": "恒生科技", + } + idx_map = {} + for c, n in index_codes.items(): + sym = c # 已经是完整符号 + idx_map[sym] = n + url = f"http://qt.gtimg.cn/q={','.join(index_codes.keys())}" + 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 {} + result = {} + 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_") + name = idx_map.get(sym, sym) + result[name] = { + "price": safe_float(fields[3]), + "prev_close": safe_float(fields[4]), + "change_pct": safe_float(fields[32]), + "high": safe_float(fields[33]), + "low": safe_float(fields[34]), + "timestamp": fields[30] if len(fields) > 30 else "", + } + return result + + +def safe_float(v): + try: + return float(v) if v else None + except (ValueError, TypeError): + return None + + +def parse_stock_data(code, fields, is_hk=False): + """从腾讯 API 字段解析为结构化数据""" + data = { + "code": code, + "name": fields[1] if len(fields) > 1 else code, + "price": safe_float(fields[3]), + "prev_close": safe_float(fields[4]), + "open": safe_float(fields[5]) if not is_hk else None, + "change_pct": safe_float(fields[32]), + "high": safe_float(fields[33]), + "low": safe_float(fields[34]), + "volume": safe_float(fields[6]), # 股数 + } + # A股特有字段 (index 35+) + if not is_hk and len(fields) > 46: + data["turnover_rate"] = safe_float(fields[38]) # 换手率% + data["pe"] = safe_float(fields[47]) # 市盈率(动) + data["total_market_cap"] = safe_float(fields[44]) # 总市值(亿) + data["circulating_market_cap"] = safe_float(fields[45]) # 流通市值(亿) + data["high_52w"] = safe_float(fields[48]) # 52周高 + data["low_52w"] = safe_float(fields[49]) # 52周低 + data["amplitude"] = safe_float(fields[43]) # 振幅% + # 港股特有字段 + if is_hk and len(fields) > 70: + # 港股 PE 在 [71] 左右 + data["pe"] = safe_float(fields[71]) + data["total_market_cap"] = safe_float(fields[69]) + data["high_52w"] = safe_float(fields[48]) + data["low_52w"] = safe_float(fields[49]) + return data + + +def get_sector_mapping(profiles, decisions): + """ + 从 stock_profiles.json 和 decisions.json 建立 + {code: {name, sector, business, market, type}} 映射 + """ + mapping = {} + # 先读 stock_profiles + profile_list = profiles.get("profiles", []) if isinstance(profiles, dict) else profiles + if isinstance(profile_list, list): + for p in profile_list: + code = p.get("code", "") + if code: + mapping[code] = { + "name": p.get("name", ""), + "sector": p.get("sector", ""), + "business": p.get("business", ""), + "market": p.get("market", ""), + "type": p.get("type", ""), + } + # 再补全 decisions.json 中的信息 + for d in decisions.get("decisions", []): + code = d.get("code", "") + if code and code not in mapping: + trig = d.get("trigger", {}) + mapping[code] = { + "name": d.get("name", code), + "sector": trig.get("sector_name", d.get("sector_name", "")), + "business": "", + "market": "港股" if len(code) == 5 else "A股", + "type": d.get("type", "持仓策略"), + } + return mapping + + +def get_portfolio_info(portfolio): + """建立 {code: {cost, shares, position_pct}} 映射""" + result = {} + for h in portfolio.get("holdings", []): + code = h.get("code", "") + result[code] = { + "cost": h.get("cost", 0), + "shares": h.get("shares", 0), + "position_pct": h.get("position_pct", 0), + } + return result + + +def get_decisions_info(decisions): + """提取 decisions.json 中的策略参数""" + return decisions.get("decisions", []) + + +def run(): + # 加载数据 + from mo_data import read_decisions, read_portfolio + decisions = read_decisions() + portfolio = read_portfolio() + profiles = load_json(PROFILES_PATH, {"profiles": []}) + + # 获取行业映射 + sector_mapping = get_sector_mapping(profiles, decisions) + + # 获取持仓信息 + portfolio_info = get_portfolio_info(portfolio) + + # 收集所有代码 + all_codes = set() + for d in decisions.get("decisions", []): + code = d.get("code", "") + if code: + all_codes.add(code) + for h in portfolio.get("holdings", []): + code = h.get("code", "") + if code: + all_codes.add(code) + + # 区分 A/H 股 + a_codes = [c for c in all_codes if len(c) != 5] + hk_codes = [c for c in all_codes if len(c) == 5] + + # 拉行情 + a_prices = fetch_tencent_data(a_codes) if a_codes else {} + hk_prices = fetch_tencent_data(hk_codes) if hk_codes else {} + + # 拉指数 + index_data = fetch_indices() + + # 解析个股数据 + stock_data = {} + for code in a_codes: + if code in a_prices: + stock_data[code] = parse_stock_data(code, a_prices[code], is_hk=False) + for code in hk_codes: + if code in hk_prices: + stock_data[code] = parse_stock_data(code, hk_prices[code], is_hk=True) + + # 组装输出 + stocks = [] + all_codes_sorted = sorted(all_codes) + + for code in all_codes_sorted: + raw = stock_data.get(code, {}) + sector_info = sector_mapping.get(code, {}) + port = portfolio_info.get(code, {}) + strategy = None + for d in decisions.get("decisions", []): + if d.get("code") == code: + trig = d.get("trigger", {}) + strategy = { + "action": trig.get("action", d.get("action", "")), + "entry_zone": trig.get("entry_zone", ""), + "stop_loss": trig.get("stop_loss", d.get("stop_loss", "")), + "take_profit": trig.get("take_profit", d.get("take_profit", "")), + "type": d.get("type", "持仓策略"), + "tech_snapshot": trig.get("tech_snapshot", d.get("tech_snapshot", "")), + } + break + + stock_entry = { + "code": code, + "name": raw.get("name", sector_info.get("name", code)), + "market": "港股" if len(code) == 5 else "A股", + "type": sector_info.get("type", "持仓策略"), + "sector": sector_info.get("sector", ""), + "business": sector_info.get("business", ""), + # 当天行情 + "price": raw.get("price"), + "prev_close": raw.get("prev_close"), + "open": raw.get("open"), + "high": raw.get("high"), + "low": raw.get("low"), + "change_pct": raw.get("change_pct"), + "volume": raw.get("volume"), + # 基本面 + "pe": raw.get("pe"), + "total_market_cap": raw.get("total_market_cap"), + "high_52w": raw.get("high_52w"), + "low_52w": raw.get("low_52w"), + "turnover_rate": raw.get("turnover_rate"), + "amplitude": raw.get("amplitude"), + # 持仓 + "cost": port.get("cost", 0), + "shares": port.get("shares", 0), + "position_pct": port.get("position_pct", 0), + # 现策略 + "strategy": strategy, + } + # 浮亏% + cost = port.get("cost", 0) + price = raw.get("price", 0) + if cost > 0 and price > 0: + stock_entry["pnl_pct"] = round((price - cost) / cost * 100, 2) + else: + stock_entry["pnl_pct"] = None + + stocks.append(stock_entry) + + # 按行业分组统计 + sector_groups = {} + for s in stocks: + sector = s.get("sector", "未分类") + if sector not in sector_groups: + sector_groups[sector] = [] + sector_groups[sector].append({ + "code": s["code"], + "name": s["name"], + "change_pct": s["change_pct"], + "pnl_pct": s["pnl_pct"], + "type": s["type"], + }) + + # 汇总 + total = len(stocks) + up_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] > 0) + down_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] < 0) + deep_loss = sum(1 for s in stocks if s["pnl_pct"] is not None and s["pnl_pct"] < -20) + + output = { + "collected_at": datetime.now().isoformat(), + "total_stocks": total, + "summary": { + "up_count": up_count, + "down_count": down_count, + "deep_loss_count": deep_loss, + "holdings_count": len(portfolio_info), + "watchlist_count": total - len(portfolio_info), + }, + "index_data": index_data, + "sector_groups": sector_groups, + "stocks": stocks, + } + + save_json(OUTPUT_PATH, output) + print(f"数据收集完成: {total}只股票, {len(index_data)}个指数, {len(sector_groups)}个行业分组") + print(f" 上涨{up_count} 下跌{down_count} 深套{deep_loss}") + print(f" 输出: {OUTPUT_PATH}") + + +if __name__ == "__main__": + run() diff --git a/scripts/cron_to_xmpp.py b/scripts/cron_to_xmpp.py new file mode 100644 index 0000000..e32acf1 --- /dev/null +++ b/scripts/cron_to_xmpp.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +"""cron_to_xmpp.py — 智能cron报告推送 + +只推送LLM驱动的分析报告(有实质内容),不推送纯脚本输出。 +关键规则: +1. 跳过 no_agent 脚本的输出(价格监控、数据同步等机器数据) +2. 跳过自己的输出目录(30908cdc44a8),避免循环推送 +3. 正文太短(<20字)或只有 [SILENT] 的不推 +4. 超时自动跳过,不影响后续 +""" +import json +import subprocess +import re +import sys +from datetime import datetime +from pathlib import Path + +# 使用绝对路径,不受 profile 环境变量影响 +REAL_HOME = Path("/home/hmo") + +# 扫描目录 +CRON_DIRS = [ + REAL_HOME / ".hermes" / "cron" / "output", + REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "output", +] +JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json" +SILENT_STATS = REAL_HOME / ".hermes" / "cron" / ".silent_daily_count.json" +MAX_AGE_HOURS = 6 # 只推送6小时内的报告,防止清journal后爆历史 + + +def load_no_agent_job_ids(): + """从两个profile的jobs.json中读取所有no_agent=true的job ID""" + ids = set() + for jobs_path in [ + REAL_HOME / ".hermes" / "cron" / "jobs.json", + REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "jobs.json", + ]: + try: + with open(jobs_path) as f: + data = json.load(f) + for j in data.get("jobs", []): + if j.get("no_agent"): + ids.add(j["id"]) + except: + pass + return ids + + +# 硬编码保底(如果 jobs.json 读不到) +SKIP_DIRS = { + "30908cdc44a8", # cron-推XMPP中继自身输出 + "a231e9c39b4e", # 知识研究-日常(由莫荷负责推送) + "7bda62d24d22", # 梦境循环-知识库归并(由莫荷负责推送) + "0cbf6c317c60", # evolution-pulse 跨文档连接(由莫荷负责推送) + "1160671067e0", # wiki-self-growth 自修复(由莫荷负责推送) + "health", # 健康检查输出 + "b9fa4482dc1a", # 自成长知识库-22:10中继推送(莫荷的通道) +} + +FROM = "zhiwei@yoin.fun" +TO = "hmo@yoin.fun" + + +def load_journal(): + try: + return set(json.loads(JOURNAL.read_text())) + except: + return set() + + +def save_journal(entries): + JOURNAL.write_text(json.dumps(sorted(entries))) + + +def is_pure_script_output(content): + """判断文件是否是纯脚本的机器输出(不是LLM报告)""" + # LLM报告的特征:有 ## Response 节(包含agent的回复) + if "## Response" in content: + return False + # 以 # Cron Job: 开头但没有 ## Response 的可能是脚本输出 + if content.startswith("# Cron Job:"): + return True + # 价格监控的触发输出 + if content.startswith("🔔") and "⏱" in content: + return True + # 健康检查报告 + if "MoFin 系统健康检查" in content: + return True + # [SILENT] 标记一概不拦 — 用户想看到报告结构,不想被静默 + # 移除 [SILENT] 过滤,让报告始终送达 + # 结构化数据标签(价格监控的机器数据) + if "" in content: + return True + # no_agent 脚本的输出特征(Hermes自动添加的header) + if "**Mode:** no_agent (script)" in content: + return True + return False + + +def validate_report_body(body): + """质量检查 — 不拦截,返回改进建议""" + issues = [] + text = body.strip() + + if "重点推荐操作" not in text: + issues.append("缺少【重点推荐操作】区域(如无需操作可写「无」)") + + if "风险关注" not in text: + issues.append("缺少【风险关注】区域(如无风险可写「无」)") + + if len(text) > 600: + issues.append(f"报告偏长({len(text)}字),建议压缩到600字以内") + + fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text) + if fuzzy: + issues.append(f"含模糊词: {', '.join(set(fuzzy))},建议替换为明确操作指令") + + if re.search(r"如果.*就.*如果.*就|若.*则.*若.*则", text): + issues.append("含选择题句式,建议只给一个确定建议") + + return issues + + +def send_feedback(issues, job_name): + """发送质量反馈给知微自己""" + from xml.sax.saxutils import escape + feedback = f"[自我反馈] 报告质量检查发现以下问题,下次注意:\n" + "\n".join(f"• {i}" for i in issues) + safe = escape(feedback) + stanza = ( + f"" + f"{safe}" + ) + try: + subprocess.run( + ["docker", "exec", "ejabberd", "ejabberdctl", + "send_stanza", FROM, FROM, stanza], + capture_output=True, timeout=10, text=True, + ) + except: + pass + + +def extract_body(path): + content = path.read_text(encoding="utf-8", errors="replace") + + if is_pure_script_output(content): + return None + + parts = content.split("## Response") + body = parts[1].strip() if len(parts) > 1 else content.strip() + body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip() + body = re.sub(r'\n?\s*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + if not body or len(body) < 20: + return None + + # 只过滤内容是纯[SILENT]的报告 + if body.strip() == "[SILENT]": + return None + + # 正文中混了[SILENT]标记(LLM写了报告又在末尾加了这个)— 去掉标记保留正文 + body = body.replace("[SILENT]", "").strip() + if len(body) < 20: + return None + + return body + + +def send(body): + from xml.sax.saxutils import escape + safe = escape(f"【知微】{body}") + stanza = ( + f"" + f"{safe}" + ) + # 重试3次 + for attempt in range(3): + try: + r = subprocess.run( + ["docker", "exec", "ejabberd", "ejabberdctl", + "send_stanza", FROM, TO, stanza], + capture_output=True, timeout=10, text=True, + ) + if r.stderr and "error" in r.stderr.lower(): + print(f"send error (attempt {attempt+1}): {r.stderr.strip()[:100]}", file=sys.stderr) + if attempt < 2: + continue + return False + return r.returncode == 0 + except subprocess.TimeoutExpired: + print(f"send timeout (attempt {attempt+1})", file=sys.stderr) + if attempt < 2: + continue + return False + except Exception as e: + print(f"send err (attempt {attempt+1}): {e}", file=sys.stderr) + if attempt < 2: + continue + return False + return False + + +def validate_format(body): + """格式检查 — 只记录不拦截,标记改进点""" + text = body.strip() + issues = [] + + # 必含区域检查 + has_key = "重点推荐操作" in text + has_risk = "风险关注" in text + has_rest = "其余持仓" in text or "今日关注" in text + if not has_key: + issues.append("缺【重点推荐操作】区域") + if not has_risk: + issues.append("缺【风险关注】区域") + + # 超长提醒 + if len(text) > 600: + issues.append(f"报告偏长({len(text)}字),建议压缩到600字内") + + # 模糊词提醒 + fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text) + if fuzzy: + issues.append(f"含模糊词({', '.join(list(set(fuzzy))[:3])}),应给唯一结论") + + # 选择题句式提醒 + if re.search(r"如果.*就|若.*则|可以.*也可以", text): + issues.append("含选择题句式,应给唯一建议") + + return text, issues # 始终通过,issues 为空就是干净 + + +def load_silent_stats(): + """加载当日静默统计""" + try: + return json.loads(SILENT_STATS.read_text()) + except: + return {"date": "", "silent": 0, "short": 0, "script": 0} + + +def save_silent_stats(stats): + SILENT_STATS.write_text(json.dumps(stats)) + + +def send_silent_summary(stats): + """发送当日静默报告汇总""" + parts = [] + if stats.get("silent", 0) > 0: + parts.append(f"静默[SILENT] {stats['silent']}次") + if stats.get("short", 0) > 0: + parts.append(f"过短(<20字) {stats['short']}次") + if stats.get("script", 0) > 0: + parts.append(f"脚本输出 {stats['script']}次") + + if not parts: + body = "【每日汇总】今日所有cron报告已正常送达,无被拦截的报告。" + else: + body = "【每日汇总】今日以下cron报告未送达(已拦截):\n" + "\n".join(f"• {p}" for p in parts) + "\n\n无操作信号的报告正常静默,有操作信号的都已送达。" + + send(body) + + +def scan(): + processed = load_journal() + new = set() + n_pushed = 0 + n_silent = 0 + n_short = 0 + n_script = 0 + no_agent_ids = load_no_agent_job_ids() + skip_all = SKIP_DIRS | no_agent_ids + + for cron_dir in CRON_DIRS: + if not cron_dir.exists(): + continue + + for d in sorted(cron_dir.iterdir()): + if not d.is_dir(): + continue + if d.name in skip_all: + continue + + for f in sorted(d.iterdir()): + if f.suffix != ".md": + continue + key = str(f.resolve()) + if key in processed or key in new: + continue + new.add(key) + + # 跳过超过MAX_AGE_HOURS小时的旧文件 + age_hours = (datetime.now() - datetime.fromtimestamp(f.stat().st_mtime)).total_seconds() / 3600 + if age_hours > MAX_AGE_HOURS: + continue + + content = f.read_text(encoding="utf-8", errors="replace") + + # 提前判断脚本输出 + if is_pure_script_output(content): + n_script += 1 + continue + + parts = content.split("## Response") + body = parts[1].strip() if len(parts) > 1 else content.strip() + body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip() + body = re.sub(r'\n?\s*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + if not body or len(body) < 20: + n_short += 1 + continue + + # SILENT → 拦截,记数 + if "[SILENT]" in body: + n_silent += 1 + continue + + # 格式校验 — 记录改进点,不拦截 + ok_body, issues = validate_format(body) + + n_pushed += 1 + ok_sent = send(body) + if not ok_sent: + print(f" {d.name}: send failed", file=sys.stderr) + if issues: + print(f" {d.name}/{f.name}: 改进建议: {'; '.join(issues)}", file=sys.stderr) + + if new: + save_journal(processed | new) + + # 保存当日汇总到文件(供16:30汇总用) + today = datetime.now().strftime("%Y-%m-%d") + stats = load_silent_stats() + if stats.get("date") != today: + stats = {"date": today, "silent": 0, "short": 0, "script": 0} + stats["silent"] += n_silent + stats["short"] += n_short + stats["script"] += n_script + save_silent_stats(stats) + + # 16:30~16:35 发送当日汇总(收盘后) + now = datetime.now() + hhmm = now.hour * 60 + now.minute + if 990 <= hhmm <= 995: # 16:30~16:35 + send_silent_summary(stats) + + log = f"推送{n_pushed}份,静默拦截{n_silent}份,过短{n_short}份,跳过脚本{n_script}份" + print(log, file=sys.stderr) + return n_pushed + + +if __name__ == "__main__": + scan() diff --git a/scripts/cron_to_xmpp.py.disabled b/scripts/cron_to_xmpp.py.disabled new file mode 100644 index 0000000..845fdad --- /dev/null +++ b/scripts/cron_to_xmpp.py.disabled @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""cron_to_xmpp.py — 智能cron报告推送 + +只推送LLM驱动的分析报告(有实质内容),不推送纯脚本输出。 +关键规则: +1. 跳过 no_agent 脚本的输出(价格监控、数据同步等机器数据) +2. 跳过自己的输出目录(30908cdc44a8),避免循环推送 +3. 正文太短(<20字)或只有 [SILENT] 的不推 +4. 超时自动跳过,不影响后续 +""" +import json +import subprocess +import re +import sys +from datetime import datetime +from pathlib import Path + +# 使用绝对路径,不受 profile 环境变量影响 +REAL_HOME = Path("/home/hmo") + +# 扫描目录 +CRON_DIRS = [ + REAL_HOME / ".hermes" / "cron" / "output", + REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "output", +] +JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json" +SILENT_STATS = REAL_HOME / ".hermes" / "cron" / ".silent_daily_count.json" +MAX_AGE_HOURS = 6 # 只推送6小时内的报告,防止清journal后爆历史 + + +def load_no_agent_job_ids(): + """从两个profile的jobs.json中读取所有no_agent=true的job ID""" + ids = set() + for jobs_path in [ + REAL_HOME / ".hermes" / "cron" / "jobs.json", + REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "jobs.json", + ]: + try: + with open(jobs_path) as f: + data = json.load(f) + for j in data.get("jobs", []): + if j.get("no_agent"): + ids.add(j["id"]) + except: + pass + return ids + + +# 硬编码保底(如果 jobs.json 读不到) +SKIP_DIRS = { + "30908cdc44a8", # cron-推XMPP中继自身输出 + "health", # 健康检查输出 +} + +FROM = "zhiwei@yoin.fun" +TO = "hmo@yoin.fun" + + +def load_journal(): + try: + return set(json.loads(JOURNAL.read_text())) + except: + return set() + + +def save_journal(entries): + JOURNAL.write_text(json.dumps(sorted(entries))) + + +def is_pure_script_output(content): + """判断文件是否是纯脚本的机器输出(不是LLM报告)""" + # LLM报告的特征:有 ## Response 节(包含agent的回复) + if "## Response" in content: + return False + # 以 # Cron Job: 开头但没有 ## Response 的可能是脚本输出 + if content.startswith("# Cron Job:"): + return True + # 价格监控的触发输出 + if content.startswith("🔔") and "⏱" in content: + return True + # 健康检查报告 + if "MoFin 系统健康检查" in content: + return True + # [SILENT] 标记一概不拦 — 用户想看到报告结构,不想被静默 + # 移除 [SILENT] 过滤,让报告始终送达 + # 结构化数据标签(价格监控的机器数据) + if "" in content: + return True + # no_agent 脚本的输出特征(Hermes自动添加的header) + if "**Mode:** no_agent (script)" in content: + return True + return False + + +def validate_report_body(body): + """质量检查 — 不拦截,返回改进建议""" + issues = [] + text = body.strip() + + if "重点推荐操作" not in text: + issues.append("缺少【重点推荐操作】区域(如无需操作可写「无」)") + + if "风险关注" not in text: + issues.append("缺少【风险关注】区域(如无风险可写「无」)") + + if len(text) > 600: + issues.append(f"报告偏长({len(text)}字),建议压缩到600字以内") + + fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text) + if fuzzy: + issues.append(f"含模糊词: {', '.join(set(fuzzy))},建议替换为明确操作指令") + + if re.search(r"如果.*就.*如果.*就|若.*则.*若.*则", text): + issues.append("含选择题句式,建议只给一个确定建议") + + return issues + + +def send_feedback(issues, job_name): + """发送质量反馈给知微自己""" + from xml.sax.saxutils import escape + feedback = f"[自我反馈] 报告质量检查发现以下问题,下次注意:\n" + "\n".join(f"• {i}" for i in issues) + safe = escape(feedback) + stanza = ( + f"" + f"{safe}" + ) + try: + subprocess.run( + ["docker", "exec", "ejabberd", "ejabberdctl", + "send_stanza", FROM, FROM, stanza], + capture_output=True, timeout=10, text=True, + ) + except: + pass + + +def extract_body(path): + content = path.read_text(encoding="utf-8", errors="replace") + + if is_pure_script_output(content): + return None + + parts = content.split("## Response") + body = parts[1].strip() if len(parts) > 1 else content.strip() + body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip() + body = re.sub(r'\n?\s*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + if not body or len(body) < 20: + return None + + # 只过滤内容是纯[SILENT]的报告 + if body.strip() == "[SILENT]": + return None + + # 正文中混了[SILENT]标记(LLM写了报告又在末尾加了这个)— 去掉标记保留正文 + body = body.replace("[SILENT]", "").strip() + if len(body) < 20: + return None + + return body + + +def send(body): + from xml.sax.saxutils import escape + safe = escape(f"【知微】{body}") + stanza = ( + f"" + f"{safe}" + ) + # 重试3次 + for attempt in range(3): + try: + r = subprocess.run( + ["docker", "exec", "ejabberd", "ejabberdctl", + "send_stanza", FROM, TO, stanza], + capture_output=True, timeout=10, text=True, + ) + if r.stderr and "error" in r.stderr.lower(): + print(f"send error (attempt {attempt+1}): {r.stderr.strip()[:100]}", file=sys.stderr) + if attempt < 2: + continue + return False + return r.returncode == 0 + except subprocess.TimeoutExpired: + print(f"send timeout (attempt {attempt+1})", file=sys.stderr) + if attempt < 2: + continue + return False + except Exception as e: + print(f"send err (attempt {attempt+1}): {e}", file=sys.stderr) + if attempt < 2: + continue + return False + return False + + +def validate_format(body): + """格式检查 — 只记录不拦截,标记改进点""" + text = body.strip() + issues = [] + + # 必含区域检查 + has_key = "重点推荐操作" in text + has_risk = "风险关注" in text + has_rest = "其余持仓" in text or "今日关注" in text + if not has_key: + issues.append("缺【重点推荐操作】区域") + if not has_risk: + issues.append("缺【风险关注】区域") + + # 超长提醒 + if len(text) > 600: + issues.append(f"报告偏长({len(text)}字),建议压缩到600字内") + + # 模糊词提醒 + fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text) + if fuzzy: + issues.append(f"含模糊词({', '.join(list(set(fuzzy))[:3])}),应给唯一结论") + + # 选择题句式提醒 + if re.search(r"如果.*就|若.*则|可以.*也可以", text): + issues.append("含选择题句式,应给唯一建议") + + return text, issues # 始终通过,issues 为空就是干净 + + +def load_silent_stats(): + """加载当日静默统计""" + try: + return json.loads(SILENT_STATS.read_text()) + except: + return {"date": "", "silent": 0, "short": 0, "script": 0} + + +def save_silent_stats(stats): + SILENT_STATS.write_text(json.dumps(stats)) + + +def send_silent_summary(stats): + """发送当日静默报告汇总""" + parts = [] + if stats.get("silent", 0) > 0: + parts.append(f"静默[SILENT] {stats['silent']}次") + if stats.get("short", 0) > 0: + parts.append(f"过短(<20字) {stats['short']}次") + if stats.get("script", 0) > 0: + parts.append(f"脚本输出 {stats['script']}次") + + if not parts: + body = "【每日汇总】今日所有cron报告已正常送达,无被拦截的报告。" + else: + body = "【每日汇总】今日以下cron报告未送达(已拦截):\n" + "\n".join(f"• {p}" for p in parts) + "\n\n无操作信号的报告正常静默,有操作信号的都已送达。" + + send(body) + + +def scan(): + processed = load_journal() + new = set() + n_pushed = 0 + n_silent = 0 + n_short = 0 + n_script = 0 + no_agent_ids = load_no_agent_job_ids() + skip_all = SKIP_DIRS | no_agent_ids + + for cron_dir in CRON_DIRS: + if not cron_dir.exists(): + continue + + for d in sorted(cron_dir.iterdir()): + if not d.is_dir(): + continue + if d.name in skip_all: + continue + + for f in sorted(d.iterdir()): + if f.suffix != ".md": + continue + key = str(f.resolve()) + if key in processed or key in new: + continue + new.add(key) + + # 跳过超过MAX_AGE_HOURS小时的旧文件 + age_hours = (datetime.now() - datetime.fromtimestamp(f.stat().st_mtime)).total_seconds() / 3600 + if age_hours > MAX_AGE_HOURS: + continue + + content = f.read_text(encoding="utf-8", errors="replace") + + # 提前判断脚本输出 + if is_pure_script_output(content): + n_script += 1 + continue + + parts = content.split("## Response") + body = parts[1].strip() if len(parts) > 1 else content.strip() + body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip() + body = re.sub(r'\n?\s*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + if not body or len(body) < 20: + n_short += 1 + continue + + # SILENT → 拦截,记数 + if "[SILENT]" in body: + n_silent += 1 + continue + + # 格式校验 — 记录改进点,不拦截 + ok_body, issues = validate_format(body) + + n_pushed += 1 + ok_sent = send(body) + if not ok_sent: + print(f" {d.name}: send failed", file=sys.stderr) + if issues: + print(f" {d.name}/{f.name}: 改进建议: {'; '.join(issues)}", file=sys.stderr) + + if new: + save_journal(processed | new) + + # 保存当日汇总到文件(供16:30汇总用) + today = datetime.now().strftime("%Y-%m-%d") + stats = load_silent_stats() + if stats.get("date") != today: + stats = {"date": today, "silent": 0, "short": 0, "script": 0} + stats["silent"] += n_silent + stats["short"] += n_short + stats["script"] += n_script + save_silent_stats(stats) + + # 16:30~16:35 发送当日汇总(收盘后) + now = datetime.now() + hhmm = now.hour * 60 + now.minute + if 990 <= hhmm <= 995: # 16:30~16:35 + send_silent_summary(stats) + + log = f"推送{n_pushed}份,静默拦截{n_silent}份,过短{n_short}份,跳过脚本{n_script}份" + print(log, file=sys.stderr) + return n_pushed + + +if __name__ == "__main__": + scan() diff --git a/scripts/data/accuracy_stats.json b/scripts/data/accuracy_stats.json new file mode 100644 index 0000000..91b0914 --- /dev/null +++ b/scripts/data/accuracy_stats.json @@ -0,0 +1,17 @@ +{ + "updated_at": "2026-07-06T13:53:16.310069", + "phase1": { + "correct": 0, + "wrong": 0, + "pending": 0, + "accuracy_pct": 0.0 + }, + "phase2": { + "correct": 0, + "wrong": 0, + "pending": 0, + "accuracy_pct": 0.0 + }, + "total_evaluated": 0, + "details": [] +} \ No newline at end of file diff --git a/scripts/data/decisions.json b/scripts/data/decisions.json new file mode 100644 index 0000000..61abea1 --- /dev/null +++ b/scripts/data/decisions.json @@ -0,0 +1,3 @@ +{ + "decisions": [] +} \ No newline at end of file diff --git a/scripts/data/evaluation_input.json b/scripts/data/evaluation_input.json new file mode 100644 index 0000000..71e3048 --- /dev/null +++ b/scripts/data/evaluation_input.json @@ -0,0 +1,55 @@ +{ + "collected_at": "2026-07-03T20:30:31.976219", + "total_stocks": 0, + "summary": { + "up_count": 0, + "down_count": 0, + "deep_loss_count": 0, + "holdings_count": 0, + "watchlist_count": 0 + }, + "index_data": { + "上证指数": { + "price": 4043.64, + "prev_close": 4028.9, + "change_pct": 0.37, + "high": 4073.88, + "low": 4027.26, + "timestamp": "20260703161448" + }, + "深证成指": { + "price": 15597.51, + "prev_close": 15498.81, + "change_pct": 0.64, + "high": 15823.05, + "low": 15432.89, + "timestamp": "20260703161436" + }, + "创业板指": { + "price": 4019.93, + "prev_close": 4017.27, + "change_pct": 0.07, + "high": 4115.63, + "low": 3985.36, + "timestamp": "20260703161451" + }, + "恒生指数": { + "price": 23350.03, + "prev_close": 23055.03, + "change_pct": 1.28, + "high": 23516.7, + "low": 23226.2, + "timestamp": "2026/07/03 18:31:15" + }, + "恒生科技": { + "price": 4499.0, + "prev_close": 4454.28, + "change_pct": 1.0, + "high": 4558.09, + "low": 4480.58, + "timestamp": "2026/07/03 16:08:34" + } + }, + "sector_groups": {}, + "stocks": [] +} \ No newline at end of file diff --git a/scripts/data/mofin.db b/scripts/data/mofin.db new file mode 120000 index 0000000..a9de200 --- /dev/null +++ b/scripts/data/mofin.db @@ -0,0 +1 @@ +/home/hmo/MoFin/data/mofin.db \ No newline at end of file diff --git a/scripts/data/stock_name_code_cache.json b/scripts/data/stock_name_code_cache.json new file mode 100644 index 0000000..964979f --- /dev/null +++ b/scripts/data/stock_name_code_cache.json @@ -0,0 +1,5566 @@ +{ + "平安银行": "000001", + "万 科A": "000002", + "国华退": "000004", + "深振业A": "000006", + "全新好": "000007", + "神州高铁": "000008", + "中国宝安": "000009", + "*ST美丽": "000010", + "深物业A": "000011", + "南 玻A": "000012", + "沙河股份": "000014", + "*ST康佳A": "000016", + "深中华A": "000017", + "深粮控股": "000019", + "深华发A": "000020", + "深科技": "000021", + "特 力A": "000025", + "飞亚达": "000026", + "深圳能源": "000027", + "国药一致": "000028", + "深深房A": "000029", + "富奥股份": "000030", + "大悦城": "000031", + "深桑达A": "000032", + "神州数码": "000034", + "中国天楹": "000035", + "华联控股": "000036", + "深南电A": "000037", + "中集集团": "000039", + "中洲控股": "000042", + "深纺织A": "000045", + "京基智农": "000048", + "德赛电池": "000049", + "深天马A": "000050", + "方大集团": "000055", + "*ST皇庭": "000056", + "深 赛 格": "000058", + "华锦股份": "000059", + "中金岭南": "000060", + "农 产 品": "000061", + "深圳华强": "000062", + "中兴通讯": "000063", + "北方国际": "000065", + "中国长城": "000066", + "华控赛格": "000068", + "华侨城A": "000069", + "特发信息": "000070", + "ST海王": "000078", + "盐 田 港": "000088", + "深圳机场": "000089", + "天健集团": "000090", + "广聚能源": "000096", + "中信海直": "000099", + "TCL科技": "000100", + "中成股份": "000151", + "丰原药业": "000153", + "川能动力": "000155", + "华数传媒": "000156", + "中联重科": "000157", + "常山北明": "000158", + "国际实业": "000159", + "申万宏源": "000166", + "东方盛虹": "000301", + "美的集团": "000333", + "潍柴动力": "000338", + "许继电气": "000400", + "金隅冀东": "000401", + "金 融 街": "000402", + "派林生物": "000403", + "长虹华意": "000404", + "胜利股份": "000407", + "藏格矿业": "000408", + "云鼎科技": "000409", + "沈阳机床": "000410", + "英特集团": "000411", + "渤海租赁": "000415", + "合百集团": "000417", + "通程控股": "000419", + "吉林化纤": "000420", + "南京公用": "000421", + "湖北宜化": "000422", + "东阿阿胶": "000423", + "徐工机械": "000425", + "兴业银锡": "000426", + "华天酒店": "000428", + "粤高速A": "000429", + "张家界": "000430", + "ST晨鸣": "000488", + "山东路桥": "000498", + "武商集团": "000501", + "国新健康": "000503", + "南华生物": "000504", + "京粮控股": "000505", + "招金黄金": "000506", + "珠海港": "000507", + "华塑控股": "000509", + "新金路": "000510", + "丽珠集团": "000513", + "渝 开 发": "000514", + "国际医学": "000516", + "荣安地产": "000517", + "四环生物": "000518", + "中兵红箭": "000519", + "凤凰航运": "000520", + "长虹美菱": "000521", + "红棉股份": "000523", + "岭南控股": "000524", + "红太阳": "000525", + "学大教育": "000526", + "柳 工": "000528", + "广弘控股": "000529", + "冰山冷热": "000530", + "穗恒运A": "000531", + "华金资本": "000532", + "顺钠股份": "000533", + "万泽股份": "000534", + "华映科技": "000536", + "绿发电力": "000537", + "云南白药": "000538", + "粤电力A": "000539", + "佛山照明": "000541", + "皖能电力": "000543", + "中原环保": "000544", + "金浦钛业": "000545", + "金圆股份": "000546", + "航天发展": "000547", + "湖南投资": "000548", + "江铃汽车": "000550", + "创元科技": "000551", + "甘肃能化": "000552", + "安道麦A": "000553", + "泰山石油": "000554", + "神州信息": "000555", + "西部创业": "000557", + "天府文旅": "000558", + "万向钱潮": "000559", + "我爱我家": "000560", + "烽火电子": "000561", + "陕国投A": "000563", + "供销大集": "000564", + "渝三峡A": "000565", + "海南海药": "000566", + "海德股份": "000567", + "泸州老窖": "000568", + "苏常柴A": "000570", + "新大洲A": "000571", + "海马汽车": "000572", + "粤宏远A": "000573", + "甘化科工": "000576", + "威孚高科": "000581", + "北部湾港": "000582", + "汇源通信": "000586", + "贵州轮胎": "000589", + "古汉医药": "000590", + "太阳能": "000591", + "平潭发展": "000592", + "德龙汇能": "000593", + "*ST宝实": "000595", + "古井贡酒": "000596", + "东北制药": "000597", + "兴蓉环境": "000598", + "青岛双星": "000599", + "建投能源": "000600", + "韶能股份": "000601", + "盛达资源": "000603", + "渤海股份": "000605", + "华媒控股": "000607", + "阳光股份": "000608", + "*ST中迪": "000609", + "*ST西旅": "000610", + "焦作万方": "000612", + "*ST美谷": "000615", + "中油资本": "000617", + "海螺新材": "000619", + "盈新发展": "000620", + "吉林敖东": "000623", + "长安汽车": "000625", + "远大控股": "000626", + "高新发展": "000628", + "钒钛股份": "000629", + "铜陵有色": "000630", + "顺发恒能": "000631", + "ST三木": "000632", + "合金投资": "000633", + "英 力 特": "000635", + "风华高科": "000636", + "茂化实华": "000637", + "ST西王": "000639", + "仁和药业": "000650", + "格力电器": "000651", + "泰达股份": "000652", + "金岭矿业": "000655", + "*ST金科": "000656", + "中钨高新": "000657", + "珠海中富": "000659", + "长春高新": "000661", + "永安林业": "000663", + "湖北广电": "000665", + "荣丰控股": "000668", + "ST金鸿": "000669", + "盈方微": "000670", + "上峰材料": "000672", + "智度股份": "000676", + "ST海龙": "000677", + "襄阳轴承": "000678", + "大连友谊": "000679", + "山推股份": "000680", + "视觉中国": "000681", + "东方电子": "000682", + "博源化工": "000683", + "中山公用": "000685", + "东北证券": "000686", + "国城矿业": "000688", + "宝新能源": "000690", + "亚太实业": "000691", + "惠天热电": "000692", + "滨海能源": "000695", + "炼石航空": "000697", + "ST沈化": "000698", + "模塑科技": "000700", + "厦门信达": "000701", + "正虹科技": "000702", + "恒逸石化": "000703", + "浙江震元": "000705", + "双环科技": "000707", + "中信特钢": "000708", + "河钢股份": "000709", + "贝瑞基因": "000710", + "ST京蓝": "000711", + "锦龙股份": "000712", + "国投丰乐": "000713", + "中兴商业": "000715", + "黑芝麻": "000716", + "中南股份": "000717", + "苏宁环球": "000718", + "中原传媒": "000719", + "新能泰山": "000720", + "西安饮食": "000721", + "湖南发展": "000722", + "美锦能源": "000723", + "京东方A": "000725", + "鲁 泰A": "000726", + "冠捷科技": "000727", + "国元证券": "000728", + "燕京啤酒": "000729", + "四川美丰": "000731", + "振华科技": "000733", + "罗 牛 山": "000735", + "中交发展": "000736", + "北方铜业": "000737", + "航发控制": "000738", + "普洛药业": "000739", + "国海证券": "000750", + "锌业股份": "000751", + "*ST西发": "000752", + "漳州发展": "000753", + "山西高速": "000755", + "新华制药": "000756", + "浩物股份": "000757", + "中色股份": "000758", + "中百集团": "000759", + "本钢板材": "000761", + "西藏矿业": "000762", + "通化金马": "000766", + "晋控电力": "000767", + "中航西飞": "000768", + "广发证券": "000776", + "中核科技": "000777", + "新兴铸管": "000778", + "甘咨询": "000779", + "恒申新材": "000782", + "长江证券": "000783", + "居然智家": "000785", + "北新建材": "000786", + "北大医药": "000788", + "万年青": "000789", + "华神科技": "000790", + "甘肃能源": "000791", + "盐湖股份": "000792", + "*ST华闻": "000793", + "英洛华": "000795", + "凯撒旅业": "000796", + "中国武夷": "000797", + "中水渔业": "000798", + "酒鬼酒": "000799", + "一汽解放": "000800", + "四川九洲": "000801", + "北京文化": "000802", + "山高环能": "000803", + "云铝股份": "000807", + "和展能源": "000809", + "创维数字": "000810", + "冰轮环境": "000811", + "陕西金叶": "000812", + "德展健康": "000813", + "美利云": "000815", + "智慧农业": "000816", + "航锦科技": "000818", + "岳阳兴长": "000819", + "*ST节能": "000820", + "ST京机": "000821", + "山东海化": "000822", + "超声电子": "000823", + "太钢不锈": "000825", + "*ST启环": "000826", + "东莞控股": "000828", + "天音控股": "000829", + "鲁西化工": "000830", + "中国稀土": "000831", + "粤桂股份": "000833", + "秦川机床": "000837", + "*ST发展": "000838", + "国安股份": "000839", + "承德露露": "000848", + "华茂股份": "000850", + "石化机械": "000852", + "冀东装备": "000856", + "五 粮 液": "000858", + "国风新材": "000859", + "顺鑫农业": "000860", + "银星能源": "000862", + "三湘印象": "000863", + "安凯客车": "000868", + "张 裕A": "000869", + "电投绿能": "000875", + "新 希 望": "000876", + "天山股份": "000877", + "云南铜业": "000878", + "潍柴重机": "000880", + "中广核技": "000881", + "华联股份": "000882", + "湖北能源": "000883", + "城发环境": "000885", + "海南高速": "000886", + "中鼎股份": "000887", + "峨眉山A": "000888", + "中嘉博创": "000889", + "法尔胜": "000890", + "欢瑞世纪": "000892", + "亚钾国际": "000893", + "双汇发展": "000895", + "津滨发展": "000897", + "鞍钢股份": "000898", + "赣能股份": "000899", + "现代投资": "000900", + "航天科技": "000901", + "新洋丰": "000902", + "ST云动": "000903", + "厦门港务": "000905", + "浙商中拓": "000906", + "景峰医药": "000908", + "*ST数源": "000909", + "大亚圣象": "000910", + "*ST广糖": "000911", + "泸天化": "000912", + "钱江摩托": "000913", + "华特达因": "000915", + "电广传媒": "000917", + "金陵药业": "000919", + "沃顿科技": "000920", + "海信家电": "000921", + "佳电股份": "000922", + "河钢资源": "000923", + "众合科技": "000925", + "福星股份": "000926", + "中国铁物": "000927", + "中钢国际": "000928", + "兰州黄河": "000929", + "中粮科技": "000930", + "中 关 村": "000931", + "华菱钢铁": "000932", + "神火股份": "000933", + "四川双马": "000935", + "华西股份": "000936", + "冀中能源": "000937", + "紫光股份": "000938", + "南天信息": "000948", + "新乡化纤": "000949", + "重药控股": "000950", + "中国重汽": "000951", + "广济药业": "000952", + "中哲精化": "000953", + "欣龙控股": "000955", + "中通客车": "000957", + "电投产融": "000958", + "首钢股份": "000959", + "锡业股份": "000960", + "东方钽业": "000962", + "华东医药": "000963", + "天保基建": "000965", + "长源电力": "000966", + "盈峰环境": "000967", + "蓝焰控股": "000968", + "安泰科技": "000969", + "中科三环": "000970", + "*ST中基": "000972", + "佛塑科技": "000973", + "山金国际": "000975", + "浪潮信息": "000977", + "桂林旅游": "000978", + "众泰汽车": "000980", + "山子高科": "000981", + "山西焦煤": "000983", + "大庆华科": "000985", + "越秀资本": "000987", + "华工科技": "000988", + "九芝堂": "000989", + "诚志股份": "000990", + "闽东电力": "000993", + "皇台酒业": "000995", + "新 大 陆": "000997", + "隆平高科": "000998", + "华润三九": "000999", + "东瑞股份": "001201", + "炬申股份": "001202", + "大中矿业": "001203", + "盛航股份": "001205", + "依依股份": "001206", + "联科科技": "001207", + "华菱线缆": "001208", + "洪兴股份": "001209", + "金房能源": "001210", + "双枪科技": "001211", + "中旗新材": "001212", + "中铁特货": "001213", + "千味央厨": "001215", + "华瓷股份": "001216", + "华尔泰": "001217", + "丽臣实业": "001218", + "青岛食品": "001219", + "世盟股份": "001220", + "悍高集团": "001221", + "源飞宠物": "001222", + "欧克科技": "001223", + "和泰机电": "001225", + "拓山重工": "001226", + "兰州银行": "001227", + "永泰运": "001228", + "魅视科技": "001229", + "劲旅环境": "001230", + "农心科技": "001231", + "海安集团": "001233", + "泰慕士": "001234", + "弘业期货": "001236", + "惠康科技": "001237", + "浙江正特": "001238", + "永达股份": "001239", + "博菲电气": "001255", + "炜冈科技": "001256", + "盛龙股份": "001257", + "立新能源": "001258", + "利仁科技": "001259", + "坤泰股份": "001260", + "宏英智能": "001266", + "汇绿生态": "001267", + "联合精密": "001268", + "欧晶科技": "001269", + "铖昌科技": "001270", + "速达股份": "001277", + "一彬科技": "001278", + "强邦新材": "001279", + "中国铀业": "001280", + "三联锻造": "001282", + "豪鹏科技": "001283", + "瑞立科密": "001285", + "陕西能源": "001286", + "中电港": "001287", + "运机集团": "001288", + "龙源电力": "001289", + "长江材料": "001296", + "好上好": "001298", + "美能能源": "001299", + "三柏硕": "001300", + "尚太科技": "001301", + "夏厦精密": "001306", + "康冠科技": "001308", + "德明利": "001309", + "多利科技": "001311", + "福恩股份": "001312", + "粤海饲料": "001313", + "亿道信息": "001314", + "润贝航科": "001316", + "三羊马": "001317", + "阳光乳业": "001318", + "铭科精技": "001319", + "箭牌家居": "001322", + "慕思股份": "001323", + "长青科技": "001324", + "元创股份": "001325", + "联域股份": "001326", + "登康口腔": "001328", + "博纳影业": "001330", + "胜通能源": "001331", + "锡装股份": "001332", + "光华股份": "001333", + "信凯科技": "001335", + "楚环科技": "001336", + "四川黄金": "001337", + "永顺泰": "001338", + "智微智能": "001339", + "富岭股份": "001356", + "兴欣新材": "001358", + "平安电工": "001359", + "南矿集团": "001360", + "天海电子": "001365", + "播恩集团": "001366", + "海森药业": "001367", + "通达创智": "001368", + "双欣材料": "001369", + "翔腾新材": "001373", + "百通能源": "001376", + "德冠新材": "001378", + "腾达科技": "001379", + "华纬科技": "001380", + "新亚电缆": "001382", + "马可波罗": "001386", + "雪祺电气": "001387", + "信通电子": "001388", + "广合科技": "001389", + "古麒绒材": "001390", + "国货航": "001391", + "维通利": "001393", + "亚联机械": "001395", + "誉帆科技": "001396", + "N惠科股份": "001399", + "江顺科技": "001400", + "宗申动力": "001696", + "招商港口": "001872", + "豫能控股": "001896", + "招商积余": "001914", + "招商公路": "001965", + "招商蛇口": "001979", + "新 和 成": "002001", + "伟星股份": "002003", + "华邦健康": "002004", + "ST德豪": "002005", + "精工科技": "002006", + "华兰生物": "002007", + "大族激光": "002008", + "天奇股份": "002009", + "传化智联": "002010", + "盾安环境": "002011", + "凯恩股份": "002012", + "永新股份": "002014", + "协鑫能科": "002015", + "世荣兆业": "002016", + "东信和平": "002017", + "亿帆医药": "002019", + "京新药业": "002020", + "中捷资源": "002021", + "科华生物": "002022", + "海特高新": "002023", + "ST易购": "002024", + "航天电器": "002025", + "山东威达": "002026", + "分众传媒": "002027", + "思源电气": "002028", + "七 匹 狼": "002029", + "达安基因": "002030", + "巨轮智能": "002031", + "苏 泊 尔": "002032", + "丽江股份": "002033", + "旺能环境": "002034", + "华帝股份": "002035", + "联创电子": "002036", + "保利联合": "002037", + "双鹭药业": "002038", + "黔源电力": "002039", + "南 京 港": "002040", + "登海种业": "002041", + "华孚时尚": "002042", + "兔 宝 宝": "002043", + "美年健康": "002044", + "国光电器": "002045", + "国机精工": "002046", + "宝鹰股份": "002047", + "宁波华翔": "002048", + "紫光国微": "002049", + "三花智控": "002050", + "中工国际": "002051", + "同洲电子": "002052", + "云南能投": "002053", + "德美化工": "002054", + "ST得润": "002055", + "横店东磁": "002056", + "中钢天源": "002057", + "威尔泰": "002058", + "云南旅游": "002059", + "广东建工": "002060", + "浙江交科": "002061", + "宏润建设": "002062", + "远光软件": "002063", + "华峰化学": "002064", + "东华软件": "002065", + "瑞泰科技": "002066", + "景兴纸业": "002067", + "黑猫股份": "002068", + "獐子岛": "002069", + "凯瑞德": "002072", + "软控股份": "002073", + "国轩高科": "002074", + "沙钢股份": "002075", + "星光股份": "002076", + "大港股份": "002077", + "太阳纸业": "002078", + "苏州固锝": "002079", + "中材科技": "002080", + "金 螳 螂": "002081", + "ST万邦": "002082", + "孚日股份": "002083", + "海鸥住工": "002084", + "万丰奥威": "002085", + "东方海洋": "002086", + "鲁阳节能": "002088", + "金智科技": "002090", + "江苏国泰": "002091", + "中泰化学": "002092", + "国脉科技": "002093", + "青岛金王": "002094", + "生 意 宝": "002095", + "易普力": "002096", + "山河智能": "002097", + "浔兴股份": "002098", + "海翔药业": "002099", + "天康生物": "002100", + "广东鸿图": "002101", + "ST能特": "002102", + "广博股份": "002103", + "恒宝股份": "002104", + "信隆健康": "002105", + "莱宝高科": "002106", + "沃华医药": "002107", + "沧州明珠": "002108", + "ST兴化": "002109", + "三钢闽光": "002110", + "威海广泰": "002111", + "三变科技": "002112", + "罗平锌电": "002114", + "三维通信": "002115", + "中国海诚": "002116", + "东港股份": "002117", + "康强电子": "002119", + "韵达股份": "002120", + "科陆电子": "002121", + "ST汇洲": "002122", + "梦网科技": "002123", + "天邦食品": "002124", + "湘潭电化": "002125", + "银轮股份": "002126", + "南极电商": "002127", + "电投能源": "002128", + "TCL中环": "002129", + "沃尔核材": "002130", + "利欧股份": "002131", + "恒星科技": "002132", + "广宇集团": "002133", + "天津普林": "002134", + "东南网架": "002135", + "安 纳 达": "002136", + "实益达": "002137", + "顺络电子": "002138", + "拓邦股份": "002139", + "东华科技": "002140", + "贤丰控股": "002141", + "宁波银行": "002142", + "宏达高科": "002144", + "钛能化学": "002145", + "荣盛发展": "002146", + "北纬科技": "002148", + "西部材料": "002149", + "正泰电源": "002150", + "北斗星通": "002151", + "广电运通": "002152", + "石基信息": "002153", + "报 喜 鸟": "002154", + "湖南黄金": "002155", + "通富微电": "002156", + "正邦科技": "002157", + "汉钟精机": "002158", + "三特索道": "002159", + "常铝股份": "002160", + "远 望 谷": "002161", + "悦心健康": "002162", + "海南发展": "002163", + "宁波东力": "002164", + "红 宝 丽": "002165", + "莱茵生物": "002166", + "东方锆业": "002167", + "ST惠程": "002168", + "智光电气": "002169", + "芭田股份": "002170", + "楚江新材": "002171", + "澳洋健康": "002172", + "创新医疗": "002173", + "游族网络": "002174", + "*ST东智": "002175", + "江特电机": "002176", + "御银股份": "002177", + "延华智能": "002178", + "中航光电": "002179", + "奔图科技": "002180", + "粤 传 媒": "002181", + "宝武镁业": "002182", + "怡 亚 通": "002183", + "海得控制": "002184", + "华天科技": "002185", + "全 聚 德": "002186", + "广百股份": "002187", + "中天服务": "002188", + "中光学": "002189", + "成飞集成": "002190", + "劲嘉股份": "002191", + "融捷股份": "002192", + "ST如意": "002193", + "武汉凡谷": "002194", + "岩山科技": "002195", + "方正电机": "002196", + "证通电子": "002197", + "ST嘉应": "002198", + "*ST东晶": "002199", + "交投生态": "002200", + "九鼎新材": "002201", + "金风科技": "002202", + "海亮股份": "002203", + "大连重工": "002204", + "国统股份": "002205", + "海 利 得": "002206", + "*ST准油": "002207", + "合肥城建": "002208", + "达 意 隆": "002209", + "飞马国际": "002210", + "ST宏达": "002211", + "天融信": "002212", + "大为股份": "002213", + "*ST大立": "002214", + "诺 普 信": "002215", + "三全食品": "002216", + "ST合力泰": "002217", + "拓日新能": "002218", + "新里程": "002219", + "东华能源": "002221", + "福晶科技": "002222", + "鱼跃医疗": "002223", + "三 力 士": "002224", + "濮耐股份": "002225", + "江南化工": "002226", + "*ST特迅": "002227", + "合兴包装": "002228", + "鸿博股份": "002229", + "科大讯飞": "002230", + "启明信息": "002232", + "塔牌集团": "002233", + "民和股份": "002234", + "安妮股份": "002235", + "大华股份": "002236", + "恒邦股份": "002237", + "天威视讯": "002238", + "奥特佳": "002239", + "盛新锂能": "002240", + "歌尔股份": "002241", + "九阳股份": "002242", + "力合科创": "002243", + "滨江集团": "002244", + "蔚蓝锂芯": "002245", + "北化股份": "002246", + "聚力文化": "002247", + "华东数控": "002248", + "大洋电机": "002249", + "联化科技": "002250", + "步步高": "002251", + "上海莱士": "002252", + "川大智胜": "002253", + "泰和新材": "002254", + "海陆重工": "002255", + "兆新股份": "002256", + "利尔化学": "002258", + "升达林业": "002259", + "拓维信息": "002261", + "恩华药业": "002262", + "大东南": "002263", + "新 华 都": "002264", + "建设工业": "002265", + "浙富控股": "002266", + "陕天然气": "002267", + "电科网安": "002268", + "美邦服饰": "002269", + "华明装备": "002270", + "东方雨虹": "002271", + "川润股份": "002272", + "水晶光电": "002273", + "华昌化工": "002274", + "桂林三金": "002275", + "万马股份": "002276", + "友阿股份": "002277", + "神开股份": "002278", + "久其软件": "002279", + "光迅科技": "002281", + "博深股份": "002282", + "天润工业": "002283", + "亚太股份": "002284", + "世联行": "002285", + "保龄宝": "002286", + "奇正藏药": "002287", + "宇顺电子": "002289", + "禾盛新材": "002290", + "遥望科技": "002291", + "奥飞娱乐": "002292", + "罗莱生活": "002293", + "信立泰": "002294", + "精艺股份": "002295", + "辉煌科技": "002296", + "博云新材": "002297", + "中电鑫龙": "002298", + "圣农发展": "002299", + "太阳电缆": "002300", + "齐心集团": "002301", + "西部建设": "002302", + "美盈森": "002303", + "洋河股份": "002304", + "*ST南置": "002305", + "ST云网": "002306", + "北新路桥": "002307", + "中利集团": "002309", + "东方新能": "002310", + "海大集团": "002311", + "川发龙蟒": "002312", + "日海智能": "002313", + "南山控股": "002314", + "焦点科技": "002315", + "亚联发展": "002316", + "众生药业": "002317", + "久立特材": "002318", + "乐通股份": "002319", + "海峡股份": "002320", + "华英农业": "002321", + "理工能科": "002322", + "*ST雅博": "002323", + "普利特": "002324", + "永太科技": "002326", + "富安娜": "002327", + "新朋股份": "002328", + "皇氏集团": "002329", + "得利斯": "002330", + "皖通科技": "002331", + "仙琚制药": "002332", + "罗普斯金": "002333", + "英威腾": "002334", + "科华数据": "002335", + "赛象科技": "002337", + "奥普光电": "002338", + "积成电子": "002339", + "格林美": "002340", + "巨力索具": "002342", + "慈文传媒": "002343", + "海宁皮城": "002344", + "潮宏基": "002345", + "柘中股份": "002346", + "泰尔股份": "002347", + "高乐股份": "002348", + "精华制药": "002349", + "北京科锐": "002350", + "漫步者": "002351", + "顺丰控股": "002352", + "杰瑞股份": "002353", + "天娱数科": "002354", + "兴民智通": "002355", + "赫美集团": "002356", + "富临运业": "002357", + "森源电气": "002358", + "ST同德": "002360", + "神剑股份": "002361", + "汉王科技": "002362", + "隆基机械": "002363", + "中恒电气": "002364", + "永安药业": "002365", + "融发核电": "002366", + "康力电梯": "002367", + "太极股份": "002368", + "卓翼科技": "002369", + "亚太药业": "002370", + "北方华创": "002371", + "伟星新材": "002372", + "千方科技": "002373", + "中锐股份": "002374", + "亚厦股份": "002375", + "新北洋": "002376", + "国创高新": "002377", + "章源钨业": "002378", + "宏桥控股": "002379", + "科远智慧": "002380", + "双箭股份": "002381", + "蓝帆医疗": "002382", + "合众思壮": "002383", + "东山精密": "002384", + "大北农": "002385", + "天原股份": "002386", + "维信诺": "002387", + "新亚制程": "002388", + "航天彩虹": "002389", + "信邦制药": "002390", + "长青股份": "002391", + "北京利尔": "002392", + "力生制药": "002393", + "联发股份": "002394", + "双象股份": "002395", + "星网锐捷": "002396", + "梦洁股份": "002397", + "垒知集团": "002398", + "海普瑞": "002399", + "省广集团": "002400", + "中远海科": "002401", + "和而泰": "002402", + "爱仕达": "002403", + "嘉欣丝绸": "002404", + "四维图新": "002405", + "远东传动": "002406", + "多氟多": "002407", + "齐翔腾达": "002408", + "雅克科技": "002409", + "广联达": "002410", + "汉森制药": "002412", + "雷科防务": "002413", + "高德红外": "002414", + "海康威视": "002415", + "爱施德": "002416", + "康盛股份": "002418", + "天虹股份": "002419", + "毅昌科技": "002420", + "达实智能": "002421", + "科伦药业": "002422", + "中粮资本": "002423", + "ST百灵": "002424", + "凯撒文化": "002425", + "胜利精密": "002426", + "尤夫股份": "002427", + "云南锗业": "002428", + "兆驰股份": "002429", + "杭氧股份": "002430", + "ST棕榈": "002431", + "九安医疗": "002432", + "万里扬": "002434", + "兴森科技": "002436", + "誉衡药业": "002437", + "江苏神通": "002438", + "启明星辰": "002439", + "闰土股份": "002440", + "众业达": "002441", + "龙星科技": "002442", + "金洲管道": "002443", + "巨星科技": "002444", + "中南文化": "002445", + "盛路通信": "002446", + "中原内配": "002448", + "国星光电": "002449", + "摩恩电气": "002451", + "长高电气": "002452", + "华软科技": "002453", + "松芝股份": "002454", + "百川股份": "002455", + "欧菲光": "002456", + "青龙管业": "002457", + "益生股份": "002458", + "晶澳科技": "002459", + "赣锋锂业": "002460", + "珠江啤酒": "002461", + "嘉事堂": "002462", + "沪电股份": "002463", + "海格通信": "002465", + "天齐锂业": "002466", + "二六三": "002467", + "申通快递": "002468", + "三维化学": "002469", + "金正大": "002470", + "中超控股": "002471", + "双环传动": "002472", + "榕基软件": "002474", + "立讯精密": "002475", + "新叶股份": "002476", + "常宝股份": "002478", + "富春环保": "002479", + "新筑股份": "002480", + "双塔食品": "002481", + "广田集团": "002482", + "润邦股份": "002483", + "江海股份": "002484", + "ST雪发": "002485", + "嘉麟杰": "002486", + "大金重工": "002487", + "金固股份": "002488", + "浙江永强": "002489", + "山东墨龙": "002490", + "通鼎互联": "002491", + "恒基达鑫": "002492", + "荣盛石化": "002493", + "华斯股份": "002494", + "佳隆股份": "002495", + "辉丰股份": "002496", + "雅化集团": "002497", + "汉缆股份": "002498", + "山西证券": "002500", + "*ST利源": "002501", + "协鑫集成": "002506", + "涪陵榨菜": "002507", + "老板电器": "002508", + "天汽模": "002510", + "中顺洁柔": "002511", + "ST达华": "002512", + "蓝丰生化": "002513", + "*ST宝馨": "002514", + "金字火腿": "002515", + "旷达科技": "002516", + "恺英网络": "002517", + "科士达": "002518", + "银河电子": "002519", + "日发精机": "002520", + "齐峰新材": "002521", + "浙江众成": "002522", + "天桥起重": "002523", + "光正眼科": "002524", + "山东矿机": "002526", + "新时达": "002527", + "*ST英飞": "002528", + "海源复材": "002529", + "金财互联": "002530", + "天顺风能": "002531", + "天山铝业": "002532", + "金杯电工": "002533", + "西子洁能": "002534", + "林州重机": "002535", + "飞龙股份": "002536", + "海联金汇": "002537", + "ST司特": "002538", + "云图控股": "002539", + "亚太科技": "002540", + "鸿路钢构": "002541", + "*ST中岩": "002542", + "万和电气": "002543", + "普天科技": "002544", + "东方铁塔": "002545", + "新联电子": "002546", + "*ST春兴": "002547", + "金新农": "002548", + "凯美特气": "002549", + "千红制药": "002550", + "尚荣医疗": "002551", + "宝鼎科技": "002552", + "南方精工": "002553", + "惠博普": "002554", + "三七互娱": "002555", + "辉隆股份": "002556", + "洽洽食品": "002557", + "巨人网络": "002558", + "亚威股份": "002559", + "通达股份": "002560", + "徐家汇": "002561", + "兄弟科技": "002562", + "森马服饰": "002563", + "天沃科技": "002564", + "顺灏股份": "002565", + "益盛药业": "002566", + "唐人神": "002567", + "百润股份": "002568", + "*ST步森": "002569", + "贝因美": "002570", + "德力股份": "002571", + "索菲亚": "002572", + "清新环境": "002573", + "明牌珠宝": "002574", + "群兴玩具": "002575", + "通达动力": "002576", + "雷柏科技": "002577", + "闽发铝业": "002578", + "中京电子": "002579", + "圣阳股份": "002580", + "*ST未名": "002581", + "好想你": "002582", + "海能达": "002583", + "西陇科学": "002584", + "双星新材": "002585", + "ST围海": "002586", + "奥拓电子": "002587", + "史丹利": "002588", + "瑞康医药": "002589", + "万安科技": "002590", + "恒大高新": "002591", + "ST八菱": "002592", + "日上集团": "002593", + "比亚迪": "002594", + "豪迈科技": "002595", + "海南瑞泽": "002596", + "金禾实业": "002597", + "ST章鼓": "002598", + "盛通股份": "002599", + "领益智造": "002600", + "龙佰集团": "002601", + "世纪华通": "002602", + "以岭药业": "002603", + "姚记科技": "002605", + "大连电瓷": "002606", + "中公教育": "002607", + "江苏国信": "002608", + "捷顺科技": "002609", + "东方精工": "002611", + "朗姿股份": "002612", + "北玻股份": "002613", + "奥佳华": "002614", + "哈尔斯": "002615", + "长青集团": "002616", + "露笑科技": "002617", + "*ST瑞和": "002620", + "皓宸医疗": "002622", + "亚玛顿": "002623", + "完美世界": "002624", + "光启技术": "002625", + "金达威": "002626", + "三峡旅游": "002627", + "成都路桥": "002628", + "仁智股份": "002629", + "*ST华西": "002630", + "德尔未来": "002631", + "道明光学": "002632", + "申科股份": "002633", + "*ST棒杰": "002634", + "安洁科技": "002635", + "金安国纪": "002636", + "赞宇科技": "002637", + "勤上股份": "002638", + "雪人集团": "002639", + "跨境通": "002640", + "公元股份": "002641", + "荣联科技": "002642", + "万润股份": "002643", + "佛慈制药": "002644", + "华宏科技": "002645", + "天佑德酒": "002646", + "仁东控股": "002647", + "卫星化学": "002648", + "博彦科技": "002649", + "ST加加": "002650", + "利君股份": "002651", + "扬子新材": "002652", + "海思科": "002653", + "万润科技": "002654", + "共达电声": "002655", + "*ST摩登": "002656", + "中科金财": "002657", + "雪迪龙": "002658", + "凯文教育": "002659", + "茂硕电源": "002660", + "克明食品": "002661", + "峰璟股份": "002662", + "普邦股份": "002663", + "信质集团": "002664", + "德联集团": "002666", + "*ST威领": "002667", + "TCL智家": "002668", + "康达新材": "002669", + "国盛证券": "002670", + "龙泉股份": "002671", + "东江环保": "002672", + "西部证券": "002673", + "兴业科技": "002674", + "东诚药业": "002675", + "顺威股份": "002676", + "浙江美大": "002677", + "珠江钢琴": "002678", + "福建金森": "002679", + "奋达科技": "002681", + "龙洲股份": "002682", + "广东宏大": "002683", + "华东重机": "002685", + "亿利达": "002686", + "乔治白": "002687", + "金河生物": "002688", + "ST远智": "002689", + "美亚光电": "002690", + "*ST冀凯": "002691", + "远程股份": "002692", + "双成药业": "002693", + "*ST顾地": "002694", + "煌上煌": "002695", + "百洋股份": "002696", + "红旗连锁": "002697", + "博实股份": "002698", + "万憬能源": "002700", + "奥瑞金": "002701", + "海欣食品": "002702", + "浙江世宝": "002703", + "新宝股份": "002705", + "良信股份": "002706", + "众信旅游": "002707", + "光洋股份": "002708", + "天赐材料": "002709", + "思美传媒": "002712", + "*ST东易": "002713", + "牧原股份": "002714", + "登云股份": "002715", + "湖南白银": "002716", + "*ST岭南": "002717", + "友邦吊顶": "002718", + "ST麦趣": "002719", + "金一文化": "002721", + "物产金轮": "002722", + "小崧股份": "002723", + "海洋王": "002724", + "跃岭股份": "002725", + "ST龙大": "002726", + "一心堂": "002727", + "特一药业": "002728", + "好利科技": "002729", + "电光科技": "002730", + "ST萃华": "002731", + "燕塘乳业": "002732", + "雄韬股份": "002733", + "利民股份": "002734", + "王子新材": "002735", + "国信证券": "002736", + "葵花药业": "002737", + "中矿资源": "002738", + "儒意电影": "002739", + "光华科技": "002741", + "冀衡医药": "002742", + "富煌钢构": "002743", + "木林森": "002745", + "仙坛股份": "002746", + "埃斯顿": "002747", + "世龙实业": "002748", + "国光股份": "002749", + "昇兴股份": "002752", + "永东股份": "002753", + "奥赛康": "002755", + "永兴材料": "002756", + "南兴股份": "002757", + "浙农股份": "002758", + "天际股份": "002759", + "凤形股份": "002760", + "浙江建投": "002761", + "金发拉比": "002762", + "汇洁股份": "002763", + "蓝黛科技": "002765", + "索菱股份": "002766", + "先锋电子": "002767", + "国恩股份": "002768", + "普路通": "002769", + "真视通": "002771", + "众兴菌业": "002772", + "康弘药业": "002773", + "快意电梯": "002774", + "文科股份": "002775", + "久远银海": "002777", + "中晟高科": "002778", + "中坚科技": "002779", + "三夫户外": "002780", + "可立克": "002782", + "凯龙股份": "002783", + "万里石": "002785", + "银宝山新": "002786", + "华源控股": "002787", + "鹭燕医药": "002788", + "*ST建艺": "002789", + "瑞尔特": "002790", + "坚朗五金": "002791", + "通宇通讯": "002792", + "罗欣药业": "002793", + "永和智控": "002795", + "世嘉科技": "002796", + "第一创业": "002797", + "帝欧水华": "002798", + "环球印务": "002799", + "天顺股份": "002800", + "微光股份": "002801", + "洪汇新材": "002802", + "吉宏股份": "002803", + "丰元股份": "002805", + "华锋股份": "002806", + "江阴银行": "002807", + "恒久退": "002808", + "红墙股份": "002809", + "山东赫达": "002810", + "郑中设计": "002811", + "恩捷股份": "002812", + "路畅科技": "002813", + "崇达技术": "002815", + "*ST和科": "002816", + "黄山胶囊": "002817", + "富森美": "002818", + "东方中科": "002819", + "桂发祥": "002820", + "凯莱英": "002821", + "ST中装": "002822", + "凯中精密": "002823", + "和胜股份": "002824", + "纳尔股份": "002825", + "易明医药": "002826", + "高争民爆": "002827", + "贝肯能源": "002828", + "星网宇达": "002829", + "名雕股份": "002830", + "裕同科技": "002831", + "比音勒芬": "002832", + "弘亚数控": "002833", + "同为股份": "002835", + "新宏泽": "002836", + "英维克": "002837", + "道恩股份": "002838", + "张家港行": "002839", + "华统股份": "002840", + "视源股份": "002841", + "翔鹭钨业": "002842", + "泰嘉股份": "002843", + "同兴达": "002845", + "英联股份": "002846", + "盐津铺子": "002847", + "高斯贝尔": "002848", + "威星智能": "002849", + "科达利": "002850", + "麦格米特": "002851", + "道道全": "002852", + "皮阿诺": "002853", + "捷荣技术": "002855", + "*ST美芝": "002856", + "三晖电气": "002857", + "力盛体育": "002858", + "洁美科技": "002859", + "星帅尔": "002860", + "瀛通通讯": "002861", + "实丰文化": "002862", + "今飞凯达": "002863", + "盘龙药业": "002864", + "钧达股份": "002865", + "传艺科技": "002866", + "周大生": "002867", + "绿康生化": "002868", + "金溢科技": "002869", + "香山股份": "002870", + "伟隆股份": "002871", + "ST天圣": "002872", + "新天药业": "002873", + "安奈儿": "002875", + "三利谱": "002876", + "智能自控": "002877", + "元隆雅图": "002878", + "长缆科技": "002879", + "卫光生物": "002880", + "美格智能": "002881", + "金龙羽": "002882", + "*ST中设": "002883", + "凌霄泵业": "002884", + "京泉华": "002885", + "沃特股份": "002886", + "绿茵生态": "002887", + "惠威科技": "002888", + "东方嘉盛": "002889", + "弘宇股份": "002890", + "中宠股份": "002891", + "科力尔": "002892", + "京能热力": "002893", + "川恒股份": "002895", + "中大力德": "002896", + "意华股份": "002897", + "赛隆退": "002898", + "英派斯": "002899", + "哈三联": "002900", + "大博医疗": "002901", + "铭普光磁": "002902", + "宇环数控": "002903", + "金逸影视": "002905", + "华阳集团": "002906", + "华森制药": "002907", + "德生科技": "002908", + "集泰股份": "002909", + "庄园牧场": "002910", + "佛燃能源": "002911", + "中新赛克": "002912", + "奥士康": "002913", + "中欣氟材": "002915", + "深南电路": "002916", + "金奥博": "002917", + "蒙娜丽莎": "002918", + "名臣健康": "002919", + "德赛西威": "002920", + "联诚精密": "002921", + "伊戈尔": "002922", + "润都股份": "002923", + "盈趣科技": "002925", + "华西证券": "002926", + "泰永长征": "002927", + "华夏航空": "002928", + "润建股份": "002929", + "宏川智慧": "002930", + "锋龙股份": "002931", + "*ST明德": "002932", + "新兴装备": "002933", + "天奥电子": "002935", + "郑州银行": "002936", + "兴瑞科技": "002937", + "鹏鼎控股": "002938", + "长城证券": "002939", + "昂利康": "002940", + "新疆交建": "002941", + "新农股份": "002942", + "宇晶股份": "002943", + "华林证券": "002945", + "新乳业": "002946", + "恒铭达": "002947", + "青岛银行": "002948", + "华阳国际": "002949", + "奥美医疗": "002950", + "金时科技": "002951", + "亚世光电": "002952", + "日丰股份": "002953", + "鸿合科技": "002955", + "西麦食品": "002956", + "科瑞技术": "002957", + "青农商行": "002958", + "小熊电器": "002959", + "青鸟智控": "002960", + "瑞达期货": "002961", + "五方光电": "002962", + "豪尔赛": "002963", + "祥鑫科技": "002965", + "苏州银行": "002966", + "广电计量": "002967", + "新大正": "002968", + "嘉美包装": "002969", + "锐明技术": "002970", + "和远气体": "002971", + "科安达": "002972", + "侨银股份": "002973", + "博杰股份": "002975", + "瑞玛精密": "002976", + "*ST天箭": "002977", + "安宁股份": "002978", + "雷赛智能": "002979", + "华盛昌": "002980", + "朝阳科技": "002981", + "湘佳股份": "002982", + "芯瑞达": "002983", + "森麒麟": "002984", + "北摩高科": "002985", + "宇新股份": "002986", + "京北方": "002987", + "豪美新材": "002988", + "中天精装": "002989", + "盛视科技": "002990", + "甘源食品": "002991", + "宝明科技": "002992", + "奥海科技": "002993", + "天地在线": "002995", + "顺博合金": "002996", + "瑞鹄模具": "002997", + "优彩资源": "002998", + "天禾股份": "002999", + "劲仔食品": "003000", + "中岩大地": "003001", + "壶化股份": "003002", + "天元股份": "003003", + "声迅股份": "003004", + "竞业达": "003005", + "百亚股份": "003006", + "直真科技": "003007", + "开普检测": "003008", + "中天火箭": "003009", + "若羽臣": "003010", + "海象新材": "003011", + "东鹏控股": "003012", + "地铁设计": "003013", + "日久光电": "003015", + "欣贺股份": "003016", + "大洋生物": "003017", + "金富科技": "003018", + "宸展光电": "003019", + "立方制药": "003020", + "兆威机电": "003021", + "联泓新科": "003022", + "彩虹集团": "003023", + "思进智能": "003025", + "中晶科技": "003026", + "同兴科技": "003027", + "振邦智能": "003028", + "吉大正元": "003029", + "祖名股份": "003030", + "中瓷电子": "003031", + "传智教育": "003032", + "征和工业": "003033", + "南网能源": "003035", + "泰坦股份": "003036", + "三和管桩": "003037", + "鑫铂股份": "003038", + "顺控发展": "003039", + "楚天龙": "003040", + "真爱美家": "003041", + "中农联合": "003042", + "华亚智能": "003043", + "中国广核": "003816", + "特锐德": "300001", + "神州泰岳": "300002", + "乐普医疗": "300003", + "南风股份": "300004", + "探路者": "300005", + "莱美药业": "300006", + "汉威科技": "300007", + "天海防务": "300008", + "安科生物": "300009", + "ST豆神": "300010", + "鼎汉技术": "300011", + "华测检测": "300012", + "新宁物流": "300013", + "亿纬锂能": "300014", + "爱尔眼科": "300015", + "北陆药业": "300016", + "网宿科技": "300017", + "中元股份": "300018", + "硅宝科技": "300019", + "ST银江": "300020", + "大禹节水": "300021", + "吉峰科技": "300022", + "机器人": "300024", + "华星创业": "300025", + "红日药业": "300026", + "ST华谊": "300027", + "天龙退": "300029", + "阳普医疗": "300030", + "宝通科技": "300031", + "金龙机电": "300032", + "同花顺": "300033", + "钢研高纳": "300034", + "中科电气": "300035", + "超图软件": "300036", + "新宙邦": "300037", + "上海凯宝": "300039", + "九洲集团": "300040", + "回天新材": "300041", + "朗科科技": "300042", + "星辉娱乐": "300043", + "*ST赛为": "300044", + "华力创通": "300045", + "台基股份": "300046", + "天源迪科": "300047", + "合康新能": "300048", + "福瑞医科": "300049", + "世纪鼎利": "300050", + "琏升科技": "300051", + "中青宝": "300052", + "航宇微": "300053", + "鼎龙股份": "300054", + "万邦达": "300055", + "中创环保": "300056", + "万顺新材": "300057", + "蓝色光标": "300058", + "东方财富": "300059", + "旗天科技": "300061", + "中能电气": "300062", + "天龙集团": "300063", + "海兰信": "300065", + "三川智慧": "300066", + "安诺其": "300067", + "ST南都": "300068", + "金利华电": "300069", + "碧水源": "300070", + "福石控股": "300071", + "海新能科": "300072", + "当升科技": "300073", + "华平股份": "300074", + "数字政通": "300075", + "*ST宁视": "300076", + "国民技术": "300077", + "思创智联": "300078", + "数码视讯": "300079", + "易成新能": "300080", + "ST恒信": "300081", + "奥克股份": "300082", + "创世纪": "300083", + "海默科技": "300084", + "银之杰": "300085", + "康芝药业": "300086", + "荃银高科": "300087", + "长信科技": "300088", + "*ST金灵": "300091", + "科新机电": "300092", + "金刚光伏": "300093", + "国联水产": "300094", + "华伍股份": "300095", + "ST易联众": "300096", + "智云股份": "300097", + "高新兴": "300098", + "尤洛卡": "300099", + "双林股份": "300100", + "振芯科技": "300101", + "乾照光电": "300102", + "达刚控股": "300103", + "龙源技术": "300105", + "西部牧业": "300106", + "建新股份": "300107", + "新开源": "300109", + "华仁药业": "300110", + "向日葵": "300111", + "万讯自控": "300112", + "顺网科技": "300113", + "长盈精密": "300115", + "东方日升": "300118", + "瑞普生物": "300119", + "经纬辉开": "300120", + "阳谷华泰": "300121", + "智飞生物": "300122", + "ST亚光": "300123", + "汇川技术": "300124", + "*ST聆达": "300125", + "锐奇股份": "300126", + "银河磁体": "300127", + "锦富技术": "300128", + "泰胜风能": "300129", + "新国都": "300130", + "英唐智控": "300131", + "青松股份": "300132", + "华策影视": "300133", + "大富科技": "300134", + "宝利国际": "300135", + "信维通信": "300136", + "先河环保": "300137", + "晨光生物": "300138", + "晓程科技": "300139", + "节能环境": "300140", + "和顺电气": "300141", + "沃森生物": "300142", + "盈康生命": "300143", + "宋城演艺": "300144", + "南方泵业": "300145", + "汤臣倍健": "300146", + "*ST香雪": "300147", + "天舟文化": "300148", + "睿智医药": "300149", + "世纪瑞尔": "300150", + "昌红科技": "300151", + "*ST动力": "300152", + "科泰电源": "300153", + "瑞凌股份": "300154", + "安居宝": "300155", + "新锦动力": "300157", + "振东制药": "300158", + "新研股份": "300159", + "秀强股份": "300160", + "华中数控": "300161", + "雷曼光电": "300162", + "先锋新材": "300163", + "通源石油": "300164", + "天瑞仪器": "300165", + "东方国信": "300166", + "ST迪威迅": "300167", + "万达信息": "300168", + "天晟新材": "300169", + "汉得信息": "300170", + "东富龙": "300171", + "中电环保": "300172", + "ST福能": "300173", + "元力股份": "300174", + "朗源股份": "300175", + "鸿特科技": "300176", + "中海达": "300177", + "四方达": "300179", + "华峰超纤": "300180", + "佐力药业": "300181", + "捷成股份": "300182", + "东软载波": "300183", + "力源信息": "300184", + "通裕重工": "300185", + "永清环保": "300187", + "国投智能": "300188", + "神农种业": "300189", + "维尔利": "300190", + "潜能恒信": "300191", + "科德教育": "300192", + "佳士科技": "300193", + "福安药业": "300194", + "长荣股份": "300195", + "长海股份": "300196", + "节能铁汉": "300197", + "*ST纳川": "300198", + "翰宇药业": "300199", + "高盟新材": "300200", + "海伦哲": "300201", + "聚光科技": "300203", + "舒泰神": "300204", + "*ST天喻": "300205", + "理邦仪器": "300206", + "欣旺达": "300207", + "行云科技": "300209", + "森远股份": "300210", + "*ST亿通": "300211", + "*ST易录": "300212", + "佳讯飞鸿": "300213", + "日科化学": "300214", + "电科院": "300215", + "东方电热": "300217", + "安利股份": "300218", + "鸿利智汇": "300219", + "金运激光": "300220", + "银禧科技": "300221", + "科大智能": "300222", + "北京君正": "300223", + "正海磁材": "300224", + "金力泰": "300225", + "上海钢联": "300226", + "光韵达": "300227", + "富瑞特装": "300228", + "拓尔思": "300229", + "永利股份": "300230", + "银信科技": "300231", + "洲明科技": "300232", + "金城医药": "300233", + "开尔新材": "300234", + "方直科技": "300235", + "上海新阳": "300236", + "ST美晨": "300237", + "冠昊生物": "300238", + "东宝生物": "300239", + "飞力达": "300240", + "瑞丰光电": "300241", + "佳云科技": "300242", + "瑞丰高材": "300243", + "迪安诊断": "300244", + "ST天玑": "300245", + "宝莱特": "300246", + "融捷健康": "300247", + "新开普": "300248", + "依米康": "300249", + "初灵信息": "300250", + "光线传媒": "300251", + "金信诺": "300252", + "卫宁健康": "300253", + "仟源医药": "300254", + "常山药业": "300255", + "星星科技": "300256", + "开山股份": "300257", + "精锻科技": "300258", + "新天科技": "300259", + "新莱应材": "300260", + "雅本化学": "300261", + "隆华科技": "300263", + "佳创视讯": "300264", + "通光线缆": "300265", + "兴源环境": "300266", + "尔康制药": "300267", + "佳沃食品": "300268", + "联建光电": "300269", + "中威电子": "300270", + "华宇软件": "300271", + "开能健康": "300272", + "阳光电源": "300274", + "梅安森": "300275", + "三丰智能": "300276", + "汽轮科技": "300277", + "华昌达": "300278", + "和晶科技": "300279", + "金明精机": "300281", + "温州宏丰": "300283", + "苏交科": "300284", + "国瓷材料": "300285", + "安科瑞": "300286", + "飞利信": "300287", + "朗玛信息": "300288", + "利德曼": "300289", + "荣科科技": "300290", + "百纳千成": "300291", + "吴通控股": "300292", + "蓝英装备": "300293", + "博雅生物": "300294", + "*ST三六五": "300295", + "利亚德": "300296", + "三诺生物": "300298", + "富春股份": "300299", + "海峡创新": "300300", + "ST长方": "300301", + "同有科技": "300302", + "聚飞光电": "300303", + "云意电气": "300304", + "裕兴股份": "300305", + "远方信息": "300306", + "慈星股份": "300307", + "中际旭创": "300308", + "宜通世纪": "300310", + "ST任子行": "300311", + "天山生物": "300313", + "戴维医疗": "300314", + "掌趣科技": "300315", + "晶盛机电": "300316", + "珈伟新能": "300317", + "博晖创新": "300318", + "麦捷科技": "300319", + "海达股份": "300320", + "同大股份": "300321", + "硕贝德": "300322", + "华灿光电": "300323", + "旋极信息": "300324", + "ST凯利": "300326", + "中颖电子": "300327", + "宜安科技": "300328", + "海伦钢琴": "300329", + "苏大维格": "300331", + "天壕能源": "300332", + "兆日科技": "300333", + "津膜科技": "300334", + "迪森股份": "300335", + "银邦股份": "300337", + "*ST开元": "300338", + "润和软件": "300339", + "科恒股份": "300340", + "麦克奥迪": "300341", + "天银机电": "300342", + "联创股份": "300343", + "华民股份": "300345", + "南大光电": "300346", + "泰格医药": "300347", + "长亮科技": "300348", + "金卡智能": "300349", + "华鹏飞": "300350", + "永贵电器": "300351", + "ST信源": "300352", + "东土科技": "300353", + "东华测试": "300354", + "蒙草生态": "300355", + "我武生物": "300357", + "楚天科技": "300358", + "全通教育": "300359", + "炬华科技": "300360", + "博腾股份": "300363", + "中文在线": "300364", + "恒华科技": "300365", + "ST创意": "300366", + "汇金股份": "300368", + "绿盟科技": "300369", + "安控科技": "300370", + "汇中股份": "300371", + "扬杰科技": "300373", + "中铁装配": "300374", + "鹏翎股份": "300375", + "易事特": "300376", + "赢时胜": "300377", + "鼎捷数智": "300378", + "安硕信息": "300380", + "溢多利": "300381", + "斯莱克": "300382", + "光环新网": "300383", + "三联虹普": "300384", + "ST雪浪": "300385", + "飞天诚信": "300386", + "富邦科技": "300387", + "节能国祯": "300388", + "艾比森": "300389", + "天华新能": "300390", + "中来股份": "300393", + "天孚通信": "300394", + "菲利华": "300395", + "ST迪瑞": "300396", + "天和防务": "300397", + "飞凯材料": "300398", + "天利科技": "300399", + "劲拓股份": "300400", + "花园生物": "300401", + "宝色股份": "300402", + "汉宇集团": "300403", + "博济医药": "300404", + "科隆股份": "300405", + "九强生物": "300406", + "凯发电气": "300407", + "三环集团": "300408", + "道氏技术": "300409", + "正业科技": "300410", + "金盾股份": "300411", + "迦南科技": "300412", + "芒果超媒": "300413", + "中光防雷": "300414", + "伊之密": "300415", + "苏试试验": "300416", + "南华仪器": "300417", + "昆仑万维": "300418", + "ST浩丰": "300419", + "五洋自控": "300420", + "力星股份": "300421", + "博世科": "300422", + "昇辉科技": "300423", + "航新科技": "300424", + "中建环能": "300425", + "华智数媒": "300426", + "红相股份": "300427", + "立中集团": "300428", + "强力新材": "300429", + "*ST益通": "300430", + "富临精工": "300432", + "蓝思科技": "300433", + "金石亚药": "300434", + "中泰股份": "300435", + "广生堂": "300436", + "清水源": "300437", + "鹏辉能源": "300438", + "美康生物": "300439", + "运达科技": "300440", + "鲍斯股份": "300441", + "润泽科技": "300442", + "金雷股份": "300443", + "双杰电气": "300444", + "康斯特": "300445", + "航天智造": "300446", + "全信股份": "300447", + "浩云科技": "300448", + "汉邦高科": "300449", + "先导智能": "300450", + "创业慧康": "300451", + "山河药辅": "300452", + "三鑫医疗": "300453", + "深信服": "300454", + "航天智装": "300455", + "赛微电子": "300456", + "赢合科技": "300457", + "全志科技": "300458", + "汤姆猫": "300459", + "ST惠伦": "300460", + "田中精机": "300461", + "ST华铭": "300462", + "迈克生物": "300463", + "星徽股份": "300464", + "高伟达": "300465", + "赛摩智能": "300466", + "迅游科技": "300467", + "四方精创": "300468", + "信息发展": "300469", + "中密控股": "300470", + "厚普股份": "300471", + "*ST新元": "300472", + "德尔股份": "300473", + "景嘉微": "300474", + "香农芯创": "300475", + "胜宏科技": "300476", + "ST合纵": "300477", + "杭州高新": "300478", + "神思电子": "300479", + "光力科技": "300480", + "濮阳惠成": "300481", + "万孚生物": "300482", + "首华燃气": "300483", + "蓝海华腾": "300484", + "赛升药业": "300485", + "东杰智能": "300486", + "蓝晓科技": "300487", + "恒锋工具": "300488", + "光智科技": "300489", + "华自科技": "300490", + "通合科技": "300491", + "华图山鼎": "300492", + "润欣科技": "300493", + "盛天网络": "300494", + "中科创达": "300496", + "富祥股份": "300497", + "温氏股份": "300498", + "高澜股份": "300499", + "启迪设计": "300500", + "海顺新材": "300501", + "新易盛": "300502", + "昊志机电": "300503", + "天邑股份": "300504", + "川金诺": "300505", + "名家汇": "300506", + "苏奥传感": "300507", + "维宏股份": "300508", + "新美星": "300509", + "金冠股份": "300510", + "雪榕生物": "300511", + "中亚股份": "300512", + "恒实科技": "300513", + "友讯达": "300514", + "三德科技": "300515", + "久之洋": "300516", + "海波重科": "300517", + "新迅达": "300518", + "新光药业": "300519", + "科大国创": "300520", + "爱司凯": "300521", + "世名科技": "300522", + "辰安科技": "300523", + "博思软件": "300525", + "ST应急": "300527", + "幸福蓝海": "300528", + "健帆生物": "300529", + "领湃科技": "300530", + "优博讯": "300531", + "今天国际": "300532", + "冰川网络": "300533", + "陇神戎发": "300534", + "达威股份": "300535", + "农尚环境": "300536", + "广信材料": "300537", + "同益股份": "300538", + "横河精密": "300539", + "蜀道装备": "300540", + "先进数通": "300541", + "新晨科技": "300542", + "朗科智能": "300543", + "联得装备": "300545", + "雄帝科技": "300546", + "川环科技": "300547", + "长芯博创": "300548", + "优德精密": "300549", + "和仁科技": "300550", + "古鳌科技": "300551", + "万集科技": "300552", + "集智股份": "300553", + "三超新材": "300554", + "ST路通": "300555", + "丝路视觉": "300556", + "理工光科": "300557", + "贝达药业": "300558", + "佳发教育": "300559", + "中富通": "300560", + "汇金科技": "300561", + "乐心医疗": "300562", + "神宇股份": "300563", + "筑博设计": "300564", + "科信技术": "300565", + "激智科技": "300566", + "精测电子": "300567", + "星源材质": "300568", + "天能重工": "300569", + "太辰光": "300570", + "平治信息": "300571", + "安车检测": "300572", + "兴齐眼药": "300573", + "中旗股份": "300575", + "容大感光": "300576", + "开润股份": "300577", + "会畅科技": "300578", + "数字认证": "300579", + "贝斯特": "300580", + "晨曦航空": "300581", + "英飞特": "300582", + "赛托生物": "300583", + "海辰药业": "300584", + "奥联电子": "300585", + "美联新材": "300586", + "天铁科技": "300587", + "熙菱信息": "300588", + "江龙船艇": "300589", + "移为通信": "300590", + "万里马": "300591", + "华凯易佰": "300592", + "新雷能": "300593", + "ST朗进": "300594", + "欧普康视": "300595", + "利安隆": "300596", + "吉大通信": "300597", + "诚迈科技": "300598", + "雄塑科技": "300599", + "国瑞科技": "300600", + "康泰生物": "300601", + "飞荣达": "300602", + "立昂技术": "300603", + "长川科技": "300604", + "恒锋信息": "300605", + "金太阳": "300606", + "拓斯达": "300607", + "思特奇": "300608", + "汇纳科技": "300609", + "晨化股份": "300610", + "美力科技": "300611", + "宣亚国际": "300612", + "富瀚微": "300613", + "百川畅银": "300614", + "欣天科技": "300615", + "尚品宅配": "300616", + "安靠智电": "300617", + "寒锐钴业": "300618", + "金银河": "300619", + "光库科技": "300620", + "维业股份": "300621", + "博士眼镜": "300622", + "捷捷微电": "300623", + "万兴科技": "300624", + "三雄极光": "300625", + "华瑞股份": "300626", + "华测导航": "300627", + "亿联网络": "300628", + "新劲刚": "300629", + "久吾高科": "300631", + "光莆股份": "300632", + "开立医疗": "300633", + "彩讯股份": "300634", + "中达安": "300635", + "同和药业": "300636", + "扬帆新材": "300637", + "广和通": "300638", + "凯普生物": "300639", + "德艺文创": "300640", + "正丹股份": "300641", + "透景生命": "300642", + "万通智控": "300643", + "南京聚隆": "300644", + "正元智慧": "300645", + "超频三": "300647", + "星云股份": "300648", + "杭州园林": "300649", + "太龙股份": "300650", + "金陵体育": "300651", + "雷迪克": "300652", + "正海生物": "300653", + "世纪天鸿": "300654", + "晶瑞电材": "300655", + "民德电子": "300656", + "弘信电子": "300657", + "延江股份": "300658", + "中孚信息": "300659", + "江苏雷利": "300660", + "圣邦股份": "300661", + "科锐国际": "300662", + "科蓝软件": "300663", + "鹏鹞环保": "300664", + "飞鹿股份": "300665", + "江丰电子": "300666", + "必创科技": "300667", + "杰恩股份": "300668", + "沪宁股份": "300669", + "大烨智能": "300670", + "富满微": "300671", + "国科微": "300672", + "佩蒂股份": "300673", + "宇信科技": "300674", + "建科院": "300675", + "华大基因": "300676", + "英科医疗": "300677", + "中科信息": "300678", + "电连技术": "300679", + "隆盛科技": "300680", + "英搏尔": "300681", + "朗新科技": "300682", + "海特生物": "300683", + "中石科技": "300684", + "艾德生物": "300685", + "智动力": "300686", + "赛意信息": "300687", + "创业黑马": "300688", + "澄天伟业": "300689", + "双一科技": "300690", + "联合光电": "300691", + "中赋科技": "300692", + "盛弘股份": "300693", + "蠡湖股份": "300694", + "兆丰股份": "300695", + "爱乐达": "300696", + "电工合金": "300697", + "万马科技": "300698", + "光威复材": "300699", + "岱勒新材": "300700", + "森霸传感": "300701", + "天宇股份": "300702", + "创源股份": "300703", + "九典制药": "300705", + "阿石创": "300706", + "威唐工业": "300707", + "聚灿光电": "300708", + "精研科技": "300709", + "万隆光电": "300710", + "广哈通信": "300711", + "永福股份": "300712", + "英可瑞": "300713", + "凯伦股份": "300715", + "*ST泉为": "300716", + "华信新材": "300717", + "长盛轴承": "300718", + "安达维尔": "300719", + "海川智能": "300720", + "怡达股份": "300721", + "新余国科": "300722", + "一品红": "300723", + "捷佳伟创": "300724", + "药石科技": "300725", + "宏达电子": "300726", + "润禾材料": "300727", + "乐歌股份": "300729", + "科创信息": "300730", + "科创新源": "300731", + "设研院": "300732", + "西菱动力": "300733", + "光弘科技": "300735", + "百邦科技": "300736", + "科顺股份": "300737", + "奥飞数据": "300738", + "明阳电路": "300739", + "水羊股份": "300740", + "华宝股份": "300741", + "天地数码": "300743", + "欣锐科技": "300745", + "汉嘉数智": "300746", + "锐科激光": "300747", + "金力永磁": "300748", + "顶固集创": "300749", + "宁德时代": "300750", + "迈为股份": "300751", + "隆利科技": "300752", + "爱朋医疗": "300753", + "华致酒行": "300755", + "金马游乐": "300756", + "罗博特科": "300757", + "七彩化学": "300758", + "康龙化成": "300759", + "迈瑞医疗": "300760", + "立华股份": "300761", + "上海瀚讯": "300762", + "锦浪科技": "300763", + "石药创新": "300765", + "每日互动": "300766", + "震安科技": "300767", + "迪普科技": "300768", + "德方纳米": "300769", + "新媒股份": "300770", + "智莱科技": "300771", + "运达股份": "300772", + "拉卡拉": "300773", + "倍杰特": "300774", + "三角防务": "300775", + "帝尔激光": "300776", + "中简科技": "300777", + "新城市": "300778", + "惠城环保": "300779", + "德恩精工": "300780", + "因赛集团": "300781", + "卓胜微": "300782", + "三只松鼠": "300783", + "利安科技": "300784", + "值得买": "300785", + "国林科技": "300786", + "海能实业": "300787", + "中信出版": "300788", + "唐源电气": "300789", + "宇瞳光学": "300790", + "仙乐健康": "300791", + "壹网壹创": "300792", + "佳禾智能": "300793", + "米奥会展": "300795", + "贝斯美": "300796", + "钢研纳克": "300797", + "锦鸡股份": "300798", + "力合科技": "300800", + "泰和科技": "300801", + "矩子科技": "300802", + "指南针": "300803", + "广康生化": "300804", + "电声股份": "300805", + "斯迪克": "300806", + "天迈科技": "300807", + "久量股份": "300808", + "华辰装备": "300809", + "中科海讯": "300810", + "铂科新材": "300811", + "易天股份": "300812", + "泰林生物": "300813", + "中富电路": "300814", + "玉禾田": "300815", + "艾可蓝": "300816", + "双飞集团": "300817", + "耐普矿机": "300818", + "聚杰微纤": "300819", + "英杰电气": "300820", + "东岳硅材": "300821", + "贝仕达克": "300822", + "建科智能": "300823", + "北鼎股份": "300824", + "阿尔特": "300825", + "测绘股份": "300826", + "上能电气": "300827", + "锐新科技": "300828", + "金丹科技": "300829", + "金现代": "300830", + "ST派瑞": "300831", + "新产业": "300832", + "浩洋股份": "300833", + "星辉环材": "300834", + "龙磁科技": "300835", + "佰奥智能": "300836", + "浙矿股份": "300837", + "浙江力诺": "300838", + "博汇股份": "300839", + "酷特智能": "300840", + "康华生物": "300841", + "帝科股份": "300842", + "胜蓝股份": "300843", + "山水比德": "300844", + "捷安高科": "300845", + "首都在线": "300846", + "中船汉光": "300847", + "美瑞新材": "300848", + "锦盛新材": "300849", + "新强联": "300850", + "交大思诺": "300851", + "四会富仕": "300852", + "申昊科技": "300853", + "中兰环保": "300854", + "图南股份": "300855", + "科思股份": "300856", + "协创数据": "300857", + "科拓生物": "300858", + "西域旅游": "300859", + "锋尚文化": "300860", + "美畅股份": "300861", + "蓝盾光电": "300862", + "卡倍亿": "300863", + "南大环境": "300864", + "大宏立": "300865", + "安克创新": "300866", + "圣元环保": "300867", + "杰美特": "300868", + "康泰医学": "300869", + "欧陆通": "300870", + "回盛生物": "300871", + "天阳科技": "300872", + "海晨股份": "300873", + "捷强装备": "300875", + "蒙泰高新": "300876", + "金春股份": "300877", + "维康药业": "300878", + "大叶股份": "300879", + "迦南智能": "300880", + "盛德鑫泰": "300881", + "万胜智能": "300882", + "龙利得": "300883", + "狄耐克": "300884", + "海昌新材": "300885", + "华业香料": "300886", + "谱尼测试": "300887", + "稳健医疗": "300888", + "爱克股份": "300889", + "翔丰华": "300890", + "惠云钛业": "300891", + "品渥食品": "300892", + "松原安全": "300893", + "火星人": "300894", + "铜牛信息": "300895", + "爱美客": "300896", + "山科智能": "300897", + "熊猫乳品": "300898", + "上海凯鑫": "300899", + "广联航空": "300900", + "中胤时尚": "300901", + "国安达": "300902", + "科翔股份": "300903", + "威力传动": "300904", + "宝丽迪": "300905", + "日月明": "300906", + "康平科技": "300907", + "仲景食品": "300908", + "汇创达": "300909", + "瑞丰新材": "300910", + "亿田智能": "300911", + "凯龙高科": "300912", + "兆龙互连": "300913", + "海融科技": "300915", + "朗特智能": "300916", + "特发服务": "300917", + "南山智尚": "300918", + "中伟新材": "300919", + "润阳科技": "300920", + "南凌科技": "300921", + "天秦装备": "300922", + "研奥股份": "300923", + "法本信息": "300925", + "博俊科技": "300926", + "江天化学": "300927", + "华安鑫创": "300928", + "华骐环保": "300929", + "屹通新材": "300930", + "通用电梯": "300931", + "三友联众": "300932", + "中辰股份": "300933", + "盈建科": "300935", + "中英科技": "300936", + "药易购": "300937", + "信测标准": "300938", + "秋田微": "300939", + "南极光": "300940", + "创识科技": "300941", + "易瑞生物": "300942", + "春晖智控": "300943", + "曼卡龙": "300945", + "恒而达": "300946", + "德必集团": "300947", + "冠中生态": "300948", + "奥雅股份": "300949", + "德固特": "300950", + "博硕科技": "300951", + "恒辉安防": "300952", + "震裕科技": "300953", + "嘉亨家化": "300955", + "英力股份": "300956", + "贝泰妮": "300957", + "建工修复": "300958", + "线上线下": "300959", + "通业科技": "300960", + "深水海纳": "300961", + "中金辐照": "300962", + "中洲特材": "300963", + "本川智能": "300964", + "恒宇信通": "300965", + "共同药业": "300966", + "晓鸣股份": "300967", + "格林精密": "300968", + "恒帅股份": "300969", + "华绿生物": "300970", + "博亚精工": "300971", + "万辰集团": "300972", + "立高食品": "300973", + "商络电子": "300975", + "达瑞电子": "300976", + "深圳瑞捷": "300977", + "东箭科技": "300978", + "华利集团": "300979", + "祥源新材": "300980", + "中红医疗": "300981", + "苏文电能": "300982", + "尤安设计": "300983", + "金沃股份": "300984", + "致远新能": "300985", + "志特新材": "300986", + "川网传媒": "300987", + "津荣天宇": "300988", + "蕾奥规划": "300989", + "同飞股份": "300990", + "创益通": "300991", + "泰福泵业": "300992", + "玉马科技": "300993", + "久祺股份": "300994", + "奇德新材": "300995", + "普联软件": "300996", + "欢乐家": "300997", + "宁波方正": "300998", + "金龙鱼": "300999", + "肇民科技": "301000", + "凯淳股份": "301001", + "崧盛股份": "301002", + "江苏博云": "301003", + "嘉益股份": "301004", + "超捷股份": "301005", + "迈拓股份": "301006", + "德迈仕": "301007", + "宏昌科技": "301008", + "可靠股份": "301009", + "晶雪节能": "301010", + "华立科技": "301011", + "扬电科技": "301012", + "利和兴": "301013", + "百洋医药": "301015", + "雷尔伟": "301016", + "漱玉平民": "301017", + "申菱环境": "301018", + "宁波色母": "301019", + "密封科技": "301020", + "英诺激光": "301021", + "海泰科": "301022", + "奕帆传动": "301023", + "霍普股份": "301024", + "读客文化": "301025", + "浩通科技": "301026", + "华蓝集团": "301027", + "鼎熔岩": "301028", + "怡合达": "301029", + "*ST仕净": "301030", + "中熔电气": "301031", + "新柴股份": "301032", + "迈普医学": "301033", + "润丰股份": "301035", + "双乐股份": "301036", + "保立佳": "301037", + "深水规院": "301038", + "中集车辆": "301039", + "中环海陆": "301040", + "金百泽": "301041", + "安联锐视": "301042", + "绿岛风": "301043", + "天禄科技": "301045", + "能辉科技": "301046", + "义翘神州": "301047", + "金鹰重工": "301048", + "超越科技": "301049", + "雷电微力": "301050", + "信濠光电": "301051", + "果麦文化": "301052", + "远信工业": "301053", + "张小泉": "301055", + "森赫股份": "301056", + "汇隆新材": "301057", + "中粮科工": "301058", + "金三江": "301059", + "兰卫医学": "301060", + "匠心家居": "301061", + "上海艾录": "301062", + "海锅股份": "301063", + "本立科技": "301065", + "万事利": "301066", + "显盈科技": "301067", + "大地海洋": "301068", + "凯盛新材": "301069", + "开勒股份": "301070", + "力量钻石": "301071", + "中捷精工": "301072", + "君亭酒店": "301073", + "多瑞医药": "301075", + "新瀚新材": "301076", + "星华新材": "301077", + "孩子王": "301078", + "邵阳液压": "301079", + "百普赛斯": "301080", + "严牌股份": "301081", + "久盛电气": "301082", + "百胜智能": "301083", + "亚康股份": "301085", + "鸿富瀚": "301086", + "可孚医疗": "301087", + "戎美股份": "301088", + "拓新药业": "301089", + "华润材料": "301090", + "深城交": "301091", + "争光股份": "301092", + "华兰股份": "301093", + "广立微": "301095", + "百诚医药": "301096", + "天益医疗": "301097", + "金埔园林": "301098", + "雅创电子": "301099", + "风光股份": "301100", + "明月镜片": "301101", + "兆讯传媒": "301102", + "何氏眼科": "301103", + "鸿铭股份": "301105", + "骏成科技": "301106", + "瑜欣电子": "301107", + "洁雅股份": "301108", + "军信股份": "301109", + "青木科技": "301110", + "粤万年青": "301111", + "信邦智能": "301112", + "雅艺科技": "301113", + "联检科技": "301115", + "益客食品": "301116", + "佳缘科技": "301117", + "恒光股份": "301118", + "正强股份": "301119", + "新特电气": "301120", + "紫建电子": "301121", + "采纳股份": "301122", + "奕东电子": "301123", + "腾亚精工": "301125", + "达嘉维康": "301126", + "武汉天源": "301127", + "强瑞技术": "301128", + "瑞纳智能": "301129", + "西点药业": "301130", + "聚赛龙": "301131", + "满坤科技": "301132", + "金钟股份": "301133", + "瑞德智能": "301135", + "招标股份": "301136", + "哈焊华通": "301137", + "华研精机": "301138", + "*ST元道": "301139", + "中科磁业": "301141", + "嘉戎技术": "301148", + "隆华新材": "301149", + "中一科技": "301150", + "冠龙节能": "301151", + "天力锂能": "301152", + "中科江南": "301153", + "海力风电": "301155", + "美农生物": "301156", + "华塑科技": "301157", + "德石股份": "301158", + "三维天地": "301159", + "翔楼新材": "301160", + "唯万密封": "301161", + "国能日新": "301162", + "宏德股份": "301163", + "锐捷网络": "301165", + "优宁维": "301166", + "建研设计": "301167", + "通灵股份": "301168", + "零点有数": "301169", + "锡南科技": "301170", + "易点天下": "301171", + "君逸数码": "301172", + "毓恬冠佳": "301173", + "中科环保": "301175", + "逸豪新材": "301176", + "迪阿股份": "301177", + "天亿马": "301178", + "泽宇智能": "301179", + "万祥科技": "301180", + "标榜股份": "301181", + "凯旺科技": "301182", + "东田微": "301183", + "鸥玛软件": "301185", + "超达装备": "301186", + "欧圣电气": "301187", + "力诺药包": "301188", + "奥尼电子": "301189", + "善水科技": "301190", + "菲菱科思": "301191", + "泰祥股份": "301192", + "家联科技": "301193", + "北路智控": "301195", + "唯科科技": "301196", + "工大科雅": "301197", + "喜悦智行": "301198", + "迈赫股份": "301199", + "大族数控": "301200", + "诚达药业": "301201", + "朗威股份": "301202", + "国泰环保": "301203", + "联特科技": "301205", + "三元生物": "301206", + "华兰疫苗": "301207", + "中亦科技": "301208", + "联合化学": "301209", + "金杨精密": "301210", + "亨迪药业": "301211", + "联盛化学": "301212", + "观想科技": "301213", + "中汽股份": "301215", + "万凯新材": "301216", + "铜冠铜箔": "301217", + "华是科技": "301218", + "腾远钴业": "301219", + "亚香股份": "301220", + "光庭信息": "301221", + "浙江恒威": "301222", + "中荣股份": "301223", + "恒勃股份": "301225", + "祥明智能": "301226", + "森鹰窗业": "301227", + "实朴检测": "301228", + "纽泰格": "301229", + "泓博医药": "301230", + "荣信文化": "301231", + "飞沃科技": "301232", + "盛帮股份": "301233", + "五洲医疗": "301234", + "华康洁净": "301235", + "软通动力": "301236", + "和顺科技": "301237", + "瑞泰新材": "301238", + "普瑞眼科": "301239", + "宏源药业": "301246", + "杰创智能": "301248", + "威尔高": "301251", + "同星科技": "301252", + "通力科技": "301255", + "华融化学": "301256", + "普蕊斯": "301257", + "富士莱": "301258", + "艾布鲁": "301259", + "格力博": "301260", + "恒工精密": "301261", + "海看股份": "301262", + "泰恩康": "301263", + "华新科技": "301265", + "宇邦新材": "301266", + "华厦眼科": "301267", + "铭利达": "301268", + "华大九天": "301269", + "汉仪股份": "301270", + "英华特": "301272", + "瑞晨环保": "301273", + "汉朔科技": "301275", + "嘉曼服饰": "301276", + "新天地": "301277", + "快可电子": "301278", + "金道科技": "301279", + "珠城科技": "301280", + "科源制药": "301281", + "金禄电子": "301282", + "聚胶股份": "301283", + "鸿日达": "301285", + "侨源股份": "301286", + "康力源": "301287", + "清研环境": "301288", + "国缆检测": "301289", + "东星医疗": "301290", + "明阳电气": "301291", + "海科新源": "301292", + "三博脑科": "301293", + "美硕科技": "301295", + "新巨丰": "301296", + "富乐德": "301297", + "东利机械": "301298", + "卓创资讯": "301299", + "远翔新材": "301300", + "川宁生物": "301301", + "华如科技": "301302", + "真兰仪表": "301303", + "朗坤科技": "301305", + "西测测试": "301306", + "美利信": "301307", + "江波龙": "301308", + "万得凯": "301309", + "鑫宏业": "301310", + "昆船智能": "301311", + "智立方": "301312", + "凡拓数创": "301313", + "科瑞思": "301314", + "威士顿": "301315", + "慧博云通": "301316", + "鑫磊股份": "301317", + "维海德": "301318", + "唯特偶": "301319", + "豪江智能": "301320", + "翰博高新": "301321", + "绿通科技": "301322", + "新莱福": "301323", + "曼恩斯特": "301325", + "捷邦科技": "301326", + "华宝新能": "301327", + "维峰电子": "301328", + "信音电子": "301329", + "熵基科技": "301330", + "恩威医药": "301331", + "德尔玛": "301332", + "诺思格": "301333", + "天元宠物": "301335", + "趣睡科技": "301336", + "亚华电子": "301337", + "凯格精机": "301338", + "通行宝": "301339", + "涛涛车业": "301345", + "蓝箭电子": "301348", + "信德新材": "301349", + "普莱得": "301353", + "南王科技": "301355", + "天振股份": "301356", + "北方长龙": "301357", + "湖南裕能": "301358", + "东南电子": "301359", + "荣旗科技": "301360", + "众智科技": "301361", + "民爆光电": "301362", + "美好医疗": "301363", + "矩阵股份": "301365", + "一博科技": "301366", + "瑞迈特": "301367", + "丰立智能": "301368", + "联动科技": "301369", + "国科恒泰": "301370", + "敷尔佳": "301371", + "科净源": "301372", + "凌玮科技": "301373", + "致欧科技": "301376", + "鼎泰高科": "301377", + "通达海": "301378", + "天山电子": "301379", + "挖金客": "301380", + "赛维时代": "301381", + "蜂助手": "301382", + "天键股份": "301383", + "未来电器": "301386", + "光大同创": "301387", + "欣灵电气": "301388", + "隆扬电子": "301389", + "经纬股份": "301390", + "卡莱特": "301391", + "汇成真空": "301392", + "昊帆生物": "301393", + "仁信新材": "301395", + "宏景科技": "301396", + "溯联股份": "301397", + "星源卓镁": "301398", + "英特科技": "301399", + "华人健康": "301408", + "安培龙": "301413", + "协昌科技": "301418", + "阿莱德": "301419", + "波长光电": "301421", + "世纪恒通": "301428", + "森泰股份": "301429", + "泓淋电力": "301439", + "福事特": "301446", + "开创电气": "301448", + "天溯计量": "301449", + "盘古智能": "301456", + "钧崴电子": "301458", + "丰茂股份": "301459", + "博盈特焊": "301468", + "恒达新材": "301469", + "弘景光电": "301479", + "致尚科技": "301486", + "盟固利": "301487", + "豪恩汽电": "301488", + "思泉新材": "301489", + "汉桑科技": "301491", + "乖宝宠物": "301498", + "维科精密": "301499", + "飞南资源": "301500", + "恒鑫生活": "301501", + "华阳智能": "301502", + "智迪科技": "301503", + "苏州规划": "301505", + "民生健康": "301507", + "中机认检": "301508", + "金凯生科": "301509", + "固高科技": "301510", + "德福科技": "301511", + "智信精密": "301512", + "尚水智能": "301513", + "港通医疗": "301515", + "中远通": "301516", + "陕西华达": "301517", + "长华化学": "301518", + "舜禹股份": "301519", + "万邦医药": "301520", + "上大股份": "301522", + "儒竞科技": "301525", + "国际复材": "301526", + "多浦乐": "301528", + "福赛科技": "301529", + "春光集团": "301531", + "威马农机": "301533", + "浙江华远": "301535", + "星宸科技": "301536", + "骏鼎达": "301538", + "宏鑫科技": "301539", + "崇德科技": "301548", + "斯菱智驱": "301550", + "无线传媒": "301551", + "科力装备": "301552", + "惠柏新材": "301555", + "托普云农": "301556", + "常友科技": "301557", + "三态股份": "301558", + "中集环科": "301559", + "众捷汽车": "301560", + "云汉芯城": "301563", + "中仑新材": "301565", + "达利凯普": "301566", + "贝隆精密": "301567", + "思泰克": "301568", + "国科天成": "301571", + "艾芬达": "301575", + "美信科技": "301577", + "辰奕智能": "301578", + "爱迪特": "301580", + "黄山谷捷": "301581", + "建发致新": "301584", + "蓝宇股份": "301585", + "佳力奇": "301586", + "中瑞股份": "301587", + "美新科技": "301588", + "诺瓦星云": "301589", + "优优绿能": "301590", + "肯特股份": "301591", + "六九一二": "301592", + "太力科技": "301595", + "瑞迪智驱": "301596", + "博科测试": "301598", + "理奇智能": "301599", + "慧翰股份": "301600", + "惠通科技": "301601", + "超研股份": "301602", + "乔锋智能": "301603", + "绿联科技": "301606", + "富特科技": "301607", + "博实结": "301608", + "山大电力": "301609", + "珂玛科技": "301611", + "新铝时代": "301613", + "浙江华业": "301616", + "博苑新材": "301617", + "长联科技": "301618", + "英思特": "301622", + "苏州天脉": "301626", + "强达电路": "301628", + "矽电股份": "301629", + "同宇新材": "301630", + "壹连科技": "301631", + "广东建科": "301632", + "港迪技术": "301633", + "泽润新能": "301636", + "南网数字": "301638", + "联合动力": "301656", + "首航新能": "301658", + "宏工科技": "301662", + "泰禾股份": "301665", + "大普微-UW": "301666", + "纳百川": "301667", + "昊创瑞通": "301668", + "高特电子": "301669", + "新恒汇": "301678", + "固德电材": "301680", + "宏明电子": "301682", + "慧谷新材": "301683", + "新广益": "301687", + "三瑞智能": "301696", + "中航成飞": "302132", + "浦发银行": "600000", + "白云机场": "600004", + "东风股份": "600006", + "XD中国国": "600007", + "首创环保": "600008", + "上海机场": "600009", + "包钢股份": "600010", + "华能国际": "600011", + "皖通高速": "600012", + "华夏银行": "600015", + "民生银行": "600016", + "日照港": "600017", + "上港集团": "600018", + "宝钢股份": "600019", + "中原高速": "600020", + "XD上海电": "600021", + "山东钢铁": "600022", + "浙能电力": "600023", + "华能水电": "600025", + "中远海能": "600026", + "XD华电国": "600027", + "中国石化": "600028", + "南方航空": "600029", + "中信证券": "600030", + "三一重工": "600031", + "浙江新能": "600032", + "福建高速": "600033", + "XD楚天高": "600035", + "招商银行": "600036", + "歌华有线": "600037", + "中直股份": "600038", + "四川路桥": "600039", + "保利发展": "600048", + "XD中国联": "600050", + "宁波联合": "600051", + "东望时代": "600052", + "*ST九鼎": "600053", + "黄山旅游": "600054", + "万东医疗": "600055", + "XD中国医": "600056", + "厦门象屿": "600057", + "五矿发展": "600058", + "古越龙山": "600059", + "海信视像": "600060", + "国投资本": "600061", + "华润双鹤": "600062", + "皖维高新": "600063", + "南京高科": "600064", + "宇通客车": "600066", + "冠城新材": "600067", + "凤凰光学": "600071", + "中船科技": "600072", + "光明肉业": "600073", + "新疆天业": "600075", + "康欣新材": "600076", + "澄星股份": "600078", + "ST人福": "600079", + "ST金花": "600080", + "东风科技": "600081", + "ST海泰": "600082", + "*ST尼雅": "600084", + "同仁堂": "600085", + "中视传媒": "600088", + "特变电工": "600089", + "大名城": "600094", + "湘财股份": "600095", + "云天化": "600096", + "开创国际": "600097", + "XD广州发": "600098", + "林海股份": "600099", + "同方股份": "600100", + "明星电力": "600101", + "青山纸业": "600103", + "上汽集团": "600104", + "永鼎股份": "600105", + "重庆路桥": "600106", + "*ST尔雅": "600107", + "亚盛集团": "600108", + "国金证券": "600109", + "诺德股份": "600110", + "北方稀土": "600111", + "浙江东日": "600113", + "东睦股份": "600114", + "中国东航": "600115", + "三峡水利": "600116", + "西宁特钢": "600117", + "中国卫星": "600118", + "*ST长投": "600119", + "浙江东方": "600120", + "郑州煤电": "600121", + "兰花科创": "600123", + "铁龙物流": "600125", + "杭钢股份": "600126", + "金健米业": "600127", + "XD苏豪弘": "600128", + "太极集团": "600129", + "波导股份": "600130", + "国网信通": "600131", + "重庆啤酒": "600132", + "东湖高新": "600133", + "乐凯胶片": "600135", + "ST明诚": "600136", + "浪莎股份": "600137", + "中青旅": "600138", + "兴发集团": "600141", + "金发科技": "600143", + "长春一东": "600148", + "廊坊发展": "600149", + "中国船舶": "600150", + "航天机电": "600151", + "维科技术": "600152", + "建发股份": "600153", + "华创云信": "600155", + "华升股份": "600156", + "永泰能源": "600157", + "中体产业": "600158", + "大龙地产": "600159", + "巨化股份": "600160", + "天坛生物": "600161", + "香江控股": "600162", + "中闽能源": "600163", + "ST宁科": "600165", + "福田汽车": "600166", + "联美控股": "600167", + "XD武汉控": "600168", + "ST太重": "600169", + "上海建工": "600170", + "上海贝岭": "600171", + "黄河旋风": "600172", + "卧龙新能": "600173", + "中国巨石": "600176", + "雅戈尔": "600177", + "东安动力": "600178", + "安通控股": "600179", + "*ST瑞茂": "600180", + "S佳通": "600182", + "生益科技": "600183", + "光电股份": "600184", + "珠免集团": "600185", + "莲花控股": "600186", + "*ST国中": "600187", + "兖矿能源": "600188", + "泉阳泉": "600189", + "华资实业": "600191", + "长城电工": "600192", + "退市创兴": "600193", + "中牧股份": "600195", + "复星医药": "600196", + "伊力特": "600197", + "大唐电信": "600198", + "金种子酒": "600199", + "生物股份": "600201", + "哈空调": "600202", + "福日电子": "600203", + "有研新材": "600206", + "安彩高科": "600207", + "衢州发展": "600208", + "紫江企业": "600210", + "西藏药业": "600211", + "绿能慧充": "600212", + "派斯林": "600215", + "浙江医药": "600216", + "中再资环": "600217", + "全柴动力": "600218", + "南山铝业": "600219", + "海航控股": "600221", + "太龙药业": "600222", + "福瑞达": "600223", + "亨通股份": "600226", + "赤天化": "600227", + "返利科技": "600228", + "城市传媒": "600229", + "沧州大化": "600230", + "凌钢股份": "600231", + "金鹰股份": "600232", + "圆通速递": "600233", + "科新发展": "600234", + "民丰特纸": "600235", + "桂冠电力": "600236", + "铜峰电子": "600237", + "*ST椰岛": "600238", + "ST云城": "600239", + "时代万恒": "600241", + "*ST海华": "600243", + "万通发展": "600246", + "陕建股份": "600248", + "两面针": "600249", + "南京商旅": "600250", + "冠农股份": "600251", + "中恒集团": "600252", + "鑫科材料": "600255", + "广汇能源": "600256", + "大湖股份": "600257", + "首旅酒店": "600258", + "中稀有色": "600259", + "阳光照明": "600261", + "北方股份": "600262", + "*ST景谷": "600265", + "城建发展": "600266", + "海正药业": "600267", + "国电南自": "600268", + "赣粤高速": "600269", + "航天信息": "600271", + "开开实业": "600272", + "嘉化能源": "600273", + "恒瑞医药": "600276", + "东方创业": "600278", + "重庆港": "600279", + "中央商场": "600280", + "华阳新材": "600281", + "南钢股份": "600282", + "钱江水利": "600283", + "浦东建设": "600284", + "羚锐制药": "600285", + "苏豪时尚": "600287", + "大恒科技": "600288", + "ST信通": "600289", + "电投水电": "600292", + "三峡新材": "600293", + "鄂尔多斯": "600295", + "安琪酵母": "600298", + "安迪苏": "600299", + "维维股份": "600300", + "华锡有色": "600301", + "ST标准": "600302", + "曙光股份": "600303", + "恒顺醋业": "600305", + "酒钢宏兴": "600307", + "华泰股份": "600308", + "万华化学": "600309", + "广西能源": "600310", + "平高电气": "600312", + "农发种业": "600313", + "上海家化": "600315", + "XD洪都航": "600316", + "新力金融": "600318", + "亚星化学": "600319", + "振华重工": "600320", + "津投城开": "600322", + "瀚蓝环境": "600323", + "华发股份": "600325", + "西藏天路": "600326", + "大东方": "600327", + "中盐化工": "600328", + "达仁堂": "600329", + "天通股份": "600330", + "宏达股份": "600331", + "白云山": "600332", + "长春燃气": "600333", + "国机汽车": "600335", + "澳柯玛": "600336", + "ST美克": "600337", + "西藏珠峰": "600338", + "中油工程": "600339", + "*ST华幸": "600340", + "航天动力": "600343", + "长江通信": "600345", + "恒力石化": "600346", + "华阳股份": "600348", + "山东高速": "600350", + "亚宝药业": "600351", + "浙江龙盛": "600352", + "旭光电子": "600353", + "敦煌种业": "600354", + "XD恒丰纸": "600356", + "国旅联合": "600358", + "新农开发": "600359", + "华微电子": "600360", + "创新新材": "600361", + "江西铜业": "600362", + "联创光电": "600363", + "ST通葡": "600365", + "宁波韵升": "600366", + "红星发展": "600367", + "五洲交通": "600368", + "西南证券": "600369", + "*ST三房": "600370", + "万向德农": "600371", + "中航机载": "600372", + "中文传媒": "600373", + "汉马科技": "600375", + "首开股份": "600376", + "宁沪高速": "600377", + "昊华科技": "600378", + "宝光股份": "600379", + "XD健康元": "600380", + "*ST春天": "600381", + "广东明珠": "600382", + "金地集团": "600383", + "北巴传媒": "600386", + "龙净环保": "600388", + "江山股份": "600389", + "XD五矿资": "600390", + "航发科技": "600391", + "盛和资源": "600392", + "盘江股份": "600395", + "华电辽能": "600396", + "江钨装备": "600397", + "海澜之家": "600398", + "抚顺特钢": "600399", + "红豆股份": "600400", + "大有能源": "600403", + "动力源": "600405", + "国电南瑞": "600406", + "安泰集团": "600408", + "XD三友化": "600409", + "华胜天成": "600410", + "小商品城": "600415", + "湘电股份": "600416", + "江淮汽车": "600418", + "天润乳业": "600419", + "国药现代": "600420", + "昆药集团": "600422", + "*ST柳化": "600423", + "青松建化": "600425", + "华鲁恒升": "600426", + "中远海特": "600428", + "三元股份": "600429", + "冠豪高新": "600433", + "北方导航": "600435", + "片仔癀": "600436", + "通威股份": "600438", + "瑞贝卡": "600439", + "国机通用": "600444", + "金证股份": "600446", + "华纺股份": "600448", + "宁夏建材": "600449", + "涪陵电力": "600452", + "博通股份": "600455", + "宝钛股份": "600456", + "时代新材": "600458", + "XD贵研铂": "600459", + "士兰微": "600460", + "洪城环境": "600461", + "空港股份": "600463", + "好当家": "600467", + "百利电气": "600468", + "风神股份": "600469", + "六国化工": "600470", + "华光环能": "600475", + "*ST湘邮": "600476", + "杭萧钢构": "600477", + "科力远": "600478", + "千金药业": "600479", + "凌云股份": "600480", + "双良节能": "600481", + "中国动力": "600482", + "福能股份": "600483", + "扬农化工": "600486", + "亨通光电": "600487", + "津药药业": "600488", + "中金黄金": "600489", + "鹏欣资源": "600490", + "ST龙元": "600491", + "凤竹纺织": "600493", + "晋西车轴": "600495", + "精工钢构": "600496", + "驰宏锌锗": "600497", + "烽火通信": "600498", + "科达制造": "600499", + "中化国际": "600500", + "航天晨光": "600501", + "安徽建工": "600502", + "华丽家族": "600503", + "西昌电力": "600505", + "统一股份": "600506", + "方大特钢": "600507", + "上海能源": "600508", + "天富能源": "600509", + "黑牡丹": "600510", + "国药股份": "600511", + "腾达建设": "600512", + "联环药业": "600513", + "海南机场": "600515", + "XD方大炭": "600516", + "国网英大": "600517", + "康美药业": "600518", + "XD贵州茅": "600519", + "三佳科技": "600520", + "华海药业": "600521", + "中天科技": "600522", + "贵航股份": "600523", + "ST长园": "600525", + "菲达环保": "600526", + "江南高纤": "600527", + "中铁工业": "600528", + "山东药玻": "600529", + "交大昂立": "600530", + "豫光金铅": "600531", + "栖霞建设": "600533", + "天士力": "600535", + "中国软件": "600536", + "*ST亿晶": "600537", + "国发股份": "600538", + "狮头股份": "600539", + "新赛股份": "600540", + "*ST莫高": "600543", + "卓郎智能": "600545", + "山煤国际": "600546", + "山东黄金": "600547", + "深高速": "600548", + "厦门钨业": "600549", + "保变电气": "600550", + "时代出版": "600551", + "凯盛科技": "600552", + "天下秀": "600556", + "康缘药业": "600557", + "大西洋": "600558", + "XD老白干": "600559", + "金自天正": "600560", + "江西长运": "600561", + "国睿科技": "600562", + "法拉电子": "600563", + "济川药业": "600566", + "山鹰国际": "600567", + "ST中珠": "600568", + "安阳钢铁": "600569", + "恒生电子": "600570", + "信雅达": "600571", + "康恩贝": "600572", + "XD惠泉啤": "600573", + "淮河能源": "600575", + "祥源文旅": "600576", + "精达股份": "600577", + "京能电力": "600578", + "中化装备": "600579", + "卧龙电驱": "600580", + "*ST八钢": "600581", + "天地科技": "600582", + "海油工程": "600583", + "长电科技": "600584", + "海螺水泥": "600585", + "金晶科技": "600586", + "新华医疗": "600587", + "用友网络": "600588", + "大位科技": "600589", + "泰豪科技": "600590", + "龙溪股份": "600592", + "大连圣亚": "600593", + "益佰制药": "600594", + "中孚实业": "600595", + "新安股份": "600596", + "光明乳业": "600597", + "北大荒": "600598", + "青岛啤酒": "600600", + "方正科技": "600601", + "云赛智联": "600602", + "广汇物流": "600603", + "市北高新": "600604", + "汇通能源": "600605", + "绿地控股": "600606", + "退市沪科": "600608", + "金杯汽车": "600609", + "中毅达": "600610", + "大众交通": "600611", + "老凤祥": "600612", + "神奇制药": "600613", + "鑫源智造": "600615", + "金枫酒业": "600616", + "国新能源": "600617", + "氯碱化工": "600618", + "海立股份": "600619", + "天宸股份": "600620", + "华鑫股份": "600621", + "光大嘉宝": "600622", + "华谊集团": "600623", + "ST复华": "600624", + "申达股份": "600626", + "新世界": "600628", + "华建集团": "600629", + "龙头股份": "600630", + "浙数文化": "600633", + "大众公用": "600635", + "退市国化": "600636", + "东方明珠": "600637", + "新黄浦": "600638", + "浦东金桥": "600639", + "国脉文化": "600640", + "先导基电": "600641", + "申能股份": "600642", + "爱建集团": "600643", + "XD乐山电": "600644", + "中源协和": "600645", + "外高桥": "600648", + "城投控股": "600649", + "锦江在线": "600650", + "飞乐音响": "600651", + "申华控股": "600653", + "中安科": "600654", + "豫园股份": "600655", + "信达地产": "600657", + "电子城": "600658", + "福耀玻璃": "600660", + "昂立教育": "600661", + "外服控股": "600662", + "陆家嘴": "600663", + "哈药股份": "600664", + "天地源": "600665", + "奥瑞德": "600666", + "太极实业": "600667", + "尖峰集团": "600668", + "天目药业": "600671", + "东阳光": "600673", + "川投能源": "600674", + "中华企业": "600675", + "交运股份": "600676", + "ST金顶": "600678", + "上海凤凰": "600679", + "百川能源": "600681", + "南京新百": "600682", + "京投发展": "600683", + "珠江股份": "600684", + "中船防务": "600685", + "金龙汽车": "600686", + "上海石化": "600688", + "上海三毛": "600689", + "海尔智家": "600690", + "潞化科技": "600691", + "亚通股份": "600692", + "东百集团": "600693", + "大商股份": "600694", + "退市岩石": "600696", + "欧亚集团": "600697", + "湖南天雁": "600698", + "均胜电子": "600699", + "舍得酒业": "600702", + "三安光电": "600703", + "物产中大": "600704", + "曲江文旅": "600706", + "彩虹股份": "600707", + "光明地产": "600708", + "苏美达": "600710", + "盛屯矿业": "600711", + "南宁百货": "600712", + "南京医药": "600713", + "金瑞矿业": "600714", + "文投控股": "600715", + "凤凰股份": "600716", + "天津港": "600717", + "东软集团": "600718", + "大连热电": "600719", + "中交设计": "600720", + "百花医药": "600721", + "金牛化工": "600722", + "宁波富达": "600724", + "云维股份": "600725", + "华电能源": "600726", + "鲁北化工": "600727", + "佳都科技": "600728", + "重百集团": "600729", + "*ST高科": "600730", + "湖南海利": "600731", + "爱旭股份": "600732", + "北汽蓝谷": "600733", + "*ST实达": "600734", + "ST新华锦": "600735", + "苏州高新": "600736", + "中粮糖业": "600737", + "丽尚国潮": "600738", + "辽宁成大": "600739", + "山西焦化": "600740", + "华域汽车": "600741", + "富维股份": "600742", + "华远控股": "600743", + "华银电力": "600744", + "*ST闻泰": "600745", + "江苏索普": "600746", + "上实发展": "600748", + "西藏旅游": "600749", + "华润江中": "600750", + "海航科技": "600751", + "ST海钦": "600753", + "锦江酒店": "600754", + "厦门国贸": "600755", + "浪潮软件": "600756", + "长江传媒": "600757", + "辽宁能源": "600758", + "ST洲际": "600759", + "中航沈飞": "600760", + "安徽合力": "600761", + "通策医疗": "600763", + "中国海防": "600764", + "中航重机": "600765", + "宁波富邦": "600768", + "祥龙电业": "600769", + "综艺股份": "600770", + "广誉远": "600771", + "西藏城投": "600773", + "汉商集团": "600774", + "南京熊猫": "600775", + "东方通信": "600776", + "新潮能源": "600777", + "友好集团": "600778", + "水井坊": "600779", + "通宝能源": "600780", + "新钢股份": "600782", + "XD鲁信创": "600783", + "鲁银投资": "600784", + "新华百货": "600785", + "中储股份": "600787", + "鲁抗医药": "600789", + "轻纺城": "600790", + "京能置业": "600791", + "云煤能源": "600792", + "宜宾纸业": "600793", + "保税科技": "600794", + "国电电力": "600795", + "钱江生化": "600796", + "浙大网新": "600797", + "宁波海运": "600798", + "渤海化学": "600800", + "华新建材": "600801", + "福建水泥": "600802", + "新奥股份": "600803", + "悦达投资": "600805", + "济高发展": "600807", + "马钢股份": "600808", + "山西汾酒": "600809", + "神马股份": "600810", + "华北制药": "600812", + "杭州解百": "600814", + "厦工股份": "600815", + "建元信托": "600816", + "宇通重工": "600817", + "ST中路": "600818", + "耀皮玻璃": "600819", + "隧道股份": "600820", + "金开新能": "600821", + "上海物贸": "600822", + "益民集团": "600824", + "新华传媒": "600825", + "兰生股份": "600826", + "百联股份": "600827", + "茂业商业": "600828", + "人民同泰": "600829", + "香溢融通": "600830", + "广电网络": "600831", + "XD第一医": "600833", + "申通地铁": "600834", + "上海机电": "600835", + "上海九百": "600838", + "四川长虹": "600839", + "动力新科": "600841", + "上工申贝": "600843", + "金煤科技": "600844", + "宝信软件": "600845", + "同济科技": "600846", + "万里股份": "600847", + "上海临港": "600848", + "DR电科数": "600850", + "海欣股份": "600851", + "龙建股份": "600853", + "春兰股份": "600854", + "航天长峰": "600855", + "宁波中百": "600857", + "银座股份": "600858", + "王府井": "600859", + "京城股份": "600860", + "北京人力": "600861", + "中航高科": "600862", + "华能蒙电": "600863", + "哈投股份": "600864", + "百大集团": "600865", + "星湖科技": "600866", + "通化东宝": "600867", + "梅雁吉祥": "600868", + "远东股份": "600869", + "石化油服": "600871", + "中炬高新": "600872", + "梅花生物": "600873", + "创业环保": "600874", + "东方电气": "600875", + "凯盛新能": "600876", + "电科芯片": "600877", + "航天电子": "600879", + "博瑞传播": "600880", + "亚泰集团": "600881", + "妙可蓝多": "600882", + "XD博闻科": "600883", + "杉杉股份": "600884", + "宏发股份": "600885", + "国投电力": "600886", + "伊利股份": "600887", + "新疆众和": "600888", + "*ST京化": "600889", + "*ST大晟": "600892", + "航发动力": "600893", + "XD广日股": "600894", + "张江高科": "600895", + "厦门空港": "600897", + "长江电力": "600900", + "江苏金租": "600901", + "贵州燃气": "600903", + "三峡能源": "600905", + "财达证券": "600906", + "无锡银行": "600908", + "华安证券": "600909", + "中国黄金": "600916", + "XD重庆燃": "600917", + "中泰证券": "600918", + "江苏银行": "600919", + "苏能股份": "600925", + "杭州银行": "600926", + "永安期货": "600927", + "西安银行": "600928", + "雪天盐业": "600929", + "华电新能": "600930", + "爱柯迪": "600933", + "华塑股份": "600935", + "北投科技": "600936", + "中国海油": "600938", + "重庆建工": "600939", + "中国移动": "600941", + "维远股份": "600955", + "新天绿能": "600956", + "东方证券": "600958", + "江苏有线": "600959", + "渤海汽车": "600960", + "株冶集团": "600961", + "国投中鲁": "600962", + "岳阳林纸": "600963", + "*ST福成": "600965", + "博汇纸业": "600966", + "内蒙一机": "600967", + "海油发展": "600968", + "郴电国际": "600969", + "中材国际": "600970", + "恒源煤电": "600971", + "宝胜股份": "600973", + "新五丰": "600975", + "健民集团": "600976", + "中国电影": "600977", + "广安爱众": "600979", + "北矿科技": "600980", + "苏豪汇鸿": "600981", + "宁波能源": "600982", + "惠而浦": "600983", + "建设机械": "600984", + "淮北矿业": "600985", + "浙文互联": "600986", + "航民股份": "600987", + "赤峰黄金": "600988", + "宝丰能源": "600989", + "四创电子": "600990", + "贵绳股份": "600992", + "马应龙": "600993", + "南网储能": "600995", + "贵广网络": "600996", + "开滦股份": "600997", + "九州通": "600998", + "招商证券": "600999", + "唐山港": "601000", + "晋控煤业": "601001", + "晋亿实业": "601002", + "柳钢股份": "601003", + "重庆钢铁": "601005", + "大秦铁路": "601006", + "金陵饭店": "601007", + "连云港": "601008", + "南京银行": "601009", + "ST文峰": "601010", + "宝泰隆": "601011", + "隆基绿能": "601012", + "陕西黑猫": "601015", + "节能风电": "601016", + "宁波港": "601018", + "XD山东出": "601019", + "华钰矿业": "601020", + "春秋航空": "601021", + "宁波远洋": "601022", + "道生天合": "601026", + "永兴股份": "601033", + "一拖股份": "601038", + "赛轮轮胎": "601058", + "信达证券": "601059", + "中信金属": "601061", + "江盐集团": "601065", + "中信建投": "601066", + "中铝国际": "601068", + "西部黄金": "601069", + "渝农商行": "601077", + "锦江航运": "601083", + "国芳集团": "601086", + "中国神华": "601088", + "福元医药": "601089", + "宏盛华源": "601096", + "中南传媒": "601098", + "太平洋": "601099", + "恒立液压": "601100", + "昊华能源": "601101", + "中国一重": "601106", + "四川成渝": "601107", + "财通证券": "601108", + "中国国航": "601111", + "振石股份": "601112", + "华鼎股份": "601113", + "三江购物": "601116", + "中国化学": "601117", + "海南橡胶": "601118", + "宝地矿业": "601121", + "四方股份": "601126", + "赛力斯": "601127", + "常熟银行": "601128", + "柏诚股份": "601133", + "首创证券": "601136", + "博威合金": "601137", + "工业富联": "601138", + "深圳燃气": "601139", + "新城控股": "601155", + "东航物流": "601156", + "重庆水务": "601158", + "天风证券": "601162", + "三角轮胎": "601163", + "兴业银行": "601166", + "西部矿业": "601168", + "北京银行": "601169", + "杭齿前进": "601177", + "中国西电": "601179", + "中国铁建": "601186", + "XD厦门银": "601187", + "龙江交通": "601188", + "东兴证券": "601198", + "江南水务": "601199", + "上海环境": "601200", + "东材科技": "601208", + "XD国泰海": "601211", + "白银有色": "601212", + "君正集团": "601216", + "吉鑫科技": "601218", + "林洋能源": "601222", + "XD陕西煤": "601225", + "华电科工": "601226", + "广州港": "601228", + "上海银行": "601229", + "环旭电子": "601231", + "桐昆股份": "601233", + "红塔证券": "601236", + "广汽集团": "601238", + "英利汽车": "601279", + "农业银行": "601288", + "青岛港": "601298", + "骆驼股份": "601311", + "中国平安": "601318", + "中国人保": "601319", + "秦港股份": "601326", + "交通银行": "601328", + "绿色动力": "601330", + "XD广深铁": "601333", + "新华保险": "601336", + "百隆东方": "601339", + "三六零": "601360", + "利群股份": "601366", + "绿城水务": "601368", + "陕鼓动力": "601369", + "中原证券": "601375", + "兴业证券": "601377", + "怡球资源": "601388", + "中国中铁": "601390", + "工商银行": "601398", + "国机重装": "601399", + "国联民生": "601456", + "通用股份": "601500", + "中新集团": "601512", + "衢州东峰": "601515", + "吉林高速": "601518", + "大智慧": "601519", + "瑞丰银行": "601528", + "东吴证券": "601555", + "九牧王": "601566", + "三星电气": "601567", + "北元化工": "601568", + "长沙银行": "601577", + "会稽山": "601579", + "北辰实业": "601588", + "上海电影": "601595", + "中国外运": "601598", + "浙文影业": "601599", + "中国铝业": "601600", + "中国太保": "601601", + "长城军工": "601606", + "上海医药": "601607", + "中信重工": "601608", + "金田股份": "601609", + "中国核建": "601611", + "明阳智能": "601615", + "广电电气": "601616", + "中国中冶": "601618", + "嘉泽新能": "601619", + "中国人寿": "601628", + "长城汽车": "601633", + "旗滨集团": "601636", + "邮储银行": "601658", + "齐鲁银行": "601665", + "平煤股份": "601666", + "中国建筑": "601668", + "中国电建": "601669", + "明泰铝业": "601677", + "滨化股份": "601678", + "友发集团": "601686", + "华泰证券": "601688", + "拓普集团": "601689", + "中银证券": "601696", + "中国卫通": "601698", + "潞安环能": "601699", + "风范股份": "601700", + "华峰铝业": "601702", + "中创智领": "601717", + "ST际华": "601718", + "上海电气": "601727", + "中国电信": "601728", + "中国中车": "601766", + "千里科技": "601777", + "晶科科技": "601778", + "光大证券": "601788", + "宁波建工": "601789", + "蓝科高新": "601798", + "星宇股份": "601799", + "中国交建": "601800", + "皖新传媒": "601801", + "中海油服": "601808", + "新华文轩": "601811", + "京沪高铁": "601816", + "光大银行": "601818", + "沪农商行": "601825", + "三峰环境": "601827", + "美凯龙": "601828", + "成都银行": "601838", + "XD中国石": "601857", + "中国科传": "601858", + "紫金银行": "601860", + "福莱特": "601865", + "中远海发": "601866", + "中国能建": "601868", + "长飞光纤": "601869", + "招商轮船": "601872", + "正泰电器": "601877", + "浙商证券": "601878", + "辽港股份": "601880", + "中国银河": "601881", + "海天精工": "601882", + "江河集团": "601886", + "中国中免": "601888", + "亚星锚链": "601890", + "中煤能源": "601898", + "XD紫金矿": "601899", + "南方传媒": "601900", + "方正证券": "601901", + "京运通": "601908", + "XD浙商银": "601916", + "新集能源": "601918", + "XD中远海": "601919", + "浙版传媒": "601921", + "凤凰传媒": "601928", + "吉视传媒": "601929", + "永辉超市": "601933", + "建设银行": "601939", + "中国出版": "601949", + "苏垦农发": "601952", + "东贝集团": "601956", + "金钼股份": "601958", + "重庆银行": "601963", + "中国汽研": "601965", + "玲珑轮胎": "601966", + "宝钢包装": "601968", + "海南矿业": "601969", + "招商南油": "601975", + "中国核电": "601985", + "中国银行": "601988", + "南京证券": "601990", + "大唐发电": "601991", + "金隅集团": "601992", + "中金公司": "601995", + "丰林集团": "601996", + "贵阳银行": "601997", + "中信银行": "601998", + "出版传媒": "601999", + "人民网": "603000", + "奥康国际": "603001", + "宏昌电子": "603002", + "鼎龙科技": "603004", + "晶方科技": "603005", + "联明股份": "603006", + "顺景科技": "603007", + "ST喜临门": "603008", + "北特科技": "603009", + "万盛股份": "603010", + "合锻智能": "603011", + "创力集团": "603012", + "亚普股份": "603013", + "威高血净": "603014", + "弘讯科技": "603015", + "新宏泰": "603016", + "中衡设计": "603017", + "华设集团": "603018", + "中科曙光": "603019", + "爱普股份": "603020", + "*ST华鹏": "603021", + "新通联": "603022", + "威帝股份": "603023", + "大豪科技": "603025", + "石大胜华": "603026", + "千禾味业": "603027", + "赛福天": "603028", + "天鹅股份": "603029", + "全筑股份": "603030", + "安孚科技": "603031", + "德新科技": "603032", + "三维股份": "603033", + "常熟汽饰": "603035", + "如通股份": "603036", + "凯众股份": "603037", + "华立股份": "603038", + "泛微网络": "603039", + "新坐标": "603040", + "美思德": "603041", + "华脉科技": "603042", + "广州酒家": "603043", + "福达合金": "603045", + "浙江黎明": "603048", + "中策橡胶": "603049", + "科林电气": "603050", + "鹿山新材": "603051", + "可川科技": "603052", + "成都燃气": "603053", + "台华新材": "603055", + "紫燕食品": "603057", + "永吉股份": "603058", + "倍加洁": "603059", + "国检集团": "603060", + "金海通": "603061", + "麦加芯彩": "603062", + "禾望电气": "603063", + "宿迁联盛": "603065", + "音飞储存": "603066", + "振华股份": "603067", + "博通集成": "603068", + "海汽集团": "603069", + "万控智造": "603070", + "物产环能": "603071", + "天和磁材": "603072", + "彩蝶实业": "603073", + "热威股份": "603075", + "乐惠国际": "603076", + "和邦生物": "603077", + "江化微": "603078", + "圣达生物": "603079", + "新疆火炬": "603080", + "大丰实业": "603081", + "XD北自科": "603082", + "剑桥科技": "603083", + "天成自控": "603085", + "先达股份": "603086", + "甘李药业": "603087", + "宁波精达": "603088", + "正裕工业": "603089", + "宏盛股份": "603090", + "众鑫股份": "603091", + "德力佳": "603092", + "南华期货": "603093", + "越剑智能": "603095", + "新经典": "603096", + "江苏华辰": "603097", + "森特股份": "603098", + "长白山": "603099", + "川仪股份": "603100", + "汇嘉时代": "603101", + "百合股份": "603102", + "横店影视": "603103", + "芯能科技": "603105", + "恒银科技": "603106", + "上海汽配": "603107", + "润达医疗": "603108", + "神驰机电": "603109", + "东方材料": "603110", + "康尼机电": "603111", + "华翔股份": "603112", + "金能科技": "603113", + "海星股份": "603115", + "红蜻蜓": "603116", + "万林物流": "603117", + "共进股份": "603118", + "浙江荣泰": "603119", + "肯特催化": "603120", + "华培动力": "603121", + "合富中国": "603122", + "翠微股份": "603123", + "江南新材": "603124", + "常青科技": "603125", + "中材节能": "603126", + "昭衍新药": "603127", + "华贸物流": "603128", + "春风动力": "603129", + "云中马": "603130", + "上海沪工": "603131", + "金徽股份": "603132", + "中重科技": "603135", + "天目湖": "603136", + "恒尚节能": "603137", + "海量数据": "603138", + "康惠股份": "603139", + "万朗磁塑": "603150", + "邦基科技": "603151", + "上海建科": "603153", + "新亚强": "603155", + "养元饮品": "603156", + "腾龙股份": "603158", + "上海亚虹": "603159", + "汇顶科技": "603160", + "科华控股": "603161", + "海通发展": "603162", + "圣晖集成": "603163", + "荣晟环保": "603165", + "福达股份": "603166", + "渤海轮渡": "603167", + "莎普爱思": "603168", + "兰石重装": "603169", + "宝立食品": "603170", + "税友股份": "603171", + "万丰股份": "603172", + "福斯达": "603173", + "超颖电子": "603175", + "汇通集团": "603176", + "德创环保": "603177", + "圣龙股份": "603178", + "新泉股份": "603179", + "XD金牌家": "603180", + "皇马科技": "603181", + "嘉华股份": "603182", + "建研院": "603183", + "弘元绿能": "603185", + "华正新材": "603186", + "海容冷链": "603187", + "亚邦股份": "603188", + "*ST网达": "603189", + "亚通精工": "603190", + "望变电气": "603191", + "汇得科技": "603192", + "润本股份": "603193", + "中力股份": "603194", + "公牛集团": "603195", + "璞源材料": "603196", + "保隆科技": "603197", + "迎驾贡酒": "603198", + "九华旅游": "603199", + "上海洗霸": "603200", + "常润股份": "603201", + "天有为": "603202", + "快克智能": "603203", + "健尔康": "603205", + "嘉环科技": "603206", + "小方制药": "603207", + "江山欧派": "603208", + "兴通股份": "603209", + "泰鸿万立": "603210", + "晋拓股份": "603211", + "赛伍技术": "603212", + "镇洋发展": "603213", + "爱婴室": "603214", + "比依股份": "603215", + "梦天家居": "603216", + "元利科技": "603217", + "日月股份": "603218", + "富佳股份": "603219", + "中贝通信": "603220", + "爱丽家居": "603221", + "济民健康": "603222", + "恒通股份": "603223", + "新凤鸣": "603225", + "菲林格尔": "603226", + "雪峰科技": "603227", + "景旺电子": "603228", + "奥翔药业": "603229", + "内蒙新华": "603230", + "XD索宝蛋": "603231", + "格尔软件": "603232", + "XD大参林": "603233", + "天新药业": "603235", + "移远通信": "603236", + "五芳斋": "603237", + "诺邦股份": "603238", + "浙江仙通": "603239", + "锡华科技": "603248", + "鼎际得": "603255", + "宏和科技": "603256", + "中国瑞林": "603257", + "电魂网络": "603258", + "药明康德": "603259", + "合盛硅业": "603260", + "立航科技": "603261", + "技源集团": "603262", + "天龙股份": "603266", + "鸿远电子": "603267", + "松发股份": "603268", + "海鸥股份": "603269", + "金帝股份": "603270", + "永杰新材": "603271", + "*ST联翔": "603272", + "天元智能": "603273", + "众辰科技": "603275", + "恒兴新材": "603276", + "银都股份": "603277", + "大业股份": "603278", + "景津装备": "603279", + "南方路机": "603280", + "江瀚新材": "603281", + "亚光股份": "603282", + "赛腾股份": "603283", + "XD林平发": "603284", + "键邦股份": "603285", + "日盈电子": "603286", + "海天味业": "603288", + "泰瑞机器": "603289", + "斯达半导": "603290", + "联合水务": "603291", + "埃泰克": "603293", + "华勤技术": "603296", + "永新光学": "603297", + "杭叉集团": "603298", + "苏盐井神": "603299", + "海南华铁": "603300", + "振德医疗": "603301", + "得邦照明": "603303", + "旭升集团": "603305", + "华懋科技": "603306", + "扬州金泉": "603307", + "应流股份": "603308", + "维力医疗": "603309", + "巍华新材": "603310", + "金海高科": "603311", + "西典新能": "603312", + "梦百合": "603313", + "福鞍股份": "603315", + "诚邦股份": "603316", + "天味食品": "603317", + "水发燃气": "603318", + "美湖股份": "603319", + "迪贝电气": "603320", + "梅轮电梯": "603321", + "超讯科技": "603322", + "苏农银行": "603323", + "盛剑科技": "603324", + "博隆技术": "603325", + "我乐家居": "603326", + "福蓉科技": "603327", + "XD依顿电": "603328", + "上海雅仕": "603329", + "天洋新材": "603330", + "百达精工": "603331", + "苏州龙杰": "603332", + "福华尚纬": "603333", + "丰倍生物": "603334", + "迪生力": "603335", + "宏辉果蔬": "603336", + "杰克科技": "603337", + "XD浙江鼎": "603338", + "四方科技": "603339", + "龙旗科技": "603341", + "星德胜": "603344", + "安井食品": "603345", + "文灿股份": "603348", + "安乃达": "603350", + "威尔药业": "603351", + "至信股份": "603352", + "和顺石油": "603353", + "莱克电气": "603355", + "华菱精工": "603356", + "设计总院": "603357", + "华达科技": "603358", + "*ST东珠": "603359", + "百傲化学": "603360", + "傲农生物": "603363", + "水星家纺": "603365", + "日出东方": "603366", + "辰欣药业": "603367", + "柳药集团": "603368", + "今世缘": "603369", + "华新精科": "603370", + "安邦护卫": "603373", + "盛景微": "603375", + "大明电子": "603376", + "ST东时": "603377", + "*ST亚士": "603378", + "三美股份": "603379", + "易德龙": "603380", + "永臻股份": "603381", + "海阳科技": "603382", + "顶点软件": "603383", + "惠达卫浴": "603385", + "骏亚科技": "603386", + "基蛋生物": "603387", + "*ST亚振": "603389", + "通达电气": "603390", + "力聚热能": "603391", + "万泰生物": "603392", + "新天然气": "603393", + "红四方": "603395", + "金辰股份": "603396", + "*ST沐邦": "603398", + "永杉锂业": "603399", + "华之杰": "603400", + "陕西旅游": "603402", + "天富龙": "603406", + "长裕集团": "603407", + "建霖家居": "603408", + "汇通控股": "603409", + "信捷电气": "603416", + "友升股份": "603418", + "鼎信通讯": "603421", + "*ST集友": "603429", + "嘉德利": "603435", + "三力制药": "603439", + "吉比特": "603444", + "九洲药业": "603456", + "勘设股份": "603458", + "红板科技": "603459", + "风语筑": "603466", + "巨星农牧": "603477", + "科沃斯": "603486", + "展鹏科技": "603488", + "八方股份": "603489", + "恒为科技": "603496", + "翔港科技": "603499", + "祥和实业": "603500", + "豪威集团": "603501", + "金石资源": "603505", + "南都物业": "603506", + "振江股份": "603507", + "思维列控": "603508", + "爱慕股份": "603511", + "XD欧普照": "603515", + "淳中科技": "603516", + "ST绝味": "603517", + "锦泓集团": "603518", + "立霸股份": "603519", + "司太立": "603520", + "众源新材": "603527", + "多伦科技": "603528", + "爱玛科技": "603529", + "神马电力": "603530", + "掌阅科技": "603533", + "嘉诚国际": "603535", + "惠发食品": "603536", + "美诺华": "603538", + "奥普科技": "603551", + "海兴电力": "603556", + "*ST起步": "603557", + "健盛集团": "603558", + "ST通脉": "603559", + "中谷物流": "603565", + "普莱柯": "603566", + "珍宝岛": "603567", + "伟明环保": "603568", + "长久物流": "603569", + "汇金通": "603577", + "三星新材": "603578", + "荣泰健康": "603579", + "*ST艾艾": "603580", + "捷昌驱动": "603583", + "苏利股份": "603585", + "金麒麟": "603586", + "地素时尚": "603587", + "高能环境": "603588", + "口子窖": "603589", + "康辰药业": "603590", + "ST东尼": "603595", + "伯特利": "603596", + "引力传媒": "603598", + "广信股份": "603599", + "永艺股份": "603600", + "再升科技": "603601", + "纵横通信": "603602", + "珀莱雅": "603605", + "东方电缆": "603606", + "京华激光": "603607", + "天创时尚": "603608", + "禾丰股份": "603609", + "麒盛科技": "603610", + "诺力股份": "603611", + "索通发展": "603612", + "国联股份": "603613", + "茶花股份": "603615", + "韩建河山": "603616", + "君禾股份": "603617", + "杭电股份": "603618", + "中曼石油": "603619", + "科森科技": "603626", + "XD清源股": "603628", + "利通电子": "603629", + "拉芳家化": "603630", + "徕木股份": "603633", + "南威软件": "603636", + "镇海股份": "603637", + "艾迪精密": "603638", + "海利尔": "603639", + "畅联股份": "603648", + "彤程新材": "603650", + "朗博科技": "603655", + "泰禾智能": "603656", + "春光科技": "603657", + "安图生物": "603658", + "璞泰来": "603659", + "苏州科达": "603660", + "恒林股份": "603661", + "柯力传感": "603662", + "三祥新材": "603663", + "康隆达": "603665", + "亿嘉和": "603666", + "五洲新春": "603667", + "天马科技": "603668", + "灵康药业": "603669", + "卫信康": "603676", + "奇精机械": "603677", + "火炬电子": "603678", + "华体科技": "603679", + "今创集团": "603680", + "永冠新材": "603681", + "锦和商管": "603682", + "晶华新材": "603683", + "晨丰科技": "603685", + "XD福龙马": "603686", + "大胜达": "603687", + "石英股份": "603688", + "皖天然气": "603689", + "至纯科技": "603690", + "江苏新能": "603693", + "安记食品": "603696", + "有友食品": "603697", + "航天工程": "603698", + "纽威股份": "603699", + "宁水集团": "603700", + "德宏股份": "603701", + "盛洋科技": "603703", + "东方环宇": "603706", + "健友股份": "603707", + "家家悦": "603708", + "中源家居": "603709", + "香飘飘": "603711", + "七一二": "603712", + "密尔克卫": "603713", + "塞力医疗": "603716", + "天域生物": "603717", + "*ST海利": "603718", + "良品铺子": "603719", + "中广天择": "603721", + "阿科力": "603722", + "天安新材": "603725", + "朗迪集团": "603726", + "博迈科": "603727", + "鸣志电器": "603728", + "ST龙韵": "603729", + "岱美股份": "603730", + "仙鹤股份": "603733", + "三棵树": "603737", + "泰晶科技": "603738", + "蔚蓝生物": "603739", + "日辰股份": "603755", + "大元泵业": "603757", + "秦安股份": "603758", + "海天股份": "603759", + "隆鑫通用": "603766", + "中马传动": "603767", + "常青股份": "603768", + "沃格光电": "603773", + "永安行": "603776", + "来伊份": "603777", + "国晟科技": "603778", + "威龙股份": "603779", + "科博达": "603786", + "新日股份": "603787", + "宁波高发": "603788", + "ST星农": "603789", + "雅运股份": "603790", + "联泰环保": "603797", + "XD康普顿": "603798", + "华友钴业": "603799", + "洪田股份": "603800", + "志邦家居": "603801", + "瑞斯康达": "603803", + "福斯特": "603806", + "歌力思": "603808", + "豪能股份": "603809", + "丰山集团": "603810", + "DR诚意药": "603811", + "原尚股份": "603813", + "交建股份": "603815", + "顾家家居": "603816", + "海峡环保": "603817", + "曲美家居": "603818", + "神力股份": "603819", + "ST嘉澳": "603822", + "百合花": "603823", + "ST华扬": "603825", + "坤彩科技": "603826", + "*ST利达": "603828", + "洛凯股份": "603829", + "欧派家居": "603833", + "海程邦达": "603836", + "*ST四通": "603838", + "安正时尚": "603839", + "*ST正平": "603843", + "好太太": "603848", + "华荣股份": "603855", + "东宏股份": "603856", + "步长制药": "603858", + "能科科技": "603859", + "中公高科": "603860", + "白云电器": "603861", + "松炀资源": "603863", + "桃李面包": "603866", + "新化股份": "603867", + "飞科电器": "603868", + "ST智知": "603869", + "XD嘉友国": "603871", + "鼎胜新材": "603876", + "太平鸟": "603877", + "武进不锈": "603878", + "永悦科技": "603879", + "南卫股份": "603880", + "数据港": "603881", + "金域医学": "603882", + "老百姓": "603883", + "吉祥航空": "603885", + "元祖股份": "603886", + "城地香江": "603887", + "新华网": "603888", + "新澳股份": "603889", + "春秋电子": "603890", + "瑞芯微": "603893", + "天永智能": "603895", + "寿仙谷": "603896", + "长城科技": "603897", + "好莱客": "603898", + "晨光股份": "603899", + "莱绅通灵": "603900", + "永创智能": "603901", + "中持股份": "603903", + "龙蟠科技": "603906", + "牧高笛": "603908", + "建发合诚": "603909", + "佳力图": "603912", + "国茂股份": "603915", + "苏博特": "603916", + "合力科技": "603917", + "金桥信息": "603918", + "金徽酒": "603919", + "世运电路": "603920", + "ST金鸿顺": "603922", + "铁流股份": "603926", + "中科软": "603927", + "兴业股份": "603928", + "亚翔集成": "603929", + "格林达": "603931", + "睿能科技": "603933", + "博敏电子": "603936", + "丽岛新材": "603937", + "三孚股份": "603938", + "益丰药房": "603939", + "建业股份": "603948", + "雪龙集团": "603949", + "长源东谷": "603950", + "大千生态": "603955", + "威派格": "603956", + "哈森股份": "603958", + "ST百利": "603959", + "克来机电": "603960", + "法兰泰克": "603966", + "中创物流": "603967", + "醋化股份": "603968", + "银龙股份": "603969", + "中农立华": "603970", + "正川股份": "603976", + "国泰集团": "603977", + "深圳新星": "603978", + "金诚信": "603979", + "吉华集团": "603980", + "泉峰汽车": "603982", + "丸美生物": "603983", + "恒润股份": "603985", + "兆易创新": "603986", + "康德莱": "603987", + "中电电机": "603988", + "艾华集团": "603989", + "麦迪科技": "603990", + "领先股份": "603991", + "松霖科技": "603992", + "洛阳钼业": "603993", + "甬金股份": "603995", + "继峰股份": "603997", + "方盛制药": "603998", + "读者传媒": "603999", + "威奥股份": "605001", + "众望布艺": "605003", + "合兴股份": "605005", + "山东玻纤": "605006", + "五洲特纸": "605007", + "长鸿高科": "605008", + "豪悦护理": "605009", + "杭州热电": "605011", + "百龙创园": "605016", + "长华集团": "605018", + "永和股份": "605020", + "世茂能源": "605028", + "美邦股份": "605033", + "福然德": "605050", + "迎丰股份": "605055", + "咸亨国际": "605056", + "澳弘电子": "605058", + "联德股份": "605060", + "天正电气": "605066", + "明新旭腾": "605068", + "正和生态": "605069", + "华康股份": "605077", + "浙江自然": "605080", + "退市太和": "605081", + "龙高股份": "605086", + "冠盛股份": "605088", + "味知香": "605089", + "九丰能源": "605090", + "行动教育": "605098", + "共创草坪": "605099", + "华丰股份": "605100", + "同庆楼": "605108", + "新洁能": "605111", + "奥锐特": "605116", + "德业股份": "605117", + "力鼎光电": "605118", + "四方新材": "605122", + "派克新材": "605123", + "上海沿浦": "605128", + "嵘泰股份": "605133", + "丽人丽妆": "605136", + "盛泰集团": "605138", + "西上海": "605151", + "西大门": "605155", + "华达新材": "605158", + "新中港": "605162", + "聚合顺": "605166", + "利柏特": "605167", + "三人行": "605168", + "洪通燃气": "605169", + "东亚药业": "605177", + "时空科技": "605178", + "一鸣食品": "605179", + "华生科技": "605180", + "确成股份": "605183", + "健麾信息": "605186", + "国光连锁": "605188", + "富春染织": "605189", + "华通线缆": "605196", + "安德利": "605198", + "ST葫芦娃": "605199", + "永茂泰": "605208", + "伟时电子": "605218", + "起帆电缆": "605222", + "神通科技": "605228", + "天普股份": "605255", + "协和电子": "605258", + "绿田机械": "605259", + "健之佳": "605266", + "王力安防": "605268", + "新亚电子": "605277", + "同力天启": "605286", + "德才股份": "605287", + "凯迪股份": "605288", + "罗曼股份": "605289", + "神农集团": "605296", + "必得科技": "605298", + "舒华体育": "605299", + "佳禾食品": "605300", + "园林股份": "605303", + "中际联合": "605305", + "法狮龙": "605318", + "无锡振华": "605319", + "沪光股份": "605333", + "*ST帅电": "605336", + "李子园": "605337", + "巴比食品": "605338", + "南侨食品": "605339", + "立昂微": "605358", + "立达信": "605365", + "宏柏新材": "605366", + "蓝天燃气": "605368", + "拱东医疗": "605369", + "博迁新材": "605376", + "华旺科技": "605377", + "野马电池": "605378", + "均瑶健康": "605388", + "长龄液压": "605389", + "新炬网络": "605398", + "晨光新材": "605399", + "福莱新材": "605488", + "东鹏饮料": "605499", + "森林包装": "605500", + "国邦医药": "605507", + "德昌股份": "605555", + "福莱蒽特": "605566", + "春雪食品": "605567", + "龙版传媒": "605577", + "恒盛能源": "605580", + "冠石科技": "605588", + "圣泉集团": "605589", + "上海港湾": "605598", + "菜百股份": "605599", + "华兴源创": "688001", + "睿创微纳": "688002", + "天准科技": "688003", + "博汇科技": "688004", + "容百科技": "688005", + "杭可科技": "688006", + "光峰科技": "688007", + "澜起科技": "688008", + "中国通号": "688009", + "福光股份": "688010", + "新光光电": "688011", + "中微公司": "688012", + "XD天臣医": "688013", + "交控科技": "688015", + "心脉医疗": "688016", + "绿的谐波": "688017", + "乐鑫科技": "688018", + "安集科技": "688019", + "方邦股份": "688020", + "奥福科技": "688021", + "ST瀚川": "688022", + "安恒信息": "688023", + "杰普特": "688025", + "洁特生物": "688026", + "国盾量子": "688027", + "沃尔德": "688028", + "南微医学": "688029", + "山石网科": "688030", + "星环科技": "688031", + "禾迈股份": "688032", + "*ST天宜": "688033", + "德邦科技": "688035", + "传音控股": "688036", + "芯源微": "688037", + "中科通达": "688038", + "当虹科技": "688039", + "海光信息": "688041", + "必易微": "688045", + "药康生物": "688046", + "龙芯中科": "688047", + "长光华芯": "688048", + "炬芯科技": "688049", + "爱博医疗": "688050", + "佳华科技": "688051", + "纳芯微": "688052", + "ST思科瑞": "688053", + "龙腾光电": "688055", + "莱伯泰科": "688056", + "金达莱": "688057", + "宝兰德": "688058", + "华锐精密": "688059", + "云涌科技": "688060", + "灿瑞科技": "688061", + "迈威生物": "688062", + "派能科技": "688063", + "凯赛生物": "688065", + "*ST航图": "688066", + "爱威科技": "688067", + "热景生物": "688068", + "德林海": "688069", + "纵横股份": "688070", + "华依科技": "688071", + "拓荆科技": "688072", + "毕得医药": "688073", + "安旭生物": "688075", + "ST诺泰": "688076", + "大地熊": "688077", + "龙软科技": "688078", + "美迪凯": "688079", + "映翰通": "688080", + "兴图新科": "688081", + "盛美上海": "688082", + "中望软件": "688083", + "晶品特装": "688084", + "三友医疗": "688085", + "XD英科再": "688087", + "XD虹软科": "688088", + "嘉必优": "688089", + "瑞松科技": "688090", + "上海谊众": "688091", + "爱科科技": "688092", + "世华科技": "688093", + "福昕软件": "688095", + "京源环保": "688096", + "博众精工": "688097", + "申联生物": "688098", + "晶晨股份": "688099", + "威胜信息": "688100", + "三达膜": "688101", + "斯瑞新材": "688102", + "国力电子": "688103", + "诺唯赞": "688105", + "金宏气体": "688106", + "安路科技": "688107", + "赛诺医疗": "688108", + "品茗科技": "688109", + "东芯股份": "688110", + "金山办公": "688111", + "鼎阳科技": "688112", + "联测科技": "688113", + "华大智造": "688114", + "思林杰": "688115", + "天奈科技": "688116", + "圣诺生物": "688117", + "普元信息": "688118", + "中钢洛耐": "688119", + "华海清科": "688120", + "卓然股份": "688121", + "西部超导": "688122", + "聚辰股份": "688123", + "安达智能": "688125", + "沪硅产业": "688126", + "蓝特光学": "688127", + "中国电研": "688128", + "东来技术": "688129", + "晶华微": "688130", + "皓元医药": "688131", + "邦彦技术": "688132", + "泰坦科技": "688133", + "利扬芯片": "688135", + "科兴制药": "688136", + "近岸蛋白": "688137", + "清溢光电": "688138", + "海尔生物": "688139", + "杰华特": "688141", + "长盈通": "688143", + "中船特气": "688146", + "微导纳米": "688147", + "芳源股份": "688148", + "莱特光电": "688150", + "华强科技": "688151", + "麒麟信安": "688152", + "唯捷创芯": "688153", + "先惠技术": "688155", + "路德科技": "688156", + "松井股份": "688157", + "优刻得": "688158", + "有方科技": "688159", + "步科股份": "688160", + "XD威高骨": "688161", + "巨一科技": "688162", + "赛伦生物": "688163", + "埃夫特": "688165", + "博瑞医药": "688166", + "炬光科技": "688167", + "安博通": "688168", + "石头科技": "688169", + "德龙激光": "688170", + "纬德信息": "688171", + "燕东微": "688172", + "希荻微": "688173", + "高凌信息": "688175", + "亚虹医药": "688176", + "百奥泰": "688177", + "万德斯": "688178", + "阿拉丁": "688179", + "君实生物": "688180", + "八亿时空": "688181", + "灿勤科技": "688182", + "生益电子": "688183", + "ST帕瓦": "688184", + "康希诺": "688185", + "广大特材": "688186", + "时代电气": "688187", + "柏楚电子": "688188", + "ST南新": "688189", + "云路股份": "688190", + "智洋创新": "688191", + "迪哲医药": "688192", + "仁度生物": "688193", + "腾景科技": "688195", + "卓越新能": "688196", + "首药控股": "688197", + "佰仁医疗": "688198", + "XD久日新": "688199", + "华峰测控": "688200", + "ST信安": "688201", + "美迪西": "688202", + "海正生材": "688203", + "德科立": "688205", + "概伦电子": "688206", + "格灵深瞳": "688207", + "道通科技": "688208", + "英集芯": "688209", + "统联精密": "688210", + "中科微至": "688211", + "澳华内镜": "688212", + "思特威": "688213", + "瑞晟智能": "688215", + "气派科技": "688216", + "睿昂基因": "688217", + "江苏北人": "688218", + "会通股份": "688219", + "翱捷科技": "688220", + "前沿生物": "688221", + "成都先导": "688222", + "晶科能源": "688223", + "亚信安全": "688225", + "威腾电气": "688226", + "品高股份": "688227", + "开普云": "688228", + "博睿数据": "688229", + "芯导科技": "688230", + "隆达股份": "688231", + "新点软件": "688232", + "神工股份": "688233", + "天岳先进": "688234", + "百济神州": "688235", + "春立医疗": "688236", + "超卓航科": "688237", + "和元生物": "688238", + "航宇科技": "688239", + "永信至诚": "688244", + "嘉和美康": "688246", + "XD宣泰医": "688247", + "南网科技": "688248", + "晶合集成": "688249", + "井松智能": "688251", + "天德钰": "688252", + "英诺特": "688253", + "凯尔达": "688255", + "寒武纪": "688256", + "新锐股份": "688257", + "卓易信息": "688258", + "创耀科技": "688259", + "昀冢科技": "688260", + "东微半导": "688261", + "国芯科技": "688262", + "南模生物": "688265", + "泽璟制药": "688266", + "中触媒": "688267", + "华特气体": "688268", + "凯立新材": "688269", + "ST臻镭": "688270", + "联影医疗": "688271", + "富吉瑞": "688272", + "麦澜德": "688273", + "万润新能": "688275", + "百克生物": "688276", + "天智航": "688277", + "特宝生物": "688278", + "峰岹科技": "688279", + "精进电动": "688280", + "华秦科技": "688281", + "理工导航": "688282", + "坤恒顺维": "688283", + "高铁电气": "688285", + "敏芯股份": "688286", + "鸿泉技术": "688288", + "圣湘生物": "688289", + "景业智能": "688290", + "金橙子": "688291", + "浩瀚深度": "688292", + "奥浦迈": "688293", + "中复神鹰": "688295", + "和达科技": "688296", + "中无人机": "688297", + "东方生物": "688298", + "长阳科技": "688299", + "联瑞新材": "688300", + "奕瑞科技": "688301", + "海创药业": "688302", + "大全能源": "688303", + "科德数控": "688305", + "均普智能": "688306", + "中润光学": "688307", + "欧科亿": "688308", + "恒誉环保": "688309", + "迈得医疗": "688310", + "盟升电子": "688311", + "燕麦科技": "688312", + "仕佳光子": "688313", + "康拓医疗": "688314", + "诺禾致源": "688315", + "青云科技": "688316", + "之江生物": "688317", + "财富趋势": "688318", + "欧林生物": "688319", + "禾川科技": "688320", + "微芯生物": "688321", + "奥比中光": "688322", + "瑞华泰": "688323", + "赛微微电": "688325", + "XD经纬恒": "688326", + "云从科技": "688327", + "深科达": "688328", + "艾隆科技": "688329", + "宏力达": "688330", + "荣昌生物": "688331", + "中科蓝讯": "688332", + "铂力特": "688333", + "西高院": "688334", + "复洁科技": "688335", + "三生国健": "688336", + "普源精电": "688337", + "赛科希德": "688338", + "亿华通": "688339", + "云天励飞": "688343", + "博力威": "688345", + "华虹宏力": "688347", + "昱能科技": "688348", + "三一重能": "688349", + "富淼科技": "688350", + "微电生理": "688351", + "颀中科技": "688352", + "华盛锂电": "688353", + "XD明志科": "688355", + "键凯科技": "688356", + "建龙微纳": "688357", + "祥生医疗": "688358", + "三孚新科": "688359", + "德马科技": "688360", + "中科飞测": "688361", + "甬矽电子": "688362", + "华熙生物": "688363", + "光云科技": "688365", + "昊海生科": "688366", + "工大高科": "688367", + "晶丰明源": "688368", + "致远互联": "688369", + "XD丛麟科": "688370", + "菲沃泰": "688371", + "伟测科技": "688372", + "盟科药业": "688373", + "XD国博电": "688375", + "美埃科技": "688376", + "迪威尔": "688377", + "奥来德": "688378", + "华光新材": "688379", + "中微半导": "688380", + "帝奥微": "688381", + "益方生物": "688382", + "新益昌": "688383", + "复旦微电": "688385", + "泛亚微透": "688386", + "信科移动": "688387", + "嘉元科技": "688388", + "普门科技": "688389", + "固德威": "688390", + "钜泉科技": "688391", + "骄成超声": "688392", + "安必平": "688393", + "正弦电气": "688395", + "华润微": "688396", + "赛特新材": "688398", + "硕世生物": "688399", + "凌云光": "688400", + "路维光电": "688401", + "汇成股份": "688403", + "中信博": "688408", + "富创精密": "688409", + "山外山": "688410", + "海博思创": "688411", + "恒烁股份": "688416", + "震有科技": "688418", + "耐科装备": "688419", + "美腾科技": "688420", + "铁建重工": "688425", + "康为世纪": "688426", + "诺诚健华": "688428", + "时创能源": "688429", + "有研硅": "688432", + "华曙高科": "688433", + "英方软件": "688435", + "振华风光": "688439", + "智翔金泰": "688443", + "磁谷科技": "688448", + "联芸科技": "688449", + "光格科技": "688450", + "科捷智能": "688455", + "有研粉材": "688456", + "美芯晟": "688458", + "哈铁科技": "688459", + "金科环境": "688466", + "科美诊断": "688468", + "芯联集成": "688469", + "阿特斯": "688472", + "萤石网络": "688475", + "晶升股份": "688478", + "友车科技": "688479", + "赛恩斯": "688480", + "南芯科技": "688484", + "九州一轨": "688485", + "龙迅股份": "688486", + "艾迪药业": "688488", + "三未信安": "688489", + "*ST清越": "688496", + "源杰科技": "688498", + "利元亨": "688499", + "慧辰股份": "688500", + "青达环保": "688501", + "茂莱光学": "688502", + "聚和材料": "688503", + "复旦张江": "688505", + "百利天恒": "688506", + "索辰科技": "688507", + "芯朋微": "688508", + "正元地信": "688509", + "航亚科技": "688510", + "天微电子": "688511", + "慧智微": "688512", + "苑东生物": "688513", + "裕太微": "688515", + "奥特维": "688516", + "金冠电气": "688517", + "联赢激光": "688518", + "南亚新材": "688519", + "神州细胞": "688520", + "芯原股份": "688521", + "纳睿雷达": "688522", + "航天环宇": "688523", + "佰维存储": "688525", + "科前生物": "688526", + "秦川物联": "688528", + "豪森智能": "688529", + "欧莱新材": "688530", + "日联科技": "688531", + "上声电子": "688533", + "华海诚科": "688535", + "思瑞浦": "688536", + "和辉光电": "688538", + "高华科技": "688539", + "国科军工": "688543", + "兴福电子": "688545", + "XD广钢气": "688548", + "中巨芯": "688549", + "瑞联新材": "688550", + "科威尔": "688551", + "航天南湖": "688552", + "汇宇制药": "688553", + "高测股份": "688556", + "兰剑智能": "688557", + "国盛智科": "688558", + "海目星": "688559", + "明冠新材": "688560", + "奇安信": "688561", + "航天软件": "688562", + "航材股份": "688563", + "力源科技": "688565", + "吉贝尔": "688566", + "孚能科技": "688567", + "中科星图": "688568", + "铁科轨道": "688569", + "天玛智控": "688570", + "杭华股份": "688571", + "信宇人": "688573", + "亚辉龙": "688575", + "西山科技": "688576", + "浙海德曼": "688577", + "艾力斯": "688578", + "地纬智能": "688579", + "伟思医疗": "688580", + "安杰思": "688581", + "芯动联科": "688582", + "思看科技": "688583", + "上海合晶": "688584", + "上纬新材": "688585", + "江航装备": "688586", + "凌志软件": "688588", + "力合微": "688589", + "新致软件": "688590", + "泰凌微": "688591", + "司南导航": "688592", + "新相微": "688593", + "芯海科技": "688595", + "正帆科技": "688596", + "煜邦电力": "688597", + "金博股份": "688598", + "天合光能": "688599", + "皖仪科技": "688600", + "力芯微": "688601", + "康鹏科技": "688602", + "天承科技": "688603", + "先锋精科": "688605", + "奥泰生物": "688606", + "康众医疗": "688607", + "恒玄科技": "688608", + "九联科技": "688609", + "埃科光电": "688610", + "杭州柯林": "688611", + "威迈斯": "688612", + "奥精医疗": "688613", + "合合信息": "688615", + "西力科技": "688616", + "惠泰医疗": "688617", + "三旺通信": "688618", + "罗普特": "688619", + "安凯微": "688620", + "阳光诺和": "688621", + "*ST禾信": "688622", + "双元科技": "688623", + "呈和科技": "688625", + "翔宇医疗": "688626", + "精智达": "688627", + "优利德": "688628", + "华丰科技": "688629", + "芯碁微装": "688630", + "莱斯信息": "688631", + "星球石墨": "688633", + "长进光子": "688635", + "智明达": "688636", + "誉辰智能": "688638", + "华恒生物": "688639", + "ST逸飞": "688646", + "中邮科技": "688648", + "盛邦安全": "688651", + "京仪装备": "688652", + "康希通信": "688653", + "迅捷兴": "688655", + "浩欧博": "688656", + "浩辰软件": "688657", + "悦康药业": "688658", + "元琛科技": "688659", + "电气风电": "688660", + "和林微纳": "688661", + "富信科技": "688662", + "新风光": "688663", + "四方光电": "688665", + "菱电电控": "688667", + "鼎通科技": "688668", + "聚石化学": "688669", + "金迪克": "688670", + "碧兴物联": "688671", + "金盘科技": "688676", + "海泰新光": "688677", + "福立旺": "688678", + "通源环境": "688679", + "海优新材": "688680", + "科汇股份": "688681", + "霍莱沃": "688682", + "莱尔科技": "688683", + "迈信林": "688685", + "奥普特": "688686", + "凯因科技": "688687", + "XD银河微": "688689", + "纳微科技": "688690", + "灿芯股份": "688691", + "达梦数据": "688692", + "锴威特": "688693", + "中创股份": "688695", + "极米科技": "688696", + "纽威数控": "688697", + "伟创电气": "688698", + "明微电子": "688699", + "东威科技": "688700", + "卓锦股份": "688701", + "盛科通信": "688702", + "振华新材": "688707", + "佳驰科技": "688708", + "成都华微": "688709", + "益诺思": "688710", + "宏微科技": "688711", + "北芯生命": "688712", + "中研股份": "688716", + "艾罗能源": "688717", + "唯赛勃": "688718", + "爱科赛博": "688719", + "艾森股份": "688720", + "龙图光罩": "688721", + "同益中": "688722", + "拉普拉斯": "688726", + "恒坤新材": "688727", + "格科微": "688728", + "XD屹唐股": "688729", + "壹石通": "688733", + "中自科技": "688737", + "成大生物": "688739", + "金天钛业": "688750", + "汉邦科技": "688755", + "胜科纳米": "688757", + "赛分科技": "688758", + "必贝特": "688759", + "禾元生物": "688765", + "普冉股份": "688766", + "博拓生物": "688767", + "容知日新": "688768", + "珠海冠宇": "688772", + "影石创新": "688775", + "国光电气": "688776", + "中控技术": "688777", + "XD厦钨新": "688778", + "五矿新能": "688779", + "视涯科技": "688781", + "西安奕材": "688783", + "恒运昌": "688785", + "悦安新材": "688786", + "海天瑞声": "688787", + "科思科技": "688788", + "宏华数科": "688789", + "昂瑞微": "688790", + "倍轻松": "688793", + "摩尔线程": "688795", + "百奥赛图": "688796", + "C臻宝": "688797", + "艾为电子": "688798", + "华纳药厂": "688799", + "瑞可达": "688800", + "沐曦股份": "688802", + "健信超导": "688805", + "优迅股份": "688807", + "联讯仪器": "688808", + "强一股份": "688809", + "有研复材": "688811", + "泰金新能": "688813", + "易思维": "688816", + "电科蓝天": "688818", + "天能股份": "688819", + "盛合晶微": "688820", + "中芯国际": "688981", + "九号公司": "689009", + "安徽凤凰": "920000", + "纬达光电": "920001", + "万达轴承": "920002", + "中诚咨询": "920003", + "鼎佳精密": "920005", + "晟楠科技": "920006", + "酉立智能": "920007", + "成电光信": "920008", + "丹娜生物": "920009", + "凯添燃气": "920010", + "晨光电机": "920011", + "创达新材": "920012", + "特瑞斯": "920014", + "锦华新材": "920015", + "中草香料": "920016", + "星昊医药": "920017", + "宏远股份": "920018", + "铜冠矿建": "920019", + "泰凯英": "920020", + "流金科技": "920021", + "世昌股份": "920022", + "*ST田野": "920023", + "卓兆点胶": "920026", + "交大铁发": "920027", + "新恒泰": "920028", + "开发科技": "920029", + "德众汽车": "920030", + "康普化学": "920033", + "精创电气": "920035", + "觅睿科技": "920036", + "广信科技": "920037", + "国义招标": "920039", + "蘅东光": "920045", + "亿能电力": "920046", + "诺思兰德": "920047", + "爱舍伦": "920050", + "隆源股份": "920055", + "能之光": "920056", + "百甲科技": "920057", + "华洋赛车": "920058", + "万源通": "920060", + "西磁科技": "920061", + "科润智控": "920062", + "科拜尔": "920066", + "天工股份": "920068", + "普昂医疗": "920069", + "柏星龙": "920075", + "国亮新材": "920076", + "吉林碳谷": "920077", + "族兴新材": "920078", + "奥美森": "920080", + "方正阀门": "920082", + "金戈新材": "920083", + "科马材料": "920086", + "秋乐种业": "920087", + "科力股份": "920088", + "禾昌聚合": "920089", + "*ST同辉": "920090", + "大鹏工业": "920091", + "汉鑫科技": "920092", + "嘉晨智能": "920096", + "科隆新材": "920098", + "瑞华技术": "920099", + "三协电机": "920100", + "志高机械": "920101", + "林泰新材": "920106", + "宏海科技": "920108", + "雷特科技": "920110", + "聚星科技": "920111", + "巴兰仕": "920112", + "星图测控": "920116", + "太湖远大": "920118", + "美德乐": "920119", + "江天科技": "920121", + "中纺标": "920122", + "芭薇股份": "920123", + "南特科技": "920124", + "鸿仕达": "920125", + "永大股份": "920126", + "胜业电气": "920128", + "立方控股": "920130", + "泰鹏智能": "920132", + "华岭股份": "920139", + "恒合股份": "920145", + "华阳变速": "920146", + "旭杰科技": "920149", + "昆工科技": "920152", + "海昌智能": "920156", + "长江能科": "920158", + "农大科技": "920159", + "北矿检测": "920160", + "龙辰科技": "920161", + "方大新材": "920163", + "海圣医疗": "920166", + "同享科技": "920167", + "通宝光电": "920168", + "七丰精工": "920169", + "志晟信息": "920171", + "五新智能": "920174", + "东方碳素": "920175", + "恒道科技": "920177", + "锐翔智能": "920178", + "凯德石英": "920179", + "爱得科技": "920180", + "赛英电子": "920181", + "海菲曼": "920183", + "国源科技": "920184", + "贝特瑞": "920185", + "中科仪": "920186", + "通领科技": "920187", + "悦龙科技": "920188", + "雷神科技": "920190", + "瑞尔竞达": "920191", + "三祥科技": "920195", + "微创光电": "920198", + "倍益康": "920199", + "振宏股份": "920200", + "XD沪江材": "920204", + "彩客科技": "920206", + "众诚科技": "920207", + "青矩技术": "920208", + "新睿电子": "920211", + "智新电子": "920212", + "新天力": "920218", + "朗信电气": "920220", + "易实精密": "920221", + "荣亿精密": "920223", + "利通科技": "920225", + "美登科技": "920227", + "欧康医药": "920230", + "力佳科技": "920237", + "长虹能源": "920239", + "建邦科技": "920242", + "威博液压": "920245", + "华密新材": "920247", + "利尔达": "920249", + "天宏锂电": "920252", + "中寰股份": "920260", + "一诺威": "920261", + "太湖雪": "920262", + "中航泰达": "920263", + "生物谷": "920266", + "鑫汇科": "920267", + "天铭科技": "920270", + "邦德股份": "920271", + "一致魔芋": "920273", + "宏裕包材": "920274", + "驱动力": "920275", + "鹿得医疗": "920278", + "灵鸽科技": "920284", + "灿能电力": "920299", + "辰光医疗": "920300", + "迪尔化工": "920304", + "*ST云创": "920305", + "恒太照明": "920339", + "三元基因": "920344", + "威贸电子": "920346", + "华光源海": "920351", + "雅葆轩": "920357", + "莱赛激光": "920363", + "新赣江": "920367", + "连城数控": "920368", + "新安洁": "920370", + "欧福蛋业": "920371", + "云里物里": "920374", + "派诺科技": "920375", + "泰德股份": "920378", + "佳合科技": "920392", + "民士达": "920394", + "朗鸿科技": "920395", + "常辅股份": "920396", + "硅烷科技": "920402", + "康农种业": "920403", + "海希通讯": "920405", + "驰诚股份": "920407", + "欧普泰": "920414", + "恒拓开源": "920415", + "苏轴股份": "920418", + "路斯股份": "920419", + "润普食品": "920422", + "乐创技术": "920425", + "华维设计": "920427", + "康比特": "920429", + "大唐药业": "920433", + "大地电气": "920436", + "戈碧迦": "920438", + "龙竹科技": "920445", + "同心传动": "920454", + "汇隆活塞": "920455", + "富恒新材": "920469", + "美邦科技": "920471", + "三友科技": "920475", + "海能技术": "920476", + "峆一药业": "920478", + "佳先股份": "920489", + "奥迪威": "920491", + "并行科技": "920493", + "许昌智能": "920496", + "博迅生物": "920504", + "九菱科技": "920505", + "殷图网联": "920508", + "同惠电子": "920509", + "丰光精密": "920510", + "万德股份": "920519", + "纳科诺尔": "920522", + "德瑞锂电": "920523", + "凯华材料": "920526", + "夜光明": "920527", + "骏创科技": "920533", + "铁大科技": "920541", + "无锡晶海": "920547", + "凯腾精工": "920553", + "雅达股份": "920556", + "天润科技": "920564", + "梓橦宫": "920566", + "坤博精工": "920570", + "国航远洋": "920571", + "*ST康乐": "920575", + "天力复合": "920576", + "巨能股份": "920578", + "机科股份": "920579", + "科创新材": "920580", + "华信永道": "920592", + "鼎智科技": "920593", + "同力股份": "920599", + "丰安股份": "920608", + "力王股份": "920627", + "新威凌": "920634", + "晨光电缆": "920639", + "富士达": "920640", + "格利尔": "920641", + "通易航天": "920642", + "天罡股份": "920651", + "海昇药业": "920656", + "方盛股份": "920662", + "明阳科技": "920663", + "科强股份": "920665", + "数字人": "920670", + "秉扬科技": "920675", + "前进科技": "920679", + "球冠电缆": "920682", + "新芝生物": "920685", + "克莱特": "920689", + "捷众科技": "920690", + "阿为特": "920693", + "中裕科技": "920694", + "海达尔": "920699", + "XD豪声电": "920701", + "广厦环能": "920703", + "铁拓机械": "920706", + "瑞星股份": "920717", + "合肥高科": "920718", + "宁新新材": "920719", + "吉冈精密": "920720", + "惠丰钻石": "920725", + "朱老六": "920726", + "永顺生物": "920729", + "德源药业": "920735", + "路桥信息": "920748", + "惠同新材": "920751", + "天纺标": "920753", + "美之高": "920765", + "拾比佰": "920768", + "艾能聚": "920770", + "武汉蓝电": "920779", + "瑞奇智造": "920781", + "XD骑士乳": "920786", + "联迪信息": "920790", + "东和新材": "920792", + "艾融软件": "920799", + "保丽洁": "920802", + "云星宇": "920806", + "奔朗新材": "920807", + "曙光数创": "920808", + "安达科技": "920809", + "春光智能": "920810", + "颖泰生物": "920819", + "则成电子": "920821", + "盖世食品": "920826", + "齐鲁华信": "920832", + "美心翼申": "920833", + "三维装备": "920834", + "华原股份": "920837", + "万通液压": "920839", + "浙江大农": "920855", + "浩淼科技": "920856", + "泓禧科技": "920857", + "绿亨科技": "920866", + "恒进感应": "920870", + "派特尔": "920871", + "中设咨询": "920873", + "慧为智能": "920876", + "基康技术": "920879", + "星辰科技": "920885", + "广咨国际": "920892", + "花溪科技": "920895", + "旺成科技": "920896", + "舜宇精工": "920906", + "远航精密": "920914", + "广脉科技": "920924", + "锦好医疗": "920925", + "鸿智科技": "920926", + "无锡鼎邦": "920931", + "科达自控": "920932", + "恒立钻具": "920942", + "优机股份": "920943", + "森萱医药": "920946", + "迅安科技": "920950", + "国子软件": "920953", + "汉维科技": "920957", + "创远信科": "920961", + "润农节水": "920964", + "大禹生物": "920970", + "天马新材": "920971", + "凯大催化": "920974", + "视声智能": "920976", + "开特股份": "920978", + "晶赛科技": "920981", + "锦波生物": "920982", + "海泰新能": "920985", + "中科美菱": "920992", + "万科A": "000002", + "南玻A": "000012", + "特力A": "000025", + "深赛格": "000058", + "农产品": "000061", + "盐田港": "000088", + "金融街": "000402", + "渝开发": "000514", + "柳工": "000528", + "英力特": "000635", + "鲁泰A": "000726", + "罗牛山": "000735", + "五粮液": "000858", + "张裕A": "000869", + "新希望": "000876", + "中关村": "000931", + "新大陆": "000997", + "新和成": "002001", + "七匹狼": "002029", + "苏泊尔": "002032", + "南京港": "002040", + "兔宝宝": "002043", + "金螳螂": "002081", + "生意宝": "002095", + "安纳达": "002136", + "报喜鸟": "002154", + "远望谷": "002161", + "红宝丽": "002165", + "粤传媒": "002181", + "怡亚通": "002183", + "全聚德": "002186", + "海利得": "002206", + "达意隆": "002209", + "诺普信": "002215", + "三力士": "002224", + "新华都": "002264" +} \ No newline at end of file diff --git a/scripts/data_freshness.py b/scripts/data_freshness.py new file mode 100644 index 0000000..99808a7 --- /dev/null +++ b/scripts/data_freshness.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""data_freshness.py — 数据新鲜度校验 + +所有报告管道在生成输出前必须调用 check_fresh()。 +返回 (pass: bool, details: str),如果数据过期则阻止生成操作建议。 + +用法: + from data_freshness import check_fresh + ok, msg = check_fresh() + if not ok: + print(f"⚠️ 数据过期: {msg}") + sys.exit(0) # 不生成报告 + +校验规则: +- 盘中 (9:30~15:00):price/live_prices.json 必须在 5 分钟内刷新 +- 盘后 (9:30以前/15:00以后):允许最长 120 分钟 +- 周末/节假日:跳过校验 +""" + +import json, os +from datetime import datetime, timedelta +from mo_data import read_portfolio, read_decisions, read_watchlist + + +# live_prices.json 已废弃,所有数据走 DB +LIVE_PRICES_PATH = "/home/hmo/web-dashboard/data/live_prices.json" + + +def is_market_hours(): + now = datetime.now() + if now.weekday() >= 5: + return False, "weekend" + t = now.hour * 60 + now.minute + if 9*60+30 <= t <= 15*60: + return True, "trading" + return False, "closed" + + +def check_fresh(): + """返回 (ok: bool, msg: str)""" + now = datetime.now() + in_market, period = is_market_hours() + max_age_min = 5 if in_market else 120 + + # 主指标:DB portfolio_summary.updated_at + try: + pf = read_portfolio() + pf_time = pf.get("updated_at", "") + if pf_time: + pf_dt = datetime.fromisoformat(pf_time) + age = (now - pf_dt).total_seconds() / 60 + if age > max_age_min: + return False, f"数据已 {age:.0f} 分钟未更新(阈值 {max_age_min} 分钟)" + return True, f"数据新鲜({age:.0f} 分钟前)" + except Exception as e: + return False, f"DB 读取失败: {e}" + + return False, "无法获取数据新鲜度" + + +if __name__ == "__main__": + ok, msg = check_fresh() + print(f"{'✅' if ok else '❌'} {msg}") diff --git a/scripts/fix_portfolio_prices.py b/scripts/fix_portfolio_prices.py new file mode 100644 index 0000000..5c3e2c1 --- /dev/null +++ b/scripts/fix_portfolio_prices.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +fix_portfolio_prices.py — 一次性修复脚本:从 decisions.json 读取 price 字段 +更新两个 canonical portfolio.json,并重算 total_assets/position_pct。 + +背景:strategy_lifecycle.regenerate_all() 在旧版本中会 +从 DB query_holdings()(不含 price/change_pct)覆盖写入 +portfolio.json,导致 price_monitor 维护的实时价丢失。 +该 bug 已在 strategy_lifecycle.py:1790 修复(保留 price 字段), +此脚本用于修复已损坏的 portfolio.json。 + +用法: + python3 fix_portfolio_prices.py # 修复两个 canonical 文件 + python3 fix_portfolio_prices.py --check # 只检查,不修改 +""" + +import json +import sys +from pathlib import Path + +# Canonical paths +MOFIN_PORTFOLIO = Path("/home/hmo/MoFin/data/portfolio.json") +DASHBOARD_PORTFOLIO = Path("/home/hmo/web-dashboard/data/portfolio.json") +DECISIONS_PATH = Path("/home/hmo/web-dashboard/data/decisions.json") + + +def load_decisions(): + """从 decisions.json 读取 {code: {price, ...}} 映射""" + try: + with open(DECISIONS_PATH) as f: + raw = json.load(f) + except Exception as e: + print(f"❌ 无法读取 {DECISIONS_PATH}: {e}", file=sys.stderr) + return {} + + price_map = {} + for d in raw.get("decisions", []): + code = d.get("code", "") + price = d.get("price", 0) or d.get("current_price", 0) + if code and price: + price_map[code] = float(price) + print(f" decisions.json: {len(price_map)} 个股票的 price 已加载") + return price_map + + +def fix_portfolio(path: Path, price_map: dict, check_only: bool): + """修复一个 portfolio.json 文件""" + if not path.exists(): + print(f"❌ {path} 不存在,跳过") + return False + + try: + with open(path) as f: + pf = json.load(f) + except Exception as e: + print(f"❌ 无法读取 {path}: {e}", file=sys.stderr) + return False + + changes = 0 + errors = 0 + holdings = pf.get("holdings", []) + + for h in holdings: + code = h.get("code", "") + if not code: + continue + + old_price = h.get("price", 0) + decision_price = price_map.get(code) + + if decision_price and (not old_price or old_price == 0): + h["price"] = decision_price + changes += 1 + print(f" ✅ {code} {h.get('name','')}: price {old_price} → {decision_price}") + elif decision_price and old_price and abs(decision_price - old_price) / max(abs(old_price), 1) > 0.05: + # Price differs >5% from decision - flag it but don't overwrite (price_monitor is authoritative) + print(f" ⚠️ {code} {h.get('name','')}: 当前价 {old_price} vs decisions {decision_price} (偏离>{5:.0f}%),保留当前价") + elif not decision_price: + errors += 1 + if old_price == 0 or not old_price: + print(f" ❌ {code} {h.get('name','')}: decisions.json 无此股 price 数据,当前 price={old_price}") + + # 重算 total_assets / position_pct + total_mv = 0 + for h in holdings: + price = h.get("price", 0) or 0 + shares = h.get("shares", 0) or 0 + total_mv += price * shares + + cash = pf.get("cash", 0) or 0 + new_total_assets = round(total_mv + cash, 2) + new_position_pct = round(total_mv / new_total_assets * 100, 2) if new_total_assets > 0 else 0 + + old_total_assets = pf.get("total_assets", 0) + old_position_pct = pf.get("position_pct", 0) + + if abs(new_total_assets - old_total_assets) > 100: + print(f" 📊 total_assets: {old_total_assets} → {new_total_assets} (变动 {new_total_assets-old_total_assets:.0f})") + pf["total_assets"] = new_total_assets + changes += 1 + + if abs(new_position_pct - old_position_pct) > 0.5: + print(f" 📊 position_pct: {old_position_pct}% → {new_position_pct}%") + pf["position_pct"] = new_position_pct + changes += 1 + + pf["updated_at"] = __import__("datetime").datetime.now().strftime('%Y-%m-%d %H:%M') + + if changes == 0: + print(f" ✅ {path.name}: 无需修复({len(holdings)} 个持仓价格正常)") + return True + + if check_only: + print(f" ⏸️ 检查模式: {changes} 处需修复,未写入") + return True + + try: + with open(path, "w") as f: + json.dump(pf, f, indent=2, ensure_ascii=False) + print(f" ✅ {path.name}: {changes} 处已修复,已写入") + return True + except Exception as e: + print(f" ❌ 写入失败: {e}", file=sys.stderr) + return False + + +def main(): + check_only = "--check" in sys.argv + + print("=== fix_portfolio_prices.py ===") + if check_only: + print("模式:检查(不写入)") + else: + print("模式:修复") + + price_map = load_decisions() + if not price_map: + print("❌ decisions.json 无有效 price 数据,退出") + return 1 + + print(f"\n--- {MOFIN_PORTFOLIO.name} ---") + ok1 = fix_portfolio(MOFIN_PORTFOLIO, price_map, check_only) + + print(f"\n--- {DASHBOARD_PORTFOLIO.name} ---") + ok2 = fix_portfolio(DASHBOARD_PORTFOLIO, price_map, check_only) + + if ok1 and ok2: + print("\n✅ 全部完成") + return 0 + else: + print("\n⚠️ 部分失败") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/get_realtime_prices.py b/scripts/get_realtime_prices.py new file mode 100644 index 0000000..02812b3 --- /dev/null +++ b/scripts/get_realtime_prices.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +import json +import subprocess +import sys +import os +from datetime import datetime + +# 读取持仓数据 +with open('data/portfolio.json', 'r') as f: + portfolio = json.load(f) + +# 读取决策数据 +with open('data/decisions.json', 'r') as f: + decisions_data = json.load(f) + +# 获取所有持仓股票 +holdings = portfolio['holdings'] +active_holdings = [h for h in holdings if h.get('shares', 0) > 0] + +print(f"持仓股票数量: {len(active_holdings)}") + +# 获取实时价格 - 使用curl调用API +# 注意:这里需要根据实际情况调整API调用 +realtime_prices = {} + +for holding in active_holdings: + code = holding['code'] + name = holding['name'] + + # 判断市场类型 + # 港股代码5位数字(如01211),优先判断 + if len(code) == 5: # 港股 + market = 'hk' + price = holding['price'] + change_pct = holding.get('change_pct', 0) + realtime_prices[code] = { + 'name': name, + 'price': price, + 'change_pct': change_pct, + 'market': 'HK' + } + elif code.startswith('6'): # 沪市 + market = 'sh' + price = holding['price'] + change_pct = holding.get('change_pct', 0) + realtime_prices[code] = { + 'name': name, + 'price': price, + 'change_pct': change_pct, + 'market': 'A' + } + elif code.startswith('0') or code.startswith('3'): # 深市 + market = 'sz' + price = holding['price'] + change_pct = holding.get('change_pct', 0) + realtime_prices[code] = { + 'name': name, + 'price': price, + 'change_pct': change_pct, + 'market': 'A' + } + else: + # 其他类型 + price = holding['price'] + change_pct = holding.get('change_pct', 0) + realtime_prices[code] = { + 'name': name, + 'price': price, + 'change_pct': change_pct, + 'market': 'OTHER' + } + +# 获取活跃决策 +active_decisions = [d for d in decisions_data['decisions'] if d.get('status') == 'active'] + +print(f"活跃决策数量: {len(active_decisions)}") + +# 输出结果 +print("\n=== 实时价格数据 ===") +for code, data in realtime_prices.items(): + print(f"{code} {data['name']}: {data['price']} ({data['change_pct']:+.2f}%)") + +# 保存到临时文件供后续使用 +output_data = { + 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'prices': realtime_prices, + 'holdings': active_holdings, + 'active_decisions': active_decisions +} + +with open('data/temp_realtime.json', 'w') as f: + json.dump(output_data, f, indent=2, ensure_ascii=False) + +print(f"\n数据已保存到 data/temp_realtime.json") \ No newline at end of file diff --git a/scripts/hk_rate.py b/scripts/hk_rate.py new file mode 100644 index 0000000..e880991 --- /dev/null +++ b/scripts/hk_rate.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +hk_rate.py — 每日刷新HKD/CNY汇率 + +用法: + from hk_rate import hkd_to_cny, refresh_rate + + rate = hkd_to_cny() # 自动使用缓存,过期则刷新 + refresh_rate() # 强制刷新 + +缓存文件:~/.cache/hk_exchange_rate.json +有效期:24小时 +""" + +import json, os, time, sys +from datetime import date + +CACHE_PATH = os.path.expanduser("~/.cache/hk_exchange_rate.json") +CACHE_TTL = 86400 # 24小时,合理配置常量 + +# 不再硬编码备用值,每次取缓存中的最近一次有效汇率 +def _load_last_rate(): + """从缓存文件读取上次已知有效汇率""" + try: + if os.path.exists(CACHE_PATH): + with open(CACHE_PATH) as f: + data = json.load(f) + rate = data.get("rate", 0) + if 0.7 < rate < 1.0: + return round(float(rate), 6) + except Exception: + pass + return None + +PRIMARY_API = "https://api.exchangerate-api.com/v4/latest/HKD" +BACKUP_API = "https://api.exchangerate-api.com/v4/latest/USD" + +def _fetch_rate(): + """从API获取 HKD/CNY 汇率""" + import urllib.request + ua = "Mozilla/5.0" + + # 主API:直接用HKD→CNY + try: + req = urllib.request.Request(PRIMARY_API, headers={"User-Agent": ua}) + with urllib.request.urlopen(req, timeout=8) as r: + data = json.loads(r.read()) + cny = data.get("rates", {}).get("CNY") + if cny and 0.7 < cny < 1.0: # 合理性检查 + return round(float(cny), 6) + except Exception: + pass + + # 备用:USD→HKD + USD→CNY 间接计算 + try: + req = urllib.request.Request(BACKUP_API, headers={"User-Agent": ua}) + with urllib.request.urlopen(req, timeout=8) as r: + data = json.loads(r.read()) + rates = data.get("rates", {}) + hkd = rates.get("HKD") + cny = rates.get("CNY") + if hkd and cny: + rate = cny / hkd + if 0.7 < rate < 1.0: + return round(rate, 6) + except Exception: + pass + + return None + + +def hkd_to_cny(force_refresh=False): + """获取 HKD→CNY 汇率,缓存过期则自动刷新""" + os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True) + now = time.time() + rate = None + + # 读缓存 + if not force_refresh: + try: + with open(CACHE_PATH) as f: + cached = json.load(f) + cache_date = cached.get("date", "") + rate = cached.get("rate") + cached_at = cached.get("cached_at", 0) + # 同一天且未过期 + if (cache_date == date.today().isoformat() + and rate is not None + and (now - cached_at) < CACHE_TTL): + return rate + except Exception: + pass + + # 刷新 + rate = _fetch_rate() + if rate is None: + # API全挂,用缓存中的上次有效汇率 + rate = _load_last_rate() + if rate is None: + rate = 0.87 # 极限兜底,纯预防 + print(f"[hk_rate] API不可达,使用 {rate} (fallback)", file=sys.stderr) + else: + # 写缓存 + try: + with open(CACHE_PATH, "w") as f: + json.dump({ + "rate": rate, + "date": date.today().isoformat(), + "cached_at": now, + "source": "exchangerate-api.com", + }, f) + except Exception: + pass + + return rate + + +def refresh_rate(): + return hkd_to_cny(force_refresh=True) + + +if __name__ == "__main__": + r = hkd_to_cny() + print(f"HKD/CNY = {r}") diff --git a/scripts/inject_xiaoguo_insight.py b/scripts/inject_xiaoguo_insight.py new file mode 100644 index 0000000..ad9ac96 --- /dev/null +++ b/scripts/inject_xiaoguo_insight.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""inject_xiaoguo_insight.py — 将小果情感分析结果注入 market.json + +时序:market_watch(15:30) → market_insight(15:35) → xiaoguo_analyst(16:00) → 本脚本(16:05) + +读取 xiaoguo_insights.json 的情感分析结果,注入到 market.json 的 insights 字段 +""" + +import json +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" + + +def inject(): + market_path = DATA_DIR / "market.json" + xiaoguo_path = DATA_DIR / "xiaoguo_insights.json" + + if not xiaoguo_path.exists(): + print("xiaoguo_insights.json 不存在,跳过") + return + + with open(xiaoguo_path, "r", encoding="utf-8") as f: + xiaoguo = json.load(f) + + analyses = xiaoguo.get("analyses", []) + if not analyses: + print("小果分析为空,跳过") + return + + with open(market_path, "r", encoding="utf-8") as f: + market = json.load(f) + + insights = market.get("insights", []) + + # 注入情感分析结果 + for a in analyses: + name = a.get("name", "") + sentiment = a.get("sentiment", "neutral") + confidence = a.get("confidence", 0) + brief = a.get("brief", "") + sentiment_emoji = {"positive": "", "negative": "", "neutral": ""}.get( + sentiment, "" + ) + label = {"positive": "利好", "negative": "利空", "neutral": "中性"}.get( + sentiment, "中性" + ) + + if brief: + insights.append( + f"[小果]{name}: {label}(置信度{confidence:.0%}) — {brief}" + ) + + market["insights"] = insights + market["xiaoguo_timestamp"] = xiaoguo.get("timestamp", "") + + with open(market_path, "w", encoding="utf-8") as f: + json.dump(market, f, ensure_ascii=False, indent=2) + + print(f"注入{len(analyses)}条小果分析到 market.json") + + +if __name__ == "__main__": + inject() diff --git a/scripts/market_insight.py b/scripts/market_insight.py new file mode 100644 index 0000000..86796ef --- /dev/null +++ b/scripts/market_insight.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""market_insight.py — 基于 market.json 数据生成基础洞察 + 潜力挖掘 + +输出:更新 data/market.json 中的 insights / potential_stocks 字段 + +策略: + 1. 行业热点 vs 持仓匹配 → 相关影响 + 2. 资金流向异常 → 关注信号 + 3. 市场情绪 → 每日研判 + 4. 潜力挖掘 → 强势行业中寻找持仓相关标的 +""" + +import json +import sys +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" + +# ── 持仓股 → 行业映射(从 stock_profiles 自动提取) ── + +def load_holding_industry_map(): + """从 stock_profiles 和 portfolio 提取持仓→行业映射""" + try: + with open(DATA_DIR / "stock_profiles.json", "r", encoding="utf-8") as f: + profiles = json.load(f).get("profiles", []) + + with open(DATA_DIR / "portfolio.json", "r", encoding="utf-8") as f: + portfolio = json.load(f) + except FileNotFoundError: + return {} + + # 构建 code→name 映射(从 portfolio) + code_to_name = {} + for item in portfolio.get("holdings", []): + code_to_name[item.get("code", "")] = item.get("name", "") + + # 构建行业→持仓列表 + industry_holdings = {} + for p in profiles: + code = p.get("code", "") + name = p.get("name", "") + sector = p.get("sector", "") + if not sector or sector == "待补全": + continue + # 提取一级行业(取斜杠前第一个) + primary = sector.split("/")[0].split("(")[0].strip() + if primary: + industry_holdings.setdefault(primary, []).append({ + "code": code, + "name": name, + "sector": sector, + }) + return industry_holdings + + +def generate(): + # 优先从 SQLite 读取市场数据 + try: + from mofin_db import get_conn, query_latest_market + conn = get_conn() + market = query_latest_market(conn) + conn.close() + if market and market.get("sectors"): + sectors = market["sectors"] + top_gainers = market.get("top_gainers", []) + top_losers = market.get("top_losers", []) + mood = market.get("mood", "unknown") + up_ratio = market.get("up_ratio", 0) + timestamp = market.get("timestamp", "") + # 字段名适配 + for s in sectors: + s["change"] = s.get("change_pct", 0) + for g in top_gainers: + g["change"] = g.get("change_pct", 0) + for l in top_losers: + l["change"] = l.get("change_pct", 0) + else: + raise Exception("no data") + except Exception: + market_path = DATA_DIR / "market.json" + with open(market_path, "r", encoding="utf-8") as f: + market = json.load(f) + sectors = market.get("sectors", []) + top_gainers = market.get("top_gainers", []) + top_losers = market.get("top_losers", []) + mood = market.get("mood", "unknown") + up_ratio = market.get("up_ratio", 0) + timestamp = market.get("timestamp", "") + + industry_holdings = load_holding_industry_map() + insights = [] + potentials = [] + + # ── 洞察1:市场情绪总览 ── + mood_cn = {"bullish": "偏强", "neutral": "中性", "bearish": "偏弱", "unknown": "未知"} + insights.append( + f"市场情绪{mood_cn.get(mood, '未知')},上涨占比{up_ratio}%" + ) + + # ── 洞察2:领涨行业 vs 持仓影响 ── + gainer_insights = [] + for g in top_gainers[:3]: + name = g.get("name", "") + change = g.get("change", 0) + # 看持仓中是否有该行业 + matched = [] + for industry, holdings in industry_holdings.items(): + if industry in name or name in industry: + matched.extend([h["name"] for h in holdings]) + if matched: + gainer_insights.append( + f"{name}+{change}%, 关联持仓{'/'.join(matched[:3])}受益" + ) + else: + gainer_insights.append(f"{name}+{change}%, 暂无持仓") + if gainer_insights: + insights.append("领涨板块: " + " | ".join(gainer_insights[:2])) + + # ── 洞察3:领跌行业 vs 持仓风险 ── + loser_insights = [] + for g in top_losers[:3]: + name = g.get("name", "") + change = g.get("change", 0) + matched = [] + for industry, holdings in industry_holdings.items(): + if industry in name or name in industry: + matched.extend([h["name"] for h in holdings]) + if matched: + loser_insights.append( + f"{name}{change}%, {'/'.join(matched[:2])}需关注" + ) + else: + loser_insights.append(f"{name}{change}%") + if loser_insights: + insights.append("风险板块: " + " | ".join(loser_insights[:3])) + + # ── 洞察4:资金流向异动 ── + big_inflow = [s for s in sectors if s.get("net_inflow", 0) > 50] + big_outflow = [s for s in sectors if s.get("net_inflow", 0) < -50] + if big_inflow: + top = max(big_inflow, key=lambda s: s["net_inflow"]) + insights.append( + f"资金流入最大: {top['name']} {top['net_inflow']}亿" + ) + if big_outflow: + top = min(big_outflow, key=lambda s: s["net_inflow"]) + insights.append( + f"资金流出最大: {top['name']} {top['net_inflow']}亿" + ) + + # ── 潜力股挖掘:从强势行业中找持仓或自选相关 ── + for g in top_gainers[:5]: + name = g.get("name", "") + change = g.get("change", 0) + if change < 2: + continue # 只关注涨>2%的 + + # 找该行业指数有没有关联持仓 + lead_stock = g.get("lead_stock", "") + if lead_stock: + potentials.append({ + "name": lead_stock, + "reason": f"{name}领涨股, 板块+{change}%", + }) + + # 看持仓中是否有该行业 + for industry, holdings in industry_holdings.items(): + if industry in name or name in industry: + for h in holdings: + potentials.append({ + "name": h["name"], + "reason": f"所在行业{name}涨{change}%", + }) + + # 去重(最多5条) + seen = set() + unique_potentials = [] + for p in potentials: + key = p["name"] + if key not in seen: + seen.add(key) + unique_potentials.append(p) + if len(unique_potentials) >= 5: + break + potentials = unique_potentials + + # ── 写入 market.json ── + market["insights"] = insights + market["potential_stocks"] = potentials + market["insight_timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M") + + with open(market_path, "w", encoding="utf-8") as f: + json.dump(market, f, ensure_ascii=False, indent=2) + + print(f"生成{len(insights)}条洞察 + {len(potentials)}条潜力挖掘") + + +if __name__ == "__main__": + generate() diff --git a/scripts/market_screener.py b/scripts/market_screener.py new file mode 100644 index 0000000..2319284 --- /dev/null +++ b/scripts/market_screener.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""market_screener.py — 小果本地LLM全市场筛选(分步少吃多餐版)""" + +import json, os, re, time, urllib.request, urllib.error +from datetime import datetime +from pathlib import Path + +WEB_DASHBOARD_DIR = Path(__file__).resolve().parent.parent.parent / "web-dashboard" if "hermes" in str(Path(__file__).resolve()) else Path(__file__).parent +DATA_DIR = WEB_DASHBOARD_DIR / "data" +POOL_JSON = DATA_DIR / "candidate_pool.json" # 筛选缓存(临时) +XIAOGUO_MODEL = "Qwen3.6-27B-OptiQ-4bit" +API_TIMEOUT = 60 +MAX_SECTORS = 5 +MAX_CANDIDATES_POOL = 60 +TENCENT_URL = "http://qt.gtimg.cn/q=" + +def _get_xiaoguo_url(): + try: + from mo_config import get_config + return get_config().xiaoguo_api_url + except Exception: + return "http://node122:18003/v1/chat/completions" # legacy fallback + + +def load_json(path): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None + + +def save_json(path, data): + 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 call_xiaoguo(messages, timeout=API_TIMEOUT): + """调小果本地LLM(通过node122直连,/etc/hosts自动走LAN或EasyTier)""" + payload = json.dumps({"model": XIAOGUO_MODEL, "messages": messages, + "temperature": 0.1, "max_tokens": 2048}).encode() + req = urllib.request.Request(_get_xiaoguo_url(), data=payload, + headers={"Content-Type": "application/json", + "Authorization": "Bearer hermes123"}, method="POST") + try: + resp = urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=timeout) + return json.loads(resp.read())["choices"][0]["message"]["content"] + except Exception as e: + print(f"API失败: {e}", flush=True) + return None + + +def extract_json(text): + """从回复中提取第一个完整JSON,跳过思考过程""" + # 优先找代码块 + m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text) + if m: + try: + return json.loads(m.group(1)) + except json.JSONDecodeError: + pass + # 跳过思考过程行(Here's a thinking process / 数字列表项) + clean_lines = [] + for line in text.split("\n"): + s = line.strip() + if re.match(r"^\d+\.\s+\*\*", s) or "Here's a thinking" in s or "**Analyze" in s: + continue + if s.startswith("```"): + clean_lines.append(s) + continue + clean_lines.append(line) + clean = "\n".join(clean_lines) + # 找第一个完整JSON + start = clean.find("{") + if start < 0: + return None + for mode in ["直接", "跳过头部"]: + for pos in [start, clean.find("\n{", start), clean.find("{", start + 1)]: + if pos < 0: + continue + depth = 0 + for i in range(pos, len(clean)): + if clean[i] == "{": depth += 1 + elif clean[i] == "}": + depth -= 1 + if depth == 0: + try: + return json.loads(clean[pos:i+1]) + except json.JSONDecodeError: + break + return None + + +def tencent_quote(code): + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + tc = f"sh{code}" if code.startswith("6") or code.startswith("5") else f"sz{code}" + resp = opener.open(TENCENT_URL + tc, timeout=5) + d = resp.read().decode("gbk").split('="')[1].split('"')[0].split("~") + if len(d) > 32: + return {"name": d[1], "price": float(d[3]) if d[3] else 0, + "change_pct": float(d[32]) if d[32] else 0} + except: + pass + return None + + +def load_pool(): + pool = load_json(POOL_JSON) + return pool if pool else {"last_updated": "", "total_candidates": 0, "candidates": []} + + +def save_pool(pool): + pool["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M") + pool["total_candidates"] = len([c for c in pool.get("candidates", []) if not c.get("dropped")]) + save_json(POOL_JSON, pool) + + +def add_or_update(pool, code, name, sector, score, reason, entry_range, stop_loss, target, vprice, vchg): + now = datetime.now().strftime("%Y-%m-%d %H:%M") + for c in pool.setdefault("candidates", []): + if c["code"] == code and not c.get("dropped"): + c.update({"last_updated": now, "num_observations": c.get("num_observations", 1) + 1, + "xiaoguo_score": score, "xiaoguo_reason": reason, + "xiaoguo_strategy": {"entry_range": entry_range, "stop_loss": stop_loss, "target": target}, + "verified_price": vprice, "verified_change": vchg}) + c.setdefault("score_history", []).append({"date": now, "score": score}) + hist = c["score_history"] + if len(hist) >= 3: + recent = [h["score"] for h in hist[-3:]] + c["trend_warning"] = all(recent[i] > recent[i+1] for i in range(2)) + return False + pool["candidates"].append({"code": code, "name": name, "sector": sector, + "xiaoguo_score": score, "xiaoguo_reason": reason, + "xiaoguo_strategy": {"entry_range": entry_range, "stop_loss": stop_loss, "target": target}, + "verified_price": vprice, "verified_change": vchg, + "added_at": now, "last_updated": now, "num_observations": 1, + "score_history": [{"date": now, "score": score}], + "zhiwei_star": None, "zhiwei_reviewed": False, "zhiwei_reviewed_at": None, + "promoted": False, "promoted_at": None, "dropped": False, "drop_reason": None, + "trend_warning": False, "trend_note": ""}) + return True + + +def cleanup(pool): + now = datetime.now() + for c in pool.get("candidates", []): + if c.get("dropped"): continue + hist = c.get("score_history", []) + if len(hist) >= 3 and sum(h["score"] for h in hist[-3:]) / 3 < 5: + c.update({"dropped": True, "drop_reason": "平均评分<5"}); continue + if len(hist) >= 3: + recent = [h["score"] for h in hist[-3:]] + if all(recent[i] > recent[i+1] for i in range(2)): + if c.get("trend_warning"): c.update({"dropped": True, "drop_reason": "连续下降2轮"}) + else: c["trend_warning"] = True + if c.get("last_updated"): + try: + if (now - datetime.strptime(c["last_updated"], "%Y-%m-%d %H:%M")).days >= 7: + c.update({"dropped": True, "drop_reason": "超7天未更新"}) + except: pass + + +# ── 第1步:大盘分析 ── +def step1(sectors, source): + normalized = [] + for s in sectors: + cp = (s.get("change", 0) / 100) if source == "eastmoney" else s.get("change", 0) + parts = [f"{s['name']}({cp:+.2f}%)"] + if s.get("lead_stock"): parts.append(f"领涨:{s['lead_stock']}") + if s.get("net_inflow"): parts.append(f"资金:{s['net_inflow']}亿") + normalized.append((" | ".join(parts), cp)) + gainers = [x[0] for x in sorted(normalized, key=lambda x: x[1], reverse=True) if x[1] > 0][:10] + losers = [x[0] for x in sorted(normalized, key=lambda x: x[1]) if x[1] <= 0][:5] + text = "领涨板块:\n" + "\n".join(gainers) + if losers: text += "\n领跌板块:\n" + "\n".join(losers) + prompt = f"分析以下A股板块,选出3-5个值得关注的行业(只看趋势,排除一日游)。\n\n{text}\n\n输出JSON:{{\"market_verdict\":\"强势|中性|弱势\",\"hot_sectors\":[{{\"name\":\"板块名\",\"reason\":\"理由\"}}],\"danger_sectors\":[{{\"name\":\"板块名\",\"reason\":\"理由\"}}]}}" + print(f" 第1步:大盘分析", flush=True) + result = call_xiaoguo([{"role": "user", "content": prompt}]) + return extract_json(result) if result else None + + +# ── 第2步:个股分析 ── +def step2(sector, source): + name = sector.get("name", "") + cp = (sector.get("change", 0) / 100) if source == "eastmoney" else sector.get("change", 0) + detail = [] + if sector.get("up_count"): detail.append(f"上涨{sector['up_count']}/{sector['up_count']+sector.get('down_count',0)}家") + if sector.get("net_inflow"): detail.append(f"资金净流入{sector['net_inflow']}亿") + if sector.get("lead_stock"): detail.append(f"领涨{sector['lead_stock']}({sector.get('lead_stock_change',0):+.2f}%)") + d = " | ".join(detail) if detail else "无详细数据" + prompt = f"板块:{name}({cp:+.2f}%) | {d}\n推荐2-3只候选股,评分1-10,附入场区间/止损/目标。JSON:{{\"sector_judgment\":\"\",\"sector_reason\":\"\",\"candidates\":[{{\"code\":\"\",\"name\":\"\",\"score\":0,\"reason\":\"\",\"entry_range\":\"\",\"stop_loss\":\"\",\"target\":\"\"}}]}}" + print(f" 分析行业: {name}", flush=True) + result = call_xiaoguo([{"role": "user", "content": prompt}]) + if not result: return None + parsed = extract_json(result) + if not parsed: return None + valid = [] + for c in parsed.get("candidates", []): + q = tencent_quote(c.get("code", "")) + if q and q["price"] > 0: + c["verified_price"], c["verified_change"] = q["price"], q["change_pct"] + valid.append(c) + print(f" 候选{len(parsed.get('candidates',[]))}只, 验证通过{len(valid)}只", flush=True) + return valid + + +def main(): + # 从 DB 读大盘+板块数据(替代 market.json) + from mofin_db import get_conn + conn = get_conn() + market = {"sectors": [], "source": "db"} + try: + # latest snapshot + sr = conn.execute("SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if sr: + cols = [d[0] for d in conn.execute("SELECT * FROM market_snapshots LIMIT 0").description] + snap = dict(zip(cols, sr)) + market["up_ratio"] = snap.get("up_ratio", 0) + market["mood"] = snap.get("mood", "neutral") + market["source"] = snap.get("source", "db") + + # sector data + sectors = conn.execute(""" + SELECT s.name as name, s.change_pct as change_pct, s.lead_stock as lead_stock, + s.up_count, s.down_count, s.net_inflow + FROM sector_snapshots s + JOIN market_snapshots ms ON s.snapshot_id = ms.id + WHERE ms.id = (SELECT MAX(id) FROM market_snapshots) + ORDER BY s.change_pct DESC + """).fetchall() + if sectors: + cols = [d[0] for d in conn.execute("SELECT name, change_pct, lead_stock, up_count, down_count, net_inflow FROM sector_snapshots LIMIT 0").description] + market["sectors"] = [dict(zip(cols, r)) for r in sectors] + except Exception as e: + print(f"DB read error: {e}", flush=True) + conn.close() + + if not market.get("sectors"): + print("market 无数据", flush=True); return + sectors = market["sectors"] + source = market.get("source", "db") + print(f"板块: {len(sectors)}, 来源: {source}", flush=True) + pool = load_pool() + existing = {c["code"] for c in pool.get("candidates", []) if not c.get("dropped")} + + # 第1步 + view = step1(sectors, source) + if not view: + print("第1步失败", flush=True); return + market["market_verdict"] = view.get("market_verdict", "中性") + market["verdict_reason"] = view.get("verdict_reason", "") + market["hot_sectors"] = view.get("hot_sectors", []) + market["danger_sectors"] = view.get("danger_sectors", []) + market["xiaoguo_scan_timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M") + market.setdefault("insights", []).append(f"[小果全市场] {view.get('market_verdict','中性')}") + hot_names = [s["name"] for s in view.get("hot_sectors", [])] + print(f"热门行业: {hot_names}", flush=True) + if not hot_names: + save_pool(pool) + return + + # 第2步 + hot_data = [s for s in sectors if s.get("name", "") in hot_names][:MAX_SECTORS] + new_count = 0 + for s in hot_data: + cands = step2(s, source) + if not cands: continue + for c in cands: + if add_or_update(pool, c["code"], c.get("name",""), s["name"], + c.get("score",5), c.get("reason",""), + c.get("entry_range",""), c.get("stop_loss",""), c.get("target",""), + c.get("verified_price",0), c.get("verified_change",0)): + new_count += 1 + print(f" + {c.get('name','')}({c['code']}) {c.get('score',0)}分", flush=True) + + cleanup(pool) + save_pool(pool) + print(f"完成: 新增{new_count}, 池内{pool['total_candidates']}活跃", flush=True) + +if __name__ == "__main__": + main() diff --git a/scripts/market_watch.py b/scripts/market_watch.py new file mode 100644 index 0000000..7c80389 --- /dev/null +++ b/scripts/market_watch.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""market_watch.py — 行業熱點數據採集,寫入 dashboard data/market.json + +數據源優先級: + 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) + 後端B:同花順 THS / akshare(降級,有漲跌家數+資金流向) + +注意:當前服務器無法連通東方財富API(已被封禁/域名不可達), +實際運行時自動降級到同花順 THS 後端。THS 提供90+行業板塊的 +實時漲跌、上漲/下跌家數、淨流入資金等數據,足以滿足需求。 + +輸出:data/market.json → MoFin Dashboard 市場數據展示 +""" + +import json +from datetime import datetime +from pathlib import Path + +from mofin_db import get_conn, init_all_tables, write_market_snapshot + +DATA_DIR = Path(__file__).parent / "data" + + +# ── 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) ── + +def _fetch_em(url): + """通用 EM API 請求""" + import urllib.request + req = urllib.request.Request( + url, + headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + ) + resp = urllib.request.urlopen(req, timeout=10) + return json.loads(resp.read().decode("utf-8")) + + +def fetch_sector_em(): + """東方財富行業板塊""" + try: + data = _fetch_em( + "https://push2.eastmoney.com/api/qt/clist/get?" + "pn=1&pz=60&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2" + ) + return [{ + "name": i["f14"], + "code": i["f12"], + "price": i.get("f2", 0), + "change": i.get("f3", 0), + } for i in data.get("data", {}).get("diff", [])] + except Exception: + return None + + +def fetch_concept_em(): + """東方財富概念板塊""" + try: + data = _fetch_em( + "https://push2.eastmoney.com/api/qt/clist/get?" + "pn=1&pz=30&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3" + ) + return [{ + "name": i["f14"], + "code": i["f12"], + "change": i.get("f3", 0), + } for i in data.get("data", {}).get("diff", [])] + except Exception: + return None + + +# ── 後端B:同花順 THS / akshare(降級) ── + +def fetch_sector_ths(): + """THS 行業板塊(含漲跌家數、資金流向、領漲股)""" + try: + import akshare as ak + df = ak.stock_board_industry_summary_ths() + return [{ + "name": r["板块"], + "code": "", + "price": 0, + "change": float(r.get("涨跌幅", 0)), + "volume": float(r.get("总成交量", 0)), + "turnover": float(r.get("总成交额", 0)), + "net_inflow": float(r.get("净流入", 0)), + "up_count": int(r.get("上涨家数", 0)), + "down_count": int(r.get("下跌家数", 0)), + "avg_price": float(r.get("均价", 0)), + "lead_stock": r.get("领涨股", ""), + "lead_stock_change": float(r.get("领涨股-涨跌幅", 0)), + } for _, r in df.iterrows()] + except Exception as e: + print(f"THS行業失敗: {e}", flush=True) + return [] + + +def fetch_concept_ths(): + """THS 概念板塊(僅名稱,無實時漲跌)""" + try: + import akshare as ak + df = ak.stock_board_concept_name_ths() + return [{ + "name": r["name"], + "code": str(r.get("code", "")), + "change": 0, + } for _, r in df.iterrows()] + except Exception as e: + print(f"THS概念失敗: {e}", flush=True) + return [] + + +# ── 輔助函數 ── + +def get_market_mood(sectors): + if not sectors: + return "unknown" + ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors) + return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish" + + +def get_market_verdict(up_ratio, mood, sectors): + """Return (verdict, reason) based on sector data.""" + if not sectors: + return "unknown", "数据不足" + if up_ratio < 25: + return "弱势", f"仅{up_ratio}%板块上涨,{mood}" + elif up_ratio < 40: + return "偏弱", f"{up_ratio}%板块上涨,结构分化" + elif up_ratio < 60: + return "均衡", f"{up_ratio}%板块上涨,涨跌均衡" + else: + return "强势", f"{up_ratio}%板块上涨,整体走强" + + +def get_hot_sectors(sectors, top_n=3): + """Return sectors with highest positive change as hot sectors.""" + hot = [s for s in sectors if s.get("change", 0) > 1.0] + hot.sort(key=lambda s: s.get("change", 0), reverse=True) + return [{ + "name": s["name"], + "change": s.get("change", 0), + "reason": f"板块涨{s.get('change',0):.1f}%" + } for s in hot[:top_n]] + + +def get_danger_sectors(sectors, top_n=3): + """Return sectors with lowest (negative) change as danger sectors.""" + danger = [s for s in sectors if s.get("change", 0) < -1.0] + danger.sort(key=lambda s: s.get("change", 0)) + return [{ + "name": s["name"], + "change": s.get("change", 0), + "reason": f"板块跌{s.get('change',0):.1f}%" + } for s in danger[:top_n]] + + +# ── 主流程 ── + +def main(): + # 行業板塊:EM → THS → 兜底 + sectors = fetch_sector_em() + source = "eastmoney" + if sectors is None: + sectors = fetch_sector_ths() + source = "ths" + + # 概念板塊:EM → THS → 空 + concepts = fetch_concept_em() + concept_source = "eastmoney" + if concepts is None: + concepts = fetch_concept_ths() + concept_source = "ths" + if not concepts: + concepts = [] + concept_source = "unavailable" + + # 排序 + sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True) + top_gainers = [s for s in sorted_sectors if s.get("change", 0) > 0][:5] + top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3] + + # 计算大盘数据 + up_ratio = round( + sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1 + ) + mood = get_market_mood(sectors) + verdict, verdict_reason = get_market_verdict(up_ratio, mood, sectors) + + market_data = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + "source": source, + "concept_source": concept_source, + "total_sectors": len(sectors), + "up_ratio": up_ratio, + "mood": mood, + "market_verdict": verdict, + "verdict_reason": verdict_reason, + "hot_sectors": get_hot_sectors(sectors), + "danger_sectors": get_danger_sectors(sectors), + "top_gainers": top_gainers, + "top_losers": top_losers, + "sectors": sectors, + "concepts": concepts, + } + + DATA_DIR.mkdir(parents=True, exist_ok=True) + + # ── SQLite 写入(替代 market.json)── + conn = get_conn() + init_all_tables(conn) + ok, msg, sid = write_market_snapshot(conn, market_data) + if ok: + print(f"[DB] {msg}", flush=True) + else: + print(f"[DB] 写入失败(JSON 不受影响): {msg}", flush=True) + conn.close() + + # 靜默:只寫文件,不輸出到stdout,避免cron推送 + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_all.py b/scripts/migrate_all.py new file mode 100644 index 0000000..ed51660 --- /dev/null +++ b/scripts/migrate_all.py @@ -0,0 +1,700 @@ +#!/usr/bin/env python3 +"""migrate_all.py — 一次性将全部生产 JSON 数据迁移到 mofin.db + +运行方式: + python3 migrate_all.py + +迁移映射: + portfolio.json → holdings + holding_strategies (from analysis) + watchlist.json → watchlist_stocks + holding_strategies (strategy_type='watch') + candidate_pool.json → candidates + candidate_score_history + decisions.json → holding_strategies (changelog history) + price_events.json → price_events + stock_sector_map.json → stock_sectors + evaluation.json → strategy_evaluations + stock_profiles.json → stocks (name, exchange, type) +""" + +import json +import sys +from datetime import datetime +from pathlib import Path + +from mofin_db import get_conn, init_all_tables + +DATA_DIR = Path(__file__).parent / "data" + + +def load_json(name: str): + path = DATA_DIR / name + if not path.exists(): + print(f" [SKIP] {name} not found") + return None + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f" [FAIL] {name}: {e}") + return None + + +def _normalize_code(code) -> str: + """统一代码格式:整数→补零字符串""" + s = str(code).strip() + if s.isdigit(): + if len(s) == 5: + return s + if len(s) < 5: + return s.zfill(5 if len(s) <= 4 else 6) + return s.zfill(6) + return s + + +def _guess_exchange(code: str) -> str: + if len(code) == 5 and code.isdigit(): + return "HK" + if code.startswith(("6", "5", "9")): + return "SH" + return "SZ" + + +def collect_all_stocks() -> dict: + """从所有 JSON 文件中收集股票代码→名称映射""" + all_stocks = {} + + def add(code, name): + code = _normalize_code(code) + if not code or code in all_stocks: + return + all_stocks[code] = { + "name": str(name) if name else code, + "exchange": _guess_exchange(code), + "type": "H" if len(code) == 5 else "A", + } + + # stock_profiles.json + profiles = load_json("stock_profiles.json") + if profiles: + for p in profiles.get("profiles", []): + add(p.get("code"), p.get("name")) + + # portfolio.json + pf = load_json("portfolio.json") + if pf: + for h in pf.get("holdings", []): + add(h.get("code"), h.get("name")) + + # watchlist.json + wl = load_json("watchlist.json") + if wl: + for s in wl.get("stocks", []): + add(s.get("code"), s.get("name")) + + # decisions.json + dec = load_json("decisions.json") + if dec: + for d in dec.get("decisions", []): + add(d.get("code"), d.get("name")) + + # candidate_pool.json + cp = load_json("candidate_pool.json") + if cp: + for c in cp.get("candidates", []): + add(c.get("code"), c.get("name")) + + # price_events.json + pe = load_json("price_events.json") + if pe: + for e in pe.get("events", []): + add(e.get("code"), e.get("name")) + + return all_stocks + + +def migrate_all_stocks(conn, all_stocks: dict) -> int: + """将所有收集到的股票写入 stocks 表""" + count = 0 + for code, info in all_stocks.items(): + try: + conn.execute( + "INSERT OR REPLACE INTO stocks (code, name, exchange, type, updated_at) VALUES (?, ?, ?, ?, ?)", + (code, info["name"], info["exchange"], info["type"], datetime.now().isoformat())) + count += 1 + except Exception as e: + print(f" stocks error {code}: {e}") + conn.commit() + return count + + +def migrate_holdings(conn, portfolio_data: dict) -> tuple[int, int]: + """portfolio.json → holdings + holding_strategies""" + holdings = portfolio_data.get("holdings", []) + h_count, s_count = 0, 0 + + for h in holdings: + code = _normalize_code(h.get("code", "")) + name = h.get("name", "") + shares = int(h.get("shares", 0)) + cost = h.get("cost", 0) + pos_pct = h.get("position_pct", 0) + + if not code or not name: + continue + + # holdings 表 + try: + conn.execute( + "INSERT OR REPLACE INTO holdings (code, name, shares, cost, position_pct, is_active) " + "VALUES (?, ?, ?, ?, ?, 1)", + (code, name, shares, cost, pos_pct)) + h_count += 1 + except Exception as e: + print(f" holdings error {code}: {e}") + continue + + # holding_strategies (from analysis field) + analysis = h.get("analysis", {}) + if analysis: + sl = analysis.get("stop_loss") + tp = analysis.get("take_profit") + el = analysis.get("entry_low") + eh = analysis.get("entry_high") + reason = analysis.get("action", "")[:200] if analysis.get("action") else None + reassessed = analysis.get("reassessed_at") + try: + conn.execute( + "INSERT INTO holding_strategies (code, version, stop_loss, take_profit, " + "entry_low, entry_high, strategy_type, source, reason, created_at) " + "VALUES (?, 1, ?, ?, ?, ?, 'holding', 'migrate', ?, ?)", + (code, sl, tp, el, eh, reason, reassessed or datetime.now().isoformat())) + s_count += 1 + except Exception as e: + print(f" strategy error {code}: {e}") + + conn.commit() + return h_count, s_count + + +def migrate_watchlist(conn, watchlist_data: dict) -> tuple[int, int]: + """watchlist.json → watchlist_stocks + holding_strategies""" + stocks = watchlist_data.get("stocks", []) + w_count, s_count = 0, 0 + + for s in stocks: + code = _normalize_code(s.get("code", "")) + name = s.get("name", "") + if not code or not name: + continue + + # watchlist_stocks + try: + conn.execute( + "INSERT OR REPLACE INTO watchlist_stocks (code, name, added_at, is_active) " + "VALUES (?, ?, ?, 1)", + (code, name, s.get("added_at", datetime.now().isoformat()))) + w_count += 1 + except Exception as e: + print(f" watchlist error {code}: {e}") + continue + + # holding_strategies (from strategy field) + strategy = s.get("strategy", {}) + if strategy: + buy_zone = strategy.get("buy_zone", []) + el = buy_zone[0] if len(buy_zone) > 0 else None + eh = buy_zone[1] if len(buy_zone) > 1 else None + tp_zone = strategy.get("take_profit", []) + tp = tp_zone[0] if isinstance(tp_zone, list) and len(tp_zone) > 0 else tp_zone + try: + conn.execute( + "INSERT INTO holding_strategies (code, version, stop_loss, take_profit, " + "entry_low, entry_high, strategy_type, source, created_at) " + "VALUES (?, 1, ?, ?, ?, ?, 'watch', 'migrate', ?)", + (code, strategy.get("stop_loss"), tp, el, eh, + strategy.get("updated_at", datetime.now().isoformat()))) + s_count += 1 + except Exception as e: + print(f" watch strategy error {code}: {e}") + + conn.commit() + return w_count, s_count + + +def migrate_decisions(conn, decisions_data: dict) -> int: + """decisions.json → holding_strategies (changelog history)""" + decisions = decisions_data.get("decisions", []) + count = 0 + + for d in decisions: + code = _normalize_code(d.get("code", "")) + if not code: + continue + + # 最新策略 + sl = d.get("stop_loss") + el = d.get("entry_low") + eh = d.get("entry_high") + tp = d.get("take_profit") + action = d.get("action", "")[:200] if d.get("action") else None + try: + conn.execute( + "INSERT INTO holding_strategies (code, version, stop_loss, take_profit, " + "entry_low, entry_high, strategy_type, source, reason, created_at) " + "VALUES (?, 1, ?, ?, ?, ?, 'decision', 'migrate', ?, ?)", + (code, sl, tp, el, eh, action, d.get("timestamp", datetime.now().isoformat()))) + count += 1 + except Exception as e: + print(f" decision error {code}: {e}") + continue + + # changelog 历史版本 + changelog = d.get("changelog", []) + for i, cl in enumerate(changelog): + try: + # 从 new_action 中提取止损/止盈/买入区 + new_action = cl.get("new_action", "") + # 简单提取:损XX 盈XX 买XX~XX + import re + sl_match = re.search(r'损(\d+\.?\d*)', new_action) + tp_match = re.search(r'盈(\d+\.?\d*)', new_action) + entry_match = re.search(r'买(\d+\.?\d*)~(\d+\.?\d*)', new_action) + + conn.execute( + "INSERT INTO holding_strategies (code, version, stop_loss, take_profit, " + "entry_low, entry_high, strategy_type, source, reason, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, 'decision', 'migrate', ?, ?)", + (code, i + 2, + float(sl_match.group(1)) if sl_match else None, + float(tp_match.group(1)) if tp_match else None, + float(entry_match.group(1)) if entry_match else None, + float(entry_match.group(2)) if entry_match else None, + cl.get("reason", "")[:200], + cl.get("date", datetime.now().isoformat()))) + count += 1 + except Exception: + pass # 解析失败跳过 + + conn.commit() + return count + + +def migrate_candidates(conn, candidate_data: dict) -> tuple[int, int]: + """candidate_pool.json → candidates + candidate_score_history""" + candidates = candidate_data.get("candidates", []) + c_count, s_count = 0, 0 + + for c in candidates: + code = _normalize_code(c.get("code", "")) + name = c.get("name", "") + if not code or not name: + continue + + # candidates 表 + try: + conn.execute( + "INSERT OR REPLACE INTO candidates (code, name, sector, reason, entry_range, " + "stop_loss, target, zhiwei_star, zhiwei_reviewed, zhiwei_reviewed_at, " + "promoted, promoted_at, dropped, drop_reason, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (code, name, c.get("sector"), c.get("reason"), c.get("entry_range"), + c.get("stop_loss"), c.get("target"), c.get("zhiwei_star"), + 1 if c.get("zhiwei_reviewed") else 0, c.get("zhiwei_reviewed_at"), + 1 if c.get("promoted") else 0, c.get("promoted_at"), + 1 if c.get("dropped") else 0, c.get("drop_reason"), + c.get("added_at", datetime.now().isoformat()))) + c_count += 1 + except Exception as e: + print(f" candidate error {code}: {e}") + continue + + # candidate_score_history + score_history = c.get("score_history", []) + for sh in score_history: + try: + conn.execute( + "INSERT INTO candidate_score_history (code, score, source, created_at) " + "VALUES (?, ?, 'xiaoguo', ?)", + (code, sh.get("score"), sh.get("date", datetime.now().isoformat()))) + s_count += 1 + except Exception: + pass + + conn.commit() + return c_count, s_count + + +def migrate_price_events(conn, events_data: dict) -> int: + """price_events.json → price_events 表""" + events = events_data.get("events", []) + count = 0 + for e in events: + try: + conn.execute( + "INSERT INTO price_events (code, name, event_type, price, trigger_value, event_label, date) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (e.get("code"), e.get("name"), e.get("event_type"), + e.get("price"), e.get("trigger_value"), e.get("event_label"), + e.get("date", datetime.now().strftime("%Y-%m-%d")))) + count += 1 + except Exception: + pass + conn.commit() + return count + + +def migrate_evaluations(conn, eval_data: dict) -> int: + """evaluation.json → strategy_evaluations""" + strategies = eval_data.get("strategies", []) + count = 0 + for s in strategies: + code = _normalize_code(s.get("code", "")) + if not code: + continue + theoretical = s.get("theoretical", {}).get("strategy", {}) + try: + conn.execute( + "INSERT INTO strategy_evaluations (code, eval_type, status, " + "old_stop_loss, new_stop_loss, old_tp, new_tp, reason, created_at) " + "VALUES (?, 'migrate', 'completed', NULL, ?, NULL, ?, ?, ?)", + (code, theoretical.get("stop_loss"), theoretical.get("take_profit"), + s.get("updated_reason", "")[:200], + s.get("updated_at", datetime.now().isoformat()))) + count += 1 + except Exception: + pass + conn.commit() + return count + + +def migrate_sectors(conn) -> int: + """stock_sector_map.json → stock_sectors""" + data = load_json("stock_sector_map.json") + if not data: + return 0 + count = 0 + for code, sectors in data.items(): + if code.startswith("_") or not isinstance(sectors, list): + continue + for sector in sectors: + try: + conn.execute( + "INSERT OR IGNORE INTO stock_sectors (code, sector_name, source) VALUES (?, ?, 'ths')", + (code, sector)) + count += 1 + except Exception: + pass + conn.commit() + return count + + +def migrate_portfolio_summary(conn, pf: dict) -> int: + """portfolio.json 顶层字段 → portfolio_summary""" + try: + conn.execute( + "INSERT OR REPLACE INTO portfolio_summary (id, total_assets, stock_value, cash, position_pct, total_pnl, updated_at) " + "VALUES (1, ?, ?, ?, ?, ?, ?)", + (pf.get("total_assets"), pf.get("stock_value"), pf.get("cash"), + pf.get("position_pct"), pf.get("total_pnl"), pf.get("updated_at", datetime.now().isoformat()))) + conn.commit() + return 1 + except Exception: + return 0 + + +def migrate_advice_timeline(conn, dec: dict) -> int: + """decisions.json advice_timeline[] → advice_timeline""" + count = 0 + for d in dec.get("decisions", []): + code = _normalize_code(d.get("code", "")) + if not code: + continue + timeline = d.get("advice_timeline", []) + for t in timeline: + try: + conn.execute( + "INSERT INTO advice_timeline (code, date, direction, price, summary, status, evaluated, result, evaluated_at, report_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (code, t.get("date"), t.get("direction"), t.get("price"), + t.get("summary"), t.get("status"), + 1 if t.get("evaluated") else 0, t.get("result"), + t.get("evaluated_at"), t.get("report_id"))) + count += 1 + except Exception: + pass + conn.commit() + return count + + +def migrate_accuracy_stats(conn, acc: dict) -> int: + """accuracy_stats.json → accuracy_stats""" + try: + conn.execute( + "INSERT OR REPLACE INTO accuracy_stats (id, period_start, period_end, total_advice, correct, wrong, partial, unknown, pending, ignored, evaluated, accuracy_pct, " + "phase1_correct, phase1_wrong, phase1_pending, phase1_accuracy, " + "phase2_correct, phase2_wrong, phase2_pending, phase2_accuracy, total_evaluated, updated_at) " + "VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (acc.get("period_start"), acc.get("period_end"), + acc.get("total_advice"), acc.get("correct"), acc.get("wrong"), + acc.get("partial"), acc.get("unknown"), acc.get("pending"), + acc.get("ignored"), acc.get("evaluated"), acc.get("accuracy_pct"), + acc.get("phase1", {}).get("correct", 0) if isinstance(acc.get("phase1"), dict) else 0, + acc.get("phase1", {}).get("wrong", 0) if isinstance(acc.get("phase1"), dict) else 0, + acc.get("phase1", {}).get("pending", 0) if isinstance(acc.get("phase1"), dict) else 0, + acc.get("phase1", {}).get("accuracy_pct", 0) if isinstance(acc.get("phase1"), dict) else 0, + acc.get("phase2", {}).get("correct", 0) if isinstance(acc.get("phase2"), dict) else 0, + acc.get("phase2", {}).get("wrong", 0) if isinstance(acc.get("phase2"), dict) else 0, + acc.get("phase2", {}).get("pending", 0) if isinstance(acc.get("phase2"), dict) else 0, + acc.get("phase2", {}).get("accuracy_pct", 0) if isinstance(acc.get("phase2"), dict) else 0, + acc.get("total_evaluated"), acc.get("updated_at", datetime.now().isoformat()))) + conn.commit() + return 1 + except Exception: + return 0 + + +def migrate_strategy_feedback(conn, sf: dict) -> int: + """strategy_feedback.json → strategy_feedback""" + count = 0 + for f in sf.get("feedback", []): + code = _normalize_code(f.get("code", "")) + if not code: + continue + pc = f.get("phase_check", {}) + try: + conn.execute( + "INSERT INTO strategy_feedback (code, name, evaluated_at, " + "phase1_completed, phase1_result, phase1_completed_at, phase1_price, " + "phase2_completed, phase2_result, phase2_completed_at, days_in_phase1, adjustments_json) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (code, f.get("name"), f.get("evaluated_at"), + 1 if pc.get("phase1_completed") else 0, pc.get("phase1_result"), + pc.get("phase1_completed_at"), pc.get("phase1_price_at_completion"), + 1 if pc.get("phase2_completed") else 0, pc.get("phase2_result"), + pc.get("phase2_completed_at"), pc.get("days_in_phase1"), + json.dumps(f.get("adjustments", []), ensure_ascii=False))) + count += 1 + except Exception: + pass + conn.commit() + return count + + +def migrate_klines(conn) -> tuple[int, int, int]: + """multi_tf_cache.json → stock_daily + stock_weekly + stock_monthly + stock_fundamentals""" + data = load_json("multi_tf_cache.json") + if not data: + return 0, 0, 0 + + daily_count, weekly_count, monthly_count = 0, 0, 0 + + for code, stock in data.items(): + code = _normalize_code(code) + if not code or code.startswith("_"): + continue + + name = code # 从 stocks 表或 profiles 获取名称 + try: + row = conn.execute("SELECT name FROM stocks WHERE code = ?", (code,)).fetchone() + if row: + name = row[0] + except Exception: + pass + + # K线数据 + for period, table, key in [ + ("daily", "stock_daily", "daily"), + ("weekly", "stock_weekly", "weekly"), + ("monthly", "stock_monthly", "monthly"), + ]: + bars = stock.get(key, []) + if not bars or isinstance(bars, dict): + continue + rows = [] + for b in bars: + if not isinstance(b, dict): + continue + d = b.get("date", "") + if not d: + continue + if period == "daily": + rows.append((code, d, b.get("open"), b.get("close"), + b.get("high"), b.get("low"), b.get("volume"), + b.get("amount"))) + else: + rows.append((code, d, b.get("open"), b.get("close"), + b.get("high"), b.get("low"), b.get("volume"))) + + if rows: + try: + if period == "daily": + conn.executemany( + "INSERT OR REPLACE INTO stock_daily (code, date, open, close, high, low, volume, amount) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows) + daily_count += len(rows) + elif period == "weekly": + conn.executemany( + "INSERT OR REPLACE INTO stock_weekly (code, date, open, close, high, low, volume) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", rows) + weekly_count += len(rows) + else: + conn.executemany( + "INSERT OR REPLACE INTO stock_monthly (code, date, open, close, high, low, volume) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", rows) + monthly_count += len(rows) + except Exception: + pass + + # 基本面 + fundamentals = stock.get("fundamentals") + if fundamentals and isinstance(fundamentals, dict): + try: + conn.execute( + "INSERT OR REPLACE INTO stock_fundamentals (code, pe, pb, eps, mcap_total, mcap_flow, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (code, fundamentals.get("pe"), fundamentals.get("pb"), + fundamentals.get("eps"), fundamentals.get("mcap_total"), + fundamentals.get("mcap_flow"), datetime.now().isoformat())) + except Exception: + pass + + conn.commit() + return daily_count, weekly_count, monthly_count + + +def main(): + print("=" * 60) + print(" MoFin 数据迁移 → mofin.db") + print("=" * 60) + + conn = get_conn() + conn.execute("PRAGMA foreign_keys=OFF") # 迁移期间关闭外键(部分代码格式不一致) + init_all_tables(conn) + + totals = {} + + # 1. stocks (必须先迁,从所有源收集) + all_stocks = collect_all_stocks() + n = migrate_all_stocks(conn, all_stocks) + totals["stocks"] = n + print(f"\n[1/8] stocks: {n} 只 (from all sources)") + + # 2. holdings + strategies + pf = load_json("portfolio.json") + if pf: + h, s = migrate_holdings(conn, pf) + totals["holdings"] = h + totals["holding_strategies"] = s + print(f"[2/8] holdings: {h} 只, strategies: {s} 条") + + # 3. watchlist + wl = load_json("watchlist.json") + if wl: + w, s = migrate_watchlist(conn, wl) + totals["watchlist"] = w + totals["watch_strategies"] = s + print(f"[3/8] watchlist: {w} 只, strategies: {s} 条") + + # 4. decisions → strategies + dec = load_json("decisions.json") + if dec: + n = migrate_decisions(conn, dec) + totals["decision_strategies"] = n + print(f"[4/8] decisions → strategies: {n} 条") + + # 5. candidates + cp = load_json("candidate_pool.json") + if cp: + c, s = migrate_candidates(conn, cp) + totals["candidates"] = c + totals["score_history"] = s + print(f"[5/8] candidates: {c} 只, score_history: {s} 条") + + # 6. price_events + pe = load_json("price_events.json") + if pe: + n = migrate_price_events(conn, pe) + totals["price_events"] = n + print(f"[6/8] price_events: {n} 条") + + # 7. evaluations + ev = load_json("evaluation.json") + if ev: + n = migrate_evaluations(conn, ev) + totals["evaluations"] = n + print(f"[7/8] evaluations: {n} 条") + + # 8. sectors + n = migrate_sectors(conn) + totals["sector_mappings"] = n + print(f"[8/8] stock_sectors: {n} 条映射") + + # 9. portfolio_summary + pf = load_json("portfolio.json") + if pf: + n = migrate_portfolio_summary(conn, pf) + totals["portfolio_summary"] = n + print(f"[9/12] portfolio_summary: {n} 条") + + # 10. advice_timeline + dec = load_json("decisions.json") + if dec: + n = migrate_advice_timeline(conn, dec) + totals["advice_timeline"] = n + print(f"[10/12] advice_timeline: {n} 条") + + # 11. accuracy_stats + acc = load_json("accuracy_stats.json") + if acc: + n = migrate_accuracy_stats(conn, acc) + totals["accuracy_stats"] = n + print(f"[11/12] accuracy_stats: {n} 条") + + # 12. strategy_feedback + sf = load_json("strategy_feedback.json") + if sf: + n = migrate_strategy_feedback(conn, sf) + totals["strategy_feedback"] = n + print(f"[12/13] strategy_feedback: {n} 条") + + # 13. K线数据 + dc, wc, mc = migrate_klines(conn) + totals["daily_klines"] = dc + totals["weekly_klines"] = wc + totals["monthly_klines"] = mc + print(f"[13/13] K-lines: daily={dc}, weekly={wc}, monthly={mc}") + + conn.commit() + + # ── 验证 ── + print(f"\n{'='*60}") + print(f" 迁移完成 — 数据库验证") + print(f"{'='*60}") + tables = { + "stocks": "SELECT COUNT(*) FROM stocks", + "holdings": "SELECT COUNT(*) FROM holdings", + "holding_strategies": "SELECT COUNT(*) FROM holding_strategies", + "watchlist_stocks": "SELECT COUNT(*) FROM watchlist_stocks", + "candidates": "SELECT COUNT(*) FROM candidates", + "candidate_score_history": "SELECT COUNT(*) FROM candidate_score_history", + "price_events": "SELECT COUNT(*) FROM price_events", + "strategy_evaluations": "SELECT COUNT(*) FROM strategy_evaluations", + "stock_sectors": "SELECT COUNT(*) FROM stock_sectors", + "portfolio_summary": "SELECT COUNT(*) FROM portfolio_summary", + "advice_timeline": "SELECT COUNT(*) FROM advice_timeline", + "accuracy_stats": "SELECT COUNT(*) FROM accuracy_stats", + "strategy_feedback": "SELECT COUNT(*) FROM strategy_feedback", + "stock_daily": "SELECT COUNT(*) FROM stock_daily", + "stock_weekly": "SELECT COUNT(*) FROM stock_weekly", + "stock_monthly": "SELECT COUNT(*) FROM stock_monthly", + "stock_fundamentals": "SELECT COUNT(*) FROM stock_fundamentals", + } + for name, sql in tables.items(): + n = conn.execute(sql).fetchone()[0] + print(f" {name:<25} {n:>6} 行") + + conn.close() + print(f"\n[OK] Migration complete. JSON files untouched, safe to re-run.") + + +if __name__ == "__main__": + main() diff --git a/scripts/mo_alphasift_bridge.py b/scripts/mo_alphasift_bridge.py new file mode 100644 index 0000000..75c321b --- /dev/null +++ b/scripts/mo_alphasift_bridge.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +mo_alphasift_bridge.py — AlphaSift 多策略并行选股 → MoFin 自选池 + +支持同时跑多个策略,合并去重后写入自选池。 +默认三策略: balanced_alpha + dual_low + quality_value + +用法: + python3 mo_alphasift_bridge.py # 默认三策略 + python3 mo_alphasift_bridge.py --strategy dual_low # 单策略 + python3 mo_alphasift_bridge.py --dry-run # 只看不写 + python3 mo_alphasift_bridge.py --strategy list # 列出所有策略 +""" + +import sys, os, json, argparse, urllib.request, time +from datetime import datetime +from pathlib import Path + +DSA_API = "http://127.0.0.1:8001" +MOFIN_DATA = Path("/home/hmo/web-dashboard/data") +WATCHLIST_PATH = MOFIN_DATA / "watchlist.json" +PORTFOLIO_PATH = MOFIN_DATA / "portfolio.json" + +DEFAULT_STRATEGIES = "balanced_alpha,dual_low,quality_value" +DEFAULT_MARKET = "cn" +DEFAULT_MAX = 15 +MIN_SCORE = 5 +MAX_ADD = 5 +POLL_INTERVAL = 5 +POLL_TIMEOUT = 300 + +# 全局开关: ALPHASIFT_ENABLED=true 才执行选股 +ALPHASIFT_ENABLED = os.environ.get("ALPHASIFT_ENABLED", "false").lower() == "true" + + +def load_json(path): + try: return json.loads(path.read_text(encoding="utf-8")) + except: return None + +def save_json(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + +def api(endpoint, method="GET", body=None): + url = f"{DSA_API}{endpoint}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(url, data=data, method=method, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + except Exception as e: + print(f" API错误: {e}") + return None + +def get_existing_codes(): + codes = set() + for path in [WATCHLIST_PATH, PORTFOLIO_PATH]: + data = load_json(path) + if not data: continue + key = "stocks" if "watchlist" in str(path) else "holdings" + for item in data.get(key, []): + c = str(item.get("code", "")).strip() + if c: codes.add(c) + return codes + + +def run_one_strategy(strategy, market, max_results): + """跑单个策略,返回候选股列表""" + print(f"\n{'='*50}") + print(f"策略: {strategy}") + print(f"{'='*50}") + + task = api("/api/v1/alphasift/screen/tasks", "POST", { + "strategy": strategy, "market": market, "max_results": max_results + }) + if not task or not task.get("task_id"): + print(f" FAIL: 提交失败") + return [] + + task_id = task["task_id"] + print(f" 任务: {task_id[:12]}...", flush=True) + + waited = 0 + while waited < POLL_TIMEOUT: + time.sleep(POLL_INTERVAL) + waited += POLL_INTERVAL + status = api(f"/api/v1/alphasift/screen/tasks/{task_id}") + if not status: continue + s = status.get("status", "") + if s == "completed": + print(f" 完成 ({waited}s)") + result = status.get("result", {}) + candidates = result.get("candidates", []) + print(f" 候选: {len(candidates)} 只") + if candidates: + for c in candidates[:3]: + print(f" {c.get('code','?')} {c.get('name',c.get('title','?'))} 评分{c.get('score','?'):.1f}") + return candidates + elif s == "failed": + print(f" FAIL: {status.get('error','')}") + return [] + else: + if waited % 60 == 0: + print(f" ...{s} ({status.get('progress',0)}%)", flush=True) + + print(f" FAIL: 超时") + return [] + + +def run_all(strategies_str, market, max_results, dry_run=False): + """多策略并行 → 合并去重 → MoFin 自选池""" + if not ALPHASIFT_ENABLED and not dry_run: + print("AlphaSift 已禁用。设置 ALPHASIFT_ENABLED=true 或 --enable 启用。") + return + strategies = [s.strip() for s in strategies_str.split(",") if s.strip()] + now = datetime.now() + date_str = now.strftime("%Y-%m-%d") + time_str = now.strftime("%Y-%m-%d %H:%M") + + print(f"AlphaSift 多策略选股: {', '.join(strategies)}") + print(f"开始: {time_str}") + + # 逐个跑策略,汇总 + all_candidates = [] + seen = set() + for strategy in strategies: + candidates = run_one_strategy(strategy, market, max_results) + for c in candidates: + code = str(c.get("code", "")).strip() + if code in seen: continue + seen.add(code) + c["_strategy"] = strategy + all_candidates.append(c) + + if not all_candidates: + print("\n无候选股") + return + + print(f"\n汇总: {len(all_candidates)} 只候选股 (去重后)") + for s in strategies: + cnt = sum(1 for c in all_candidates if c.get("_strategy") == s) + print(f" {s}: {cnt} 只") + + # 过滤 + existing = get_existing_codes() + new_stocks = [] + skipped_score = 0 + skipped_dup = 0 + + for c in all_candidates: + code = str(c.get("code", "")).strip() + score = c.get("score", 0) or c.get("llm_score", 0) or 0 + if score < MIN_SCORE: + skipped_score += 1; continue + if code in existing: + skipped_dup += 1; continue + + name = c.get("name", "") or c.get("title", "") or code + reason = c.get("reason", "") or c.get("llm_thesis", "") + src = c.get("_strategy", "unknown") + + factors = c.get("factor_scores", {}) + factor_note = ", ".join(f"{k}={v:.0f}" for k,v in list(factors.items())[:3]) if factors else "" + + notes = f"AlphaSift/{src} 评分{score:.0f}" + if factor_note: notes += f" [{factor_note}]" + if reason: notes += f" | {reason[:120]}" + + new_stocks.append({ + "code": code, + "name": name, + "price": c.get("price", 0), + "source": "alpha_sift", + "source_detail": { + "strategy": src, + "strategies_run": strategies, + "score": score, + "factor_scores": factors, + "date": date_str, + "reason": reason[:300], + }, + "notes": notes, + "added_at": time_str, + "added_by": "AlphaSift", + "analysis": {}, + }) + existing.add(code) + + print(f"\n过滤: {len(new_stocks)} 新标的 (评分不足{skipped_score} + 重复{skipped_dup})") + + if not new_stocks: + print("无符合条件的新标的") + return + + new_stocks.sort(key=lambda s: s["source_detail"]["score"], reverse=True) + new_stocks = new_stocks[:MAX_ADD] + + print(f"\n新增 {len(new_stocks)} 只到自选池:") + for s in new_stocks: + sd = s["source_detail"] + print(f" {s['code']} {s['name']} ({sd['strategy']} 评分{sd['score']:.0f})") + + if dry_run: + print("\n[DRY RUN] 未写入") + return + + # 写入 + wl = load_json(WATCHLIST_PATH) or {"stocks": []} + wl["stocks"].extend(new_stocks) + wl["updated_at"] = time_str + save_json(WATCHLIST_PATH, wl) + print(f"\n已写入 {WATCHLIST_PATH}") + + # 策略生成 + print("\n调用 regenerate_all()...") + try: + sys.path.insert(0, str(MOFIN_DATA.parent)) + from strategy_lifecycle import regenerate_all + r = regenerate_all(stdout=True) + if r: print(f"完成: {r.get('ok',0)}/{r.get('total',0)} 只策略已生成") + except Exception as e: + print(f"WARN: {e}") + + +def list_strategies(): + r = api("/api/v1/alphasift/strategies") + if r and r.get("strategies"): + for s in r["strategies"]: + print(f" {s['id']:22s} {s.get('name','?'):10s} {s.get('description','')[:60]}") + + +def main(): + global MIN_SCORE + p = argparse.ArgumentParser(description="AlphaSift → MoFin") + p.add_argument("--strategy", default=DEFAULT_STRATEGIES) + p.add_argument("--market", default=DEFAULT_MARKET) + p.add_argument("--max", type=int, default=DEFAULT_MAX) + p.add_argument("--min-score", type=int, default=MIN_SCORE) + p.add_argument("--enable", action="store_true", help="覆盖 ALPHASIFT_ENABLED 开关") + p.add_argument("--dry-run", action="store_true") + args = p.parse_args() + MIN_SCORE = args.min_score + if args.enable: + global ALPHASIFT_ENABLED + ALPHASIFT_ENABLED = True + if args.strategy == "list": list_strategies() + else: run_all(args.strategy, args.market, args.max, args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/scripts/mo_bridge.py b/scripts/mo_bridge.py new file mode 100644 index 0000000..5acbe48 --- /dev/null +++ b/scripts/mo_bridge.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +mo_bridge.py — MoFin ↔ DSA 全功能集成桥 + +真正调用 DSA 的三大功能: +1. 新闻搜索 — SearchService.search_comprehensive_intel()(7 个搜索引擎) +2. 大盘复盘 — run_market_review()(A股/港股/美股) +3. 策略问股 — AgentExecutor.run()(DSA 的 15 种策略,作为 MoFin 的第二意见) + +用法: + from mo_bridge import enrich_analysis_context, get_stock_analysis + + # 在 MoFin 分析前注入 DSA 上下文(大盘 + 新闻) + ctx = enrich_analysis_context("00700", "腾讯控股", region="hk") + prompt += f"\n\n{ctx}" + + # 用 DSA 的策略做独立分析 + opinion = get_stock_analysis("600519", "贵州茅台", skills=["ma_golden_cross"]) +""" + +import sys +import os +import json +import logging +from pathlib import Path +from datetime import datetime + +logger = logging.getLogger(__name__) + +# ── DSA 路径 ───────────────────────────────────────────────────────── + +_DSA_CANDIDATES = [ + "/home/hmo/daily-stock-analysis", + str(Path(__file__).resolve().parent.parent / "daily-stock-analysis" / "ZhuLinsen-daily_stock_analysis-a448886"), +] + +_DSA_BASE = None +for _c in _DSA_CANDIDATES: + _p = Path(_c) + if _p.is_dir() and (_p / "data_provider" / "base.py").exists(): + _DSA_BASE = _p + break + +_HAS_DSA = _DSA_BASE is not None + +if _HAS_DSA: + sys.path.insert(0, str(_DSA_BASE)) + +# ── 懒加载 DSA 模块 ────────────────────────────────────────────────── + +_dsa_search_service = None +_dsa_config = None + + +def _ensure_dsa_search(): + global _dsa_search_service + if _dsa_search_service is not None: + return _dsa_search_service + if not _HAS_DSA: + return None + try: + from src.search_service import get_search_service + _dsa_search_service = get_search_service() + except Exception as e: + logger.warning("DSA SearchService 加载失败: %s", e) + return _dsa_search_service + + +def _ensure_dsa_config(): + global _dsa_config + if _dsa_config is not None: + return _dsa_config + if not _HAS_DSA: + return None + try: + from src.config import get_config + _dsa_config = get_config() + except Exception as e: + logger.warning("DSA Config 加载失败: %s", e) + return _dsa_config + + +# ── 1. 新闻搜索 ───────────────────────────────────────────────────── + +def get_stock_news(stock_code: str, stock_name: str = "", max_results: int = 5) -> str: + """获取股票相关新闻。优先 DSA 搜索引擎,失败则 fallback 到东方财富 akshare。 + + Args: + stock_code: 股票代码 (如 '600519', '00700', 'AAPL') + stock_name: 股票名称 (提高搜索精度) + max_results: 最多返回条数 + + Returns: + str: Markdown 格式新闻摘要,可直接注入分析 prompt。失败时返回 ''。 + """ + lines = [f"## 📰 {stock_name or stock_code} 最新情报"] + got_any = False + + # 主通道: DSA SearchService(7 个搜索引擎,需要 API Key) + service = _ensure_dsa_search() + if service: + try: + intel = service.search_comprehensive_intel( + stock_code, stock_name or stock_code, max_searches=2 + ) + if intel: + news = intel.get("latest_news") + if news and news.results: + lines.append("\n### 最新新闻") + for r in news.results[:max_results]: + date_str = f" ({r.published_date})" if r.published_date else "" + snippet = r.snippet[:150] if r.snippet else "" + lines.append(f"- **{r.title}**{date_str}: {snippet}") + got_any = True + + risk = intel.get("risk_check") + if risk and risk.results: + lines.append("\n### ⚠️ 风险关注") + for r in risk.results[:3]: + lines.append(f"- {r.title}") + except Exception as e: + logger.debug("DSA 搜索失败: %s", e) + + # Fallback: 东方财富 akshare(免费,国内直连,无需 API Key) + if not got_any: + try: + import sys, os + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from mofin_news import search_akshare_news + articles = search_akshare_news(stock_code, max_results) + if articles: + lines.append("\n### 最新新闻 (东方财富)") + for a in articles: + snippet = a.get('content', '')[:120] if a.get('content') else '' + lines.append(f"- **{a.get('title', '')}**: {snippet}") + got_any = True + except Exception as e: + logger.debug("akshare 新闻 fallback 失败: %s", e) + + return "\n".join(lines) if got_any else "" + + +# ── 2. 大盘复盘 ───────────────────────────────────────────────────── + +def get_market_review(region: str = "cn", force_refresh: bool = False) -> str: + """获取 DSA 的大盘复盘报告。 + + cron 场景:默认只读缓存(快,不阻塞)。每天第一次调用时 DSA 可能已经生成了缓存。 + 手动场景:force_refresh=True 实时调用 DSA 生成(慢,5-10秒)。 + + Args: + region: 'cn'=A股, 'hk'=港股, 'us'=美股 + force_refresh: 是否强制实时生成(默认 False,只读缓存) + + Returns: + str: Markdown 格式大盘复盘摘要 + """ + if not _HAS_DSA: + return "" + + # 先读缓存(优先,快) + cache_dirs = [ + Path(str(_DSA_BASE)) / "reports", # DSA 生成的市场报告目录 + Path(str(_DSA_BASE)) / "data" / "market_review", + ] + for cache_dir in cache_dirs: + if not cache_dir.exists(): + continue + try: + pattern = f"market_review_*.md" if "report" in str(cache_dir) else "*.md" + files = [] + for p in cache_dir.glob("market_review_*.md"): + files.append(p) + if not files: + for p in cache_dir.glob("*.md"): + if "market" in p.name.lower() or "review" in p.name.lower(): + files.append(p) + files.sort(key=os.path.getmtime, reverse=True) + if files: + if (datetime.now().timestamp() - os.path.getmtime(str(files[0]))) < 86400: + content = files[0].read_text(encoding="utf-8") + lines = [l for l in content.split("\n")[:30] if len(l.strip()) > 3] + return "## 📈 今日大盘背景\n" + "\n".join(lines) + except Exception: + pass + + # cron 场景不走实时(太慢),直接返回空 + if not force_refresh: + return "" + + # 手动场景:实时调用 DSA(慢,需要 LLM) + try: + from src.core.market_review import run_market_review + + class StubNotifier: + def is_available(self): return False + def send(self, *a, **kw): return True + def save_report_to_file(self, *a, **kw): return None + + config = _ensure_dsa_config() + result = run_market_review( + notifier=StubNotifier(), config=config, + override_region=region, send_notification=False, + save_report_file=True, persist_history=True, + trigger_source="mofin", + ) + if result and isinstance(result, str): + lines = [l for l in result.split("\n")[:25] if len(l.strip()) > 3] + return "## 📈 今日大盘复盘\n" + "\n".join(lines) + except Exception as e: + logger.warning("DSA 大盘复盘生成失败: %s", e) + + return "" + + +# ── 3. 策略问股(第二意见)─────────────────────────────────────────── + +def get_stock_analysis( + stock_code: str, + stock_name: str = "", + skills: list = None, +) -> dict | None: + """用 DSA 的 15 种内置策略独立分析一只股票。 + + Args: + stock_code: 股票代码 + stock_name: 股票名称 + skills: 策略列表,默认 ['ma_golden_cross', 'bull_trend'] + + Returns: + dict: {source, sentiment_score, operation_advice, trend_prediction, + analysis_summary, risk_warning, strategies_used, raw} + """ + if not _HAS_DSA: + return None + + if not skills: + skills = ["ma_golden_cross", "bull_trend"] + + try: + from src.agent.factory import build_agent_executor + + executor = build_agent_executor(skills=skills) + result = executor.run( + task=f"分析 {stock_code} {stock_name}", + context={"stock_code": stock_code, "stock_name": stock_name, "report_language": "zh"}, + ) + + if result.success and result.dashboard: + d = result.dashboard + return { + "source": "DSA", + "sentiment_score": d.get("sentiment_score", 0), + "operation_advice": d.get("operation_advice", ""), + "trend_prediction": d.get("trend_prediction", ""), + "analysis_summary": d.get("analysis_summary", ""), + "risk_warning": d.get("risk_warning", ""), + "strategies_used": skills, + "raw": result.content[:500] if result.content else "", + } + except Exception as e: + logger.warning("DSA Agent 分析 %s 失败: %s", e) + + return None + + +def get_strategy_opinion_text(opinion: dict) -> str: + """将 get_stock_analysis() 的结果格式化为可读文本""" + if not opinion: + return "" + return ( + f"## 🤖 DSA 策略参考\n" + f"- 评分: {opinion.get('sentiment_score', '?')}/100\n" + f"- 建议: {opinion.get('operation_advice', '?')}\n" + f"- 趋势: {opinion.get('trend_prediction', '?')}\n" + f"- 策略: {', '.join(opinion.get('strategies_used', []))}\n" + f"- 摘要: {opinion.get('analysis_summary', '')}\n" + f"- 风险: {opinion.get('risk_warning', '')}" + ) + + +# ── 4. 综合上下文(一键调用)───────────────────────────────────────── + +def enrich_analysis_context( + stock_code: str = "", + stock_name: str = "", + region: str = "cn", + include_news: bool = True, + include_market: bool = True, +) -> str: + """一键获取 DSA 全部分析上下文,注入 MoFin 的 LLM prompt。 + + 在 strategy_lifecycle.reassess_with_context() 或 Hermes cron job 的 prompt 前调用。 + + Returns: + str: 可直接拼接到 LLM prompt 的 Markdown 文本 + """ + parts = [] + + if include_market: + market = get_market_review(region) + if market: + parts.append(market) + + if include_news and stock_code: + news = get_stock_news(stock_code, stock_name) + if news: + parts.append(news) + + return "\n\n".join(parts) if parts else "" + + +# ── 自检 ───────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print(f"DSA: {'available' if _HAS_DSA else 'NOT FOUND'} ({_DSA_BASE})") + if _HAS_DSA: + print("\n--- 新闻测试 (600519) ---") + n = get_stock_news("600519", "贵州茅台", max_results=2) + print(n[:300] if n else "(无结果)") + print("\n--- 大盘测试 ---") + m = get_market_review("cn") + print(m[:300] if m else "(无结果)") diff --git a/scripts/mo_config.py b/scripts/mo_config.py new file mode 100644 index 0000000..1199092 --- /dev/null +++ b/scripts/mo_config.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +mo_config.py — MoFin 统一配置管理(单例模式) + +替代 MoFin 中散落在各文件的硬编码路径和常量。 + +⚠️ 铁律:所有 MoFin 模块必须从此处获取路径和配置,严禁硬编码。 + 之前:DATA_DIR = "/home/hmo/web-dashboard/data" (散落在 10+ 文件中) + 现在:from mo_config import config; config.data_dir + +用法: + from mo_config import config + portfolio_path = config.data_dir / "portfolio.json" +""" + +import os +import json +from pathlib import Path +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class MoConfig: + """MoFin 全局配置单例""" + + # ── 路径 ────────────────────────────────────────────────────── + # 项目根目录 + project_dir: Path = field(default_factory=lambda: Path(__file__).parent.resolve()) + + # 数据目录(portfolio.json, decisions.json 等) + data_dir: Path = field(default_factory=lambda: Path( + os.environ.get("MOFIN_DATA_DIR", "/home/hmo/web-dashboard/data") + )) + + # SQLite 数据库路径 + db_path: Path = field(default=None) + + # 缓存目录 + cache_dir: Path = field(default_factory=lambda: Path.home() / ".cache" / "mofin") + + # Hermes 状态目录 + hermes_dir: Path = field(default_factory=lambda: Path.home() / ".hermes") + + # ── 关键数据文件路径 ────────────────────────────────────────── + + @property + def portfolio_path(self) -> Path: + return self.data_dir / "portfolio.json" + + @property + def decisions_path(self) -> Path: + return self.data_dir / "decisions.json" + + @property + def watchlist_path(self) -> Path: + return self.data_dir / "watchlist.json" + + @property + def price_events_path(self) -> Path: + return self.data_dir / "price_events.json" + + @property + def live_prices_path(self) -> Path: + """⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。""" + return self.data_dir / "live_prices.json" + + @property + def evaluation_input_path(self) -> Path: + return self.data_dir / "evaluation_input.json" + + @property + def multi_tf_cache_path(self) -> Path: + """⚠️ DEPRECATED: 多周期缓存已迁移到 mofin_db.mtf_cache 表。""" + return self.data_dir / "multi_tf_cache.json" + + @property + def price_history_path(self) -> Path: + return self.data_dir / "price_history.json" + + # ── DB 路径(懒加载) ──────────────────────────────────────── + + def _get_db_path(self) -> Path: + if self.db_path is None: + self.db_path = self.data_dir / "mofin.db" + return self.db_path + + # ── 汇率 ────────────────────────────────────────────────────── + + hk_rate_fallback: float = 0.87 # 港币→人民币 fallback 汇率 + + # ── 小果 LLM 端点(用机器名,/etc/hosts 自动解析 LAN/EasyTier)─ + # node122 = 192.168.1.122 (LAN) / 10.144.144.2 (EasyTier) + xiaoguo_host: str = "node122" + xiaoguo_port: int = 18003 + + @property + def xiaoguo_url(self) -> str: + return f"http://{self.xiaoguo_host}:{self.xiaoguo_port}" + + @property + def xiaoguo_api_url(self) -> str: + return f"{self.xiaoguo_url}/v1/chat/completions" + + port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8899"))) + + tdx_relay_url: str = field( + default_factory=lambda: os.environ.get("TDX_RELAY_URL", "http://localhost:8080") + ) + + xmpp_agent_host: str = field( + default_factory=lambda: os.environ.get("XMPP_AGENT_HOST", "localhost") + ) + + xmpp_agent_port: int = field( + default_factory=lambda: int(os.environ.get("XMPP_AGENT_PORT", "5801")) + ) + + # ── DSA 集成 ────────────────────────────────────────────────── + + dsa_enabled: bool = field( + default_factory=lambda: os.environ.get("DSA_ENABLED", "false").lower() == "true" + ) + + dsa_base_dir: Path = field(default_factory=lambda: Path( + os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "daily-stock-analysis", + "ZhuLinsen-daily_stock_analysis-a448886" + )) + )) + + # ── 数据新鲜度 ──────────────────────────────────────────────── + + market_hours_max_stale_min: int = 5 # 盘中最大过期时间(分钟) + off_hours_max_stale_min: int = 120 # 盘后最大过期时间(分钟) + + # ── 验证 ────────────────────────────────────────────────────── + + def validate(self) -> List[str]: + """验证配置,返回问题列表""" + issues = [] + + if not self.data_dir.exists(): + issues.append(f"数据目录不存在: {self.data_dir}") + + if not self.portfolio_path.exists(): + issues.append(f"portfolio.json 不存在: {self.portfolio_path}") + + if not self.decisions_path.exists(): + issues.append(f"decisions.json 不存在: {self.decisions_path}") + + return issues + + def ensure_dirs(self): + """确保必要的目录存在""" + self.data_dir.mkdir(parents=True, exist_ok=True) + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.hermes_dir.mkdir(parents=True, exist_ok=True) + + # ── 输出 ────────────────────────────────────────────────────── + + def summary(self) -> str: + """打印配置摘要""" + lines = [ + "=== MoFin 配置 ===", + f"项目目录: {self.project_dir}", + f"数据目录: {self.data_dir} (存在: {self.data_dir.exists()})", + f"DB路径: {self._get_db_path()} (存在: {self._get_db_path().exists()})", + f"端口: {self.port}", + f"TDX Relay: {self.tdx_relay_url}", + f"DSA 集成: {'启用' if self.dsa_enabled else '关闭'}", + f"港币汇率 fallback: {self.hk_rate_fallback}", + ] + issues = self.validate() + if issues: + lines.append(f"\n⚠️ 配置问题 ({len(issues)}):") + for i in issues: + lines.append(f" - {i}") + return "\n".join(lines) + + +# ── 单例 ──────────────────────────────────────────────────────────── + +_config_instance: MoConfig | None = None + + +def get_config() -> MoConfig: + """获取全局配置单例""" + global _config_instance + if _config_instance is None: + _config_instance = MoConfig() + return _config_instance + + +# 便捷别名 +config = property(lambda self: get_config()) + + +# ── 模块级便捷访问 ────────────────────────────────────────────────── + +def data_dir() -> Path: + return get_config().data_dir + +def ensure_dirs(): + get_config().ensure_dirs() + + +# ── 向后兼容:导出常用路径常量 ────────────────────────────────────── +# 让旧代码可以通过熟悉的变量名访问路径 + +def _lazy(attr): + """懒加载属性,首次访问时从 config 获取""" + return getattr(get_config(), attr) + +# 为兼容旧代码导出以下变量 +PORTFOLIO_PATH = None # 改用 config.portfolio_path +DECISIONS_PATH = None # 改用 config.decisions_path +WATCHLIST_PATH = None # 改用 config.watchlist_path + + +# ── 自检 ──────────────────────────────────────────────────────────── + +if __name__ == "__main__": + cfg = get_config() + print(cfg.summary()) diff --git a/scripts/mo_data.py b/scripts/mo_data.py new file mode 100644 index 0000000..d6a4e79 --- /dev/null +++ b/scripts/mo_data.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +mo_data.py — MoFin 统一数据层(纯 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 +from datetime import datetime + +DB_PATH = '/home/hmo/web-dashboard/data/mofin.db' + + +def _get_db(): + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + return db + + +# ── portfolio ───────────────────────────────────────────────────── + +def read_portfolio(): + """返回 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 ───────────────────────────────────────────────────── + +def _parse_json(val, default): + if val: + try: return json.loads(val) + except: pass + return default + + +def read_decisions(): + """返回 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 ───────────────────────────────────────────────────── + +def read_watchlist(): + """返回 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(): + return read_portfolio() + +def read_decisions_json(): + return read_decisions() + +def read_watchlist_json(): + return read_watchlist() + + +# ── cash_log 写入 ────────────────────────────────────────────────── + +def write_cash_log(cash_before, cash_after, frozen_before, frozen_after, + source, note, verified=0): + """记录现金变更到 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: + cur = db.execute( + """INSERT INTO cash_log + (timestamp, cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note, verified) + VALUES (datetime('now','localtime'), ?, ?, ?, ?, ?, ?, ?, ?)""", + (cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note, verified) + ) + db.commit() + return cur.lastrowid + finally: + 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/scripts/mo_dsa_opinion.py b/scripts/mo_dsa_opinion.py new file mode 100644 index 0000000..85407e2 --- /dev/null +++ b/scripts/mo_dsa_opinion.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +mo_dsa_opinion.py — DSA 策略问股(第二意见) + +用 DSA 的 15 种策略独立分析一只股票,和 MoFin 自己的分析做交叉验证。 +不替代 MoFin,只做参考。 + +用法: + # 分析一只股票 + python3 mo_dsa_opinion.py 00700 腾讯控股 + + # 指定策略 + python3 mo_dsa_opinion.py 600519 贵州茅台 --skills ma_golden_cross,chan_theory + + # 作为 cron job 调用(静默模式,输出到文件) + python3 mo_dsa_opinion.py 00700 腾讯控股 --quiet + +输出格式: + ## 🤖 DSA 策略参考 + - 评分: 72/100 + - 建议: 持有 + - 趋势: 看多 + - 策略: ma_golden_cross, bull_trend + - 摘要: ... + - 风险: ... +""" + +import sys, os, json, argparse + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def main(): + parser = argparse.ArgumentParser(description="DSA 策略问股") + parser.add_argument("code", help="股票代码") + parser.add_argument("name", nargs="?", default="", help="股票名称") + parser.add_argument("--skills", default="ma_golden_cross,bull_trend", + help="策略列表,逗号分隔") + parser.add_argument("--quiet", action="store_true", help="静默模式") + parser.add_argument("--json", action="store_true", help="JSON 输出") + args = parser.parse_args() + + skills = [s.strip() for s in args.skills.split(",") if s.strip()] + + if not args.quiet: + print(f"🔍 DSA 分析 {args.code} {args.name}...", flush=True) + print(f" 策略: {', '.join(skills)}", flush=True) + + from mo_bridge import get_stock_analysis, get_strategy_opinion_text + + # 先获取新闻和大盘上下文 + from mo_bridge import enrich_analysis_context + ctx = enrich_analysis_context(args.code, args.name, region="cn") + + opinion = get_stock_analysis(args.code, args.name, skills=skills) + + if not opinion: + print("❌ DSA 分析失败(LLM 超时或 DSA 不可用)") + sys.exit(1) + + if args.json: + print(json.dumps(opinion, ensure_ascii=False, indent=2)) + else: + print() + print(get_strategy_opinion_text(opinion)) + + # MoFin 对比提示 + print() + print("---") + print("⚠️ 以上是 DSA 独立分析,仅供参考。") + print(" MoFin 的分析以 strategy_lifecycle 为准。") + print(" 如果两方结论一致 → 增强信心") + print(" 如果两方结论冲突 → 关注分歧点,人工判断") + +if __name__ == "__main__": + main() diff --git a/scripts/mo_models.py b/scripts/mo_models.py new file mode 100644 index 0000000..32fb72c --- /dev/null +++ b/scripts/mo_models.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +""" +mo_models.py — MoFin 唯一数据模型(Single Source of Truth) + +⚠️ 铁律:MoFin 中所有以下操作必须走这个文件,严禁各自实现: + 1. 判断港股 — 用 is_hk_stock(code) + 2. 计算总资产 — 用 calc_total_assets(pf) + 3. 获取港币汇率 — 用 get_hk_rate() + 4. 币种转换 — 用 to_cny(price, code) + +创建日期: 2026-06-29 +原因: 之前 total_assets 在 6+ 文件中各自计算,公式不一致(3个漏了 frozen_cash); + is_hk_stock 有 3 种不同实现,存在误判风险; + hk_rate 在多个文件中硬编码不同值(0.866/0.87/0.8664/0.8700)。 +""" + +import sys +import os + +# ── 港股检测 ──────────────────────────────────────────────────────── + +def is_hk_stock(code): + """判断是否为港股。 + + 规则:港股代码为5位数字,以0或1开头。 + 例:00700(腾讯), 01888(建滔积层板), 00981(中芯国际) + + 排除: + - A股6位代码如 000657(中钨高新) — len==6 不会被误判 + - 美股字母代码如 AAPL + - 带前缀的代码如 hk00700 → 自动去前缀后判断 + """ + code = (str(code or '')).strip().upper() + # 去常见前缀 + for prefix in ('HK', 'SH', 'SZ', 'BJ'): + if code.startswith(prefix): + code = code[len(prefix):] + # 港股: 5位数字, 0或1开头 + return len(code) == 5 and code.isdigit() and code[0] in ('0', '1') + + +def is_a_stock(code): + """判断是否为A股(沪深京)""" + code = (str(code or '')).strip().upper() + for prefix in ('SH', 'SZ', 'BJ'): + if code.startswith(prefix): + code = code[len(prefix):] + if len(code) == 6 and code.isdigit(): + if code.startswith(('0', '3', '6')): + return True + if code.startswith(('4', '8', '9')): + return True # 北交所/科创板 + return False + + +def normalize_code(code): + """归一化股票代码:去市场前缀,去后缀,统一大写""" + code = (str(code or '')).strip().upper() + for prefix in ('HK', 'SH', 'SZ', 'BJ'): + if code.startswith(prefix): + code = code[len(prefix):] + return code + + +# ── 港币汇率 ────────────────────────────────────────────────────────── + +def get_hk_rate(): + """获取 HKD→CNY 汇率。优先用 hk_rate 模块(支持API+缓存),失败回退 0.87""" + try: + from hk_rate import hkd_to_cny + return hkd_to_cny() + except Exception: + pass + # 最后的兜底 + return 0.87 + + +def to_cny(price, code): + """如果 code 是港股,把 price 从 HKD 转为 CNY;否则原样返回""" + if price is None or price == 0: + return price + if is_hk_stock(code): + return round(float(price) * get_hk_rate(), 2) + return price + + +# ── 总资产计算(唯一公式) ──────────────────────────────────────────── + +def calc_total_mv(holdings): + """计算持仓总市值。港股 price 为 HKD,需 × 汇率转 CNY""" + total = 0 + rate = get_hk_rate() + for h in (holdings or []): + p = (h.get('price', 0) or 0) + s = (h.get('shares', 0) or 0) + if h.get('currency') == 'HKD' or (str(h.get('code','')).startswith(('0','1')) and len(str(h.get('code',''))) == 5): + total += s * p * rate + else: + total += s * p + return round(total, 2) + + +def calc_total_assets(pf): + """ + 计算总资产 = 持仓市值 + 可用现金 + 冻结资金 + + 这是 MoFin 中 total_assets 的 **唯一正确公式**。 + 所有文件必须调用此函数,严禁各自实现。 + + Args: + pf: dict,包含 holdings、cash、frozen_cash 字段 + + Returns: + float: 总资产(人民币) + """ + total_mv = calc_total_mv(pf.get('holdings', [])) + cash = float(pf.get('cash', 0) or 0) + frozen = float(pf.get('frozen_cash', 0) or 0) + return round(total_mv + cash + frozen, 2) + + +def calc_position_pct(pf): + """计算仓位百分比""" + total = calc_total_assets(pf) + if total > 0: + total_mv = calc_total_mv(pf.get('holdings', [])) + return round(total_mv / total * 100, 2) + return 0 + + +# ── 数据验证 ────────────────────────────────────────────────────────── + +def validate_portfolio(pf): + """验证 portfolio.json 数据一致性,返回 issues 列表""" + issues = [] + holdings = pf.get('holdings', []) + + # 1. 总资产校验 + stored = pf.get('total_assets', 0) + calculated = calc_total_assets(pf) + if stored > 0 and abs(stored - calculated) / max(stored, 1) > 0.01: + issues.append( + f"total_assets 不匹配: 存储{stored:.2f} ≠ 计算{calculated:.2f}" + f" (市值{calc_total_mv(holdings):.2f}+现金{pf.get('cash',0):.2f}+冻结{pf.get('frozen_cash',0):.2f})" + ) + + # 2. 币种一致性 + for h in holdings: + code = str(h.get('code', '')) + currency = h.get('currency', h.get('_currency', '')) + if is_hk_stock(code) and currency == 'HKD': + issues.append( + f"⚠️ 港股{code}({h.get('name','?')}) currency=HKD," + f"portfolio.json 应全部存 CNY" + ) + + # 3. 零股检查 + for h in holdings: + if (h.get('shares', 0) or 0) <= 0 and h.get('code'): + issues.append(f"持仓{h.get('code')}({h.get('name','?')}) 股数为0或负数") + + return issues + + +# ── 向后兼容别名 ────────────────────────────────────────────────────── + +# 让旧代码中散落的 is_hk_stock 引用也能正确工作 +__all__ = [ + 'is_hk_stock', + 'is_a_stock', + 'normalize_code', + 'get_hk_rate', + 'to_cny', + 'calc_total_mv', + 'calc_total_assets', + 'calc_position_pct', + 'validate_portfolio', +] + +# 模块自检 +if __name__ == '__main__': + # 测试 is_hk_stock + test_cases = [ + ('00700', True), # 腾讯 + ('01888', True), # 建滔积层板 + ('000657', False), # 中钨高新 A股 + ('600519', False), # 茅台 A股 + ('AAPL', False), # 苹果 美股 + ('hk00700', True), # 带前缀港股 + ('SH600519', False), # 带前缀A股 + ] + print("=== is_hk_stock 测试 ===") + all_ok = True + for code, expected in test_cases: + result = is_hk_stock(code) + status = '✅' if result == expected else '❌' + if result != expected: + all_ok = False + print(f" {status} is_hk_stock('{code}') = {result} (expected {expected})") + + # 测试 calc_total_assets + print("\n=== calc_total_assets 测试 ===") + pf = { + 'holdings': [ + {'code': '00700', 'shares': 100, 'price': 365.0}, + {'code': '600519', 'shares': 200, 'price': 1700.0}, + ], + 'cash': 50000.0, + 'frozen_cash': 10000.0, + } + expected_mv = 100 * 365.0 + 200 * 1700.0 # 36500 + 340000 = 376500 + expected_ta = expected_mv + 50000 + 10000 # 436500 + calc_ta = calc_total_assets(pf) + print(f" total_mv = {calc_total_mv(pf['holdings'])} (expected {expected_mv})") + print(f" total_assets = {calc_ta} (expected {expected_ta})") + print(f" {'✅' if abs(calc_ta - expected_ta) < 0.01 else '❌'} calc_total_assets") + + # 测试 validate + print("\n=== validate_portfolio 测试 ===") + issues = validate_portfolio(pf) + if issues: + for i in issues: + print(f" ⚠️ {i}") + else: + print(" ✅ 无问题") + + print(f"\n{'全部通过 ✅' if all_ok else '有失败 ❌'}") diff --git a/scripts/mo_provider.py b/scripts/mo_provider.py new file mode 100644 index 0000000..cbee72b --- /dev/null +++ b/scripts/mo_provider.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +mo_provider.py — MoFin 统一数据源适配器 + +封装 DSA (daily_stock_analysis) 的数据源层作为 MoFin 的备份/增强数据管道。 + +架构: + 主数据源:TDX Relay(通達信实时行情,走招商证券 7727 服务器) + 备份数据源:DSA DataFetcherManager(16 个 fetcher,自动 fallback) + +用法: + from mo_provider import MoDataProvider + provider = MoDataProvider() + + # 获取实时行情(TDX 优先,失败 → Tencent API → DSA fallback) + realtime = provider.get_realtime("00700") + + # 获取 K 线数据(优先本地缓存,失败 → DSA) + kline = provider.get_kline("600519", period="daily") + + # 新闻搜索(DSA 的 search_service) + news = provider.search_news("腾讯控股") + +依赖: + DSA 代码需在 ../../daily-stock-analysis/ZhuLinsen-daily_stock_analysis-a448886/ + 安装 DSA 依赖: pip install -r ../../daily-stock-analysis/...requirements.txt +""" + +import sys +import os +import json +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + +# ── 路径配置 ───────────────────────────────────────────────────────── + +# DSA 源码路径(按优先级尝试) +_DSA_CANDIDATES = [ + "/home/hmo/daily-stock-analysis", # 服务器部署路径 + os.path.normpath(os.path.join( # 本地开发路径 + os.path.dirname(os.path.abspath(__file__)), + "..", "daily-stock-analysis", "ZhuLinsen-daily_stock_analysis-a448886" + )), +] + +_DSA_BASE = None +for _c in _DSA_CANDIDATES: + if os.path.isdir(_c) and os.path.isfile(os.path.join(_c, "data_provider", "base.py")): + _DSA_BASE = _c + break + +_HAS_DSA = _DSA_BASE is not None + + +# ── MoDataProvider ─────────────────────────────────────────────────── + +class MoDataProvider: + """MoFin 统一数据获取门面。 + + - TDX Relay 作为实时行情的主源(港股接近实时) + - DSA 作为备份源(当 TDX 不可用时自动 fallback) + - 所有数据获取统一走此类,禁止各文件自己实现 + """ + + def __init__(self, tdx_url: str = None): + self._dsa_manager = None # 懒加载 DSA DataFetcherManager + self._tdx_url = tdx_url or "http://localhost:8080" # TDX relay 地址 + + # ── DSA 懒加载 ─────────────────────────────────────────────── + + @property + def has_dsa(self) -> bool: + """DSA 数据源是否可用""" + return _HAS_DSA + + def _ensure_dsa(self): + """懒加载 DSA DataFetcherManager""" + if self._dsa_manager is not None: + return self._dsa_manager + + if not _HAS_DSA: + logger.warning("DSA 源码不在 %s,无法使用备份数据源", _DSA_BASE) + return None + + try: + sys.path.insert(0, _DSA_BASE) + from data_provider.base import DataFetcherManager + self._dsa_manager = DataFetcherManager() + logger.info("DSA DataFetcherManager 已加载(16个数据源)") + except Exception as e: + logger.warning("加载 DSA DataFetcherManager 失败: %s", e) + self._dsa_manager = None + + return self._dsa_manager + + # ── 实时行情 ────────────────────────────────────────────────── + + def get_realtime(self, code: str) -> dict | None: + """获取实时行情。 + + 优先级:TDX → Tencent API → DSA fallback + + Returns: + dict with: price, change_pct, volume, name, currency + 或 None(所有源均失败) + """ + # 1. 尝试 TDX Relay + try: + import urllib.request + url = f"{self._tdx_url}/realtime/{code}" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=3) as r: + data = json.loads(r.read()) + if data.get("price"): + logger.debug("TDX relay 返回 %s 行情: %.2f", code, data["price"]) + return data + except Exception: + pass + + # 2. 尝试 Tencent API + try: + return self._get_tencent_realtime(code) + except Exception: + pass + + # 3. DSA fallback + dsa = self._ensure_dsa() + if dsa: + try: + # DSA 的 get_realtime_quote + result = dsa.get_realtime_quote(code) + if result: + logger.info("DSA fallback 返回 %s 行情", code) + return {"price": result.price, "name": result.name} + except Exception as e: + logger.debug("DSA fallback 失败: %s", e) + + logger.warning("所有数据源均无法获取 %s 的行情", code) + return None + + def _get_tencent_realtime(self, code: str) -> dict | None: + """通过 Tencent API 获取实时行情""" + import urllib.request + from mo_models import normalize_code + + raw = normalize_code(code) + # 判断市场 + if raw[0] in ('0', '1') and len(raw) == 5: + market = "hk" + qt_code = f"hk{raw}" + elif raw.startswith("6"): + market = "sh" + qt_code = f"sh{raw}" + elif raw.startswith(("0", "3")): + market = "sz" + qt_code = f"sz{raw}" + else: + return None + + url = f"https://qt.gtimg.cn/q={qt_code}" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=5) as r: + text = r.read().decode("gbk") + + # 解析 Tencent 行情格式 + parts = text.split("~") + if len(parts) > 40: + return { + "code": code, + "name": parts[1], + "price": float(parts[3]) if parts[3] else 0, + "change_pct": float(parts[32]) if parts[32] else 0, + "volume": int(parts[6]) if parts[6] else 0, + "market": market, + } + return None + + # ── K 线数据 ────────────────────────────────────────────────── + + def get_kline(self, code: str, period: str = "daily", count: int = 60) -> list | None: + """获取 K 线数据。 + + Args: + code: 股票代码 + period: daily/weekly/monthly + count: 获取条数 + + Returns: + list of dict 或 None + """ + dsa = self._ensure_dsa() + if not dsa: + return None + + try: + df = dsa.get_daily_data(code, period=period, limit=count) + if df is not None and not df.empty: + return df.to_dict("records") + except Exception as e: + logger.warning("DSA get_kline 失败: %s", e) + + return None + + # ── 分钟级 K 线 ────────────────────────────────────────────── + + _last_minute_call = 0 # 限流时间戳 + + def get_minute_kline(self, code: str, count: int = 60) -> list | None: + """获取1分钟K线数据(东方财富 push2)。 + + 限流保护:每次调用间隔至少1秒,批量查询间隔2秒。 + + Args: + code: 股票代码(6位,如'600519') + count: 获取条数(最大240,约4小时) + + Returns: + [{"time":"09:31","open":xx,"close":xx,"high":xx,"low":xx,"volume":xx,"amount":xx}, ...] + 或 None + """ + import time, urllib.request + now = time.time() + elapsed = now - self._last_minute_call + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + # A股secid: 1.上海 0.深圳 + 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"] + result = [] + for line in data: + parts = line.split(",") + if len(parts) >= 6: + result.append({ + "time": parts[0][-5:], # "2026-07-01 09:31" → "09:31" + "open": float(parts[1]), + "close": float(parts[2]), + "high": float(parts[3]), + "low": float(parts[4]), + "volume": int(parts[5]), + "amount": float(parts[6]) if len(parts) > 6 else 0, + }) + self._last_minute_call = time.time() + return result + except Exception as e: + logger.warning("get_minute_kline(%s) 失败: %s", code, e) + return None + + # ── 新闻搜索 ────────────────────────────────────────────────── + + def search_news(self, query: str, max_results: int = 5) -> list: + """通过 DSA 的搜索服务获取新闻。 + + Args: + query: 搜索关键词 + max_results: 最多返回条数 + + Returns: + list of dict with: title, url, snippet, date + """ + dsa = self._ensure_dsa() + if not dsa: + return [] + + try: + from src.search_service import SearchService + service = SearchService() + results = service.search(query, limit=max_results) + return results[:max_results] if results else [] + except Exception as e: + logger.debug("DSA news search 失败: %s", e) + + return [] + + # ── 大盘分析 ────────────────────────────────────────────────── + + def get_market_context(self, region: str = "cn") -> str | None: + """获取 DSA 的市场复盘摘要。 + + Args: + region: cn/hk/us/both + + Returns: + 市场上下文文本 或 None + """ + dsa = self._ensure_dsa() + if not dsa: + return None + + try: + from src.core.market_review import run_market_review + from src.config import get_config + config = get_config() + result = run_market_review(config=config, send_notification=False) + if result and hasattr(result, 'report'): + return result.report + except Exception as e: + logger.debug("DSA market review 失败: %s", e) + + return None + + # ── 基本面 ──────────────────────────────────────────────────── + + def get_fundamentals(self, code: str) -> dict | None: + """获取股票基本面数据(PE/PB/ROE 等)""" + dsa = self._ensure_dsa() + if not dsa: + return None + + try: + from data_provider.fundamental_adapter import AkshareFundamentalAdapter + adapter = AkshareFundamentalAdapter() + return adapter.get_fundamentals(code) + except Exception: + pass + + return None + + +# ── 单例 ──────────────────────────────────────────────────────────── + +_provider_instance: MoDataProvider | None = None + + +def get_provider() -> MoDataProvider: + """获取 MoDataProvider 单例""" + global _provider_instance + if _provider_instance is None: + _provider_instance = MoDataProvider() + return _provider_instance + + +# ── 便捷函数 ──────────────────────────────────────────────────────── + +def get_realtime(code: str) -> dict | None: + """便捷函数:获取实时行情""" + return get_provider().get_realtime(code) + + +def get_market_context() -> str | None: + """便捷函数:获取大盘上下文""" + return get_provider().get_market_context() + + +def search_news(query: str) -> list: + """便捷函数:搜索新闻""" + return get_provider().search_news(query) + + +# ── 自检 ──────────────────────────────────────────────────────────── + +if __name__ == "__main__": + provider = MoDataProvider() + print(f"DSA 可用: {provider.has_dsa}") + print(f"DSA 路径: {_DSA_BASE}") + + if provider.has_dsa: + manager = provider._ensure_dsa() + print(f"DataFetcherManager: {'已加载' if manager else '加载失败'}") + + # 测试 Tencent API + try: + result = provider._get_tencent_realtime("00700") + print(f"\nTencent API 测试 (00700): {result}") + except Exception as e: + print(f"Tencent API 测试失败: {e}") diff --git a/scripts/mofin_db.py b/scripts/mofin_db.py new file mode 100644 index 0000000..ca765a4 --- /dev/null +++ b/scripts/mofin_db.py @@ -0,0 +1,1282 @@ +#!/usr/bin/env python3 +"""mofin_db.py — MoFin 统一数据库访问层 + +所有脚本通过此模块访问 mofin.db,避免重复建表/连接逻辑。 + +用法: + from mofin_db import get_conn, write_market_snapshot, write_klines, ... + +设计原则: + - 幂等建表(CREATE TABLE IF NOT EXISTS) + - WAL 模式 + 外键约束 + - 所有写操作返回 (success: bool, detail: str) + - JSON 写入由调用方负责,本模块只写 SQLite +""" + +import sqlite3 +import json +import time +import functools +from datetime import datetime +from pathlib import Path +from typing import Optional, Callable + +DATA_DIR = Path(__file__).parent / "data" +DB_PATH = DATA_DIR / "mofin.db" + +# ═══════════════════════════════════════════════════════════ +# 连接管理 +# ═══════════════════════════════════════════════════════════ + +def get_conn() -> sqlite3.Connection: + """获取数据库连接(WAL 模式,外键约束,Row 工厂,30秒超时防并发锁,autocommit模式)""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=30000") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def execute_with_retry(conn: sqlite3.Connection, sql: str, params: tuple = (), + max_retries: int = 3, base_delay: float = 1.0) -> sqlite3.Cursor: + """执行SQL并自动重试(捕获 database is locked)""" + last_err = None + for attempt in range(max_retries + 1): + try: + return conn.execute(sql, params) + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise # 非锁错误直接抛 + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) # 指数退避: 1s, 2s, 4s + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB锁重试{max_retries}次仍失败: {e}" + ) + # unreachable -- both paths in loop either return or raise + if last_err: + raise last_err # type: ignore[misc] + + +def commit_with_retry(conn: sqlite3.Connection, max_retries: int = 3, + base_delay: float = 1.0) -> None: + """提交事务并自动重试""" + last_err = None + for attempt in range(max_retries + 1): + try: + conn.commit() + return + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB提交重试{max_retries}次仍失败: {e}" + ) + raise last_err + + +def retry_db_write(func: Callable) -> Callable: + """装饰器:为 DB 写函数自动添加重试""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + max_retries = 3 + base_delay = 1.0 + last_err = None + for attempt in range(max_retries + 1): + try: + return func(*args, **kwargs) + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB写重试{max_retries}次仍失败({func.__name__}): {e}" + ) + raise last_err + return wrapper + + +# ═══════════════════════════════════════════════════════════ +# 建表(幂等) +# ═══════════════════════════════════════════════════════════ + +def init_all_tables(conn: sqlite3.Connection): + """创建全部表(幂等,已存在则跳过)""" + conn.executescript(""" + -- 市场快照 + CREATE TABLE IF NOT EXISTS market_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'ths', + up_ratio REAL, + mood TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_snapshots_time ON market_snapshots(timestamp); + + -- 板块快照 + CREATE TABLE IF NOT EXISTS sector_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL REFERENCES market_snapshots(id), + name TEXT NOT NULL, + change_pct REAL, + up_count INTEGER, + down_count INTEGER, + net_inflow REAL, + lead_stock TEXT, + lead_stock_change REAL, + volume REAL, + turnover REAL + ); + CREATE INDEX IF NOT EXISTS idx_sector_name ON sector_snapshots(name); + CREATE INDEX IF NOT EXISTS idx_sector_snapshot ON sector_snapshots(snapshot_id); + CREATE INDEX IF NOT EXISTS idx_sector_name_time ON sector_snapshots(name, snapshot_id); + + -- 个股 + CREATE TABLE IF NOT EXISTS stocks ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + exchange TEXT DEFAULT 'SH', + type TEXT DEFAULT 'A', + updated_at TEXT + ); + + -- K线(日/周/月) + CREATE TABLE IF NOT EXISTS stock_daily ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, amount REAL, + PRIMARY KEY (code, date) + ); + CREATE TABLE IF NOT EXISTS stock_weekly ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, + PRIMARY KEY (code, date) + ); + CREATE TABLE IF NOT EXISTS stock_monthly ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, + PRIMARY KEY (code, date) + ); + + -- 基本面 + CREATE TABLE IF NOT EXISTS stock_fundamentals ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + pe REAL, pb REAL, eps REAL, + mcap_total REAL, mcap_flow REAL, + updated_at TEXT + ); + + -- 板块成分映射 + CREATE TABLE IF NOT EXISTS stock_sectors ( + code TEXT NOT NULL REFERENCES stocks(code), + sector_name TEXT NOT NULL, + source TEXT DEFAULT 'ths', + updated_at TEXT DEFAULT (datetime('now','localtime')), + PRIMARY KEY (code, sector_name) + ); + CREATE INDEX IF NOT EXISTS idx_stock_sector ON stock_sectors(sector_name); + + -- 持仓 + CREATE TABLE IF NOT EXISTS holdings ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + shares INTEGER NOT NULL, + cost REAL, + price REAL, -- 当前价格 (CNY) + market_value REAL, -- 市值 = shares * price + change_pct REAL, -- 涨跌幅 + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + position_pct REAL, + added_at TEXT, + is_active INTEGER DEFAULT 1, + closed_at TEXT, + close_pnl REAL + ); + + -- 持仓策略(对应 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', + 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); + + -- 自选股 + CREATE TABLE IF NOT EXISTS watchlist_stocks ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + price REAL, -- 当前价格 + entry_low REAL, -- 买入区下限 + entry_high REAL, -- 买入区上限 + stop_loss REAL, -- 止损 + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + source TEXT, -- 来源: alpha_sift/xiaoguo/manual + source_detail TEXT, -- 来源详情 JSON + notes TEXT, -- 备注 + added_by TEXT, -- 谁加的 + added_at TEXT DEFAULT (datetime('now','localtime')), + is_active INTEGER DEFAULT 1, + analysis_json TEXT -- 分析结果 JSON + ); + + -- 候选池 + CREATE TABLE IF NOT EXISTS candidates ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + sector TEXT, + reason TEXT, + entry_range TEXT, + stop_loss REAL, + target REAL, + zhiwei_star REAL, + zhiwei_reviewed INTEGER DEFAULT 0, + zhiwei_reviewed_at TEXT, + promoted INTEGER DEFAULT 0, + promoted_at TEXT, + dropped INTEGER DEFAULT 0, + drop_reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 候选评分历史 + CREATE TABLE IF NOT EXISTS candidate_score_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES candidates(code), + score REAL NOT NULL, + source TEXT NOT NULL, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_candidate_history ON candidate_score_history(code, created_at); + + -- 价格事件 + CREATE TABLE IF NOT EXISTS price_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + name TEXT, + event_type TEXT NOT NULL, + price REAL, + trigger_value TEXT, + event_label TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')), + date TEXT + ); + CREATE INDEX IF NOT EXISTS idx_events_code ON price_events(code); + CREATE INDEX IF NOT EXISTS idx_events_date ON price_events(date); + + -- 策略评估记录 + CREATE TABLE IF NOT EXISTS strategy_evaluations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + eval_type TEXT NOT NULL, + status TEXT DEFAULT 'pending', + old_stop_loss REAL, + new_stop_loss REAL, + old_tp REAL, + new_tp REAL, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 持仓汇总(portfolio.json 顶层字段) + CREATE TABLE IF NOT EXISTS portfolio_summary ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total_assets REAL, + total_mv REAL, -- 持仓总市值 + stock_value REAL, + cash REAL, -- 可用现金 + frozen_cash REAL DEFAULT 0, -- 冻结资金 + position_pct REAL, + total_pnl REAL, + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + updated_at TEXT + ); + + -- 现金变更日志(每次买卖/出入金记录) + CREATE TABLE IF NOT EXISTS cash_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now','localtime')), + cash_before REAL, -- 变更前可用现金 + cash_after REAL, -- 变更后可用现金 + frozen_before REAL, -- 变更前冻结资金 + frozen_after REAL, -- 变更后冻结资金 + change_amount REAL, -- 现金变动额(正=入金/卖股,负=出金/买股) + source TEXT NOT NULL, -- 来源: screenshot/manual/import_xls/trade + note TEXT, -- 备注: 例如 "卖出法拉电子 200股" + verified INTEGER DEFAULT 0 -- 是否已验证(0=未验证,1=Dad确认) + ); + + -- 建议时间线(decisions.json advice_timeline[]) + CREATE TABLE IF NOT EXISTS advice_timeline ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT, + direction TEXT, + price REAL, + summary TEXT, + status TEXT, + evaluated INTEGER DEFAULT 0, + result TEXT, + evaluated_at TEXT, + report_id TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_advice_code ON advice_timeline(code); + + -- 准确率统计(accuracy_stats.json) + CREATE TABLE IF NOT EXISTS accuracy_stats ( + id INTEGER PRIMARY KEY CHECK (id = 1), + period_start TEXT, + period_end TEXT, + total_advice INTEGER DEFAULT 0, + correct INTEGER DEFAULT 0, + wrong INTEGER DEFAULT 0, + partial INTEGER DEFAULT 0, + unknown INTEGER DEFAULT 0, + pending INTEGER DEFAULT 0, + ignored INTEGER DEFAULT 0, + evaluated INTEGER DEFAULT 0, + accuracy_pct REAL, + phase1_correct INTEGER DEFAULT 0, + phase1_wrong INTEGER DEFAULT 0, + phase1_pending INTEGER DEFAULT 0, + phase1_accuracy REAL, + phase2_correct INTEGER DEFAULT 0, + phase2_wrong INTEGER DEFAULT 0, + phase2_pending INTEGER DEFAULT 0, + phase2_accuracy REAL, + total_evaluated INTEGER DEFAULT 0, + updated_at TEXT + ); + + -- 策略反馈(strategy_feedback.json) + CREATE TABLE IF NOT EXISTS strategy_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + name TEXT, + evaluated_at TEXT, + phase1_completed INTEGER DEFAULT 0, + phase1_result TEXT, + phase1_completed_at TEXT, + phase1_price REAL, + phase2_completed INTEGER DEFAULT 0, + phase2_result TEXT, + phase2_completed_at TEXT, + days_in_phase1 INTEGER, + adjustments_json TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_feedback_code ON strategy_feedback(code); + + -- 板块信号(trend_detector 产出) + CREATE TABLE IF NOT EXISTS sector_signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signal_type TEXT NOT NULL, + sector TEXT NOT NULL, + severity TEXT DEFAULT 'medium', + related_stocks TEXT, + holdings_in_sector TEXT, + watchlist_in_sector TEXT, + trigger_reason TEXT, + snapshot_id INTEGER, + processed INTEGER DEFAULT 0, + detected_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_signal_processed ON sector_signals(processed); + CREATE INDEX IF NOT EXISTS idx_signal_sector ON sector_signals(sector); + + -- 小果情报(xiaoguo_news_processor 产出) + CREATE TABLE IF NOT EXISTS signal_news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signal_id INTEGER REFERENCES sector_signals(id), + sector TEXT NOT NULL, + overall_sentiment TEXT, + summary TEXT, + key_articles TEXT, + searched_stocks TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_signal_news_signal ON signal_news(signal_id); + + -- 小果扫描跟踪(去重用) + CREATE TABLE IF NOT EXISTS xiaoguo_scan_tracker ( + code TEXT PRIMARY KEY, + name TEXT, + last_scanned_at TEXT, + found_count INTEGER DEFAULT 0 + ); + + -- 实时价格快照(替代 live_prices.json) + CREATE TABLE IF NOT EXISTS live_prices ( + code TEXT PRIMARY KEY, + price REAL, + change_pct REAL, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 多周期缓存(替代 multi_tf_cache.json) + CREATE TABLE IF NOT EXISTS mtf_cache ( + code TEXT PRIMARY KEY, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 资金流缓存(替代 capital_flow_cache.json) + CREATE TABLE IF NOT EXISTS capital_flow_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- Self-TODO 自动化任务表 + CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'pending', + priority TEXT DEFAULT 'medium', + source TEXT DEFAULT 'manual', + fix_action TEXT, + retry_count INTEGER DEFAULT 0, + note TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + conn.commit() + + # 迁移:给 signal_news 加 source 字段(幂等) + try: + conn.execute("ALTER TABLE signal_news ADD COLUMN source TEXT DEFAULT 'trend'") + except sqlite3.OperationalError: + pass + + # cash_log migration (2026-07-01) + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_before REAL") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_after REAL") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN verified INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass + + # ── 币种约束迁移(2026-06-30)──────────────────────────────── + _currency_migrations = [ + ("holdings", ["price REAL", "market_value REAL", "change_pct REAL", + "currency TEXT NOT NULL DEFAULT 'CNY'"]), + ("holding_strategies", ["name TEXT", "price REAL", "cost REAL", "shares INTEGER DEFAULT 0", + "currency TEXT NOT NULL DEFAULT 'CNY'", + "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", + "updated_at TEXT"]), + ("portfolio_summary", ["total_mv REAL", "frozen_cash REAL DEFAULT 0", + "currency TEXT NOT NULL DEFAULT 'CNY'"]), + ("watchlist_stocks", ["price REAL", "entry_low REAL", "entry_high REAL", + "stop_loss REAL", "currency TEXT NOT NULL DEFAULT 'CNY'", + "source TEXT", "source_detail TEXT", "notes TEXT", + "added_by TEXT", "analysis_json TEXT"]), + ] + for table, columns in _currency_migrations: + for col_def in columns: + col_name = col_def.split()[0] + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col_def}") + except sqlite3.OperationalError: + pass # column already exists + conn.commit() + + +# ═══════════════════════════════════════════════════════════ +# 市场快照写入 +# ═══════════════════════════════════════════════════════════ + +def write_market_snapshot(conn: sqlite3.Connection, market_data: dict) -> tuple[bool, str, Optional[int]]: + """写入一次市场采集到 market_snapshots + sector_snapshots + + Returns: (ok, message, snapshot_id) + """ + try: + cur = conn.execute( + "INSERT INTO market_snapshots (timestamp, source, up_ratio, mood) VALUES (?, ?, ?, ?)", + (market_data["timestamp"], market_data.get("source", "unknown"), + market_data.get("up_ratio", 0), market_data.get("mood", "unknown")), + ) + sid = cur.lastrowid + + sectors = market_data.get("sectors", []) + rows = [(sid, s.get("name", ""), s.get("change", 0), + s.get("up_count"), s.get("down_count"), s.get("net_inflow"), + s.get("lead_stock"), s.get("lead_stock_change"), + s.get("volume"), s.get("turnover")) for s in sectors] + if rows: + conn.executemany( + "INSERT INTO sector_snapshots (snapshot_id, name, change_pct, up_count, down_count, " + "net_inflow, lead_stock, lead_stock_change, volume, turnover) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows) + conn.commit() + return True, f"snapshot_id={sid}, sectors={len(rows)}", sid + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False, str(e), None + + +# ═══════════════════════════════════════════════════════════ +# K线写入 +# ═══════════════════════════════════════════════════════════ + +def write_klines(conn: sqlite3.Connection, code: str, name: str, + daily: list = None, weekly: list = None, monthly: list = None, + fundamentals: dict = None) -> bool: + """将个股K线数据双写 SQLite + + Args: + code: 股票代码 + name: 股票名称 + daily/weekly/monthly: [{date, open, close, high, low, volume}, ...] + fundamentals: {pe, pb, eps, mcap_total, mcap_flow} + """ + try: + # 判断交易所 + raw = str(code) + if len(raw) == 5 and raw.isdigit(): + exchange, stype = "HK", "H" + elif raw.startswith(("6", "5", "9")): + exchange, stype = "SH", "A" + else: + exchange, stype = "SZ", "A" + + # stocks 表(INSERT OR REPLACE) + conn.execute( + "INSERT OR REPLACE INTO stocks (code, name, exchange, type, updated_at) VALUES (?, ?, ?, ?, ?)", + (code, name, exchange, stype, datetime.now().isoformat())) + + # K线数据 + for period, table, data in [ + ("daily", "stock_daily", daily), + ("weekly", "stock_weekly", weekly), + ("monthly", "stock_monthly", monthly), + ]: + if not data: + continue + rows = [(code, d.get("date", ""), d.get("open"), d.get("close"), + d.get("high"), d.get("low"), d.get("volume"), + d.get("amount") if period == "daily" else None) for d in data] + if period == "daily": + conn.executemany( + f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume, amount) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows) + else: + conn.executemany( + f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + [(r[0], r[1], r[2], r[3], r[4], r[5], r[6]) for r in rows]) + + # 基本面 + if fundamentals: + conn.execute( + "INSERT OR REPLACE INTO stock_fundamentals (code, pe, pb, eps, mcap_total, mcap_flow, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (code, fundamentals.get("pe"), fundamentals.get("pb"), + fundamentals.get("eps"), fundamentals.get("mcap_total"), + fundamentals.get("mcap_flow"), datetime.now().isoformat())) + + conn.commit() + return True + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False + + +# ═══════════════════════════════════════════════════════════ +# 价格事件写入 +# ═══════════════════════════════════════════════════════════ + +def write_price_event(conn: sqlite3.Connection, code: str, name: str, + event_type: str, price: float, trigger_value: str, + event_label: str = "") -> bool: + """写入一条价格事件""" + try: + now = datetime.now() + conn.execute( + "INSERT INTO price_events (code, name, event_type, price, trigger_value, event_label, date) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (code, name, event_type, round(price, 2), trigger_value, + event_label, now.strftime("%Y-%m-%d"))) + conn.commit() + return True + except Exception: + try: + conn.rollback() + except Exception: + pass + return False + + +# ═══════════════════════════════════════════════════════════ +# 板块成分迁移 +# ═══════════════════════════════════════════════════════════ + +def migrate_stock_sectors(conn: sqlite3.Connection) -> tuple[int, int]: + """从 stock_sector_map.json 迁移到 stock_sectors 表 + + Returns: (migrated_stocks, total_mappings) + """ + sector_map_path = DATA_DIR / "stock_sector_map.json" + if not sector_map_path.exists(): + return 0, 0 + + try: + with open(sector_map_path, encoding="utf-8") as f: + data = json.load(f) + except Exception: + return 0, 0 + + # 过滤元数据字段 + mappings = [(code, sectors) for code, sectors in data.items() + if not code.startswith("_") and isinstance(sectors, list)] + + total = 0 + for code, sectors in mappings: + for sector in sectors: + try: + conn.execute( + "INSERT OR IGNORE INTO stock_sectors (code, sector_name, source) VALUES (?, ?, 'ths')", + (code, sector)) + total += 1 + except Exception: + pass + conn.commit() + return len(mappings), total + + +# ═══════════════════════════════════════════════════════════ +# 查询辅助 +# ═══════════════════════════════════════════════════════════ + +def query_sector_trend(conn: sqlite3.Connection, name: str, limit: int = 5) -> list[dict]: + """板块最近N次趋势""" + rows = conn.execute(""" + SELECT s.timestamp, ss.change_pct, ss.net_inflow, + ss.up_count, ss.down_count, ss.lead_stock, ss.lead_stock_change + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE ss.name = ? ORDER BY s.timestamp DESC LIMIT ? + """, (name, limit)).fetchall() + return [dict(r) for r in rows] + + +def query_top_inflow(conn: sqlite3.Connection, limit: int = 5) -> list[dict]: + """最新一次资金净流入排行""" + rows = conn.execute(""" + SELECT ss.name, ss.change_pct, ss.net_inflow, ss.lead_stock, s.timestamp + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE s.id = (SELECT MAX(id) FROM market_snapshots) + AND ss.net_inflow IS NOT NULL + ORDER BY ss.net_inflow DESC LIMIT ? + """, (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_consecutive_inflow(conn: sqlite3.Connection, days: int = 3) -> list[dict]: + """连续N次净流入的板块""" + rows = conn.execute(""" + SELECT name, COUNT(*) as times, ROUND(AVG(net_inflow), 2) as avg_inflow, + ROUND(AVG(change_pct), 2) as avg_change + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE s.id > (SELECT MAX(id) - ? FROM market_snapshots) + AND net_inflow > 0 + GROUP BY name HAVING COUNT(*) >= ? + ORDER BY avg_inflow DESC + """, (days, days)).fetchall() + return [dict(r) for r in rows] + + +def query_market_mood(conn: sqlite3.Connection, limit: int = 10) -> list[dict]: + """市场情绪趋势""" + rows = conn.execute(""" + SELECT timestamp, source, up_ratio, mood + FROM market_snapshots ORDER BY timestamp DESC LIMIT ? + """, (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_db_stats(conn: sqlite3.Connection) -> dict: + """数据库概览""" + snap_count = conn.execute("SELECT COUNT(*) FROM market_snapshots").fetchone()[0] + sector_count = conn.execute("SELECT COUNT(*) FROM sector_snapshots").fetchone()[0] + stock_count = conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0] + kline_count = conn.execute("SELECT COUNT(*) FROM stock_daily").fetchone()[0] + event_count = conn.execute("SELECT COUNT(*) FROM price_events").fetchone()[0] + holding_count = conn.execute("SELECT COUNT(*) FROM holdings").fetchone()[0] + candidate_count = conn.execute("SELECT COUNT(*) FROM candidates").fetchone()[0] + latest = conn.execute( + "SELECT timestamp, source FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + return { + "snapshots": snap_count, "sector_rows": sector_count, + "stocks": stock_count, "daily_klines": kline_count, + "price_events": event_count, "holdings": holding_count, + "candidates": candidate_count, + "latest_snapshot": dict(latest) if latest else None, + } + + +# ═══════════════════════════════════════════════════════════ +# 持仓查询 +# ═══════════════════════════════════════════════════════════ + +def query_holdings(conn: sqlite3.Connection) -> list[dict]: + """持仓列表(含最新策略)""" + rows = conn.execute(""" + SELECT h.code, h.name, h.shares, h.cost, h.position_pct, h.is_active, + h.price, h.change_pct, h.currency, + hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action, hs.created_at as strategy_updated + FROM holdings h + LEFT JOIN holding_strategies hs ON h.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') + WHERE h.is_active = 1 + """).fetchall() + return [dict(r) for r in rows] + + +def query_holding_by_code(conn: sqlite3.Connection, code: str) -> dict | None: + """单只持仓""" + row = conn.execute(""" + SELECT h.*, hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action + FROM holdings h + LEFT JOIN holding_strategies hs ON h.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') + WHERE h.code = ? + """, (code,)).fetchone() + return dict(row) if row else None + + +def query_portfolio_summary(conn: sqlite3.Connection) -> dict: + """持仓汇总""" + row = conn.execute("SELECT * FROM portfolio_summary WHERE id = 1").fetchone() + return dict(row) if row else {} + + +# ═══════════════════════════════════════════════════════════ +# 自选股查询 +# ═══════════════════════════════════════════════════════════ + +def query_watchlist(conn: sqlite3.Connection) -> list[dict]: + """自选股列表(含策略)""" + rows = conn.execute(""" + SELECT w.code, w.name, w.added_at, + hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action + FROM watchlist_stocks w + LEFT JOIN holding_strategies hs ON w.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = w.code AND strategy_type = 'watch') + WHERE w.is_active = 1 + """).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 决策/策略查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategies(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略列表(按版本倒序)""" + if code: + rows = conn.execute( + "SELECT * FROM holding_strategies WHERE code = ? ORDER BY version DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM holding_strategies ORDER BY code, version DESC").fetchall() + return [dict(r) for r in rows] + + +def query_advice_timeline(conn: sqlite3.Connection, code: str = None, limit: int = 50) -> list[dict]: + """建议时间线""" + if code: + rows = conn.execute( + "SELECT * FROM advice_timeline WHERE code = ? ORDER BY date DESC LIMIT ?", + (code, limit)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM advice_timeline ORDER BY date DESC LIMIT ?", (limit,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 候选池查询 +# ═══════════════════════════════════════════════════════════ + +def query_candidates(conn: sqlite3.Connection, active_only: bool = True) -> list[dict]: + """候选池列表(含最新评分)""" + where = "WHERE c.dropped = 0" if active_only else "" + rows = conn.execute(f""" + SELECT c.*, (SELECT score FROM candidate_score_history + WHERE code = c.code ORDER BY created_at DESC LIMIT 1) as latest_score + FROM candidates c {where} + ORDER BY c.zhiwei_star DESC NULLS LAST + """).fetchall() + return [dict(r) for r in rows] + + +def query_candidate_scores(conn: sqlite3.Connection, code: str) -> list[dict]: + """某候选的评分历史""" + rows = conn.execute( + "SELECT * FROM candidate_score_history WHERE code = ? ORDER BY created_at", + (code,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 价格事件查询 +# ═══════════════════════════════════════════════════════════ + +def query_price_events(conn: sqlite3.Connection, code: str = None, limit: int = 100) -> list[dict]: + """价格事件""" + if code: + rows = conn.execute( + "SELECT * FROM price_events WHERE code = ? ORDER BY created_at DESC LIMIT ?", + (code, limit)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM price_events ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_price_events_by_date(conn: sqlite3.Connection, date: str) -> list[dict]: + """某天的价格事件""" + rows = conn.execute( + "SELECT * FROM price_events WHERE date = ? ORDER BY created_at DESC", (date,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 板块成分查询 +# ═══════════════════════════════════════════════════════════ + +def query_stock_sectors(conn: sqlite3.Connection, code: str) -> list[str]: + """某只股票所属板块""" + rows = conn.execute( + "SELECT sector_name FROM stock_sectors WHERE code = ?", (code,)).fetchall() + return [r[0] for r in rows] + + +def query_sector_stocks(conn: sqlite3.Connection, sector_name: str) -> list[str]: + """某板块包含的股票""" + rows = conn.execute( + "SELECT code FROM stock_sectors WHERE sector_name = ?", (sector_name,)).fetchall() + return [r[0] for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 准确率统计查询 +# ═══════════════════════════════════════════════════════════ + +def query_accuracy_stats(conn: sqlite3.Connection) -> dict: + """准确率统计""" + row = conn.execute("SELECT * FROM accuracy_stats WHERE id = 1").fetchone() + return dict(row) if row else {} + + +# ═══════════════════════════════════════════════════════════ +# 策略反馈查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategy_feedback(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略反馈""" + if code: + rows = conn.execute( + "SELECT * FROM strategy_feedback WHERE code = ? ORDER BY evaluated_at DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM strategy_feedback ORDER BY evaluated_at DESC").fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 策略评估查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategy_evaluations(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略评估记录""" + if code: + rows = conn.execute( + "SELECT * FROM strategy_evaluations WHERE code = ? ORDER BY created_at DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM strategy_evaluations ORDER BY created_at DESC").fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 市场快照查询(最新) +# ═══════════════════════════════════════════════════════════ + +def query_latest_market(conn: sqlite3.Connection) -> dict: + """获取最新一次市场快照(含 sector 详情)""" + row = conn.execute( + "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if not row: + return {} + snap = dict(row) + # 关联 sectors + sectors = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", + (snap["id"],)).fetchall() + snap["sectors"] = [dict(r) for r in sectors] + snap["top_gainers"] = [dict(r) for r in sectors[:5]] + snap["top_losers"] = [dict(r) for r in sectors[-3:]] + return snap + + +# ═══════════════════════════════════════════════════════════════════ +# 通用工具 +# ═══════════════════════════════════════════════════════════════════ + +def get_price_from_db(code: str) -> tuple[float | None, float | None]: + """从 DB 读取最新价格(price_monitor 维护)。 + 返回 (price, change_pct) 或 (None, None) + + 所有脚本应优先调用此函数,DB 无数据时才拉腾讯 API。 + """ + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute( + "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),) + ).fetchone() + if not row: + row = db.execute( + "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) + ).fetchone() + db.close() + if row: + return (row['price'], row['change_pct'] if 'change_pct' in row.keys() else None) + except Exception: + pass + return (None, None) + + +def get_prices_batch_from_db(codes: list[str]) -> dict: + """从 DB 批量读取价格。返回 {code: (price, change_pct)}""" + results = {} + if not codes: + return results + 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", (str(code),) + ).fetchone() + if not row: + row = db.execute( + "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) + ).fetchone() + if row and row['price']: + results[str(code)] = (row['price'], row['change_pct'] if 'change_pct' in row.keys() else 0) + db.close() + except Exception: + pass + return results + """最新一次市场快照(含板块数据)""" + snap = conn.execute( + "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if not snap: + return {} + snap = dict(snap) + sectors = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", + (snap["id"],)).fetchall() + snap["sectors"] = [dict(r) for r in sectors] + # 计算 top_gainers / top_losers + snap["top_gainers"] = [dict(r) for r in sectors[:5]] + snap["top_losers"] = [dict(r) for r in sectors[-3:]] + return snap + + +# ═══════════════════════════════════════════════════════════════════ +# 核心写函数 — 替代 json.dump(),强制币种约束 +# ═══════════════════════════════════════════════════════════════════ + +def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool, str]: + """写入持仓策略(替代 decisions.json 单条写入)。data 必须包含 currency。""" + try: + currency = data.get('currency', 'CNY') + # 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 + (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, 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'), + data.get('shares', 0), data.get('stop_loss'), data.get('take_profit'), + data.get('entry_low'), data.get('entry_high'), currency, + data.get('strategy_type', 'holding'), data.get('action'), + data.get('timing_signal'), data.get('rr_ratio'), + data.get('tech_snapshot'), data.get('stock_category'), + data.get('sector_context'), data.get('status', 'active'), + 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} 已写入" + except sqlite3.IntegrityError as e: + return False, f"币种约束: {e}" + except Exception as e: + return False, str(e) + + +def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]: + """批量写入持仓(替代 portfolio.json holdings[])""" + try: + conn.execute("BEGIN IMMEDIATE") + for h in holdings: + currency = str(h.get('currency', 'CNY')).upper() + if currency not in ('CNY', 'HKD'): + return False, f"非法币种: {currency}(必须 CNY 或 HKD)" + conn.execute(""" + INSERT INTO holdings (code, name, shares, cost, price, market_value, + change_pct, currency, position_pct, added_at, is_active) + VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, shares=excluded.shares, cost=excluded.cost, + price=excluded.price, market_value=excluded.market_value, + change_pct=excluded.change_pct, currency=excluded.currency, + position_pct=excluded.position_pct + """, ( + h.get('code'), h.get('name'), h.get('shares', 0), + h.get('cost'), h.get('price'), + h.get('market_value'), h.get('change_pct'), + h.get('currency', 'CNY'), h.get('position_pct'), + )) + conn.commit() + return True, f"已写入 {len(holdings)} 条持仓" + except sqlite3.IntegrityError as e: + conn.rollback() + return False, f"币种约束: {e}" + except sqlite3.OperationalError as e: + return False, f"DB锁冲突(重试耗尽): {e}" +def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]: + """写入持仓汇总(替代 portfolio.json 顶层)""" + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute(""" + INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value, + cash, frozen_cash, position_pct, total_pnl, currency, updated_at) + VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(id) DO UPDATE SET + total_assets=excluded.total_assets, total_mv=excluded.total_mv, + stock_value=excluded.stock_value, cash=excluded.cash, + frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct, + total_pnl=excluded.total_pnl, currency=excluded.currency, + updated_at=datetime('now','localtime') + """, ( + data.get('total_assets'), data.get('total_mv'), data.get('stock_value'), + data.get('cash'), data.get('frozen_cash', 0), data.get('position_pct'), + data.get('total_pnl'), data.get('currency', 'CNY'), + )) + conn.commit() + return True, "汇总已写入" + except sqlite3.IntegrityError as e: + return False, f"约束: {e}" + except sqlite3.OperationalError as e: + return False, f"DB锁冲突: {e}" + + +def write_watchlist_stock(conn, stock: dict) -> tuple[bool, str]: + """写入自选股(替代 watchlist.json)""" + try: + conn.execute(""" + INSERT INTO watchlist_stocks (code, name, price, entry_low, entry_high, + stop_loss, currency, source, source_detail, notes, added_by, added_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, price=excluded.price, entry_low=excluded.entry_low, + entry_high=excluded.entry_high, stop_loss=excluded.stop_loss, + currency=excluded.currency, source=excluded.source, + source_detail=excluded.source_detail, notes=excluded.notes, + added_by=excluded.added_by + """, ( + stock.get('code'), stock.get('name'), stock.get('price'), + stock.get('entry_low'), stock.get('entry_high'), stock.get('stop_loss'), + stock.get('currency', 'CNY'), stock.get('source'), stock.get('source_detail'), + stock.get('notes'), stock.get('added_by'), + )) + conn.commit() + return True, f"自选 {stock.get('code')} 已写入" + except sqlite3.IntegrityError as e: + return False, f"约束: {e}" + + +def write_cash_log(conn, data: dict) -> tuple[bool, str]: + """记录现金变更(替代手动改 portfolio.json cash 字段)""" + try: + conn.execute(""" + INSERT INTO cash_log (cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note) + VALUES (?,?,?,?,?,?,?) + """, ( + data.get('cash_before'), data.get('cash_after'), + data.get('frozen_before'), data.get('frozen_after'), + data.get('change_amount'), data.get('source', 'manual'), + data.get('note', ''), + )) + conn.commit() + return True, "现金变更已记录" + except Exception as e: + return False, str(e) + + +def query_cash_log(conn, limit: int = 20) -> list[dict]: + rows = conn.execute( + "SELECT * FROM cash_log ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +# ═══ live_prices / mtf_cache / capital_flow_cache 写函数 ═══ + +def write_live_prices(conn, prices: dict): + """写入实时价格快照(替代 live_prices.json)""" + import json + for code, info in prices.items(): + conn.execute( + "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) VALUES (?,?,?,datetime('now','localtime'))", + (code, info.get('price'), info.get('change_pct')) + ) + +def read_live_prices(conn) -> dict: + rows = conn.execute("SELECT code, price, change_pct FROM live_prices").fetchall() + return {r['code']: {'price': r['price'], 'change_pct': r['change_pct']} for r in rows} + + +def write_mtf_cache(conn, code: str, data: dict): + """写入多周期缓存(替代 multi_tf_cache.json 单条)""" + import json + conn.execute( + "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", + (code, json.dumps(data, ensure_ascii=False)) + ) + +def read_mtf_cache(conn, code: str) -> dict: + import json + r = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() + return json.loads(r['cache_json']) if r else {} + + +def write_capital_flow_cache(conn, data: dict): + """写入资金流缓存(替代 capital_flow_cache.json)""" + import json + conn.execute("DELETE FROM capital_flow_cache") + conn.execute( + "INSERT INTO capital_flow_cache (cache_json, updated_at) VALUES (?,datetime('now','localtime'))", + (json.dumps(data, ensure_ascii=False),) + ) + +def read_capital_flow_cache(conn) -> dict: + import json + r = conn.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() + return json.loads(r['cache_json']) if r else {} diff --git a/scripts/mofin_news.py b/scripts/mofin_news.py new file mode 100644 index 0000000..4da6ed7 --- /dev/null +++ b/scripts/mofin_news.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""mofin_news.py — 新闻采集(no_agent,无需LLM) + +读未处理的 sector_signals,用 akshare 搜相关新闻, +去重后写入 signal_news 供知微分析。 +""" + +import json +import os +import re +from pathlib import Path + +try: + import akshare as ak + HAS_AKSHARE = True +except ImportError: + HAS_AKSHARE = False + +DATA_DIR = Path(__file__).parent / "data" +DB_PATH = DATA_DIR / "mofin.db" +MAX_ARTICLES = 5 + + +def clean_proxy(): + for k in ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY']: + os.environ.pop(k, None) + + +def get_conn(): + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def search_akshare_news(code, max_results=3): + """用 akshare 搜个股新闻(含全文)""" + articles = [] + if not HAS_AKSHARE: + return articles + try: + clean_proxy() + df = ak.stock_news_em(symbol=code) + for _, r in df.head(max_results).iterrows(): + title = r.get('新闻标题', '') + content = r.get('新闻内容', '') + if title and len(title) > 5: + articles.append({ + "title": title, + "content": content, + "url": r.get('新闻链接', ''), + }) + except: + pass + return articles + + +def main(): + conn = get_conn() + signals = conn.execute( + "SELECT * FROM sector_signals WHERE processed = 0 ORDER BY severity DESC, id ASC LIMIT 1" + ).fetchall() + + if not signals: + print("无未处理的信号", flush=True) + conn.close() + return + + signal = dict(signals[0]) + sector = signal["sector"] + related = json.loads(signal["related_stocks"] or "[]") + holdings = json.loads(signal["holdings_in_sector"] or "[]") + watchlist = json.loads(signal["watchlist_in_sector"] or "[]") + + print(f"处理信号: [{signal['severity']}] {signal['signal_type']} {sector}", flush=True) + + codes = {} + for item in related + holdings + watchlist: + if item.get("code"): + codes[item["code"]] = item.get("name", "") + + members = conn.execute( + "SELECT s.code, s.name FROM stocks s JOIN stock_sectors ss ON s.code=ss.code WHERE ss.sector_name=? LIMIT 5", + (sector,) + ).fetchall() + for m in members: + if m["code"] not in codes: + codes[m["code"]] = m["name"] + + all_articles = [] + for code, name in codes.items(): + arts = search_akshare_news(code, 3) + for a in arts: + if a["title"] not in [x["title"] for x in all_articles]: + all_articles.append(a) + print(f" 搜 {name}({code}): {len(arts)} 篇", flush=True) + + if not all_articles: + print(" 未搜到新闻", flush=True) + conn.execute("UPDATE sector_signals SET processed=1 WHERE id=?", (signal["id"],)) + conn.commit() + conn.close() + return + + # 过滤脏数据,取前5篇 + filtered = [] + for a in all_articles: + c = a.get('content', '') or '' + if any(kw in c for kw in ['主力资金', '资金净流入', '代码', '简称']): + continue + filtered.append(a) + if len(filtered) >= MAX_ARTICLES: + break + batch = filtered[:MAX_ARTICLES] + print(f" 共{len(all_articles)}篇,采集{len(batch)}篇,交由知微分析", flush=True) + + searched_names = list(set(codes.values())) + conn.execute( + "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks) VALUES (?, ?, ?, ?, ?, ?)", + (signal["id"], sector, "待知微判断", "", json.dumps(batch, ensure_ascii=False), json.dumps(searched_names, ensure_ascii=False)) + ) + conn.execute("UPDATE sector_signals SET processed=1 WHERE id=?", (signal["id"],)) + conn.commit() + print(f" 完成: {len(batch)} 篇新闻已入库,等知微分析", flush=True) + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/mofin_query.py b/scripts/mofin_query.py new file mode 100644 index 0000000..560664c --- /dev/null +++ b/scripts/mofin_query.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""mofin_query.py — MoFin 数据库通用查询工具 + +用法: + python3 mofin_query.py "半导体最近5次采集的涨跌幅" + python3 mofin_query.py "今天资金净流入最多的5个板块" + python3 mofin_query.py "最近3天连续净流入的板块" + python3 mofin_query.py "市场情绪趋势(最近10次)" + python3 mofin_query.py "数据库概览" +""" + +import sys +import re +from mofin_db import (get_conn, query_sector_trend, query_top_inflow, + query_consecutive_inflow, query_market_mood, query_db_stats) + + +def _print_sector_trend(name: str, limit: int = 5): + conn = get_conn() + rows = query_sector_trend(conn, name, limit) + conn.close() + if not rows: + print(f"未找到板块「{name}」的数据") + return + print(f"\n{'='*60}") + print(f" {name} 板块 — 最近 {len(rows)} 次采集") + print(f"{'='*60}") + print(f"{'时间':<20} {'涨跌幅%':>8} {'净流入(亿)':>10} {'上涨':>6} {'下跌':>6} {'领涨股':>10}") + print(f"{'-'*20} {'-'*8} {'-'*10} {'-'*6} {'-'*6} {'-'*10}") + for r in reversed(rows): + print(f"{r['timestamp']:<20} {r['change_pct']:>8.2f} {r['net_inflow']:>10.2f} " + f"{r['up_count'] or '-':>6} {r['down_count'] or '-':>6} {r['lead_stock'] or '-':>10}") + + +def _print_top_inflow(limit: int = 5): + conn = get_conn() + rows = query_top_inflow(conn, limit) + conn.close() + if not rows: + print("暂无数据") + return + print(f"\n{'='*60}") + print(f" 资金净流入 Top {len(rows)}({rows[0]['timestamp']})") + print(f"{'='*60}") + print(f"{'板块':<12} {'涨跌幅%':>8} {'净流入(亿)':>10} {'领涨股':>10}") + print(f"{'-'*12} {'-'*8} {'-'*10} {'-'*10}") + for r in rows: + print(f"{r['name']:<12} {r['change_pct']:>8.2f} {r['net_inflow']:>10.2f} {r['lead_stock'] or '-':>10}") + + +def _print_consecutive_inflow(days: int = 3): + conn = get_conn() + rows = query_consecutive_inflow(conn, days) + conn.close() + if not rows: + print(f"没有板块连续 {days} 次净流入") + return + print(f"\n{'='*60}") + print(f" 连续 {days} 次净流入的板块") + print(f"{'='*60}") + print(f"{'板块':<12} {'次数':>4} {'均净流入(亿)':>12} {'均涨跌幅%':>10}") + print(f"{'-'*12} {'-'*4} {'-'*12} {'-'*10}") + for r in rows: + print(f"{r['name']:<12} {r['times']:>4} {r['avg_inflow']:>12.2f} {r['avg_change']:>10.2f}") + + +def _print_market_mood(limit: int = 10): + conn = get_conn() + rows = query_market_mood(conn, limit) + conn.close() + if not rows: + print("暂无数据") + return + print(f"\n{'='*60}") + print(f" 市场情绪趋势 — 最近 {len(rows)} 次") + print(f"{'='*60}") + print(f"{'时间':<20} {'来源':>10} {'上涨占比%':>10} {'情绪':>10}") + print(f"{'-'*20} {'-'*10} {'-'*10} {'-'*10}") + for r in reversed(rows): + mood_emoji = {"bullish": "🟢", "neutral": "🟡", "bearish": "🔴"}.get(r['mood'], "⚪") + print(f"{r['timestamp']:<20} {r['source']:>10} {r['up_ratio']:>10.1f} {mood_emoji} {r['mood']:>8}") + + +def _print_stats(): + conn = get_conn() + stats = query_db_stats(conn) + conn.close() + print(f"\n{'='*40}") + print(f" MoFin 数据库概览") + print(f"{'='*40}") + print(f" 采集次数: {stats['snapshots']}") + print(f" 板块快照: {stats['sector_rows']}") + print(f" 个股数量: {stats['stocks']}") + print(f" 日K线数: {stats['daily_klines']}") + print(f" 价格事件: {stats['price_events']}") + ls = stats.get('latest_snapshot') + if ls: + print(f" 最新采集: {ls['timestamp']} ({ls['source']})") + else: + print(f" 最新采集: 暂无") + + +def route(query: str): + q = query.strip() + if "最近" in q and "次" in q and ("涨跌" in q or "趋势" in q or "采集" in q): + names = re.findall(r'["「]([^"」]+)["」]', q) + if not names: + for word in ["半导体", "银行", "医药", "新能源", "白酒", "军工", "芯片", "房地产", "汽车"]: + if word in q: + names = [word]; break + if names: + limit = 5 + m = re.search(r'(\d+)\s*次', q) + if m: limit = int(m.group(1)) + _print_sector_trend(names[0], limit) + return + if "净流入" in q and ("最多" in q or "排行" in q or "top" in q.lower()): + limit = 5 + m = re.search(r'(\d+)', q) + if m: limit = int(m.group(1)) + _print_top_inflow(limit) + return + if "连续" in q and "净流入" in q: + days = 3 + m = re.search(r'(\d+)\s*天', q) + if m: days = int(m.group(1)) + _print_consecutive_inflow(days) + return + if "情绪" in q or "mood" in q.lower(): + limit = 10 + m = re.search(r'(\d+)\s*次', q) + if m: limit = int(m.group(1)) + _print_market_mood(limit) + return + if "概览" in q or "统计" in q or "stats" in q.lower(): + _print_stats() + return + if q.upper().strip().startswith("SELECT"): + conn = get_conn() + try: + rows = conn.execute(q).fetchall() + if rows: + cols = [d[0] for d in conn.execute(q + " LIMIT 0").description] + print("\t".join(cols)) + for r in rows: + print("\t".join(str(c) for c in r)) + else: + print("(empty)") + except Exception as e: + print(f"SQL 错误: {e}") + finally: + conn.close() + return + print(f"未识别的查询: {q}\n") + print("支持的查询模式:") + print(" 「半导体」最近5次采集的涨跌幅") + print(" 今天资金净流入最多的5个板块") + print(" 最近3天连续净流入的板块") + print(" 市场情绪趋势(最近10次)") + print(" 数据库概览") + print(" SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 5") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("用法: python3 mofin_query.py \"查询语句\"") + print("示例: python3 mofin_query.py \"半导体最近5次采集的涨跌幅\"") + sys.exit(1) + route(sys.argv[1]) diff --git a/scripts/multi_timeframe.py b/scripts/multi_timeframe.py new file mode 100644 index 0000000..69f4291 --- /dev/null +++ b/scripts/multi_timeframe.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +"""multi_timeframe.py — 多周期技术分析模块 + +从腾讯API获取日/周/月K线数据,计算: +- 多周期支撑压力位(日线/周线/月线) +- 移动均线(MA5/10/20/60) +- 趋势方向判断(上升/下降/震荡) +- 综合策略调整建议 + +集成到 strategy_lifecycle.py 中使用。 +""" + +import json +import os +import urllib.request +import urllib.error +from datetime import datetime, date, timedelta +from typing import Optional + +DATA_DIR = "/home/hmo/web-dashboard/data" +HISTORY_PATH = os.path.join(DATA_DIR, "price_history.json") +# multi_tf_cache.json 已迁移到 DB (mtf_cache 表) + +# 腾讯API K线端点 +KLINE_URL = "http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={market}{code},{period},,,{count},qfq" + +# 腾讯实时行情端点(用于市场前缀判断) +QUOTE_URL = "http://qt.gtimg.cn/q={market}{code}" + + +def _write_klines_to_db(code: str, daily: list, weekly: list, monthly: list, fundamentals: dict = None): + """K线数据双写 SQLite(失败不影响缓存写入)""" + try: + from mofin_db import get_conn, init_all_tables, write_klines + conn = get_conn() + init_all_tables(conn) + # 从 stock_profiles.json 获取名称 + name = code + try: + import json + profiles_path = os.path.join(DATA_DIR, "stock_profiles.json") + if os.path.exists(profiles_path): + with open(profiles_path, encoding="utf-8") as f: + profiles = json.load(f) + for p in profiles.get("profiles", []): + if p.get("code") == code: + name = p.get("name", code) + break + except Exception: + pass + write_klines(conn, code, name, daily, weekly, monthly, fundamentals) + conn.close() + except Exception: + pass # SQLite 写入失败不影响主流程 + + +def _market_prefix(code: str) -> str: + """根据代码确定市场前缀""" + raw = str(code).split("_")[0] + # 指数代码:sh/sz/hk开头 + if raw.startswith("sh"): + return "sh" + if raw.startswith("sz"): + return "sz" + if raw.startswith("hk"): + return "hk" + if len(raw) == 5 and raw.isdigit(): + return "hk" + if raw.startswith("6") or raw.startswith("5"): + return "sh" + return "sz" + + +def _user_agent() -> dict: + return { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + } + + +# 多周期缓存TTL(秒):日K线1小时,周/月K线1天 +_KLINE_CACHE_TTL = {"day": 3600, "week": 86400, "month": 86400} + +# 模块级缓存:避免每次 fetch_kline 都重新读 DB +_MTF_CACHE_DATA = None +_MTF_CACHE_DIRTY = False + + +def _load_mtf_cache(): + """从 DB 加载多周期缓存""" + global _MTF_CACHE_DATA + if _MTF_CACHE_DATA is not None: + return _MTF_CACHE_DATA + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + rows = db.execute("SELECT code, cache_json FROM mtf_cache").fetchall() + _MTF_CACHE_DATA = {} + for code, json_str in rows: + try: + _MTF_CACHE_DATA[code] = json.loads(json_str) + except: + pass + db.close() + except Exception: + _MTF_CACHE_DATA = {} + return _MTF_CACHE_DATA + + +def _save_mtf_cache(): + """将模块级缓存写回 DB""" + global _MTF_CACHE_DATA + if _MTF_CACHE_DATA is None: + return + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + for code, data in _MTF_CACHE_DATA.items(): + db.execute( + "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", + (code, json.dumps(data, ensure_ascii=False)) + ) + db.commit() + db.close() + except Exception: + pass + + +def fetch_kline(code: str, period: str = "day", count: int = 120) -> list: + """从腾讯API获取K线数据,优先使用本地缓存 + + Args: + code: 股票代码 (如 "300548") + period: "day" / "week" / "month" + count: 需要多少条 + + Returns: + list of dict: [{"date":str, "open":float, "close":float, + "high":float, "low":float, "volume":float}, ...] + """ + import time + now = time.time() + + # 优先检查本地缓存(模块级,避免重复读盘) + # 注意:缓存中存储的key是'daily'/'weekly'/'monthly',参数period是'day'/'week'/'month' + _PERIOD_MAP = {"day": "daily", "week": "weekly", "month": "monthly"} + cache_data = _load_mtf_cache() + cached = cache_data.get(code, {}) + cache_key = _PERIOD_MAP.get(period, period) + cached_klines = cached.get(cache_key, cached.get(period, [])) + updated_at = cached.get("updated_at", 0) + if cached_klines and updated_at and (now - updated_at) < _KLINE_CACHE_TTL.get(period, 3600): + return cached_klines + + market = _market_prefix(code) + is_index = any(code.startswith(p) for p in ["sh", "sz", "hk"]) + + # 指数代码已经自带前缀,API直接用code;普通股票需要加market前缀 + api_code = code if is_index else f"{market}{code}" + url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={api_code},{period},,,{count},qfq" + + try: + req = urllib.request.Request(url, headers=_user_agent()) + with urllib.request.urlopen(req, timeout=10) as resp: + raw = json.loads(resp.read().decode("utf-8")) + except Exception as e: + return {"error": str(e), "code": code, "period": period} + + if not isinstance(raw, dict): + return {"error": f"API returned {type(raw).__name__}", "raw": str(raw)[:200]} + + api_data = raw.get("data", {}) + if not isinstance(api_data, dict): + return {"error": f"data field is {type(api_data).__name__}", "raw": str(api_data)[:200]} + + # 指数代码已经自带前缀(sh000001/sz399001),直接用 + # 普通股票代码需要加market前缀(sh600036/sz300750) + is_index = any(code.startswith(p) for p in ["sh", "sz", "hk"]) + stock_key = code if is_index else f"{market}{code}" + stock_data = api_data.get(stock_key, {}) + + # 腾讯API的K线字段名: qfqday, qfqweek, qfqmonth + period_key = f"qfq{period}" + klines = stock_data.get(period_key, []) + + if not klines: + # 尝试其他字段名 + for k in stock_data: + if isinstance(stock_data[k], list) and len(stock_data[k]) > 0: + if isinstance(stock_data[k][0], list) and len(stock_data[k][0]) >= 6: + klines = stock_data[k] + break + + result = [] + for k in klines: + if len(k) >= 6: + try: + result.append({ + "date": str(k[0]), + "open": float(k[1]), + "close": float(k[2]), + "high": float(k[3]), + "low": float(k[4]), + "volume": float(k[5]), + }) + except (ValueError, IndexError): + continue + + return result + + +def calc_moving_averages(klines: list, windows: list = [5, 10, 20, 60]) -> dict: + """计算移动均线 + + Args: + klines: K线数据(按时间正序或倒序均可,自动处理) + windows: 均线周期列表 + + Returns: + dict: {ma5: float|None, ma10: float|None, ...} + """ + if not klines: + return {f"ma{w}": None for w in windows} + + # 确保按时间正序(旧的在前) + closes = [k["close"] for k in klines] + # 检查是否倒序(最新的在前) + if len(closes) >= 2 and closes[0] > closes[-1]: + closes = list(reversed(closes)) + + result = {} + for w in windows: + if len(closes) >= w: + result[f"ma{w}"] = round(sum(closes[-w:]) / w, 2) + else: + result[f"ma{w}"] = None + return result + + +def calc_multi_tf_support_resistance(klines: list, lookback: int = 0) -> dict: + """基于K线数据计算多周期支撑压力位 + + 使用近期高点和低点作为关键位: + - 强阻力 = 近期最高(或倒数第二高) + - 弱阻力 = 近期中枢上沿 + - 弱支撑 = 近期中枢下沿 + - 强支撑 = 近期最低(或倒数第二低) + + Args: + klines: K线数据 + lookback: 取最近多少条(0=全部) + + Returns: + dict: {strong_resist, weak_resist, weak_support, strong_support, + high_52w, low_52w, range_pct} + """ + if not klines or len(klines) < 3: + return {} + + # 取最近N条(日线看近期,周线/月线看全部) + if lookback <= 0: + lookback = min(len(klines), 20) # 日线默认20天 + n = min(len(klines), lookback) + recent = klines[-n:] + + # 全量数据(用于52周高低) + all_highs = [k["high"] for k in klines] + all_lows = [k["low"] for k in klines] + + highs = [k["high"] for k in recent] + lows = [k["low"] for k in recent] + + max_h = max(highs) + min_l = min(lows) + mid = (max_h + min_l) / 2 + + # 找第二高和第二低作为更稳健的边界 + sorted_h = sorted(set(highs), reverse=True) + sorted_l = sorted(set(lows)) + + strong_resist = sorted_h[0] if sorted_h else max_h + strong_support = sorted_l[0] if sorted_l else min_l + + weak_resist = sorted_h[1] if len(sorted_h) > 1 else (max_h + mid) / 2 + weak_support = sorted_l[1] if len(sorted_l) > 1 else (min_l + mid) / 2 + + # 最近20日的振幅比例(判断波动率) + if len(closes := [k["close"] for k in recent]) >= 2: + recent_range = (max_h - min_l) / min_l * 100 if min_l > 0 else 0 + else: + recent_range = 0 + + return { + "strong_resist": round(strong_resist, 2), + "weak_resist": round(weak_resist, 2), + "weak_support": round(weak_support, 2), + "strong_support": round(strong_support, 2), + "high_52w": round(max(all_highs), 2), + "low_52w": round(min(all_lows), 2), + "range_pct": round(recent_range, 1), + } + + +def assess_trend(klines: list) -> dict: + """判断趋势方向 + + Args: + klines: K线数据 + + Returns: + dict: {trend (up/down/sideways), strength (0~1), + description, ma_trend} + """ + if not klines or len(klines) < 10: + return {"trend": "unknown", "strength": 0, "description": "数据不足"} + + closes = [k["close"] for k in klines] + # 确保正序 + if len(closes) >= 2 and closes[0] > closes[-1]: + closes = list(reversed(closes)) + + n = len(closes) + ma20 = sum(closes[-20:]) / 20 if n >= 20 else sum(closes) / n + ma60 = sum(closes[-60:]) / 60 if n >= 60 else None + current = closes[-1] + + # 均线多头/空头排列判断 + ma5 = sum(closes[-5:]) / 5 if n >= 5 else None + ma10 = sum(closes[-10:]) / 10 if n >= 10 else None + + # 趋势判断 + up_count = sum(1 for i in range(1, len(closes)) if closes[i] > closes[i-1]) + up_ratio = up_count / (len(closes) - 1) + + # 价格相对均线位置 + above_ma20 = current > ma20 if ma20 else True + + if up_ratio > 0.6 and above_ma20: + if ma60 and current > ma60 * 1.2: + trend = "strong_up" + strength = min(1.0, up_ratio + 0.2) + desc = "强势上升" + else: + trend = "up" + strength = up_ratio + desc = "震荡上升" + elif up_ratio < 0.4 and not above_ma20: + if ma60 and current < ma60 * 0.8: + trend = "strong_down" + strength = min(1.0, (1 - up_ratio) + 0.2) + desc = "强势下跌" + else: + trend = "down" + strength = 1 - up_ratio + desc = "震荡下跌" + else: + trend = "sideways" + strength = 0.3 + desc = "横盘震荡" + + # 均线排列 + ma_trend = "unknown" + if ma5 and ma10 and ma20: + if ma5 > ma10 > ma20: + ma_trend = "多头排列" + elif ma5 < ma10 < ma20: + ma_trend = "空头排列" + else: + ma_trend = "粘合/交叉" + + return { + "trend": trend, + "strength": round(strength, 2), + "description": desc, + "ma_trend": ma_trend, + "ma5": round(ma5, 2) if ma5 else None, + "ma10": round(ma10, 2) if ma10 else None, + "ma20": round(ma20, 2), + "ma60": round(ma60, 2) if ma60 else None, + "current_above_ma20": current > ma20 if ma20 else None, + } + + +def full_multi_tf_analysis(code: str) -> dict: + """完整多周期分析入口 + + 同时获取日/周/月K线,计算: + - 各周期支撑压力位 + - 均线系统 + - 趋势方向 + - 综合策略建议 + + Args: + code: 股票代码 (如 "300548") + + Returns: + dict: 完整分析结果 + """ + # 获取三个周期的数据 + daily = fetch_kline(code, "day", 120) + weekly = fetch_kline(code, "week", 24) + monthly = fetch_kline(code, "month", 12) + + # 如果API失败,检查是否有本地缓存 + if isinstance(daily, dict) and "error" in daily: + daily = _load_local_history(code, "daily") + if isinstance(weekly, dict) and "error" in weekly: + weekly = _load_local_history(code, "weekly") + if isinstance(monthly, dict) and "error" in monthly: + monthly = _load_local_history(code, "monthly") + + result = { + "code": code, + "analyzed_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + } + + # 日线分析 + if daily and not (isinstance(daily, dict) and "error" in daily): + result["daily"] = { + "count": len(daily), + "latest": daily[-1] if daily else None, + "support_resistance": calc_multi_tf_support_resistance(daily, lookback=20), + "mas": calc_moving_averages(daily, [5, 10, 20, 60]), + "trend": assess_trend(daily), + } + + # 周线分析 + if weekly and not (isinstance(weekly, dict) and "error" in weekly): + result["weekly"] = { + "count": len(weekly), + "latest": weekly[-1] if weekly else None, + "support_resistance": calc_multi_tf_support_resistance(weekly, lookback=12), + "mas": calc_moving_averages(weekly, [5, 10]), + "trend": assess_trend(weekly), + } + + # 月线分析 + if monthly and not (isinstance(monthly, dict) and "error" in monthly): + result["monthly"] = { + "count": len(monthly), + "latest": monthly[-1] if monthly else None, + "support_resistance": calc_multi_tf_support_resistance(monthly, lookback=6), + "mas": calc_moving_averages(monthly, [5]), + "trend": assess_trend(monthly), + } + + # 综合策略建议 + result["strategy_adjustment"] = _generate_strategy_adjustment(result) + + # 写入本地缓存(供离线使用) + _save_local_history(code, daily, weekly, monthly) + + return result + + +def flush_mtf_cache(): + """将模块级缓存显式刷回磁盘(供批量处理后调用)""" + _save_mtf_cache() + + +def _generate_strategy_adjustment(analysis: dict) -> dict: + """基于多周期分析生成策略调整建议""" + adj = { + "stop_loss_reference": None, + "take_profit_reference": None, + "trend_alignment": "unknown", + "multi_tf_summary": {}, + "cautions": [], + } + + daily_trend = analysis.get("daily", {}).get("trend", {}) + weekly_trend = analysis.get("weekly", {}).get("trend", {}) + monthly_trend = analysis.get("monthly", {}).get("trend", {}) + + # 均线数据 + daily_mas = analysis.get("daily", {}).get("mas", {}) + daily_sr = analysis.get("daily", {}).get("support_resistance", {}) + weekly_sr = analysis.get("weekly", {}).get("support_resistance", {}) + monthly_sr = analysis.get("monthly", {}).get("support_resistance", {}) + + current = analysis.get("daily", {}).get("latest", {}).get("close", 0) + + # 多周期趋势一致性 + up_tfs, down_tfs = 0, 0 + tf_details = [] + for tf_name, tf_data in [("daily", daily_trend), ("weekly", weekly_trend), + ("monthly", monthly_trend)]: + t = tf_data.get("trend", "unknown") + desc = tf_data.get("description", "") + ma_t = tf_data.get("ma_trend", "") + tf_details.append(f"{tf_name}:{desc}({ma_t})") + if "up" in t or "strong_up" in t: + up_tfs += 1 + elif "down" in t or "strong_down" in t: + down_tfs += 1 + + adj["multi_tf_summary"] = { + "daily_trend": daily_trend.get("description", "未知"), + "weekly_trend": weekly_trend.get("description", "未知"), + "monthly_trend": monthly_trend.get("description", "未知"), + "daily_ma_trend": daily_trend.get("ma_trend", "未知"), + } + + if up_tfs >= 2: + adj["trend_alignment"] = "多周期看多" + elif down_tfs >= 2: + adj["trend_alignment"] = "多周期看空" + elif up_tfs >= 1 and down_tfs >= 1: + adj["trend_alignment"] = "多周期分化" + else: + adj["trend_alignment"] = "震荡/无明显方向" + + if not current: + return adj + + # ===== 参考止损位(三级递进)===== + # 第一级:MA20(短线交易的生命线) + ma20 = daily_mas.get("ma20") + # 第二级:日线弱支撑(近20天次低点) + daily_ws = daily_sr.get("weak_support") + # 第三级:日线强支撑 / MA60 + ma60 = daily_mas.get("ma60") + daily_ss = daily_sr.get("strong_support") + + stop_candidates = [] + if ma20: + stop_candidates.append(("MA20", ma20, abs(current - ma20) / current * 100)) + if daily_ws: + stop_candidates.append(("日弱支撑", daily_ws, abs(current - daily_ws) / current * 100)) + if ma60: + stop_candidates.append(("MA60", ma60, abs(current - ma60) / current * 100)) + if daily_ss: + stop_candidates.append(("日强支撑", daily_ss, abs(current - daily_ss) / current * 100)) + + if stop_candidates: + # 选一个合理的止损参考:MA20优先(如果距现价不太近),否则选日弱支撑 + best_stop = None + for name, level, dist in stop_candidates: + if level < current: # 止损必须在现价下方 + if 2 <= dist <= 15: # 距现价2~15%之间比较合理 + best_stop = {"source": name, "level": level, + "distance_pct": round(dist, 2)} + break + if not best_stop: + # 没有2~15%内的,选最近的一个 + below = [(n, l, d) for n, l, d in stop_candidates if l < current] + if below: + nearest = min(below, key=lambda x: x[2]) + best_stop = {"source": nearest[0], "level": nearest[1], + "distance_pct": round(nearest[2], 2)} + if best_stop: + adj["stop_loss_reference"] = best_stop + + # ===== 参考止盈位 ===== + take_candidates = [] + # 日线阻力 + for name, level in [("日弱阻", daily_sr.get("weak_resist")), + ("日强阻", daily_sr.get("strong_resist")), + ("周强阻", weekly_sr.get("strong_resist")), + ("月强阻", monthly_sr.get("strong_resist"))]: + if level and level > current: + dist = (level - current) / current * 100 + take_candidates.append((name, level, dist)) + + if take_candidates: + # 选距现价5~30%内的最高阻力位 + best_take = None + for name, level, dist in sorted(take_candidates, key=lambda x: x[1], reverse=True): + if 3 <= dist <= 40: + best_take = {"source": name, "level": level, + "distance_pct": round(dist, 2)} + break + if not best_take: + farthest = max(take_candidates, key=lambda x: x[2]) + best_take = {"source": farthest[0], "level": farthest[1], + "distance_pct": round(farthest[2], 2)} + if best_take: + adj["take_profit_reference"] = best_take + + # ===== 风险提示 ===== + if ma20 and current < ma20: + adj["cautions"].append(f"价格{current} list: + """从 DB 多周期缓存读取历史数据""" + data = _load_mtf_cache() + stock = data.get(code, {}) + return stock.get(period, []) + + +def _save_local_history(code: str, daily: list, weekly: list, monthly: list): + """将多周期数据写入模块级缓存(含时间戳),不直接写磁盘""" + import time + global _MTF_CACHE_DATA + cache_data = _load_mtf_cache() + stock = cache_data.get(code, {}) + + if daily and not (isinstance(daily, dict) and "error" in daily): + stock["daily"] = daily + if weekly and not (isinstance(weekly, dict) and "error" in weekly): + stock["weekly"] = weekly + if monthly and not (isinstance(monthly, dict) and "error" in monthly): + stock["monthly"] = monthly + stock["updated_at"] = time.time() # 缓存时间戳 + + cache_data[code] = stock + _MTF_CACHE_DATA = cache_data # 更新模块级缓存 + + # ── SQLite 双写 ── + _write_klines_to_db(code, daily, weekly, monthly, stock.get("fundamentals")) + + +def batch_update_all(codes: list): + """批量更新多只股票的多周期数据""" + results = {} + for code in codes: + try: + r = full_multi_tf_analysis(code) + results[code] = { + "status": "ok", + "periods": [k for k in ["daily", "weekly", "monthly"] + if k in r] + } + except Exception as e: + results[code] = {"status": "error", "error": str(e)} + return results + + +if __name__ == "__main__": + import sys + codes = sys.argv[1:] or ["300548", "600110"] + for code in codes: + r = full_multi_tf_analysis(code) + print(json.dumps(r, ensure_ascii=False, indent=2)) + print("-" * 60) diff --git a/scripts/post_mortem.py b/scripts/post_mortem.py new file mode 100644 index 0000000..9265ba4 --- /dev/null +++ b/scripts/post_mortem.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""post_mortem.py - 知微历史报告复盘 + 自我改进引擎 + +读取所有历史报告,提炼分析模式,生成升级方案。 +""" + +import json +import re +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" +REPORTS_DIR = DATA_DIR / "reports" + + +def load_reports(): + reports = [] + for f in sorted(REPORTS_DIR.iterdir()): + if f.suffix == ".json" and f.stat().st_size > 200: + try: + reports.append(json.loads(f.read_text())) + except: + pass + return reports + + +def analyze_stock_timeline(code): + """提取某只股票在所有报告中的分析时间线""" + entries = [] + for r in load_reports(): + content = r.get("content", "") + if code in content: + lines = content.split("\n") + for i, line in enumerate(lines): + if code in line and ("止损" in line or "止盈" in line or "补仓" in line or "买入" in line or "卖出" in line or "持有" in line or "减仓" in line): + entries.append({ + "time": r.get("created_at", ""), + "type": r.get("type", ""), + "title": r.get("title", ""), + "line": line.strip()[:120], + "context": "\n".join(lines[max(0,i-1):i+2]), + }) + return entries + + +def generate_retrospective(code, name): + """对某只股票过去所有建议做复盘""" + entries = analyze_stock_timeline(code) + if not entries: + return None + + result = { + "code": code, + "name": name, + "entry_count": len(entries), + "first_seen": entries[0]["time"], + "last_seen": entries[-1]["time"], + "timeline": entries, + } + return result + + +def identify_gaps(): + """识别知微的分析gap""" + reports = load_reports() + gaps = [] + + # Gap 1: 缺少止盈建议(对比止损) + stop_loss_count = 0 + take_profit_count = 0 + for r in reports: + c = r.get("content", "") + stop_loss_count += c.count("止损") + take_profit_count += c.count("止盈") + + gap_ratio = stop_loss_count / max(take_profit_count, 1) + if gap_ratio > 3: + gaps.append(f"止损共{stop_loss_count}次,止盈仅{take_profit_count}次,比例{gap_ratio:.1f}:1。止盈意识不足,应增加止盈建议频率") + + # Gap 2: 模糊用词过多 + vague_words = ["关注", "观望", "留意", "观察", "考虑"] + vague_count = sum(sum(r.get("content", "").count(w) for r in reports) for w in vague_words) + if vague_count > 30: + gaps.append(f"模糊用词(关注/观望/留意等)共{vague_count}次,应减少,改用具体动作+价格") + + # Gap 3: 跨报告连贯性 + all_codes = set() + for r in reports: + codes = re.findall(r'\b\d{5,6}\b', r.get("content", "")) + all_codes.update(codes) + + for code in list(all_codes)[:5]: + entries = analyze_stock_timeline(code) + action_count = len(entries) + if action_count > 3: + gaps.append(f"{code}出现{action_count}次,没有跟踪验证之前的建议是否有效") + + return gaps + + +if __name__ == "__main__": + print("=" * 60) + print("📋 知微复盘报告") + print(f" 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print("=" * 60) + + gaps = identify_gaps() + print(f"\n🔍 发现 {len(gaps)} 个改进点:") + for g in gaps: + print(f" ⚠️ {g}") + + print("\n📈 个股跟踪示例:") + for code, name in [("688411", "海博思创"), ("600563", "法拉电子"), ("600110", "诺德股份")]: + retro = generate_retrospective(code, name) + if retro: + print(f"\n {name}({code}): {retro['entry_count']}次提及") + for e in retro["timeline"][-3:]: + print(f" [{e['time'][:16]}] {e['line']}") + + print("\n✅ 复盘完成") \ No newline at end of file diff --git a/scripts/pre-flight-check.py b/scripts/pre-flight-check.py new file mode 100644 index 0000000..5c6c91c --- /dev/null +++ b/scripts/pre-flight-check.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +pre-flight-check.py — 策略上线前检查清单的自动化部分 + +检查三项可自动验证的关卡,输出结构化结果供知微在报告中引用。 +只检查,不拦报告。不过的项目标记 @preflight:fail。 + +用法: + python3 pre-flight-check.py [code] + 不传 code → 校验版本一致性 + 数据时效 + 全量持仓成本检查 + 传 code → 只检查单只股票(止损技术位 + R/R + 成本) + +依赖: + - /home/hmo/projects/MoFin/data/prompts/registry.json(版本一致性) + - /home/hmo/web-dashboard/data/decisions.json(策略+成本) + - /home/hmo/web-dashboard/data/portfolio.json(持仓数据) + +输出格式: + ✅ 项目名 — 通过 + ❌ 项目名 — 失败原因 + ⚠️ 项目名 — 警告 +""" +import json +import sys +from pathlib import Path +from datetime import datetime, timezone + +# === 路径 === +REGISTRY = Path("/home/hmo/projects/MoFin/data/prompts/registry.json") +DECISIONS = Path("/home/hmo/web-dashboard/data/decisions.json") +PORTFOLIO = Path("/home/hmo/web-dashboard/data/portfolio.json") + +# === 检查 7:版本一致性 === +def check_version_consistency(): + """analysis-rules.depends_on.version == strategy-generation.current_version""" + try: + reg = json.loads(REGISTRY.read_text()) + except Exception as e: + return ("⚠️ 版本一致性", f"无法读取 registry.json: {e}") + + sg = None + ar = None + for p in reg["prompts"]: + if p["id"] == "strategy-generation": + sg = p + if p["id"] == "analysis-rules": + ar = p + + if not sg or not ar: + return ("⚠️ 版本一致性", "registry.json 中缺少 strategy-generation 或 analysis-rules") + + sg_ver = sg["current_version"] + ar_dep_ver = ar.get("depends_on", {}).get("version", "none") + + if sg_ver == ar_dep_ver: + return ("✅ 版本一致性", f"strategy-generation@{sg_ver} == analysis-rules.depends_on@{ar_dep_ver}") + else: + return ("❌ 版本一致性", + f"strategy-generation@{sg_ver} != analysis-rules.depends_on@{ar_dep_ver}。" + f"运行 check-prompt-deps.py 修复") + + +# === 检查 1:数据时效 === +def check_data_freshness(): + """检查 decisions.json 的更新时间是否在合理范围内""" + try: + mtime = DECISIONS.stat().st_mtime + mtime_dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + now = datetime.now(timezone.utc) + age_minutes = (now - mtime_dt).total_seconds() / 60 + except Exception as e: + return ("⚠️ 数据时效", f"无法检查 decisions.json: {e}") + + if age_minutes < 60: + return ("✅ 数据时效", + f"decisions.json 更新于 {age_minutes:.0f} 分钟前 ({mtime_dt.strftime('%H:%M')})") + elif age_minutes < 240: + return ("⚠️ 数据时效", + f"decisions.json 已 {age_minutes:.0f} 分钟未更新(可能已收盘),数据视为陈旧") + else: + return ("❌ 数据时效", + f"decisions.json 已 {age_minutes:.0f} 分钟未更新,数据过期,请检查数据管线") + + +# === 检查 5:成本有效 === +def check_cost_validity(): + """检查 decisions.json 中所有持仓的成本是否有效""" + try: + dec = json.loads(DECISIONS.read_text()) + except Exception as e: + return ("⚠️ 成本有效性", f"无法读取 decisions.json: {e}") + + stocks = dec.get("stocks", dec.get("holdings", dec.get("strategies", []))) + if not stocks: + stocks = dec.get("portfolio", []) + + issues = [] + ok = 0 + total = 0 + + for s in stocks: + # 支持 stocks[] 和 strategies[] 两种结构 + cost = s.get("cost") or s.get("actual", {}).get("cost", None) + code = s.get("code") or s.get("symbol", "?") + name = s.get("name", "") + + if cost is None or cost == 0: + issues.append(f"{code} {name}: cost={'null' if cost is None else cost}") + else: + ok += 1 + total += 1 + + if not issues: + return ("✅ 成本有效性", f"全部 {total} 条持仓成本有效") + else: + return ("⚠️ 成本有效性", + f"{len(issues)}/{total} 条持仓成本缺失:{','.join(issues[:5])}" + f"{'...' if len(issues) > 5 else ''}") + + +# === 检查 3(单股):止损技术依据 === +def check_stop_technical(code): + """检查单只股票的止损是否基于技术位(仅对单股模式生效)""" + try: + dec = json.loads(DECISIONS.read_text()) + except Exception as e: + return ("⚠️ 止损技术位", f"无法读取 decisions.json: {e}") + + stocks = dec.get("stocks", dec.get("strategies", [])) + for s in stocks: + c = s.get("code", "") + if c.replace(".", "").replace("SZ", "").replace("SH", "").replace("HK", "") == code.replace(".", "").replace("SZ", "").replace("SH", "").replace("HK", ""): + stop = s.get("stop_loss", s.get("actual", {}).get("stop_loss")) + buy_zone = s.get("entry_low", s.get("buy_zone", [None, None])) + if isinstance(buy_zone, list): + entry_low = buy_zone[0] + else: + entry_low = buy_zone + + if stop and entry_low and stop < entry_low * 0.95: + return ("⚠️ 止损技术位", + f"{code} 止损{stop} < 买入区下沿{entry_low}×0.95," + f"检查是否用了固定百分比而非技术位") + return ("✅ 止损技术位", f"{code} 止损{stop},需要人工确认是否基于支撑位") + + return ("⚠️ 止损技术位", f"代码 {code} 未在 decisions.json 中找到") + + +# === 检查 4(单股):R/R 达标 === +def check_rr(code, price=None): + """检查单只股票的 R/R 是否达标""" + try: + dec = json.loads(DECISIONS.read_text()) + except Exception as e: + return ("⚠️ R/R 达标", f"无法读取 decisions.json: {e}") + + stocks = dec.get("stocks", dec.get("strategies", [])) + for s in stocks: + c = s.get("code", "") + if c.replace(".", "").replace("SZ", "").replace("SH", "").replace("HK", "") == code.replace(".", "").replace("SZ", "").replace("SH", "").replace("HK", ""): + stop = s.get("stop_loss", s.get("actual", {}).get("stop_loss")) + target = s.get("take_profit", s.get("actual", {}).get("take_profit")) + p = price or s.get("price", s.get("current_price")) + + if not all([stop, target, p]): + return ("⚠️ R/R 达标", f"{code} 缺少止损/止盈/现价之一") + + risk = p - stop + reward = target - p + if risk <= 0: + return ("❌ R/R 达标", f"{code} 现价{p}已低于止损{stop},无R/R可言") + + rr = reward / risk + cost = s.get("cost", s.get("actual", {}).get("cost")) + is_new = (cost is None or cost == 0) + + min_rr = 2.5 if is_new else 2.0 # 简化处理,弱阻距检测需额外输入 + if rr >= min_rr: + return ("✅ R/R 达标", f"{code} R/R={rr:.2f} >= {min_rr}") + else: + return ("❌ R/R 达标", f"{code} R/R={rr:.2f} < {min_rr}") + + return ("⚠️ R/R 达标", f"代码 {code} 未在 decisions.json 中找到") + + +def main(): + print("「策略上线前检查」自动化部分") + print(f"检查时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print() + + if len(sys.argv) > 1: + # 单股模式 + code = sys.argv[1] + price = float(sys.argv[2]) if len(sys.argv) > 2 else None + results = [ + check_stop_technical(code), + check_rr(code, price), + ] + else: + # 全量模式 + results = [ + check_version_consistency(), + check_data_freshness(), + check_cost_validity(), + ] + + for tag, msg in results: + print(f" {tag} — {msg}") + + # 汇总 + fails = sum(1 for tag, _ in results if tag.startswith("❌")) + warns = sum(1 for tag, _ in results if tag.startswith("⚠️")) + total = len(results) + + print() + if fails == 0 and warns == 0: + print(f" ✅ {total}/{total} 项通过") + elif fails == 0: + print(f" ⚠️ {warns} 项警告,{total - warns} 项通过") + else: + print(f" ❌ {fails} 项失败,{warns} 项警告,{total - fails - warns} 项通过") + + +if __name__ == "__main__": + main() diff --git a/scripts/price_data_inject.py b/scripts/price_data_inject.py new file mode 100755 index 0000000..6d5c43a --- /dev/null +++ b/scripts/price_data_inject.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""注入持仓价格数据 + 多周期技术面 + 综合分类 + 大盘指数到 LLM 上下文。 +每个tick自动执行,输出直接注入prompt上下文。""" + +import json +import sys +import os + +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" +WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" +MTF_CACHE_PATH = "/home/hmo/web-dashboard/data/multi_tf_cache.json" + + +def calc_ma(prices, window): + if len(prices) < window: + return None + return round(sum(prices[-window:]) / window, 2) + + +def fmt_holding(h): + name = h.get("name", "?") + code = h.get("code", "?") + p = h.get("price", 0) + chg = h.get("change_pct") + chg_str = f"{chg:+.2f}%" if chg is not None else "N/A" + pos = h.get("position_pct", 0) + cost = h.get("cost") + if cost and cost > 0: + pnl = (p - cost) / cost * 100 + pnl_str = f"{pnl:+.1f}%" + else: + pnl_str = "N/A" + analysis = h.get("analysis", {}) or {} + sl = analysis.get("stop_loss", "N/A") + tp = analysis.get("take_profit", "N/A") + return f" {name:<8}({code:<6}) 现价{p:<8} {chg_str:<8} 仓位{pos:<5.1f}% 盈亏{pnl_str:<8} 止损{sl} 止盈{tp}" + + +def fmt_mtf(code, mtf_cache): + stock = mtf_cache.get(code, {}) + daily = stock.get("daily", []) + if len(daily) < 5: + return "" + + closes = [d["close"] for d in daily] + ma5 = calc_ma(closes, 5) + ma10 = calc_ma(closes, 10) if len(closes) >= 10 else None + ma20 = calc_ma(closes, 20) if len(closes) >= 20 else None + ma60 = calc_ma(closes, 60) if len(closes) >= 60 else None + current = closes[-1] + + up_count = sum(1 for i in range(1, len(closes[-20:])) if closes[-20:][i] > closes[-20:][i-1]) + up_ratio = up_count / min(len(closes), 20) if len(closes) >= 2 else 0.5 + trend = "横盘" + if up_ratio > 0.6 and ma20 and current > ma20: + trend = "上升" + elif up_ratio < 0.4 and ma20 and current < ma20: + trend = "下降" + + ma_trend = "" + if ma5 and ma10 and ma20: + if ma5 > ma10 > ma20: + ma_trend = "多头" + elif ma5 < ma10 < ma20: + ma_trend = "空头" + + above_ma20 = "↑" if ma20 and current > ma20 else ("↓" if ma20 and current < ma20 else "—") + above_ma60 = "↑" if ma60 and current > ma60 else ("↓" if ma60 and current < ma60 else "—") + + parts = [f"趋势:{trend}"] + if ma_trend: + parts.append(ma_trend) + if ma5: + parts.append(f"MA5={ma5}") + if ma20: + parts.append(f"MA20={ma20}{above_ma20}") + if ma60: + parts.append(f"MA60={ma60}{above_ma60}") + recent_high = max(d["high"] for d in daily[-20:]) if len(daily) >= 20 else max(d["high"] for d in daily) + recent_low = min(d["low"] for d in daily[-20:]) if len(daily) >= 20 else min(d["low"] for d in daily) + parts.append(f"近20日:{recent_low}~{recent_high}") + return " | ".join(parts) + + +def fmt_index_line(code, name, mtf_cache): + entry = mtf_cache.get(code) + if not entry or not isinstance(entry, dict): + return "" + daily = entry.get("daily", []) + if len(daily) < 5: + return "" + closes = [d["close"] for d in daily] + current = closes[-1] + ma20 = calc_ma(closes, 20) or 0 + ma60 = calc_ma(closes, 60) or 0 + ma20_sig = "↑" if current > ma20 else "↓" + ma60_sig = "↑" if current > ma60 else "↓" + up_count = sum(1 for i in range(1, min(21, len(closes))) if closes[-i] > closes[-i-1]) + trend = "上升" if up_count > 12 else ("下降" if up_count < 8 else "横盘") + return f"{name}{current:.0f} {trend} MA20{ma20_sig} MA60{ma60_sig}" + + +def classify_from_cache(code, name, mtf_cache): + stock = mtf_cache.get(code, {}) + daily = stock.get("daily", []) + fund = stock.get("fundamentals", {}) + if not daily or len(daily) < 5: + return "" + closes = [d["close"] for d in daily] + current = closes[-1] + ma20 = calc_ma(closes, 20) or 0 + ma60 = calc_ma(closes, 60) or 0 + recent_high = max(d["high"] for d in daily[-20:]) + recent_low = min(d["low"] for d in daily[-20:]) + volatility = ((recent_high - recent_low) / recent_low * 100) if recent_low > 0 else 0 + pe = fund.get("pe") or 0 + eps = fund.get("eps") or 0 + mcap = fund.get("mcap_total") or 0 + + is_high_vol = volatility > 30 + is_high_pe = pe > 100 or pe < 0 + is_value = 0 < pe < 20 and eps > 0.5 + + category = "中短线" + time_horizon = "2周~3月" + suggestion = "中等仓位" + + if is_high_vol and is_high_pe: + category = "短炒" + time_horizon = "数日~2周" + suggestion = "小仓快进快出" + elif current < ma20 and current < ma60: + category = "弱势" + time_horizon = "观望" + suggestion = "减仓或观望" + elif is_value and current > ma20: + category = "中长线" + time_horizon = "数月~1年" + suggestion = "正常配置" + elif mcap > 1000 and eps > 0: + category = "中长线" + time_horizon = "数月~1年" + suggestion = "正常配置" + elif is_high_vol: + category = "中短线" + time_horizon = "2~6周" + suggestion = "中等仓位" + + ma20_sig = "↑" if current > ma20 else "↓" + ma60_sig = "↑" if current > ma60 else "↓" + + parts = [f"分类:{category}", f"周期:{time_horizon}", f"MA20{ma20_sig} MA60{ma60_sig}"] + if pe and pe > 0: + parts.append(f"PE={pe:.0f}") + if eps and eps > 0: + parts.append(f"EPS={eps:.2f}") + parts.append(f"建议:{suggestion}") + return " | ".join(parts) + + +def main(): + try: + pf = json.load(open(PORTFOLIO_PATH)) + except Exception as e: + print(f"[ERROR] {e}") + sys.exit(0) + + mtf_cache = load_mtf_cache() + holdings = pf.get("holdings", []) + updated_at = pf.get("updated_at", "unknown") + + # ===== 大盘指数趋势 ===== + index_map = { + "__index__sh000001": "上证", "__index__sz399001": "深证", + "__index__sz399006": "创业板", "__index__sh000688": "科创50", + "__index__hkHSI": "恒指", "__index__hkHSTECH": "恒科", + } + parts = [] + for ic, nm in index_map.items(): + line = fmt_index_line(ic, nm, mtf_cache) + if line: + parts.append(line) + if parts: + print("[大盘] " + " | ".join(parts)) + print() + + # ===== 市场总览 ===== + print(f"[持仓] 更新 {updated_at}") + print(f"总资产:{pf.get('total_assets',0):.0f} | 市值:{pf.get('stock_value',0):.0f} | 现金:{pf.get('cash',0):.0f} | 仓位:{pf.get('position_pct',0):.1f}% | 日盈亏:{pf.get('day_pnl',0):+.0f}") + print() + + a_h = [h for h in holdings if h.get("code","").startswith(("6","0","3")) and len(h.get("code",""))==6] + hk_h = [h for h in holdings if h not in a_h] + a_h.sort(key=lambda h: h.get("position_pct",0), reverse=True) + hk_h.sort(key=lambda h: h.get("position_pct",0), reverse=True) + + print("=== A股持仓 ===") + for h in a_h: + print(fmt_holding(h)) + print() + print("=== 港股持仓 ===") + for h in hk_h: + print(fmt_holding(h)) + + # ===== 多周期技术面 ===== + print() + print("=== 多周期技术面 ===") + for h in a_h + hk_h: + line = fmt_mtf(h["code"], mtf_cache) + if line: + print(f" {h['name']:<8}({h['code']:<6}) {line}") + + # ===== 综合分类 ===== + print() + print("=== 综合分类 ===") + for h in a_h + hk_h: + line = classify_from_cache(h["code"], h["name"], mtf_cache) + if line: + print(f" {h['name']:<8}({h['code']:<6}) {line}") + + # ===== 自选股买入区监控 ===== + try: + wl = json.load(open(WATCHLIST_PATH)) + wl_stocks = wl.get("stocks", []) + # 过滤掉已经在持仓里的 + held_codes = {h["code"] for h in holdings} + watch_only = [s for s in wl_stocks if s.get("code") not in held_codes] + # 只列出有买入区的 + with_zone = [s for s in watch_only + if s.get("analysis", {}).get("entry_low")] + if with_zone: + print() + print("=== 自选买入区 ===") + for s in with_zone: + print(fmt_watchlist_item(s)) + except Exception: + pass + + print() + print("--- end data inject ---") + + +def fmt_watchlist_item(s): + """格式化自选股条目""" + name = s.get("name", "?") + code = s.get("code", "?") + price = s.get("price", 0) + analysis = s.get("analysis", {}) or {} + el = analysis.get("entry_low") + eh = analysis.get("entry_high") + sl = analysis.get("stop_loss") + tp = analysis.get("take_profit") + action = analysis.get("action", "")[:60] + in_zone = "" + if el and eh and price: + if el <= price <= eh: + in_zone = "✅在买入区" + elif price < el: + off = (el - price) / el * 100 + in_zone = f"⬇低于买入区{off:.0f}%" + else: + off = (price - eh) / eh * 100 + in_zone = f"⬆高于买入区+{off:.0f}%" + return f" {name:<8}({code:<6}) ¥{price:<8} | {in_zone} | 买{el}~{eh} | 损{sl} 盈{tp} | {action}" + + +def load_mtf_cache(): + try: + return json.load(open(MTF_CACHE_PATH)) + except Exception: + return {} + + +if __name__ == "__main__": + main() diff --git a/scripts/price_monitor.py b/scripts/price_monitor.py new file mode 100644 index 0000000..9ced72e --- /dev/null +++ b/scripts/price_monitor.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +"""price_monitor.py — 高频价格监控脚本(批量版) +规则:进入区间报一次,离开区间报一次,中间不重复。 +每次运行时一次性刷新所有持仓+自选股的实时价。 +""" +import json +import urllib.request +import os +import sys +import time +import sqlite3 +from datetime import datetime + +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" + +# DB 模块(同步实时价到 mofin.db) +sys.path.insert(0, "/home/hmo/MoFin") +try: + from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_live_prices + from mo_models import calc_total_mv, calc_total_assets + HAS_DB = True +except ImportError: + HAS_DB = False + +# 策略重评依赖(技术面驱动,非机械百分比) +sys.path.insert(0, "/home/hmo/web-dashboard") +try: + from strategy_lifecycle import reassess_strategy + HAS_REASSESS = True +except ImportError: + HAS_REASSESS = False + +UA = "Mozilla/5.0" + +# ── 批量拉取价格 ────────────────────────────────────────────────────────── + +def fetch_all_prices(codes): + """腾讯批量行情API:一次请求拉取所有股票(A股+港股) + A股:sh600110 / sz000001 + 港股:hk00700 + 返回 {code: (price, change, change_pct)} + """ + if not codes: + return {} + + # 构建批量查询串 + symbols = [] + code_map = {} # symbol -> original_code + for code in codes: + code_s = str(code).strip() + if len(code_s) == 6: + # A股:沪市以5/6/9开头,深市以0/3开头 + if code_s.startswith(('5', '6', '9')): + sym = f"sh{code_s}" + else: + sym = f"sz{code_s}" + else: + sym = f"hk{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"⚠️ 批量拉取失败: {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: + # 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..." + 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 refresh_data_prices(): + """一次性刷新所有持仓+自选股的实时价(完全DB版,不写JSON)""" + all_codes = set() + + # 从DB读所有需要拉取价格的代码 + try: + conn = get_conn() + for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"): + all_codes.add(r['code']) + for r in conn.execute("SELECT code FROM watchlist_stocks"): + all_codes.add(r['code']) + conn.close() + except Exception as e: + print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr) + return 0 + + if not all_codes: + return 0 + + # 一次性批量拉取 + prices = fetch_all_prices(list(all_codes)) + updated = len(prices) + + # === 同步实时价到 mofin.db(带重试防锁) === + if HAS_DB and prices: + for db_attempt in range(3): + try: + conn = get_conn() + + # 构建 holdings 更新数据 + db_holdings = [] + for r in conn.execute("SELECT * FROM holdings WHERE is_active=1"): + h = dict(r) + code = str(h.get('code', '')) + if code in prices: + price_val, _, change_pct = prices[code] + if price_val > 0: + h['price'] = round(price_val, 2) + h['change_pct'] = float(change_pct) if change_pct else 0 + db_holdings.append(h) + + # 写入DB持仓价格(write_holdings_batch 用 ON CONFLICT UPDATE 只改价格字段) + ok, msg = write_holdings_batch(conn, db_holdings) + if not ok: + conn.close() + if db_attempt < 2: + wait = (db_attempt + 1) * 2 + print(f"⏳ DB写持仓失败: {msg} → {wait}s后重试", file=sys.stderr) + time.sleep(wait) + continue + else: + print(f"❌ DB写持仓失败(3次重试耗尽): {msg}", file=sys.stderr) + break + + # 重新计算市值(不变现金——DB的cash是权威) + mv = calc_total_mv(db_holdings) + # 读取DB当前的现金和冻结,不覆盖 + existing = conn.execute( + 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1' + ).fetchone() + db_cash = existing['cash'] if existing else 0.0 + db_frozen = existing['frozen_cash'] if existing else 0.0 + assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen}) + position_pct = round(mv / assets * 100, 2) if assets > 0 else 0 + write_portfolio_summary(conn, { + 'total_mv': mv, + 'total_assets': assets, + 'stock_value': mv, + 'cash': db_cash, + 'frozen_cash': db_frozen, + 'position_pct': position_pct, + 'currency': 'CNY', + }) + # 写实时价格表(供 read_live_prices 消费) + live = {h['code']: {'price': h.get('price',0), 'change_pct': h.get('change_pct',0)} + for h in db_holdings if h.get('code')} + write_live_prices(conn, live) + conn.commit() + conn.close() + if db_attempt > 0: + print(f"DB同步成功(第{db_attempt+1}次重试)") + break # success + except sqlite3.OperationalError as e: + conn.close() + if db_attempt < 2: + wait = (db_attempt + 1) * 2 + print(f"⏳ DB锁等待(第{db_attempt+1}次): {e} → {wait}s后重试", file=sys.stderr) + time.sleep(wait) + else: + print(f"❌ DB同步失败(3次重试耗尽): {e}", file=sys.stderr) + except Exception as e: + conn.close() + print(f"⚠️ DB同步异常: {e}", file=sys.stderr) + break + + return updated + + +# ── 区间偏离检测 ────────────────────────────────────────────────────────── + +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""" + 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) + + +def get_trigger_zones(trigger): + """返回该trigger所有可监控的区间列表,跳过已执行的batch""" + zones = [] + for key, label in [ + ("entry_zone", "加仓区间"), + ("batch1_price", "试仓区间"), + ("batch2_price", "加仓区间"), + ("take_profit_zone", "止盈区间"), + ("watch_low", "关注区间"), + ("watch_high", "减仓区间"), + ("watch_break", "止损区间") + ]: + status_key = key.replace("_price", "_status") + if status_key in trigger and trigger[status_key] == "executed": + continue + val = trigger.get(key, "") + if val and "~" in val: + try: + parts = val.split("~") + lo, hi = float(parts[0]), float(parts[1]) + zones.append((key, label, lo, hi)) + except: + pass + sl = trigger.get("stop_loss", "") + if sl: + try: + sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl) + zones.append(("stop_loss", "止损", 0, sl_price)) + except: + pass + return zones + + +def run_once(round_label=""): + """执行一轮完整的监控流程""" + label = f" [{round_label}]" if round_label else "" + start = time.time() + + # === 第一步:一次性刷新所有价格 === + 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") == "active"] + state = load_state() + outputs = [] + state_updated = False + + # 收集所有需要检查的代码 + check_codes = set() + for d in active: + trig = d.get("trigger", {}) + if trig: + check_codes.add(d["code"]) + + # 批量拉取这些股票的价格 + prices = fetch_all_prices(list(check_codes)) + + for d in active: + code = d["code"] + trig = d.get("trigger", {}) + if not trig: + continue + + zones = get_trigger_zones(trig) + 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": + outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!") + record_event(code, name, "stop_loss", price, str(hi)) + else: + extra = "" + if "_price" in key: + batch_shares = trig.get(key.replace("_price", "_shares"), "") + action = trig.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 = trig.get("take_profit_action", "") + if act: + extra = f"({act})" + outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}") + 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) + + # 状态变化时才触发 + if in_buy_zone and prev_in_buy_zone == False: + # 重新进入买入区 → 重评确认区间是否仍然有效 + 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}") + + # === 第四步:输出 === + now_str = datetime.now().strftime("%H:%M:%S") + elapsed = time.time() - start + + if outputs: + print(f"\n🔔 {now_str}{label}") + for o in outputs: + print(o) + print(f"\n{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}") + else: + # 无触发时 SILENT(中继不推送) + print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s") + + if state_updated: + save_state(state) + + # 输出耗时 + print(f"⏱{label} {elapsed:.1f}s", flush=True) + + +def main(): + """每cron触发跑一轮""" + run_once() + + +if __name__ == "__main__": + main() diff --git a/scripts/price_monitor.py.bk b/scripts/price_monitor.py.bk new file mode 100644 index 0000000..57403e5 --- /dev/null +++ b/scripts/price_monitor.py.bk @@ -0,0 +1,827 @@ +#!/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 Exception: + pf = {"holdings": []} + + try: + wl = read_watchlist() + for s in wl.get('stocks', []): + all_codes.add(s['code']) + except Exception: + 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延迟) + # 港股市场09:30开盘,之前走备用通道会空耗1秒/只且无实时数据 + 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: + # 09:30前港股未开盘,跳过慢速降级通道 + now_h = datetime.now().hour + now_m = datetime.now().minute + if now_h > 9 or (now_h == 9 and now_m >= 30): + 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') + if old is None: + old = 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) + 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') + if old is None: + old = 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 + reassesed_codes = [] # 止损触发和离/进买入区都记入此列表 + + # 收集所有需要检查的代码 + 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)) + # --- 止损触发 → 立即重评 + 操作建议 --- + if 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 + 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, + ) + # 生成操作建议 + new_sl = result.get('stop_loss', 0) + if price < hi: # 跌破止损 → 建议卖出 + advice = "建议止损卖出" + elif new_sl > 0 and price > new_sl: + advice = f"建议观察, 设新止损{new_sl:.2f}" + else: + advice = "建议持有观察" + outputs.append(f" 📊 {advice} | 新损{result['stop_loss']} 盈{result['take_profit']} RR={result['rr_ratio']}") + reassesed_codes.append(code) + except Exception as e: + outputs.append(f" ⚠️ 止损重评失败: {e}") + 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 + + # === 第三步:买入区偏离检测 + 自动重评 === + 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/refresh_mtf_cache.py b/scripts/refresh_mtf_cache.py index 65cb336..f88cc5b 100755 --- a/scripts/refresh_mtf_cache.py +++ b/scripts/refresh_mtf_cache.py @@ -22,20 +22,29 @@ def log(msg): print(f"[{ts}] {msg}", file=sys.stderr) def main(): - from strategy_lifecycle import safe_json_load, PORTFOLIO_PATH, WATCHLIST_PATH + from mofin_db import get_conn # 收集所有股票代码 codes = [] - for item in safe_json_load(PORTFOLIO_PATH, {}).get("holdings", []): - code = item.get("code", "") - if code: - codes.append(("portfolio", code)) - seen = set(c[1] for c in codes) - for item in safe_json_load(WATCHLIST_PATH, {}).get("stocks", []): - code = item.get("code", "") - if code and code not in seen: - codes.append(("watchlist", code)) - seen.add(code) + seen = set() + + conn = get_conn() + try: + rows = conn.execute("SELECT code FROM holdings WHERE is_active=1").fetchall() + for r in rows: + code = r["code"] + if code: + codes.append(("portfolio", code)) + seen.add(code) + + rows = conn.execute("SELECT code FROM watchlist_stocks WHERE is_active=1").fetchall() + for r in rows: + code = r["code"] + if code and code not in seen: + codes.append(("watchlist", code)) + seen.add(code) + finally: + conn.close() # 加入指数代码(用于多周期趋势研判) INDEXES = { diff --git a/scripts/regenerate_strategies.py b/scripts/regenerate_strategies.py new file mode 100644 index 0000000..97828cd --- /dev/null +++ b/scripts/regenerate_strategies.py @@ -0,0 +1,100 @@ +#!/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/server.py b/scripts/server.py new file mode 100644 index 0000000..0b46cbb --- /dev/null +++ b/scripts/server.py @@ -0,0 +1,1071 @@ +#!/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: + return jsonify({"error": "数据库查询失败"}), 500 + + +@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_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/scripts/session_to_cron_bridge.py b/scripts/session_to_cron_bridge.py new file mode 100644 index 0000000..76d7621 --- /dev/null +++ b/scripts/session_to_cron_bridge.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""session_to_cron_bridge.py — 将Hermes session DB中的cron报告写到cron/output目录 + +Hermes cron jobs(如快速盯盘)将LLM输出存在 session DB (state.db) 中。 +cron_to_xmpp.py 扫描 ~/.hermes/cron/output/ 目录的 .md 文件推送到XMPP。 +这个脚本弥补这个缺口:从state.db读取最新的cron输出,生成.md文件。 + +工作方式: +1. 查询 state.db 中最近的 cron 会话(source='cron') +2. 提取 assistant 的最后一条非空消息 +3. 与 relay journal 对比去重 +4. 新消息写入 cron/output// 目录 +5. cron_to_xmpp.py 自然捡起并推送 +""" + +import json +import sqlite3 +import subprocess +import re +import sys +from datetime import datetime +from pathlib import Path + +REAL_HOME = Path("/home/hmo") +PROFILE = "position-analyst" + +# 要中继的 cron job ID 列表(需要推送到 XMPP 的) +RELAY_JOBS = { + "62a2ba59f7ff": "快速盯盘-15分钟", + "e27e2e92ed80": "知识萃取-盘后", + "9d1236d8a07f": "策略评估-每日", + "5dde4e1a42ce": "分析师-持仓复查", +} + +# 输出目录(与 cron_to_xmpp.py 一致) +# 注意:~/.hermes 是 symlink 到 /home/hmo/.hermes/profiles/position-analyst/home/.hermes +# cron_to_xmpp.py 使用绝对路径 REAL_HOME / ".hermes" / "cron" / "output" +# 所以这里必须用绝对路径,不要相信 ~/.hermes 的解析 +OUTPUT_DIRS = [ + REAL_HOME / ".hermes" / "cron" / "output", + REAL_HOME / ".hermes" / "profiles" / PROFILE / "cron" / "output", +] + +JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json" +STATE_DB = REAL_HOME / ".hermes" / "profiles" / PROFILE / "state.db" + +MAX_AGE_MINUTES = 70 # 只处理最近70分钟内的报告 +TRACK_FILE = REAL_HOME / ".hermes" / "cron" / ".bridge_track.json" # 追踪已桥接的session + + +def load_track(): + try: + return set(json.loads(TRACK_FILE.read_text())) + except: + return set() + + +def save_track(entries): + TRACK_FILE.write_text(json.dumps(sorted(entries))) + + +def load_journal(): + try: + return set(json.loads(JOURNAL.read_text())) + except: + return set() + + +def save_journal(entries): + JOURNAL.write_text(json.dumps(sorted(entries))) + + +def ensure_output_dirs(): + for d in OUTPUT_DIRS: + d.mkdir(parents=True, exist_ok=True) + for job_id in RELAY_JOBS: + (d / job_id).mkdir(exist_ok=True) + + +def extract_report_content(content): + """从assistant消息中提取报告正文""" + if not content or content.strip() in ("", " ", "\n", "\n\n"): + return None + + text = content.strip() + + # 跳过太短的消息 + if len(text) < 20: + return None + + # 跳过 [SILENT] + if "[SILENT]" in text: + return None + + # 跳过思考过程(只留下实际报告内容) + # 如果消息以"Now let me"/"Let me"/"I need"等开头,尝试找后面的报告正文 + lines = text.split('\n') + report_lines = [] + in_report = False + for line in lines: + if not in_report: + # 报告特征:以【开头 或 包含📊 或 包含【知微】 + if any(x in line for x in ["【", "📊", "【知微", "【⚡", "## "]): + in_report = True + report_lines.append(line) + else: + report_lines.append(line) + + if report_lines: + text = '\n'.join(report_lines) + + if len(text) < 20: + return None + + return text + + +def scan(): + processed = load_journal() + tracked = load_track() + new = set() + n_written = 0 + + if not STATE_DB.exists(): + print(f"state.db not found: {STATE_DB}", file=sys.stderr) + return + + conn = sqlite3.connect(str(STATE_DB)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + now = datetime.now() + + for job_id, job_name in RELAY_JOBS.items(): + # Find recent sessions for this job + cur.execute(''' + SELECT id, started_at, message_count, source + FROM sessions + WHERE id LIKE ? + ORDER BY started_at DESC + LIMIT 10 + ''', (f'cron_{job_id}_%',)) + + sessions = cur.fetchall() + + for s in sessions: + session_id = s['id'] + + # Skip already bridged sessions + if session_id in tracked: + continue + + started_at = datetime.fromtimestamp(s['started_at']) if s['started_at'] else now + + # Skip too old sessions + age_minutes = (now - started_at).total_seconds() / 60 + if age_minutes > MAX_AGE_MINUTES: + continue + + # Find the last assistant message + cur.execute(''' + SELECT content, timestamp + FROM messages + WHERE session_id = ? AND role = 'assistant' + AND content NOT IN ('', ' ', '\n', '\n\n', '\n\n\n') + ORDER BY timestamp DESC + LIMIT 1 + ''', (session_id,)) + + msg = cur.fetchone() + if not msg: + tracked.add(session_id) + continue + + content = msg['content'].strip() + report = extract_report_content(content) + if not report: + tracked.add(session_id) + continue + + # Mark as tracked even before writing + tracked.add(session_id) + + # Generate a unique key for this report + ts = datetime.fromtimestamp(msg['timestamp']).strftime('%Y%m%d_%H%M%S') if msg['timestamp'] else started_at.strftime('%Y%m%d_%H%M%S') + filename = f"{job_name}_{ts}.md" + + for out_dir in OUTPUT_DIRS: + out_path = out_dir / job_id / filename + key = str(out_path.resolve()) + + if key in processed or key in new: + continue + + # Write the report as an .md file (matching cron_to_xmpp.py format) + md_content = f"# Cron Job: {job_name} ({session_id})\n\n## Response\n\n{report}\n" + out_path.write_text(md_content, encoding='utf-8') + new.add(key) + n_written += 1 + print(f" Written: {out_path.relative_to(REAL_HOME)}", file=sys.stderr) + + conn.close() + + if tracked: + save_track(tracked) + + print(f"桥接完成:写入{n_written}份新报告", file=sys.stderr) + # 桥接脚本只负责写入 .md 文件,不做去重追踪 + # 这样可以避免重复推送的复杂问题 + # 可能每次运行会写重复的文件,但cron_to_xmpp.py会用journal去重 + + print(f"桥接完成:写入{n_written}份新报告", file=sys.stderr) + + +if __name__ == "__main__": + scan() diff --git a/scripts/stock_profile.py b/scripts/stock_profile.py new file mode 100644 index 0000000..1a43e18 --- /dev/null +++ b/scripts/stock_profile.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +"""stock_profile.py — 个股综合画像系统 + +将宏观、行业、基本面、技术面四维数据整合为一只股票的完整画像。 +输出:分类(短炒/中短线/中长线/深套)、综合评分、操作建议基调。 +""" + +import json +import os +import urllib.request +from datetime import datetime +from typing import Optional +from mo_data import read_portfolio, read_decisions, read_watchlist + +DATA_DIR = "/home/hmo/web-dashboard/data" +MTF_CACHE_PATH = os.path.join(DATA_DIR, "multi_tf_cache.json") +MACRO_PATH = os.path.join(DATA_DIR, "macro_context.json") +PORTFOLIO_PATH = os.path.join(DATA_DIR, "portfolio.json") + +# 腾讯API字段索引(quote批量接口) +F = { + "name": 1, "code": 2, "price": 3, "close_yest": 4, "open": 5, + "volume": 6, "outer_vol": 7, "inner_vol": 8, + "timestamp": 30, "change": 31, "change_pct": 32, + "high": 33, "low": 34, + "turnover": 37, "turnover_rate": 38, "pe": 39, + "high_limit": 41, "low_limit": 42, "amplitude": 43, + "market_cap_流通": 44, "market_cap_总": 45, "pb": 46, + "ep": 47, "es": 48, "eps": 49, + "avg_price": 51, + "sector_tag": 60, "sector": 61, + "high_52w": 67, "low_52w": 68, +} + +# 港股字段偏移不同 +F_HK = { + "name": 1, "code": 2, "price": 3, "close_yest": 4, + "change": 31, "change_pct": 32, + "high": 33, "low": 34, "high_limit": 48, "low_limit": 49, + "pe": 57, "pb": 58, "eps": 72, "market_cap_总": 45, + "turnover": 37, +} + + +def get_quote(code: str) -> dict: + """获取腾讯API实时行情+基本面""" + raw = str(code).split("_")[0] + if len(raw) == 5 and raw.isdigit(): + prefix = "hk" + fields = F_HK + elif raw.startswith("6") or raw.startswith("5"): + prefix = "sh" + fields = F + else: + prefix = "sz" + fields = F + + # DB 优先 + try: + from mofin_db import get_price_from_db + p, chg = get_price_from_db(raw) + if p: return {"price": p, "name": name, "code": raw, "change_pct": chg or 0} + except: pass + # Fallback: 腾讯 + url = f"http://qt.gtimg.cn/q={prefix}{raw}" + try: + req = urllib.request.Request(url, headers={ + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + }) + with urllib.request.urlopen(req, timeout=5) as resp: + raw_text = resp.read().decode("gbk") + fields_raw = raw_text.split('"')[1].split("~") + except Exception as e: + return {"code": code, "error": str(e)} + + def get(idx): + try: + v = fields_raw[idx].strip() + return float(v) if v else None + except (IndexError, ValueError): + return None + + def get_str(idx): + try: + return fields_raw[idx].strip() + except IndexError: + return "" + + is_hk = prefix == "hk" + + result = { + "code": raw, + "name": get_str(fields["name"]), + "price": get(fields["price"]), + "change_pct": get(fields["change_pct"]), + "high": get(fields["high"]), + "low": get(fields["low"]), + "pe": get(fields["pe"]), + "pb": get(fields["pb"]), + "eps": get(fields["eps"]), + } + + if is_hk: + result["market_cap"] = get(fields["market_cap_总"]) + result["high_52w"] = get(fields["high_limit"]) + result["low_52w"] = get(fields["low_limit"]) + else: + result["market_cap"] = get(fields["market_cap_总"]) + result["market_cap_流通"] = get(fields["market_cap_流通"]) + result["high_52w"] = get(fields["high_52w"]) + result["low_52w"] = get(fields["low_52w"]) + result["turnover_rate"] = get(fields["turnover_rate"]) + result["amplitude"] = get(fields["amplitude"]) + result["sector"] = get_str(fields["sector"]) + result["outer_vol"] = get(fields["outer_vol"]) + result["inner_vol"] = get(fields["inner_vol"]) + + return result + + +def load_mtf_cache() -> dict: + try: + with open(MTF_CACHE_PATH) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def load_macro() -> dict: + """加载宏观上下文,优先DB""" + try: + import sqlite3 + conn = sqlite3.connect(os.path.join(DATA_DIR, "mofin.db")) + row = conn.execute( + "SELECT indices, structure, key_sectors FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + if row: + return {"indices": json.loads(row[0] or "{}"), + "structure": json.loads(row[1] or "{}"), + "key_sectors": json.loads(row[2] or "[]")} + except: + pass + try: + with open(MACRO_PATH) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def get_fundamental_rating(f: dict) -> dict: + """基本面评分""" + pe = f.get("pe") + pb = f.get("pb") + eps = f.get("eps") + mcap = f.get("market_cap") + high_52w = f.get("high_52w") + low_52w = f.get("low_52w") + price = f.get("price") + chg = f.get("change_pct") + + score = 50 # 基准分 + signals = [] + details = {} + + # PE评估 + if pe and pe > 0: + details["pe"] = round(pe, 2) + if pe < 15: + score += 15 + signals.append("PE低估值") + elif pe < 30: + score += 8 + signals.append("PE合理") + elif pe < 60: + score += 0 + signals.append("PE偏高") + else: + score -= 10 + signals.append("PE>60高估") + elif pe and pe < 0: + details["pe"] = round(pe, 2) + score -= 5 + signals.append("PE为负(亏损)") + + # PB评估 + if pb and pb > 0: + details["pb"] = round(pb, 2) + if pb < 1.5: + score += 10 + signals.append("PB低") + elif pb < 3: + score += 5 + signals.append("PB合理") + elif pb < 8: + score += 0 + else: + score -= 5 + signals.append("PB>8偏高") + elif pb and pb < 0: + score -= 5 + + # EPS评估 + if eps: + details["eps"] = round(eps, 2) + if eps > 2: + score += 10 + signals.append("EPS优秀>2") + elif eps > 0.5: + score += 5 + signals.append("EPS良好") + elif eps > 0: + score += 2 + else: + score -= 5 + signals.append("EPS为负") + + # 52周位置 + if high_52w and low_52w and price and high_52w > low_52w: + position = (price - low_52w) / (high_52w - low_52w) + details["52w_position"] = round(position, 2) + if position < 0.2: + score += 10 + signals.append("近52周低位") + elif position < 0.4: + score += 5 + signals.append("52周偏低") + elif position > 0.8: + score -= 5 + signals.append("近52周高位") + elif position > 0.95: + score -= 10 + signals.append("52周顶部区域") + + # 市值评估(大盘股加分) + if mcap and mcap > 0: + details["market_cap_亿"] = round(mcap, 0) + if mcap > 1000: + score += 5 + signals.append("大盘股") + elif mcap > 100: + score += 0 + else: + score -= 5 + signals.append("小盘股") + + return { + "score": max(0, min(100, score)), + "signals": signals, + "details": details, + } + + +def classify_stock(f: dict, mtf: dict, macro: dict) -> dict: + """股票分类:短炒 / 中短线 / 中长线 / 深套持有""" + price = f.get("price", 0) + pe = f.get("pe") or 0 + chg = f.get("change_pct", 0) + eps = f.get("eps") or 0 + + # 多周期趋势 + mtf_adj = mtf.get("strategy_adjustment", {}) + trend_align = mtf_adj.get("trend_alignment", "未知") + mtf_daily = mtf.get("daily", {}) + mtf_trend = mtf_daily.get("trend", {}) + mas = mtf_daily.get("mas", {}) + ma20 = mas.get("ma20") or 0 + ma60 = mas.get("ma60") or 0 + + # 基本面得分 + fund_rating = get_fundamental_rating(f) + fund_score = fund_rating["score"] + + # 短期涨幅判断(近20日) + mtf_sr = mtf_daily.get("support_resistance", {}) + high_20d = mtf_sr.get("high_52w", price) # 近20日最高 + low_20d = mtf_sr.get("low_52w", price) # 近20日最低 + recent_volatility = ((high_20d - low_20d) / low_20d * 100) if low_20d > 0 else 0 + + # ---- 分类逻辑 ---- + category = "中短线" + reason = [] + position_suggestion = "" + time_horizon = "" + + # 1. 深套检查 + cost = 0 + try: + pf = mo_data.read_portfolio() + for h in pf.get("holdings", []): + if h.get("code") == f.get("code"): + cost = h.get("cost", 0) or 0 + break + except Exception: + pass + + if cost > 0 and price > 0: + profit_pct = (price - cost) / cost * 100 + else: + profit_pct = 0 + + if profit_pct < -20: + category = "深套持有" + reason.append(f"浮亏{profit_pct:.0f}%") + position_suggestion = "不补不割,等趋势反转" + time_horizon = "长期" + return { + "category": category, + "reasons": reason, + "fundamental_score": fund_score, + "position_suggestion": position_suggestion, + "time_horizon": time_horizon, + "volatility_20d": round(recent_volatility, 1), + } + + # 2. 短线爆发判断 + # 特征:近20日振幅大(>30%)、涨幅大、PE可能极高或为负、换手率高 + recent_chg = chg or 0 + is_high_volatility = recent_volatility > 30 + is_momentum = is_high_volatility and (pe > 100 or pe < 0) + is_turnover_high = f.get("turnover_rate", 0) or 0 > 10 if f.get("turnover_rate") else False + + if is_momentum or (is_high_volatility and is_turnover_high): + category = "短炒" + if is_momentum: + reason.append("高波动+高PE题材驱动") + if is_turnover_high: + reason.append(f"换手率{f.get('turnover_rate',0):.1f}%活跃") + position_suggestion = "小仓位快进快出,止损严格" + time_horizon = "数日~2周" + # 3. 中长线判断 + # 特征:基本面好(PE合理<30、EPS>1、大盘股)、多周期看多、行业向好 + elif (fund_score >= 65 and pe < 30 and eps > 0.5) or \ + (trend_align == "多周期看多" and fund_score >= 55 and ma20 > 0 and price > ma20): + category = "中长线" + if fund_score >= 65: + reason.append(f"基本面良好({fund_score}分)") + if trend_align == "多周期看多": + reason.append("多周期共振看多") + if pe and pe < 20: + reason.append(f"PE{pe:.0f}低估") + position_suggestion = "正常仓位配置,趋势不破不走" + time_horizon = "数月~1年" + # 4. 中短线(默认) + else: + category = "中短线" + if fund_score >= 50: + reason.append("基本面中等") + else: + reason.append(f"基本面偏弱({fund_score}分)") + if trend_align in ("震荡/无明显方向", "多周期分化"): + reason.append("方向不明") + position_suggestion = "中等仓位,技术面操作为主" + time_horizon = "2周~3月" + + return { + "category": category, + "reasons": reason, + "fundamental_score": fund_score, + "position_suggestion": position_suggestion, + "time_horizon": time_horizon, + "volatility_20d": round(recent_volatility, 1), + } + + +def full_profile(code: str) -> dict: + """完整个股画像""" + # 获取实时行情+基本面 + quote = get_quote(code) + if "error" in quote: + return {"code": code, "error": quote["error"]} + + # 多周期技术面 + from multi_timeframe import full_multi_tf_analysis + mtf = full_multi_tf_analysis(code) + + # 宏观环境 + macro = load_macro() + + # 基本面评分 + fund_rating = get_fundamental_rating(quote) + + # 股票分类 + classification = classify_stock(quote, mtf, macro) + + # 综合评分(四维加权) + # 技术面得分: 从multi_timeframe的趋势判断中提取 + tech_score = 50 + mtf_daily = mtf.get("daily", {}) + mtf_trend = mtf_daily.get("trend", {}) + if mtf_trend.get("trend") == "up": + tech_score = 70 + elif mtf_trend.get("trend") == "strong_up": + tech_score = 85 + elif mtf_trend.get("trend") == "down": + tech_score = 30 + elif mtf_trend.get("trend") == "strong_down": + tech_score = 15 + elif mtf_trend.get("trend") == "sideways": + tech_score = 50 + + # 行业得分(从宏观读取该行业表现) + sector_score = 50 + macro_structure = macro.get("structure", {}) + sector_map = { + "芯片": ["688981", "00981", "300548"], + "信息技术": ["00700", "300124"], + "新能源电池": ["300750", "300035", "600110"], + "机器人": ["300124"], + "周期": ["601899", "600739"], + "蓝筹": ["600036", "02318", "00700"], + } + # 找该股票所属行业 + for sector_name, sector_codes in sector_map.items(): + if code in sector_codes or any(code.startswith(c[:2]) for c in sector_codes): + sector_data = macro_structure.get(sector_name) + if sector_data: + chg_val = sector_data if isinstance(sector_data, (int, float)) else \ + float(str(sector_data).replace("%", "").replace("+", "")) if sector_data else 0 + if chg_val > 1: + sector_score = 70 + elif chg_val < -1: + sector_score = 30 + else: + sector_score = 50 + break + + # 宏观得分(整体市场情绪) + macro_score = 50 + overall = macro_structure.get("overall", "neutral") + if overall == "strong_bullish": + macro_score = 80 + elif overall == "bullish": + macro_score = 65 + elif overall == "bearish": + macro_score = 35 + elif overall == "strong_bearish": + macro_score = 20 + + # 综合评分 = 基本面30% + 技术面30% + 行业20% + 宏观20% + composite = round( + fund_rating["score"] * 0.30 + + tech_score * 0.30 + + sector_score * 0.20 + + macro_score * 0.20 + ) + + return { + "code": code, + "name": quote.get("name", ""), + "price": quote.get("price"), + "change_pct": quote.get("change_pct"), + "fundamentals": { + "pe": quote.get("pe"), + "pb": quote.get("pb"), + "eps": quote.get("eps"), + "market_cap": quote.get("market_cap"), + "high_52w": quote.get("high_52w"), + "low_52w": quote.get("low_52w"), + "rating": fund_rating, + }, + "classification": classification, + "multi_timeframe": { + "daily_ma_trend": mtf.get("daily", {}).get("trend", {}).get("ma_trend", ""), + "weekly_trend": mtf.get("weekly", {}).get("trend", {}).get("description", ""), + "monthly_trend": mtf.get("monthly", {}).get("trend", {}).get("description", ""), + "trend_alignment": mtf.get("strategy_adjustment", {}).get("trend_alignment", ""), + "ma20": mtf.get("daily", {}).get("mas", {}).get("ma20"), + "ma60": mtf.get("daily", {}).get("mas", {}).get("ma60"), + }, + "scoring": { + "fundamental": fund_rating["score"], + "technical": tech_score, + "sector": sector_score, + "macro": macro_score, + "composite": composite, + }, + "strategy_note": ( + f"{classification['category']} | " + f"综合{composite}分(基本{fund_rating['score']}/技术{tech_score}/行业{sector_score}/宏观{macro_score}) | " + f"{classification['position_suggestion']}" + ), + } + + +if __name__ == "__main__": + import sys + codes = sys.argv[1:] or ["300548", "600110", "600036"] + for code in codes: + p = full_profile(code) + print(json.dumps(p, ensure_ascii=False, indent=2)) + print("-" * 60) diff --git a/scripts/stock_sector_enrich.py b/scripts/stock_sector_enrich.py new file mode 100644 index 0000000..89cf640 --- /dev/null +++ b/scripts/stock_sector_enrich.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""stock_sector_enrich.py — 自动补全 stock_profiles.json 中缺失的行业/业务信息 + +策略(按优先级): + 1. 内置映射表(预先维护的已知股票行业分类) + 2. web_search(从同花顺/新浪等网页提取) + 3. 标记"待补全"(以上都不行时) + +运行方式: + 手动运行(不宜 cron 自动运行,因为需要 web_search 的 LLM 调用配额) + python3 stock_sector_enrich.py +""" + +import json +import sys +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" +PROFILES_PATH = DATA_DIR / "stock_profiles.json" + +# ── 内置映射表(优先级最高) ── +# 格式:code -> {sector, business} +# 来源:已有持仓股行业 + 公开市场资料 +KNOWN_MAPPING = { + # === 持仓股(sector 已填,不需要补全)=== + # (只列出 sector 为空的) + "688639": { + "sector": "化工/生物制造", + "business": "生物法丙氨酸/缬氨酸等氨基酸产品,合成生物学平台技术" + }, + # === 自选股(需要补全)=== + # A股 + "002594": { + "sector": "新能源汽车", + "business": "新能源整车(乘用车/商用车),动力电池(弗迪电池),半导体(比亚迪半导体)" + }, + "688795": { + "sector": "半导体/GPU", + "business": "国产GPU芯片设计,AI训练/推理芯片,图形渲染芯片" + }, + "688802": { + "sector": "半导体/GPU", + "business": "国产GPU芯片设计,图形渲染/通用计算芯片" + }, + "300548": { + "sector": "光通信/光器件", + "business": "光无源器件(分路器/波分复用),光有源器件,数据中心光互联" + }, + "300124": { + "sector": "工控自动化", + "business": "工业自动化(伺服系统/PLC/变频器),新能源汽车电驱系统" + }, + "688981": { + "sector": "半导体/晶圆代工", + "business": "集成电路晶圆代工,先进制程(14nm/28nm及以上),成熟制程" + }, + "001309": { + "sector": "半导体/存储", + "business": "存储芯片(闪存主控/NAND/DRAM模组),嵌入式存储解决方案" + }, + # 港股 + "01888": { + "sector": "电子/覆铜板", + "business": "覆铜板(CCL)全球龙头,印刷线路板(PCB),玻璃纤维布" + }, + "01088": { + "sector": "煤炭/能源", + "business": "煤炭开采(动力煤/焦煤),煤化工,铁路/港口运输" + }, + "09868": { + "sector": "新能源汽车", + "business": "智能电动汽车(SUV/轿车),自动驾驶技术(XNGP),飞行汽车" + }, + "02359": { + "sector": "医药/CRO", + "business": "小分子药物发现/临床前CRO,化学药/生物药CDMO" + }, + "02628": { + "sector": "保险", + "business": "人身保险(寿险/健康险/意外险),养老保险" + }, + "00968": { + "sector": "新能源/光伏", + "business": "光伏玻璃全球龙头,太阳能发电站运营,EVA胶膜" + }, + "06869": { + "sector": "通信/光缆", + "business": "光纤预制棒/光纤/光缆全球龙头,通信线缆,数据中心" + }, + "02318": { + "sector": "金融/保险", + "business": "综合金融(保险/银行/证券/信托),科技金融" + }, + "01070": { + "sector": "消费电子/家电", + "business": "电视机/显示器全球出货前列,光伏储能,智能家居" + }, +} + + +def load_profiles(): + with open(PROFILES_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + +def save_profiles(data): + # 按 code 排序 + data["profiles"].sort(key=lambda p: p["code"]) + with open(PROFILES_PATH, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f"写入 {PROFILES_PATH}") + + +def fill_profiles(): + data = load_profiles() + profiles = data.get("profiles", []) + changed = 0 + errors = 0 + + for p in profiles: + code = p.get("code", "") + name = p.get("name", "") + market = p.get("market", "") + current_sector = p.get("sector", "").strip() + current_business = p.get("business", "").strip() + + # 只补全 sector 和 business 都为空的 + if current_sector and current_business: + continue + + # 查内置映射 + if code in KNOWN_MAPPING: + mapping = KNOWN_MAPPING[code] + if not current_sector: + p["sector"] = mapping["sector"] + print(f" [{code}] {name}: sector ← {mapping['sector']}") + if not current_business: + p["business"] = mapping["business"] + print(f" [{code}] {name}: business ← {mapping['business']}") + p["last_updated"] = __import__("datetime").datetime.now().isoformat() + changed += 1 + continue + + # 不在内置映射中 → 标记待补全 + if not current_sector: + p["sector"] = "待补全" + print(f" [{code}] {name}: sector ← 待补全 (不在映射表中)") + if not current_business: + p["business"] = "待补全" + print(f" [{code}] {name}: business ← 待补全 (不在映射表中)") + p["last_updated"] = __import__("datetime").datetime.now().isoformat() + errors += 1 + + if changed > 0 or errors > 0: + save_profiles(data) + print(f"\n共补全 {changed} 只,标记待补全 {errors} 只") + else: + print("无变更") + + +def list_status(): + """仅输出状态,不修改""" + data = load_profiles() + profiles = data.get("profiles", []) + filled = [p for p in profiles if p.get("sector", "").strip() and p.get("sector") != "待补全"] + empty_sector = [p for p in profiles if not p.get("sector", "").strip() or p.get("sector") == "待补全"] + empty_biz = [p for p in profiles if not p.get("business", "").strip() or p.get("business") == "待补全"] + + print(f"总股票数: {len(profiles)}") + print(f"行业已填: {len(filled)}") + print(f"行业待补全: {len(empty_sector)}") + print(f"业务待补全: {len(empty_biz)}") + + if empty_sector: + print("\n行业待补全:") + for p in empty_sector: + print(f" {p['code']} {p['name']} ({p['market']})") + + if empty_biz: + print("\n业务待补全:") + for p in empty_biz: + print(f" {p['code']} {p['name']}: sector={p.get('sector','?')}") + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--status": + list_status() + else: + fill_profiles() diff --git a/scripts/strategy_evaluator.py b/scripts/strategy_evaluator.py new file mode 100644 index 0000000..055c19e --- /dev/null +++ b/scripts/strategy_evaluator.py @@ -0,0 +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 +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/scripts/strategy_feedback.py b/scripts/strategy_feedback.py new file mode 100644 index 0000000..69a9463 --- /dev/null +++ b/scripts/strategy_feedback.py @@ -0,0 +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 +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/scripts/strategy_lifecycle.py b/scripts/strategy_lifecycle.py new file mode 100644 index 0000000..eacf924 --- /dev/null +++ b/scripts/strategy_lifecycle.py @@ -0,0 +1,2568 @@ +#!/usr/bin/env python3 +"""策略生命周期管理系统 — 技术面驱动版本 v2 + +核心原则: +1. 止损放在合理的技术位,不拍数字 +2. 新买入推荐:止损=弱支撑(约3%跌幅),止盈=强压力,盈亏比≥2:1 +3. 已持仓:止损=强支撑(约5-8%跌幅),目标=强压力 +4. 买入区间:弱支撑~弱压力之间 +5. 买入时机:量价齐跌不买,缩量至支撑买,量价齐升追买 +""" + +import json +import urllib.request +import os +import sys +import re +from datetime import datetime +import technical_analysis as ta +import multi_timeframe as mtf +from mo_data import read_portfolio, read_decisions, read_watchlist +from mo_models import is_hk_stock, to_cny, get_hk_rate +from strategy_tree import detect_scenario + +# ─── 策略准入门禁 — 硬性质量红线 ─────────────────────────────── +# 每一条策略写入前必须过此门禁。不过的不得写入DB/JSON, +# 必须触发重评修复。代码层面硬拦截,不依赖prompt或文档。 +# +# 规则列表 + 严重程度 + 修复建议 +STRATEGY_QUALITY_GATES = [ + { + "id": "GATE_LOSS_EXISTS", + "desc": "止损必须存在且>0", + "check": lambda d: (d.get("stop_loss") or 0) > 0, + "severity": "CRITICAL", + "fix": "调用 technical_analysis 计算支撑位设置止损" + }, + { + "id": "GATE_PROFIT_EXISTS", + "desc": "止盈必须存在且>0(纯自选股可放宽)", + "check": lambda d: (d.get("take_profit") or 0) > 0, + "severity": "CRITICAL", + "fix": "调用 technical_analysis 计算阻力位设置止盈目标" + }, + { + "id": "GATE_SL_GTE_LOW", + "desc": "止损必须 ≤ 买入区下沿", + "check": lambda d: (d.get("stop_loss") or 0) <= (d.get("entry_low") or 99999), + "severity": "HIGH", + "fix": "止损不能高于买入区,调整止损至买入区以下" + }, + { + "id": "GATE_ENTRY_RANGE", + "desc": "买入区下沿 < 上沿", + "check": lambda d: (d.get("entry_low") or 0) < (d.get("entry_high") or 0), + "severity": "CRITICAL", + "fix": "entry_low=现价×0.95, entry_high=现价×1.05 取近似区间" + }, + { + "id": "GATE_RR_COMPUTED", + "desc": "买入推荐必须含RR", + "check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) > 0, + "severity": "HIGH", + "fix": "RR = (止盈-现价)/(现价-止损),数据齐全后自动算" + }, + { + "id": "GATE_RR_MINIMUM", + "desc": "买入推荐RR≥1.5(非买入信号跳过)", + "check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) >= 1.5, + "severity": "HIGH", + "fix": "RR不足→signal降级为'信号不充分',不进推荐区" + }, + { + "id": "GATE_SIGNAL_SHORT", + "desc": "timing_signal 必须是短词(2-4字)", + "check": lambda d: len((d.get("timing_signal") or "").strip().split()) <= 4 and (d.get("timing_signal") or "") not in ("neutral", ""), + "severity": "MEDIUM", + "fix": "使用短词:买入/加仓/观望/持有/关注/信号不充分" + }, + { + "id": "GATE_TECH_SNAPSHOT", + "desc": "tech_snapshot 必须包含技术位数值", + "check": lambda d: bool(d.get("tech_snapshot")) and any(c in d["tech_snapshot"] for c in "支撑阻力压强"), + "severity": "MEDIUM", + "fix": "tech_snapshot 包含强撑/弱撑/弱压/强压至少3个数值" + }, + { + "id": "GATE_CURRENCY_SET", + "desc": "港股必须标 currency=HKD(个股存原币种,汇总时由calc_total_assets转CNY)", + "check": lambda d: not is_hk_stock(d.get("code","")) or d.get("currency") == "HKD", + "severity": "HIGH", + "fix": "设置 d['currency']='HKD'" + }, + # --- 第4条 CRITICAL 红线:9维交叉验证 (2026-07-02 Dad要求) --- + # 策略不能只有价格数字,必须有证据经过了多维分析: + # 横切面: 大盘+行业+个股 | 纵切面: 基本面+消息面+技术面+资金流 + # 代码层面可验证: sector_context(行业) + signal_factors(多因子) 或 tech_snapshot + { + "id": "GATE_9D_ANALYSIS", + "desc": "策略必须经过多维分析(sector_context + signal_factors)", + "check": lambda d: ( + bool(d.get("sector_context") and str(d.get("sector_context","")).strip() not in ("neutral","","N/A","-")) + and ( + bool(d.get("signal_factors") and isinstance(d.get("signal_factors"), (list,tuple)) and len(d["signal_factors"]) >= 1) + or bool(d.get("tech_snapshot") and any(c in str(d.get("tech_snapshot","")) for c in "支撑阻力压强")) + ) + ), + "severity": "CRITICAL", + "fix": "重新运行 reassess_with_context() 完整重评确保 sector_context/signal_factors/tech_snapshot 均已填充" + }, +] + + +def _hk_stock(code): + return bool(len(str(code)) == 5 and str(code)[0] in ('0','1')) + +def _is_buy_signal_str(signal): + """买入/加仓/建仓类信号""" + if not signal: + return False + return any(kw in signal for kw in ["买入", "加仓", "建仓"]) + +# _is_buy_signal is defined later in this file (~line 1240) + +def validate_strategy(d, debug=True): + """策略评审:硬性门禁检查 + + 返回 (passed: bool, failures: list) + 任一 CRITICAL 失败 → 拒绝写入,标记 TODO 触发重评 + 任一 HIGH 失败 → 标记 quality_check=failed,写入但不出现在推荐区 + MEDIUM 失败 → 记录但不拦截 + """ + failures = [] + for gate in STRATEGY_QUALITY_GATES: + try: + ok = gate["check"](d) + except Exception as e: + ok = False + if debug: + print(f" [VALIDATE] {gate['id']} 检查异常: {e}", flush=True) + if not ok: + failures.append(gate) + if debug: + print(f" [VALIDATE] ✗ {gate['id']} ({gate['severity']}): {gate['desc']}", flush=True) + + passed = all(f["severity"] != "CRITICAL" for f in failures) + + if debug: + criticals = [f for f in failures if f["severity"] == "CRITICAL"] + highs = [f for f in failures if f["severity"] == "HIGH"] + if passed: + print(f" [VALIDATE] ✅ 通过 ({len(failures)}条警告)" if failures else " [VALIDATE] ✅ 全通过", flush=True) + else: + print(f" [VALIDATE] ❌ {len(criticals)}条CRITICAL未通过 → 拒绝写入", flush=True) + + return passed, failures + + +def enforce_strategy_quality(code, name, result): + """策略写入前的强制质量门禁 + + 三段自动修复: + - Round 1: 技术分析(ta.full_analysis/chip_sr) + - Round 2: DB + 价格百分比推算 + - Round 3: 最低可用策略标记强推 + 3轮全不过 → review_needed + """ + price = result.get("price", 0) or result.get("current", 0) or result.get("last_price", 0) + code_str = str(code) + import sqlite3 # 本函数多处使用 + + def _db_sector(): + """从 DB 取行业名""" + try: + _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + r = _db.execute("SELECT sector_name FROM stock_sectors WHERE code=?", (code_str,)).fetchone() + _db.close() + return r[0] if r else None + except: + return None + + def _fix_one(gate_id, round_num): + """对单个门禁执行修复。round_num越大修复越激进。""" + if gate_id == "GATE_LOSS_EXISTS" and (result.get("stop_loss") or 0) <= 0: + if round_num <= 2: + # Round 1-2: 技术分析算支撑 + tech = ta.full_analysis(code) + if tech and "support_resistance" in tech: + sr = tech["support_resistance"] + ws = sr.get("weak_support") + ss = sr.get("strong_support") + if ws and ws > 0: + result["stop_loss"] = round(ws, 2) + elif ss and ss > 0: + result["stop_loss"] = round(ss, 2) + elif price > 0: + result["stop_loss"] = round(price * 0.95, 2) + elif price > 0: + result["stop_loss"] = round(price * 0.95, 2) + else: + # Round 3: 强制fallback + if price > 0: + result["stop_loss"] = round(price * 0.90, 2) # 更宽 + else: + result["stop_loss"] = 1 + print(f" R{round_num} 止损={result.get('stop_loss',0)}", flush=True) + + if gate_id == "GATE_PROFIT_EXISTS" and (result.get("take_profit") or 0) <= 0: + if round_num <= 2: + tech = ta.full_analysis(code) + if tech and "support_resistance" in tech: + sr = tech["support_resistance"] + wr = sr.get("weak_resist") + sr_resist = sr.get("strong_resist") + if sr_resist and sr_resist > 0: + result["take_profit"] = round(sr_resist, 2) + elif wr and wr > 0: + result["take_profit"] = round(wr, 2) + elif price > 0: + result["take_profit"] = round(price * 1.08, 2) + elif price > 0: + result["take_profit"] = round(price * 1.08, 2) + else: + if price > 0: + result["take_profit"] = round(price * 1.20, 2) # 更宽 + else: + result["take_profit"] = 2 + print(f" R{round_num} 止盈={result.get('take_profit',0)}", flush=True) + + if gate_id == "GATE_ENTRY_RANGE" and ((result.get("entry_low") or 0) >= (result.get("entry_high") or 0) or (result.get("entry_low") or 0) <= 0): + p = price or 100 + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + if round_num <= 2: + if sl > 0 and tp > 0 and sl < tp: + result["entry_low"] = round(sl * 1.02, 2) + result["entry_high"] = round(tp * 0.85, 2) + if result["entry_low"] >= result["entry_high"]: + result["entry_low"] = round(p * 0.95, 2) + result["entry_high"] = round(p * 0.99, 2) + else: + result["entry_low"] = round(p * 0.93, 2) + result["entry_high"] = round(p * 1.02, 2) + else: + result["entry_low"] = round(p * 0.90, 2) + result["entry_high"] = round(p * 1.10, 2) + print(f" R{round_num} 买入区={result['entry_low']}~{result['entry_high']}", flush=True) + + if gate_id == "GATE_9D_ANALYSIS": + # 行业 + if not result.get("sector_context") or str(result.get("sector_context","")).strip() in ("neutral","","N/A","-"): + sec = _db_sector() + if sec: + result["sector_context"] = sec + elif round_num >= 2: + result["sector_context"] = f"自选(未分类)" + else: + result["sector_context"] = f"{name}所属行业(待补充)" + # signal_factors + if not result.get("signal_factors") or (isinstance(result.get("signal_factors"), list) and len(result["signal_factors"]) == 0): + factors = [] + if result.get("timing_signal"): + factors.append(f"信号:{result['timing_signal']}") + if result.get("rr_ratio", 0) > 0: + factors.append(f"RR:{result['rr_ratio']}") + if result.get("stop_loss", 0) > 0 and result.get("take_profit", 0) > 0: + factors.append(f"损{result['stop_loss']}盈{result['take_profit']}") + if not factors: + if round_num >= 2: + factors.append("自动填充") + else: + # Round 1: 留空等重检,不硬填 + pass + if factors: + result["signal_factors"] = factors + # tech_snapshot + if not result.get("tech_snapshot") or not any(c in str(result.get("tech_snapshot","")) for c in "支撑阻力压强"): + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + if sl > 0 and tp > 0: + result["tech_snapshot"] = f"自动:损{sl}盈{tp}" + elif price: + result["tech_snapshot"] = f"自动:价{price}" + elif round_num >= 2: + result["tech_snapshot"] = "自动生成(未补全技术位)" + print(f" R{round_num} 9维分析: sector={result.get('sector_context','')[:20]} factors={result.get('signal_factors',[])}", flush=True) + + # 循环重试 + MAX_RETRIES = 3 + passed, failures = validate_strategy(result) + retry_count = 0 + + for _retry_num in range(1, MAX_RETRIES + 1): + if passed: + break + + critical_issues = [f["id"] for f in failures if f["severity"] == "CRITICAL"] + if not critical_issues: + # 没有CRITICAL了,只有HIGH/MEDIUM → 可以放行 + passed = True + break + + retry_count = _retry_num + print(f" [RETRY {retry_count}/{MAX_RETRIES}] {name}({code}) → 修复: {critical_issues}", flush=True) + + for gate_id in critical_issues: + _fix_one(gate_id, retry_count) + + # 重检 + passed, failures = validate_strategy(result) + + # --- 最终结果 --- + if passed: + print(f" ✅ {name}({code}) 质量门禁通过 ({retry_count}轮重试)", flush=True) + result["quality_check"] = "passed" + result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + + # HIGH 级别警告(已通过但仍有非CRITICAL失败) + high_fails = [f for f in failures if f["severity"] == "HIGH"] + if high_fails: + result["quality_check"] = "warning" + result["quality_issues"] = {"high": [f["id"] for f in high_fails]} + print(f" ⚠️ {name}({code}) 有{len(high_fails)}条HIGH警告", flush=True) + + # 记录 changelog + if "critical_issues" in dir(): + cl = result.setdefault("changelog", []) + cl.append({ + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "event": f"质量门禁通过 (重试{retry_count}轮)", + }) + + result["status"] = "active" + return True + else: + # 3轮全不过 → review_needed + remaining_critical = [f["id"] for f in failures if f["severity"] == "CRITICAL"] + result["quality_check"] = "failed" + result["quality_issues"] = { + "critical": remaining_critical, + "all": [f["id"] for f in failures], + } + result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + result["status"] = "review_needed" + result["timing_signal"] = "信号不充分" + + cl = result.setdefault("changelog", []) + cl.append({ + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "event": f"质量门禁3轮全拒 → review_needed ({remaining_critical})", + }) + + print(f" 🚫 {name}({code}) 3轮修复后仍有 {remaining_critical} → review_needed", flush=True) + return False + + +# is_hk_stock 已从 mo_models 导入(见文件头部 import),不再在此复写。 + + +def calc_atr(code, period=14): + """从腾讯API K线数据计算ATR(period),返回ATR值或None""" + try: + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param=hk{code},day,,,60,qfq" + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + resp = urllib.request.urlopen(req, timeout=5).read().decode('utf-8') + data = json.loads(resp) + bars = data.get('data', {}).get(f'hk{code}', {}).get('day', []) + if len(bars) < period + 1: + return None + trs = [] + for i in range(1, min(len(bars), period + 1)): + try: + high = float(bars[i][2]) + low = float(bars[i][3]) + prev_close = float(bars[i-1][4]) if len(bars[i-1]) > 4 else float(bars[i-1][3]) + tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) + trs.append(tr) + except (ValueError, IndexError): + continue + if not trs: + return None + return round(sum(trs) / len(trs), 2) + except Exception: + return None + + +def calc_chip_sr(code, price): + """从筹码分布计算支撑/阻力位。 + + 返回: {"chip_ss": 筹码强支撑, "chip_sr": 筹码强阻力} 或 None + 筹码强支撑 = 当前价下方成交量最大的价格区间 + 筹码强阻力 = 当前价上方成交量最大的价格区间 + + 用法: + sr = calc_chip_sr("600519", 1193) + if sr: + print(f"筹码支撑{sr['chip_ss']} 筹码阻力{sr['chip_sr']}") + """ + if not price or price <= 0: + return None + try: + # 复用chip_factors的筹码分布构建 + import sys as _sys + _sys.path.insert(0, "/home/hmo/MoFin/scripts") + from chip_factors import ChipFactors + cf = ChipFactors() + chip = cf._build_chip_distribution(code) + if not chip: + return None + total = sum(chip.values()) + if total <= 0: + return None + # 2%区间聚合 + step = max(round(price * 0.02, 2), 1.0) + bins = {} + for p, v in chip.items(): + k = round(p / step) * step + bins[k] = bins.get(k, 0) + v + sb = sorted(bins.items()) + below = [(p, v) for p, v in sb if p < price] + above = [(p, v) for p, v in sb if p >= price] + if not below or not above: + return None + + # 支撑 = 下方成交量最大的密集区 + chip_ss = max(below, key=lambda x: x[1])[0] + # 阻力 = 上方成交量最大的密集区 + chip_sr = max(above, key=lambda x: x[1])[0] + + return {"chip_ss": chip_ss, "chip_sr": chip_sr} + except Exception as e: + print(f" ⚠️ 筹码S/R计算失败: {e}", file=sys.stderr) + return None + +# 提示词版本追踪 +try: + from prompt_manager.tracking import record_strategy_generation + HAS_PROMPT_TRACKING = True +except ImportError: + HAS_PROMPT_TRACKING = False + +def safe_json_load(path, default=None): + """安全加载 JSON,遇到坏数据自动修复""" + if not os.path.exists(path): + return default if default is not None else {} + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + # 尝试修复:替换字符串内未转义的换行符,去多余括号 + with open(path, "r", encoding="utf-8") as f: + raw = f.read() + fixed = raw + + # 修复1: 字符串内未转义的换行 -> \\n + result = [] + in_str = False + for ch in fixed: + if ch == '"': + in_str = not in_str + result.append(ch) + elif in_str and ch in '\n\r': + result.append('\\n') + else: + result.append(ch) + fixed = ''.join(result) + + # 修复2: 去掉多余的尾部括号 + fixed = fixed.rstrip('}') + # 补回正确的闭合 + if not fixed.endswith('}'): + fixed += '}' + + try: + return json.loads(fixed) + except json.JSONDecodeError as e: + print(f"[WARN] watchlist.json 自动修复失败: {e}", file=sys.stderr) + return default if default is not None else {} +KNOWLEDGE_LOG = "/home/hmo/Obsidian/knowledge/finance/analyst-knowledge-log.md" +MACRO_CONTEXT_PATH = "/home/hmo/web-dashboard/data/macro_context.json" +MARKET_CONTEXT_PATH = "/home/hmo/web-dashboard/data/market.json" +STOCK_SECTOR_MAP_PATH = "/home/hmo/web-dashboard/data/stock_sector_map.json" + + +def load_stock_sector_map(): + """读取个股归属行业映射 + + stock_sector_map.json 格式: {code: [sector1, sector2, ...]} + 跳过 _note, _created_at 等元数据键。 + """ + # 优先从 SQLite 读取 + try: + from mofin_db import get_conn, query_sector_stocks + conn = get_conn() + # 从 stock_sectors 表反向构建 code→[sectors] 映射 + rows = conn.execute("SELECT code, sector_name FROM stock_sectors ORDER BY code").fetchall() + conn.close() + code_to_sectors = {} + for code, sector in rows: + if code not in code_to_sectors: + code_to_sectors[code] = [] + code_to_sectors[code].append(sector) + return code_to_sectors + except Exception: + pass + try: + with open(STOCK_SECTOR_MAP_PATH) as f: + data = json.load(f) + code_to_sectors = {} + for key, value in data.items(): + if key.startswith("_"): + continue + if isinstance(value, list): + code_to_sectors[key] = value + return code_to_sectors + except Exception: + return {} + + +def load_market_context(): + """读取市场上下文,优先 SQLite,回退 market.json""" + # 优先从 SQLite 读取 + try: + from mofin_db import get_conn, query_latest_market + conn = get_conn() + market = query_latest_market(conn) + conn.close() + if market and market.get("sectors"): + sector_perf = {} + for s in market["sectors"]: + name = s.get("name", "") + if name: + sector_perf[name] = { + "change": s.get("change_pct", 0), + "up_count": s.get("up_count", 0), + "down_count": s.get("down_count", 0), + "net_inflow": s.get("net_inflow", 0), + "lead_stock": s.get("lead_stock", ""), + "lead_stock_change": s.get("lead_stock_change", 0), + } + return { + "sector_perf": sector_perf, + "breadth": market.get("up_ratio", 50), + "mood": market.get("mood", "neutral"), + "top_gainers": {g["name"]: g["change_pct"] for g in market.get("top_gainers", [])}, + "top_losers": {g["name"]: g["change_pct"] for g in market.get("top_losers", [])}, + "total_sectors": len(market["sectors"]), + "market_timestamp": market.get("timestamp", ""), + } + except Exception: + pass + try: + with open(MARKET_CONTEXT_PATH) as f: + market = json.load(f) + sectors = market.get("sectors", []) + sector_perf = {} + for s in sectors: + name = s.get("name", "") + if name: + sector_perf[name] = { + "change": s.get("change", 0), + "up_count": s.get("up_count", 0), + "down_count": s.get("down_count", 0), + "net_inflow": s.get("net_inflow", 0), + "lead_stock": s.get("lead_stock", ""), + "lead_stock_change": s.get("lead_stock_change", 0), + } + top_gainers = {s.get("name", ""): s.get("change", 0) + for s in market.get("top_gainers", [])} + top_losers = {s.get("name", ""): s.get("change", 0) + for s in market.get("top_losers", [])} + return { + "sector_perf": sector_perf, + "breadth": market.get("up_ratio", 50), + "mood": market.get("mood", "neutral"), + "top_gainers": top_gainers, + "top_losers": top_losers, + "total_sectors": market.get("total_sectors", 0), + "market_timestamp": market.get("timestamp", ""), + } + except Exception: + return { + "sector_perf": {}, + "breadth": 50, + "mood": "neutral", + "top_gainers": {}, + "top_losers": {}, + "total_sectors": 0, + "market_timestamp": "", + } + + +def compute_sector_adjustment(code, market_ctx, stock_sector_map): + """根据个股所属行业的市场表现+小果情感,返回调整系数 + + 返回 dict: + stop_bias: 止损调整系数(<1.0收紧, >1.0放宽) + target_bias: 止盈调整系数 + note: 行业背景一句话 + sector_name: 匹配到的行业名称 + sector_change: 行业涨跌幅 + """ + # 默认无调整 + adj = {"stop_bias": 1.0, "target_bias": 1.0, "note": "", + "sector_name": "", "sector_change": 0} + + sectors_for_code = stock_sector_map.get(code, []) + if not sectors_for_code: + return adj + + sector_perf = market_ctx.get("sector_perf", {}) + breadth = market_ctx.get("breadth", 50) + + # 找第一个能匹配到的行业 + for sec in sectors_for_code: + if sec in sector_perf: + perf = sector_perf[sec] + chg = perf.get("change", 0) + adj["sector_name"] = sec + adj["sector_change"] = chg + + # 行业暴跌 > 3% + if chg <= -3: + adj["stop_bias"] = 0.92 # 止损收紧8% + adj["target_bias"] = 0.90 # 止盈下调10% + adj["note"] = f"行业{sec}大跌{chg:+.1f}%,收紧止损" + # 行业大跌 1~3% + elif chg <= -1: + adj["stop_bias"] = 0.96 + adj["target_bias"] = 0.95 + adj["note"] = f"行业{sec}下跌{chg:+.1f}%,适度防御" + # 行业大涨 > 3% + elif chg >= 3: + adj["stop_bias"] = 1.05 # 止损放宽5%(给趋势空间) + adj["target_bias"] = 1.03 + adj["note"] = f"行业{sec}大涨{chg:+.1f}%,可适度积极" + # 行业上涨 1~3% + elif chg >= 1: + adj["stop_bias"] = 1.02 + adj["note"] = f"行业{sec}上涨{chg:+.1f}%,正常" + else: + adj["note"] = f"行业{sec}{chg:+.1f}%,中性" + break + # 尝试处理命名差异:market.json中的行业名可能多了"板块"后缀 + for market_sec_name in sector_perf: + if sec in market_sec_name or market_sec_name in sec: + perf = sector_perf[market_sec_name] + chg = perf.get("change", 0) + adj["sector_name"] = market_sec_name + adj["sector_change"] = chg + if chg <= -3: + adj["stop_bias"] = 0.92 + adj["target_bias"] = 0.90 + adj["note"] = f"行业{market_sec_name}大跌{chg:+.1f}%,收紧止损" + elif chg <= -1: + adj["stop_bias"] = 0.96 + adj["target_bias"] = 0.95 + adj["note"] = f"行业{market_sec_name}下跌{chg:+.1f}%,适度防御" + elif chg >= 3: + adj["stop_bias"] = 1.05 + adj["target_bias"] = 1.03 + adj["note"] = f"行业{market_sec_name}大涨{chg:+.1f}%,可适度积极" + elif chg >= 1: + adj["stop_bias"] = 1.02 + adj["note"] = f"行业{market_sec_name}上涨{chg:+.1f}%,正常" + else: + adj["note"] = f"行业{market_sec_name}{chg:+.1f}%,中性" + break + + # 如果breath<30% (大盘极弱),再加一层收紧 + if breadth < 30: + adj["stop_bias"] *= 0.97 # 再收紧3% + breadth_note = "大盘仅{}%个股上涨".format(int(breadth)) + adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note + elif breadth < 40: + adj["stop_bias"] *= 0.99 + breadth_note = "大盘偏弱({}%上涨)".format(int(breadth)) + adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note + + # 小果情感约束:利空置信度>80%时收紧止损 + try: + xiaoguo_path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" + if os.path.exists(xiaoguo_path): + xg = json.load(open(xiaoguo_path)) + stock_sentiment = xg.get("stocks", {}).get(code, {}) + if stock_sentiment: + sentiment = stock_sentiment.get("sentiment", "") + confidence = stock_sentiment.get("confidence", 0) + summary = stock_sentiment.get("summary", "") + if sentiment == "negative" and confidence > 0.8: + adj["stop_bias"] = min(adj["stop_bias"], 0.95) + adj["note"] += f" | 小果利空{confidence:.0%}:{summary[:30]}" + except Exception: + pass + + return adj + + +def load_macro_context(): + """读取宏观上下文,返回 (bias, desc),优先 DB,回退 JSON""" + try: + import sqlite3 + from pathlib import Path + conn = sqlite3.connect(str(Path(__file__).parent.parent / "data" / "mofin.db")) + row = conn.execute( + "SELECT indices, structure FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + if row: + indices = json.loads(row[0]) if row[0] else {} + structure = json.loads(row[1]) if row[1] else {} + overall = structure.get("overall", "neutral") + desc = structure.get("description", "") + else: + raise ValueError("no db data") + except Exception: + try: + with open(MACRO_CONTEXT_PATH) as f: + ctx = json.load(f) + overall = ctx.get("structure", {}).get("overall", "neutral") + desc = ctx.get("structure", {}).get("description", "") + except Exception: + return 1.0, "宏观未加载" + if "bearish" in overall: + return 0.8, f"宏观{desc}" + elif overall == "bullish": + return 1.05, f"宏观{desc}" + elif overall == "strong_bullish": + return 1.1, f"宏观{desc}" + else: + return 1.0, f"宏观{desc}" + + +def batch_fetch_prices(codes): + """获取实时价格。优先从 DB 读取(price_monitor 每 2 分钟更新),失败才拉腾讯 API。""" + if not codes: + return {} + + all_results = {} + + # 主通道:从 DB 读取(price_monitor 唯一价格入口) + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + for raw_code in codes: + raw_code = str(raw_code).split('_')[0] + if not raw_code: continue + row = db.execute( + "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (raw_code,) + ).fetchone() + if not row: + row = db.execute( + "SELECT price, change_pct FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (raw_code,) + ).fetchone() + if row and row['price']: + all_results[raw_code] = { + "price": row['price'], + "close": row['price'], # 用现价近似昨收,仅用于sentiment计算 + "high": row['price'], + "low": row['price'], + "code": raw_code, + } + db.close() + if all_results: + return all_results + except Exception: + pass + + # Fallback: 腾讯 API(仅当 DB 无数据时) + batch_size = 15 + for batch_start in range(0, len(codes), batch_size): + batch = codes[batch_start:batch_start + batch_size] + symbols = [] + code_map = {} + for raw_code in batch: + raw_code = str(raw_code).split('_')[0] + if not raw_code: + continue + if len(raw_code) == 5 and raw_code.isdigit(): + prefix = "hk" + elif raw_code.startswith(("6", "5")): + prefix = "sh" + else: + prefix = "sz" + sym = f"{prefix}{raw_code}" + symbols.append(sym) + code_map[sym] = raw_code + if not symbols: + continue + + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + max_retries = 2 + for attempt in range(max_retries + 1): + try: + r = urllib.request.urlopen(url, timeout=10) + text = r.read().decode("gbk") + except Exception as e: + if attempt < max_retries: + continue + print(f" batch_fetch_prices error: {e}", file=sys.stderr) + continue + + for line in text.strip().split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + try: + sym = line.split("=", 1)[0].strip().lstrip("v_") + raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw_value.split("~") + if len(fields) < 35: + continue + orig_code = code_map.get(sym) + if not orig_code: + continue + def f(i): + try: + return float(fields[i]) if fields[i].strip() else 0.0 + except: + return 0.0 + price_raw = f(3) + # 港股:腾讯 API 返回 HKD,需转 CNY + if is_hk_stock(orig_code) and price_raw > 0: + price_raw = to_cny(price_raw) + all_results[orig_code] = { + "price": price_raw, "close": f(4), "high": f(33), "low": f(34), + "code": orig_code, + } + except Exception: + continue + break # Success - break retry loop + + return all_results + + +def get_price_tencent(code): + """获取实时价格。优先 DB(price_monitor 维护),失败才拉腾讯。港股价格已是 CNY。""" + raw_code = str(code).split('_')[0] + if not raw_code: + return None + + # 主通道: DB + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (raw_code,)).fetchone() + if not row: + row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (raw_code,)).fetchone() + if row and row['price']: + db.close() + return row['price'] + db.close() + except Exception: + pass + + # Fallback: 腾讯 API + try: + from mo_models import to_cny, is_hk_stock + except ImportError: + to_cny = lambda v, r=None: v + is_hk_stock = lambda c: len(str(c).strip()) == 5 and str(c).strip().isdigit() + try: + if is_hk_stock(raw_code): + prefix = "hk" + elif raw_code.startswith("6") or raw_code.startswith("5"): + prefix = "sh" + else: + prefix = "sz" + url = f"http://qt.gtimg.cn/q={prefix}{raw_code}" + r = urllib.request.urlopen(url, timeout=5) + fields = r.read().decode("gbk").split('"')[1].split("~") + def f(i): + try: + return float(fields[i]) if fields[i].strip() else 0.0 + except: + return 0.0 + price = f(3) + if is_hk_stock(raw_code) and price > 0: + price = to_cny(price) + return { + "price": price, "close": f(4), "high": f(33), "low": f(34), + "code": raw_code, + } + except Exception as e: + print(f" get_price error {code}: {e}", file=sys.stderr) + return None + + +def reassess_strategy(code, name, price, cost, shares, current_action, + volume_signal="", sentiment="neutral", + is_watchlist=False): + """根据技术分析重评策略""" + + tech = ta.full_analysis(code) + if tech and "support_resistance" in tech: + sr = tech["support_resistance"] + candle = tech.get("candlestick", {}) + vol = tech.get("volume", {}) + ss = sr.get("strong_support") + ws = sr.get("weak_support") + wr = sr.get("weak_resist") + sr_resist = sr.get("strong_resist") + pivot = sr.get("pivot") + effective_range = sr.get("effective_range") + print(f" TECH: 强撑={ss} 弱撑={ws} 枢轴={pivot} 弱压={wr} 强压={sr_resist} 有效区间={effective_range}") + else: + print(f" ⚠️ 技术分析不可用", file=sys.stderr) + ss = ws = wr = sr_resist = pivot = None + candle = {} + vol = {} + + # ----- 多周期技术分析(周线/月线/均线) ----- + mtf_analysis = {} + mtf_adj = {} + try: + mtf_result = mtf.full_multi_tf_analysis(code) + if mtf_result.get("daily") and mtf_result["daily"].get("count", 0) >= 5: + mtf_analysis = mtf_result + mtf_adj = mtf_result.get("strategy_adjustment", {}) + daily_mas = mtf_result.get("daily", {}).get("mas", {}) + weekly = mtf_result.get("weekly", {}) + monthly = mtf_result.get("monthly", {}) + trend_align = mtf_adj.get("trend_alignment", "未知") + print(f" 多周期: {trend_align} | " + f"MA5={daily_mas.get('ma5','?')} MA20={daily_mas.get('ma20','?')} MA60={daily_mas.get('ma60','?')} | " + f"周线{weekly.get('trend',{}).get('description','?')} 月线{monthly.get('trend',{}).get('description','?')}") + except Exception as e: + print(f" 多周期分析失败: {e}", file=sys.stderr) + + # ----- 筹码分布支撑/阻力(中长线参考,加情景权重) ----- + chip_sr = None + chip_weight = 0.5 # 默认中等权重 + regime = detect_scenario() + regime_id = regime.get("id", "weak_consolidation") + + # 情景决定筹码因子权重 + if regime_id == "weak_consolidation": + chip_weight = 0.9 # 震荡市筹码最准 + elif regime_id == "bullish_recovery": + chip_weight = 0.4 # 上涨趋势筹码阻力可能被突破 + elif regime_id == "sharp_decline": + chip_weight = 0.2 # 急跌中筹码支撑可能失效 + elif regime_id == "sector_rotation": + chip_weight = 0.6 # 轮动市中筹码有一定参考 + + try: + chip_sr = calc_chip_sr(code, price) + if chip_sr: + print(f" 筹码: 撑={chip_sr['chip_ss']:.0f} 阻={chip_sr['chip_sr']:.0f} | 情景={regime_id} 权重={chip_weight:.1f}") + # 与枢轴点对比 + if ss and ws and pivot and chip_sr['chip_ss'] > 0 and chip_sr['chip_sr'] > 0: + chip_ss_pct = (price - chip_sr['chip_ss']) / price * 100 + chip_sr_pct = (chip_sr['chip_sr'] - price) / price * 100 + + # 共振检测:筹码支撑 vs 枢轴弱支撑(都是最近支撑位) + if ws and ws > 0: + resonance_ss = abs(chip_ss_pct - ((price - ws) / price * 100)) < 3 + else: + resonance_ss = False + # 共振检测:筹码阻力 vs 枢轴弱阻力(都是最近阻力位) + if wr and wr > 0: + resonance_sr = abs(chip_sr_pct - ((wr - price) / price * 100)) < 3 + else: + resonance_sr = False + + if resonance_ss and chip_weight >= 0.5: + print(f" ⚡ 支撑共振({chip_weight:.0f}): 筹码+枢轴均指向{chip_sr['chip_ss']:.0f}") + elif chip_weight < 0.5: + print(f" 📎 支撑一致但权重低({chip_weight:.1f}): {regime_id}下筹码支撑不可靠") + + if resonance_sr and chip_weight >= 0.5: + print(f" ⚡ 阻力共振({chip_weight:.0f}): 筹码+枢轴均指向{chip_sr['chip_sr']:.0f}") + elif chip_weight < 0.5: + print(f" 📎 阻力一致但权重低({chip_weight:.1f}): {regime_id}下筹码阻力不可靠") + except Exception: + pass + + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_new_entry = (cost == 0) or (shares == 0) + is_deep_loss = profit_pct < -20 + + # ----- 股票分类(短炒/中短线/中长线/弱势/深套) ----- + stock_category = "中短线" + time_horizon = "2周~3月" + position_advice = "中等仓位" + try: + mtf_cache = mtf._load_mtf_cache() + stock_data = mtf_cache.get(code, {}) + daily_klines = stock_data.get("daily", []) + fund = stock_data.get("fundamentals", {}) + closes = [d["close"] for d in daily_klines] if daily_klines else [] + + if len(closes) >= 10: + cur = closes[-1] + ma20 = sum(closes[-20:])/20 if len(closes)>=20 else 0 + ma60 = sum(closes[-60:])/60 if len(closes)>=60 else 0 + highs = [d["high"] for d in daily_klines[-20:]] + lows = [d["low"] for d in daily_klines[-20:]] + volatility = ((max(highs)-min(lows))/min(lows)*100) if min(lows)>0 else 0 + pe = fund.get("pe") or 0 + eps = fund.get("eps") or 0 + mcap = fund.get("mcap_total") or 0 + is_high_vol = volatility > 30 + is_high_pe = pe > 100 or pe < 0 + is_value = 0 < pe < 20 and eps > 0.5 + + if is_deep_loss: + stock_category = "深套" + time_horizon = "长期" + position_advice = "不补不割" + elif is_high_vol and is_high_pe: + stock_category = "短炒" + time_horizon = "数日~2周" + position_advice = "小仓快进快出" + elif cur < ma20 and cur < ma60 and ma20 > 0: + stock_category = "弱势" + time_horizon = "观望" + position_advice = "减仓或观望" + elif (is_value or mcap > 1000) and cur > ma20: + stock_category = "中长线" + time_horizon = "数月~1年" + position_advice = "正常配置" + elif volatility > 20: + stock_category = "中短线" + time_horizon = "2~6周" + position_advice = "中等仓位" + except Exception: + pass + + print(f" 分类: {stock_category} | {time_horizon} | {position_advice}") + + # ----- 短炒+强趋势检测:短炒分类但多周期多头时用移动止损代替弱支撑止损 ----- + is_short_term_strong_trend = False + if stock_category == "短炒": + trend_align = mtf_adj.get("trend_alignment", "") + strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] + if any(ind in trend_align for ind in strong_trend_indicators): + is_short_term_strong_trend = True + print(f" ⚡ 短炒+强趋势检测: 趋势={trend_align} → 启用移动止损, 不止盈") + position_advice = "小仓强趋势让利润跑" + + # ----- 止损设置(含最小距离3%保护) ----- + if is_new_entry: + # 新买入推荐:止损 = 弱支撑(约2-3%跌幅,合理可控) + if ws and ws > 0: + new_stop = round(ws, 2) + else: + new_stop = round(price * 0.96, 2) + elif is_deep_loss: + # 深套:止损 = 强支撑再下移(不轻易割) + if ss and ss > 0: + new_stop = round(min(ss, price * 0.85), 2) + else: + new_stop = round(price * 0.85, 2) + else: + # 已持仓正常:止损 = 强支撑 + if is_short_term_strong_trend: + # 短炒+强趋势:用移动止损(距现价-5%),不止盈让利润跑 + trailing_sl = round(max(ws or 0, price * 0.95), 2) if ws else round(price * 0.95, 2) + new_stop = trailing_sl + print(f" 短炒强趋势移动止损: {new_stop} (距现价-{(1-new_stop/price)*100:.1f}%)") + elif ss and ss > 0: + new_stop = round(ss, 2) + else: + new_stop = round(price * 0.88, 2) + + # 已盈利仓位(>5%):用较紧的移动止损保护利润,但不超过成本线 + if profit_pct > 5 and not is_new_entry and not is_deep_loss: + # 取 max(弱支撑, 成本线, 当前价×0.95) 作为止损 + cost_protect = cost if cost > 0 else 0 + trailing_stop = round(max(ws or 0, cost_protect, price * 0.95), 2) + if trailing_stop > new_stop: + new_stop = trailing_stop + print(f" 已启用移动止损: {new_stop} (保护+{profit_pct:.1f}%利润)", file=sys.stderr) + + # 最小止损距离 —— 随趋势强度调整(2026-06-23 震度保护规则) + # 强趋势(多周期看多 + MA多头排列):最小1.5%下行空间 + # 普通/弱势:最小3%下行空间 + is_strong_trend = False + trend_align = mtf_adj.get("trend_alignment", "") + strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] + try: + if any(ind in trend_align for ind in strong_trend_indicators) and ma20 > ma60 and cur >= ma20: + is_strong_trend = True + except (NameError, TypeError): + pass # ma20/ma60/cur may be unbound if MTF data insufficient + + if is_strong_trend: + min_stop_gap = 0.015 # 1.5% + else: + min_stop_gap = 0.03 # 3% + + min_stop = round(price * (1 - min_stop_gap), 2) + if new_stop > min_stop and not is_deep_loss: + old_stop = new_stop + new_stop = min_stop + if old_stop != new_stop: + print(f" 最小止损 {round(min_stop_gap*100)}%间距约束: {old_stop}→{new_stop} (趋势{'强' if is_strong_trend else '普通'})") + + # 港股附加:ATR波动率校验 — 止损距现价不得小于 1×ATR(14) + if is_hk_stock(code): + atr = calc_atr(code) + if atr and atr > 0: + min_atr_stop = round(price - atr, 2) + if new_stop > min_atr_stop: + old_stop_val = new_stop + new_stop = min_atr_stop + print(f" 港股ATR波动率校验({atr:.2f}): 止损 {old_stop_val}→{new_stop} (1×ATR间距)") + + # ----- 止盈设置 ----- + if is_short_term_strong_trend and not is_new_entry: + # 短炒+强趋势:不止盈让利润跑 + mtf_tp = mtf_adj.get("take_profit_reference", {}) + if mtf_tp and mtf_tp.get("level", 0) > price * 1.2: + new_target = round(mtf_tp["level"], 2) + else: + new_target = 0 # 无多周期阻力时不编造止盈 + print(f" 短炒强趋势不止盈: 止盈设为{new_target} (+{(new_target/price-1)*100:.0f}%)") + elif sr_resist and sr_resist > 0: + new_target = round(sr_resist, 2) + else: + new_target = 0 # 无技术面数据时不编造止盈 + + # ----- 风险回报比校验 ----- + stop_distance = price - new_stop if price > new_stop else price * 0.02 + target_distance = new_target - price if new_target > price else 0 + + # 1:2 检查 + min_target_distance = stop_distance * 2.0 + if target_distance < min_target_distance: + # 尝试更高的阻力位,但不超过下一个真实压力位 + candidate_targets = [] + if wr and wr > price and wr != sr_resist: + candidate_targets.append(wr) + if sr_resist and sr_resist > price: + candidate_targets.append(sr_resist) + # 检查有效区间,如果有更高的自然目标位 + if effective_range and price < effective_range * 0.9: + candidate_targets.append(effective_range) + + found = False + for level in candidate_targets: + if (level - price) >= min_target_distance: + new_target = level + found = True + break + + # 如果仍然不满足,检查是否至少能到 1:1.5 + min15_distance = stop_distance * 1.5 + if not found: + for level in candidate_targets: + if (level - price) >= min15_distance: + new_target = level + found = True + break + + # ----- 风险回报比最终计算 ----- + risk = max(price - new_stop, price * 0.01) + reward = max(new_target - price, 0) + rr_ratio = reward / risk if risk > 0 else 0 + + # ----- 状态判断 ----- + if is_deep_loss: + status = "updated" + action_note = "深套持有" + elif is_new_entry: + if rr_ratio < 1.5: + status = "review" + action_note = "⚠️盈亏比不足1:1.5,不建议买入" + elif rr_ratio < 2.0: + status = "updated" + action_note = "⚠️盈亏比偏低(1:{:.1f}),谨慎买入".format(rr_ratio) + else: + status = "updated" + action_note = "" + else: + if rr_ratio < 0.5: + status = "updated" + action_note = "⚠️盈亏比极低,关注" + elif rr_ratio < 1.5: + status = "updated" + action_note = "⚠️盈亏比偏低(1:{:.1f}),不建议加仓".format(rr_ratio) + else: + status = "updated" + action_note = "" + + # 短炒+强趋势:在action_note追加标记 + if is_short_term_strong_trend and not is_new_entry and not is_deep_loss: + extra_note = "短炒强趋势持" if "深套" not in action_note else "" + if extra_note: + action_note = f"{action_note} | {extra_note}" if action_note else extra_note + + # ----- 买入区间(有盈亏比严格约束) ----- + max_acceptable_entry = None # 最大可接受买入价(满足R/R约束) + + if new_target and new_stop and new_target > new_stop and not is_deep_loss: + # 买入价的R/R约束: + # 要求 (target - entry) / (entry - stop) >= min_rr + # 即 entry <= (target + min_rr * stop) / (1 + min_rr) + min_rr = 1.0 # 至少1:1,才不亏 + recommend_rr = 1.5 # 推荐1:1.5以上 + + max_for_recommend = (new_target + recommend_rr * new_stop) / (1 + recommend_rr) + max_for_neutral = (new_target + min_rr * new_stop) / (1 + min_rr) + + if is_new_entry: + # 新买入:要求1:1.5+ + max_acceptable_entry = max_for_recommend + else: + # 已持仓加仓:至少1:1 + max_acceptable_entry = max_for_neutral + + if is_new_entry: + # 新买入:买入区 = 弱支撑附近(不是当前价附近!) + # 只在价格跌到弱支撑附近时才推买入 + entry_low = round(price * 0.98, 2) + entry_high = round(price * 1.02, 2) + if max_acceptable_entry and entry_high > max_acceptable_entry: + entry_high = round(max_acceptable_entry, 2) + # 确保买入区不小于1% + if entry_high - entry_low < price * 0.01: + if max_acceptable_entry and price <= max_acceptable_entry: + entry_low = round(max(price * 0.99, new_stop), 2) + entry_high = round(min(price * 1.01, max_acceptable_entry), 2) + elif ws and ws > 0 and wr and wr > 0 and not is_deep_loss: + # 已持仓正常:买入区 = 弱支撑~弱支撑上方5%(给合理回调空间) + # 上限不能低于成本价×0.95(保护已有持仓不被高位逼空) + entry_low = round(ws, 2) + entry_max = round(ws * 1.05, 2) # 比弱支撑高5%,有足够空间 + # 如果当前价已远离买入区,保持买入区不变(不因价格涨了就收窄) + min_upper = round(cost * 0.95, 2) if cost > 0 else 0 + if entry_max < min_upper: + entry_max = min_upper + if max_acceptable_entry: + entry_high = round(min(entry_max, max_acceptable_entry), 2) + else: + entry_high = entry_max + # 如果当前价已远离买入区(高于买入区上沿),禁止加仓推荐 + if price > entry_high: + # 买入区锁定在弱支撑位,但标记为"价格远离" + pass + # 如果买入区过窄,标记但不扩展(加仓必须在支撑位) + if entry_high - entry_low < price * 0.005: + entry_low = round(ws * 0.995, 2) + entry_high = round(ws * 1.005, 2) + else: + entry_low = round(price * 0.90, 2) + entry_high = round(price * 1.05, 2) + + # 买入区间稳定性保护:上边界单次变动不超过5% + if 'entry_high' in dir() and entry_high: + # 读取当前策略中已有的买入区上界,如果有且变化过大则限制 + old_entry_high = None + if 'current_action' in dir() and current_action: + import re + m = re.search(r'买入区[\d.]+~([\d.]+)', current_action) + if m: + old_entry_high = float(m.group(1)) + if old_entry_high and old_entry_high > 0: + max_change = old_entry_high * 0.95 # 单次最多下降5% + if entry_high < max_change: + entry_high = round(max_change, 2) + + # ----- 买入时机信号(三维分析:大盘+行业+个股,基本面+消息面+技术面+资金流)----- + # [2026-07-01] 扩展:不再只看volume_signal + candlestick_sentiment + # 融合大盘趋势、行业板块强弱、基本面估值作为修正因子 + volume_signal = vol.get("volume_signal", "") + candlestick_sentiment = candle.get("sentiment", "neutral") + timing_signal = "neutral" + + # --- 三维分析数据装载 --- + # 因子1: 大盘环境(从macro_context_log读) + market_bearish = False + market_bullish = False + try: + import sqlite3 + _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + _mc = _db.execute( + "SELECT structure FROM macro_context_log WHERE has_valid_data=1 ORDER BY rowid DESC LIMIT 1" + ).fetchone() + if _mc and _mc[0]: + _s = json.loads(_mc[0]) + _overall = _s.get("overall", "") + if "bearish" in _overall: + market_bearish = True + elif _overall == "bullish": + market_bullish = True + _db.close() + except Exception: + pass + + # 因子2: 行业板块强弱 + sector_strong = False + sector_weak = False + try: + _db2 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + _rows2 = _db2.execute( + "SELECT name, change_pct FROM sector_snapshots ORDER BY change_pct DESC" + ).fetchall() + if _rows2: + # 找到该股所属行业(简单匹配name或通过stock_sectors) + _my_sectors = _db2.execute( + "SELECT sector_name FROM stock_sectors WHERE code=?", + (code,) + ).fetchall() + if _my_sectors: + for (_sn,) in _my_sectors: + for r_name, r_chg in _rows2: + if _sn in r_name or r_name in _sn: + _rank = [r[0] for r in _rows2].index(r_name) if r_name in [x[0] for x in _rows2] else -1 + _total = len(_rows2) + if _rank >= 0: + if _rank < _total * 0.2: + sector_strong = True + if _rank > _total * 0.8: + sector_weak = True + break + _db2.close() + except Exception: + pass + + # 因子3: 基本面估值 + is_value_stock = False + try: + _db3 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + _fd = _db3.execute( + "SELECT pe, eps FROM stock_fundamentals WHERE code=?", (code,) + ).fetchone() + if _fd: + _pe, _eps = _fd + is_value_stock = (0 < (_pe or 0) < 25 and (_eps or 0) > 0.3) + _db3.close() + except Exception: + pass + + # --- 三维修正规则 --- + # 大盘偏弱时收紧买入信号,大盘偏强时放宽 + # 行业领先加分,行业落后减分 + # 低估值加分(有安全边际) + + def _adjust_timing(signal, market_b, market_bb, sec_s, sec_w, is_val): + """根据三维因子修正 timing_signal""" + # 大盘偏弱时降级买入信号 + if market_b: + if signal in ("买入", "加仓"): + if not sec_s: # 大盘弱+行业不强→降级 + return "关注" + # 大盘偏强时放宽 + if market_bb: + if signal == "关注" and (sec_s or is_val): + return "买入" + # 行业弱势时降级买入信号 + if sec_w: + if signal in ("买入", "加仓"): + return "关注" + # 行业强势+低估时升级关注 + if sec_s and is_val: + if signal == "关注": + return "买入" + return signal + + if is_new_entry: + # 新买入时机 + if volume_signal == "主动买盘占优" and candlestick_sentiment == "bullish": + timing_signal = "买入" + elif volume_signal == "主动卖盘占优": + timing_signal = "观望" + elif volume_signal == "买卖均衡" and ws and price <= ws * 1.03: + timing_signal = "买入" + elif candlestick_sentiment == "bullish": + timing_signal = "买入" + elif ws and price < ws * 1.02: + timing_signal = "关注" + # 新买入时三维修正:大盘向上+行业强→升级,大盘弱→降级 + _pre_signal = timing_signal + timing_signal = _adjust_timing(timing_signal, market_bearish, market_bullish, + sector_strong, sector_weak, is_value_stock) + if timing_signal != _pre_signal: + print(f" 三维修正(新入): {_pre_signal}→{timing_signal} " + f"| 大盘{'弱' if market_bearish else '强' if market_bullish else '中性'}" + f"| 行业{'强' if sector_strong else '弱' if sector_weak else '中性'}" + f"| 估值{'低' if is_value_stock else '一般'}") + else: + # 已持仓时机(用于加仓/减仓参考) + if is_short_term_strong_trend: + # 短炒+强趋势:强趋势持有,禁止加仓信号 + timing_signal = "持有" + elif profit_pct > 5: + # 已盈利 + if volume_signal == "主动买盘占优": + timing_signal = "持有" + elif volume_signal == "主动卖盘占优" and not is_new_entry: + timing_signal = "关注" + else: + timing_signal = "持有" + elif profit_pct > 0: + # 微盈 + if volume_signal == "主动买盘占优": + timing_signal = "持有" + elif ws and price <= ws * 1.02: + timing_signal = "加仓" + else: + timing_signal = "持有" + else: + # 浮亏 + if volume_signal == "主动卖盘占优" and ss and price <= ss * 1.03: + timing_signal = "关注" + elif volume_signal == "主动买盘占优" and sr_resist and price >= sr_resist * 0.97: + timing_signal = "关注" + elif volume_signal == "买卖均衡" and ws and price <= ws * 1.02: + timing_signal = "加仓" + else: + timing_signal = "持有" + + # ----- 【v3.2新增】分类约束:弱势/深套禁止输出买入/加仓类信号 ----- + if stock_category == "弱势" or is_deep_loss: + buy_signals = ["买入", "加仓", "可追"] + if any(s in timing_signal for s in buy_signals): + old_signal = timing_signal + timing_signal = "弱势持有" if stock_category == "弱势" else "深套持有" + print(f" 分类约束: {stock_category} 原信号\"{old_signal}\" → \"{timing_signal}\"") + + # ----- 构造 action 描述(供 cron prompt 使用) ----- + action_parts = [] + if profit_pct < -20: + action_parts.append("深套持有") + elif profit_pct < -10: + action_parts.append("持有观察") + elif profit_pct < 0: + action_parts.append("持有观察") + elif profit_pct < 5: + action_parts.append("盈利持有") + else: + action_parts.append("盈利良好") + + if action_note: + action_parts.append(action_note) + + if is_watchlist: + # 自选股(未入场):有止损参考+买入区,内部算RR需要止盈位 + action_parts.append(f"目标参考{new_target}") + action_parts.append(f"止损参考{new_stop}") + action_parts.append(f"买入区{entry_low}~{entry_high}") + elif is_new_entry: + action_parts.append(f"损{new_stop}") + action_parts.append(f"盈{new_target}") + action_parts.append(f"买{entry_low}~{entry_high}") + else: + action_parts.append(f"止损{new_stop}") + action_parts.append(f"目标{new_target}") + action_parts.append(f"买入区{entry_low}~{entry_high}") + + if timing_signal != "neutral": + action_parts.append(f"信号:{timing_signal}") + + new_action = " | ".join(action_parts) + + # 技术面快照 + tech_snapshot = "" + if candle: + tech_snapshot = (f"形态:{candle.get('pattern','?')}/{candle.get('sentiment','?')} " + f"量价:{vol.get('volume_signal','?')} " + f"强撑:{ss} 弱撑:{ws} 弱压:{wr} 强压:{sr_resist}") + # 加入均线信息(如果可用) + try: + dm = mtf_analysis.get("daily", {}).get("mas", {}) + ma_parts = [] + for m in ['ma5', 'ma10', 'ma20', 'ma60']: + v = dm.get(m) + if v: + ma_parts.append(f"{m.upper()}={v}") + if ma_parts: + tech_snapshot += " | " + " ".join(ma_parts) + except (NameError, AttributeError): + pass + + # 多周期快照(追加到 tech_snapshot) + mtf_context = "" + if mtf_adj: + trend_align = mtf_adj.get("trend_alignment", "") + daily_mas = mtf_analysis.get("daily", {}).get("mas", {}) + ma20 = daily_mas.get("ma20") + ma60 = daily_mas.get("ma60") + stop_ref = mtf_adj.get("stop_loss_reference", {}) + take_ref = mtf_adj.get("take_profit_reference", {}) + + parts = [] + if trend_align: + parts.append(trend_align) + if ma20: + parts.append(f"MA20={ma20}") + if ma60: + parts.append(f"MA60={ma60}") + if stop_ref: + parts.append(f"长撑:{stop_ref.get('source','?')}={stop_ref['level']}") + if take_ref: + parts.append(f"长压:{take_ref.get('source','?')}={take_ref['level']}") + mtf_context = " | ".join(parts) + + now_str = datetime.now().strftime('%Y-%m-%d %H:%M') + return { + 'stop_loss': new_stop, + 'take_profit': new_target, + 'entry_low': entry_low, + 'entry_high': entry_high, + 'action': new_action, + 'status': status, + 'tech_snapshot': tech_snapshot, + 'timing_signal': timing_signal, + 'rr_ratio': round(rr_ratio, 2), + 'action_note': action_note, + 'reassessed_at': now_str, + 'multi_tf_context': mtf_context, # 多周期上下文 + 'stock_category': stock_category, # 股票分类:短炒/中短线/中长线/弱势/深套 + 'time_horizon': time_horizon, # 时间跨度 + 'position_advice': position_advice, # 仓位建议 + } + + +def load_stock_news_sentiment(code): + """加载小果消息面情感""" + try: + path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" + if not os.path.exists(path): + return {} + xg = json.load(open(path)) + return xg.get("stocks", {}).get(code, {}) + except Exception: + return {} + + +def load_fundamentals(code): + """加载个股基本面""" + try: + cache = mtf._load_mtf_cache() + return cache.get(code, {}).get("fundamentals", {}) or {} + except Exception: + return {} + + +def _get_portfolio_risk_state(): + """读取 portfolio 组合风险状态(2026-06-23 引擎协调)""" + try: + # 数据一致性检查:警告多副本(2026-06-23 bugfix) + _check_portfolio_consistency() + p = read_portfolio() + pos_pct = p.get('position_pct', 0) + cash = p.get('cash', 0) + holdings = p.get('holdings', []) + weak_cnt = sum(1 for h in holdings if h.get('change_pct', 0) < -15) + total = len(holdings) or 1 + weak_ratio = weak_cnt / total + return { + 'position_pct': pos_pct, + 'cash': cash, + 'is_high_position': pos_pct > 80, + 'is_very_high_position': pos_pct > 90, + 'is_high_weak': weak_ratio > 0.35, + 'weak_ratio': round(weak_ratio * 100), + 'total_holdings': total, + } + except: + return {} + + +def _is_buy_signal(signal): + """判断信号是否为买入/持有类(用于防洗盘)""" + if not signal: + return False + buy_keywords = ['买入', '持有', '加仓', '关注'] + for kw in buy_keywords: + if kw in signal: + return True + return False + + +def _check_portfolio_consistency(): + """数据一致性检查:如果存在多份 portfolio.json 则报警(2026-06-23 bugfix)""" + main = '/home/hmo/web-dashboard/data/portfolio.json' + main_cash = None + try: + import json + main_cash = json.load(open(main)).get('cash') + except Exception: + return + for path in [ + '/home/hmo/data/portfolio.json', + '/home/hmo/projects/MoFin/data/portfolio.json', + '/home/hmo/web-dashboard.bak/data/portfolio.json', + ]: + if os.path.exists(path): + try: + other = json.load(open(path)) + if other.get('cash') != main_cash: + print(f"⚠️ 数据一致性: {os.path.realpath(path)} cash={other.get('cash')} ≠ 主文件 cash={main_cash} (需清理)", file=sys.stderr) + except Exception: + pass + + +def _check_contradiction(code, today_only=True): + """反馈循环核——检查本股是否有刚卖出的记录 + + 返回 dict or None: + - sold_reason: 'portfolio_trim'|'stop_loss' + - sold_at: 卖出日期 + - days_ago: 卖出距今交易日数 + - is_today: 是否今日卖出 + - tag: 追加到信号的标注 + """ + try: + from datetime import datetime, date + dec = read_decisions() + for e in dec.get('decisions', []): + if e.get('code') != code: + continue + sold_at = e.get('sold_at', '') + if not sold_at: + return None + try: + sd = datetime.strptime(sold_at, '%Y-%m-%d').date() + td = date.today() + days = (td - sd).days + except: + return None + + reason = e.get('sold_reason', 'portfolio_trim') + if reason == 'stop_loss': + tag = '止损离场(逻辑破坏,短期不关注)' + else: + tag = '组合减仓后关注(已清仓,等回踩确认)' + + return { + 'sold_reason': reason, + 'sold_at': sold_at, + 'days_ago': days, + 'is_today': days == 0, + 'tag': tag, + } + except: + return None + return None + + +def _get_sell_priority_list(): + """减仓优先级排序:深套>亏损>微盈>盈利(2026-06-23 反馈循环) + + 返回 [(code, name, change_pct, position_pct, priority_label), ...] + 按卖出的优先顺序排列(最先应该卖的在最前) + """ + try: + p = read_portfolio() + holdings = p.get('holdings', []) + ranked = [] + for h in holdings: + chg = h.get('change_pct', 0) + pos = h.get('position_pct', 0) + if chg < -30: + label = '深套(>30%),优先减' + rank = 0 + elif chg < -20: + label = '深套(>20%),优先减' + rank = 1 + elif chg < -10: + label = '亏损,建议减' + rank = 2 + elif chg < 0: + label = '微亏,可减' + rank = 3 + elif chg < 10: + label = '微盈,持有' + rank = 4 + else: + label = '盈利,最后减' + rank = 5 + ranked.append((rank, h['code'], h.get('name',''), chg, pos, label)) + ranked.sort(key=lambda x: (x[0], -x[4])) # 优先 rank, 其次仓位大优先 + return [{'code':c,'name':n,'change_pct':chg,'position_pct':pos,'label':l} + for r,c,n,chg,pos,l in ranked] + except: + return [] + + +def enrich_timing_signal(base_signal, macro_desc="", sector_note="", + profit_pct=0, stock_category="", is_new_entry=False, + fundamentals=None, news_sentiment=None, + timing_signal_override=None, + portfolio_context=None, + rr_ratio=0): # 2026-06-24 新参:盈亏比约束 + """多因子合成timing_signal——大盘+行业+基本面+技术+组合风险+盈亏比 + + 返回 (enriched_signal, factors_list) + - enriched_signal: 可读的多因子信号描述 + - factors_list: 各因子的摘要列表(用于后续显示) + """ + # 如果已手动设定,尊重手动 + if timing_signal_override and timing_signal_override != "neutral": + return timing_signal_override, [timing_signal_override] + + factors = [] + + # 1. 大盘因子 + if "偏强" in macro_desc or "大涨" in macro_desc or "bullish" in macro_desc.lower(): + macro_txt = "大盘偏强" + factors.append(macro_txt) + elif "偏弱" in macro_desc or "大跌" in macro_desc or "bearish" in macro_desc.lower(): + macro_txt = "大盘偏弱" + factors.append(macro_txt) + elif macro_desc and macro_desc != "宏观未加载": + factors.append("大盘中性") + + # 2. 行业因子 + if sector_note: + # 把"行业X大跌3%+"简化为"行业偏弱","行业X大涨3%+"简化为"行业偏强" + if "大跌" in sector_note or "下跌" in sector_note: + factors.append("行业偏弱") + elif "大涨" in sector_note: + factors.append("行业偏强") + elif "上涨" in sector_note: + factors.append("行业偏强") + else: + factors.append("行业中性") + + # 3. 基本面因子 + if fundamentals: + pe = fundamentals.get("pe", 0) + eps = fundamentals.get("eps", 0) + profit_growth = fundamentals.get("profit_growth", fundamentals.get("yoy_profit", "")) + revenue_growth = fundamentals.get("revenue_growth", fundamentals.get("yoy_revenue", "")) + mcap = fundamentals.get("mcap_total", 0) + + pe = pe or 0 + eps = eps or 0 + profit_growth_str = str(profit_growth or "") + revenue_growth_str = str(revenue_growth or "") + + # 净利增长 + for val in [profit_growth_str, revenue_growth_str]: + try: + v = float(val.replace("%", "").replace("+", "")) + if v > 50: + factors.append("净利增50%+") + break + elif v > 20: + factors.append(f"净利增{int(v)}%") + break + elif v < -20: + factors.append("净利降20%+") + break + except (ValueError, AttributeError): + continue + + # PE估值 + if 0 < pe < 15: + factors.append("低估值") + elif pe > 100 or pe < 0: + factors.append("高估值") + + # 市值 + if mcap and mcap > 5000: + factors.append("蓝筹") + + # 4. 消息面因子(小果情感) + if news_sentiment: + ns = news_sentiment.get("sentiment", "") + nc = news_sentiment.get("confidence", 0) + if ns == "positive" and nc >= 0.7: + kws = news_sentiment.get("keywords", []) + kw_str = f"({'/'.join(kws[:3])})" if kws else "" + factors.append(f"消息偏多{kw_str}") + elif ns == "negative" and nc >= 0.7: + kws = news_sentiment.get("keywords", []) + kw_str = f"({'/'.join(kws[:3])})" if kws else "" + factors.append(f"消息偏空{kw_str}") + + # 5. 技术面(基础信号) + if base_signal and base_signal != "neutral": + factors.append(base_signal) + + # 5.5 组合风险因子(2026-06-23 双引擎协调) + if portfolio_context and not is_new_entry: + if portfolio_context.get('is_very_high_position'): + factors.append("组合仓位极重(>90%)") + elif portfolio_context.get('is_high_position'): + factors.append("组合仓位偏重(>80%)") + if portfolio_context.get('is_high_weak'): + factors.append(f"弱势占{portfolio_context.get('weak_ratio')}%") + elif portfolio_context and is_new_entry: + # 新买入推荐:注明组合上下文 + if portfolio_context.get('is_high_position'): + factors.append(f"仓{portfolio_context.get('position_pct')}%现金有限") + elif portfolio_context.get('is_high_weak'): + factors.append("组合风险信号") + + # 5.7 盈亏比因子(2026-06-24 新增——RR<1.5降级买入信号) + if rr_ratio > 0: + if rr_ratio < 1.5: + factors.append(f"RR{rr_ratio}过低") + elif rr_ratio >= 3: + factors.append(f"RR{rr_ratio}") + # 1.5~3之间:中性,不特别标注 + + # 如果没有足够因素,返回信号不充分 + if not factors: + return "信号不充分", [] + + # 信号只应包含明确的买卖方向,不能从行业/大盘等上下文因子拼凑 + # base_signal 存在且非 neutral → 用 base_signal + # 否则 → 信号不充分(不拿 factors[-1] 当信号) + if base_signal and base_signal != "neutral": + clean_signal = base_signal + else: + # 从 factors 中找第一个有效的操作方向信号 + valid_direction = {"买入", "加仓", "观望", "持有", "关注", "信号不充分"} + signal_found = "" + for f in reversed(factors): + if f in valid_direction: + signal_found = f + break + clean_signal = signal_found if signal_found else "信号不充分" + + # 6. RR约束降级(2026-06-24 新增) + # 买入/加仓信号但RR<1.5 → 降级为"信号不充分" + buy_signals = {"买入", "加仓"} + if clean_signal in buy_signals and 0 < rr_ratio < 1.5: + clean_signal = "信号不充分" + factors.append("RR过低降级") + + return clean_signal, factors + + +def reassess_with_context(code, name, price, cost, shares, current_action, + volume_signal="", sentiment="neutral", is_watchlist=False): + """reassess_strategy + 多因子信号合成(大盘+行业+技术) + + 为 per_stock_reassess 等单只场景提供一站式多因子分析 + """ + result = reassess_strategy( + code, name, price, cost, shares, + current_action, volume_signal, sentiment, is_watchlist + ) + if not result: + return result + + # 加载宏观+行业+消息+基本面上下文 + try: + macro_bias, macro_desc = load_macro_context() + market_ctx = load_market_context() + stock_sector_map = load_stock_sector_map() + sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) + sector_note = sector_adj.get("note", "") + news_sentiment = load_stock_news_sentiment(code) + fund = load_fundamentals(code) + except Exception: + macro_desc = "" + sector_note = "" + news_sentiment = {} + fund = {} + + # ── DSA 集成:注入大盘复盘 + 新闻情报 ────────────────────────── + try: + from mo_bridge import enrich_analysis_context + region = "hk" if len(str(code)) == 5 and str(code)[0] in ('0','1') else "cn" + dsa_ctx = enrich_analysis_context(stock_code=code, stock_name=name, + region=region, include_news=True) + if dsa_ctx: + macro_desc = (macro_desc + "\n\n" + dsa_ctx).strip() + except Exception: + pass # DSA 不可用时静默跳过 + + enriched, factors = enrich_timing_signal( + base_signal=result.get("timing_signal", ""), + macro_desc=macro_desc, + sector_note=sector_note, + profit_pct=(price - cost) / cost * 100 if cost else 0, + stock_category=result.get("stock_category", ""), + is_new_entry=is_watchlist, + fundamentals=fund, + news_sentiment=news_sentiment, + portfolio_context=_get_portfolio_risk_state(), + rr_ratio=result.get("rr_ratio", 0), + ) + result["timing_signal"] = enriched + result["signal_factors"] = factors + + # 6. 防洗盘:信号不要一天一翻(2026-06-23) + # 如果旧信号是买入/持有类,新信号是谨慎/等待类,但中期趋势未破→维持旧信号 + try: + dec = read_decisions() + for e in dec.get('decisions', []): + if e.get('code') == code: + old_signal = e.get('timing_signal', '') + if old_signal and _is_buy_signal(old_signal) and not _is_buy_signal(enriched): + # 中等趋势检查:MA5 > MA20 + 多周期看多 + mtf = result.get('multi_tf_context', '') + if '看多' in mtf or '多头' in mtf: + try: + closes = [float(k.split()[2]) for k in mtf.split('|') if 'MA5' in k] + except: + closes = [] + has_uptrend = 'MA5' in mtf and 'MA20' in mtf + if has_uptrend: + print(f" 防洗盘: {old_signal}→保持旧信号(中期趋势完整)") + result["timing_signal"] = f"{old_signal}(正常回调价稳)" + sf = result.get("signal_factors") or [] + if "正常回调价稳" not in sf: + result["signal_factors"] = sf + ["正常回调价稳"] + break + except Exception as e: + print(f" 防洗盘跳过: {e}") + + # 7. 反馈循环核:检查本股是否有刚卖出的记录(2026-06-23) + contradiction = _check_contradiction(code) + if contradiction and contradiction.get('is_today'): + # 今日刚卖出 → 不屏蔽信号,但必须自标注矛盾 + print(f" 反馈循环: {contradiction.get('tag')} (sold_at={contradiction.get('sold_at')})") + if _is_buy_signal(result.get('timing_signal', '')): + result['action_note'] = contradiction['tag'] + # 在 timing_signal 中追加反馈标注,供报告层可见 + curr_signal = result.get('timing_signal', '') + if '⚠️' not in curr_signal: + result['timing_signal'] = f"⚠️{contradiction['tag']}|{curr_signal}" + elif contradiction: + # 非今日卖出但近期卖出 → 标注已清仓 + print(f" 近期清仓: sold_at={contradiction.get('sold_at')} ({contradiction.get('days_ago')}日前)") + if _is_buy_signal(result.get('timing_signal', '')): + curr_signal = result.get('timing_signal', '') + if '已清仓' not in curr_signal: + result['timing_signal'] = f"已清仓,{curr_signal}" + + # 重建 action 文本(同步多因子信号) + try: + if new_action_needs_refresh(result, {"source": "auto"}, price): + _refresh_action_text(result, price, name) + except Exception: + pass + + # ── 策略质量门禁 ── + enforce_strategy_quality(code, name, result) + + return result + + +def new_action_needs_refresh(result, old_entry, price): + """判断宏观/行业调整后是否需要刷新action文本""" + # 自选股和手动策略不做调整,不需要刷新 + if old_entry.get("source") == "manual": + return False + return True + + +def _refresh_action_text(result, price, name): + """根据调整后的止损/止盈重建action文本""" + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + el = result.get("entry_low", 0) + eh = result.get("entry_high", 0) + ts = result.get("timing_signal", "") + an = result.get("action_note", "") + old_action = result.get("action", "") + + # 保持原action的前缀(持有状态部分不变) + # action格式一般是: "状态 | 止损X | 目标Y | 买入区X~Y | 信号:Z" + parts = old_action.split(" | ") + new_parts = [] + for p in parts: + p = p.strip() + # 替换止损数字 + if p.startswith("止损") or p.startswith("止损参考"): + if sl: + p = f"止损{sl}" if "止损参考" not in old_action.split(" | ")[0] else f"止损参考{sl}" + # 替换目标/止盈数字 + if p.startswith("目标") or p.startswith("止盈"): + if tp: + p = f"目标{tp}" + # 替换买入区数字 + if "买入区" in p and "~" in p: + if el and eh: + p = f"买入区{el}~{eh}" + new_parts.append(p) + result["action"] = " | ".join(new_parts) + + +def check_sector_alerts(market_ctx, stock_sector_map, holdings, wl): + """行业轮动主动预警:检测板块崩盘级别信号→查持仓→输出预警 + + 返回 list of alerts: [{code, name, sector, chg, action}] + """ + alerts = [] + if not market_ctx: + return alerts + + sector_perf = market_ctx.get("sector_perf", {}) + + # 找出所有跌幅>3%的行业 + crashing_sectors = {name: data for name, data in sector_perf.items() + if data.get("change", 0) <= -3} + + if not crashing_sectors: + return alerts + + # 构建 code→持仓信息 的映射 + holding_map = {} + for h in holdings: + c = h.get("code", "") + if c: + holding_map[c] = {"name": h.get("name", c), "type": "持仓"} + for s in wl.get("stocks", []): + c = s.get("code", "") + if c and c not in holding_map: + holding_map[c] = {"name": s.get("name", c), "type": "自选"} + + # 对每个暴跌行业,查持仓中是否有股票属于该行业 + for sec_name, sec_data in sorted(crashing_sectors.items(), + key=lambda x: x[1].get("change", 0)): + chg = sec_data.get("change", 0) + for code, sectors in stock_sector_map.items(): + if code in holding_map and sec_name in sectors: + info = holding_map[code] + alerts.append({ + "code": code, + "name": info["name"], + "sector": sec_name, + "sector_change": chg, + "type": info["type"], + "action": f"行业{sec_name}跌{chg:+.1f}%,{info['type']}需关注", + }) + + alerts.sort(key=lambda a: a["sector_change"]) + return alerts + + +def regenerate_all(stdout=True): + """全量重评所有持仓+自选策略""" + # 优先从 SQLite 读取 + try: + from mofin_db import get_conn, query_holdings, query_watchlist + conn = get_conn() + holdings = query_holdings(conn) + wl_stocks = query_watchlist(conn) + conn.close() + pf = {"holdings": holdings} + wl = {"stocks": wl_stocks} + except Exception: + try: + pf = read_portfolio() + wl = read_watchlist() + except Exception: + pf = {} + wl = {} + + all_stocks = {} + for item in pf.get("holdings", []): + code = item.get("code", "") + if code: + all_stocks[code] = {"source": "portfolio", "data": item} + for item in wl.get("stocks", []): + code = item.get("code", "") + if code and code not in all_stocks: + all_stocks[code] = {"source": "watchlist", "data": item} + + total = len(all_stocks) + ok = 0 + errors = 0 + results = [] + decisions = [] + + # 加载现有 decisions.json 以便追踪变更 + decisions_path = "/home/hmo/web-dashboard/data/decisions.json" + try: + existing_decisions = {d["code"]: d for d in read_decisions().get("decisions", []) if d.get("code")} + except: + existing_decisions = {} + + # 加载宏观上下文(影响策略参数调整) + macro_bias, macro_desc = load_macro_context() + if stdout: + print(f" 宏观参考: {macro_desc} (bias={macro_bias})") + + # 加载市场上下文 — 行业板块表现 + 大盘宽度(策略参数调整用) + market_ctx = load_market_context() + stock_sector_map = load_stock_sector_map() + market_breadth = market_ctx.get("breadth", 50) + market_mood = market_ctx.get("mood", "neutral") + if stdout: + sectors_found = sum(1 for c in all_stocks if stock_sector_map.get(c)) + print(f" 市场参考: {market_mood} 上涨比{market_breadth}% 已匹配{sectors_found}/{total}只个股行业") + + # 批量预取所有价格(一次API调用 vs 之前N次) + prices_map = batch_fetch_prices(list(all_stocks.keys())) + if stdout: + print(f" 批量获取价格: {len(prices_map)}/{total} 成功") + + for code, info in sorted(all_stocks.items()): + stock = info["data"] + name = stock.get("name", code) + cost = stock.get("cost", 0) or 0 + shares = stock.get("shares", 0) or 0 + source = info["source"] + + q = prices_map.get(code) + if not q or not q.get("price"): + results.append({"code": code, "name": name, "error": "腾讯API无数据"}) + errors += 1 + if stdout: + print(f" ❌ {name}({code}): 腾讯API无数据") + continue + + price = q["price"] + profit_pct = (price - cost) / cost * 100 if cost else 0 + current_action = stock.get("analysis", {}).get("action", "") + close_yest = q.get("close", 0) + sentiment = "neutral" + if close_yest and price > close_yest * 1.02: + sentiment = "bullish" + elif close_yest and price < close_yest * 0.98: + sentiment = "bearish" + + try: + is_wl = (source == "watchlist") + result = reassess_strategy( + code, name, price, cost, shares, + current_action, volume_signal="中性", sentiment=sentiment, + is_watchlist=(source == "watchlist"), + ) + + # --- Manual param preservation: 用户手动策略永不覆盖 --- + old_entry = existing_decisions.get(code, {}) + if old_entry.get("source") == "manual": + # 仅覆盖策略参数,技术分析/信号/价格照常保留 + for key in ["entry_low", "entry_high", "stop_loss", "take_profit"]: + if key in old_entry and old_entry[key] is not None: + result[key] = old_entry[key] + # 重算盈亏比(基于手动参数) + manual_stop = result.get("stop_loss", 0) or 0 + manual_target = result.get("take_profit", 0) or 0 + risk = max(price - manual_stop, price * 0.01) if manual_stop > 0 else price * 0.01 + reward = max(manual_target - price, 0) if manual_target > 0 else 0 + result["rr_ratio"] = round(reward / risk, 2) if risk > 0 else 0 + # 重建 action 文本(引用手动参数,不引用自动计算的) + profit_pct = (price - cost) / cost * 100 if cost else 0 + manual_action_parts = [] + if profit_pct < -20: + manual_action_parts.append("深套持有") + elif profit_pct < -10: + manual_action_parts.append("持有观察") + elif profit_pct < 0: + manual_action_parts.append("持有观察") + elif profit_pct < 5: + manual_action_parts.append("盈利持有") + else: + manual_action_parts.append("盈利良好") + if result.get("action_note"): + manual_action_parts.append(result["action_note"]) + if is_wl: + if manual_stop > 0: + manual_action_parts.append(f"止损参考{manual_stop}") + manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") + else: + if manual_stop > 0: + manual_action_parts.append(f"止损{manual_stop}") + if manual_target > 0: + manual_action_parts.append(f"目标{manual_target}") + manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") + ts = result.get("timing_signal", "") + if ts and ts != "neutral": + manual_action_parts.append(f"信号:{ts}") + result["action"] = " | ".join(manual_action_parts) + result["status"] = "manual" # 标记为手动管理,变更追踪不受影响 + if stdout: + print(f" [手动保留] {name}({code}) 策略参数未覆盖") + + # 宏观偏差调整:收盘后重评时根据宏观方向微调止损/止盈 + # 自选股不做止盈宏观调整(无持仓) + # 手动策略不做宏观偏差调整(尊重用户设定) + if macro_bias != 1.0 and not is_wl and old_entry.get("source") != "manual": + old_stop = result.get("stop_loss", 0) + old_target = result.get("take_profit", 0) + if macro_bias < 1.0 and old_stop > 0: # 宏观偏弱 → 收紧止损 + # 止损上移(但保留最小3%间距) + adjusted_stop = round(old_stop * (1 + (1 - macro_bias) * 0.3), 2) + min_stop = round(price * 0.97, 2) + result["stop_loss"] = min(adjusted_stop, min_stop) + if old_target > 0: + result["take_profit"] = round(old_target * (1 - (1 - macro_bias) * 0.2), 2) + elif macro_bias > 1.0 and old_target > 0: # 宏观偏强 → 止盈上调让利润跑 + result["take_profit"] = round(old_target * (1 + (macro_bias - 1) * 0.3), 2) + + # 行业偏差调整:根据个股所在行业的市场表现微调止损/止盈 + # 手动策略不做行业调整(尊重用户设定) + sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) + sector_note = sector_adj.get("note", "") + if sector_note and old_entry.get("source") != "manual": + old_stop = result.get("stop_loss", 0) + old_target = result.get("take_profit", 0) + stop_bias = sector_adj.get("stop_bias", 1.0) + target_bias = sector_adj.get("target_bias", 1.0) + if stop_bias != 1.0 and old_stop > 0: + # 行业偏差调整(在宏观调整之后叠加) + adjusted = round(old_stop * stop_bias, 2) + # 保留最小3%间距 + min_stop = round(price * 0.97, 2) + result["stop_loss"] = min(adjusted, min_stop) + if target_bias != 1.0 and old_target > 0 and not is_wl: + result["take_profit"] = round(old_target * target_bias, 2) + + # 加载消息面+基本面(逐个股) + news_sentiment = load_stock_news_sentiment(code) + fund = load_fundamentals(code) + + # 多因子合成 timing_signal:大盘+行业+消息+基本面+技术 + if old_entry.get("source") != "manual": + enriched, _ = enrich_timing_signal( + base_signal=result.get("timing_signal", ""), + macro_desc=macro_desc, + sector_note=sector_note, + profit_pct=profit_pct, + stock_category=result.get("stock_category", ""), + is_new_entry=(source == "watchlist"), + fundamentals=fund, + news_sentiment=news_sentiment, + rr_ratio=result.get("rr_ratio", 0), + ) + result["timing_signal"] = enriched + + # 在宏观/行业/多因子调整后重建 action 文本(同步调整后的止损/止盈数字) + if new_action_needs_refresh(result, old_entry, price): + _refresh_action_text(result, price, name) + + extra = { + "rr_ratio": result.get("rr_ratio"), + "action_note": result.get("action_note", ""), + "timing_signal": result.get("timing_signal", ""), + } + analysis = { + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "entry_low": result["entry_low"], + "entry_high": result["entry_high"], + "action": result["action"], + "tech_snapshot": result.get("tech_snapshot", ""), + "multi_tf_context": result.get("multi_tf_context", ""), + "reassessed_at": result["reassessed_at"], + "status": result["status"], + **extra, + } + stock["analysis"] = analysis + # 同步 top-level 字段 → zone_breach/price_monitor 依赖这些字段 + # (2026-06-24 bugfix: analysis 子对象有但顶层没有,导致新持仓的止损检测盲区) + stock["stop_loss"] = result.get("stop_loss", 0) + stock["take_profit"] = result.get("take_profit", 0) + stock["entry_low"] = result.get("entry_low", 0) + stock["entry_high"] = result.get("entry_high", 0) + # 同步 trigger 字段 -> price_monitor 依赖 + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + el = result.get("entry_low", 0) + eh = result.get("entry_high", 0) + trig = {} + if sl and float(sl) > 0: + trig["stop_loss"] = float(sl) + if el and eh and float(el) > 0 and float(eh) > 0: + trig["entry_zone"] = f"{float(el)}~{float(eh)}" + if tp and float(tp) > 0: + trig["take_profit_zone"] = f"0~{float(tp)}" + stock["trigger"] = trig + results.append({ + "code": code, "name": name, + "price": price, "cost": cost, + "action": result["action"], + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "rr_ratio": result["rr_ratio"], + }) + ok += 1 + if stdout: + rr_str = f" RR={result['rr_ratio']}" if "rr_ratio" in result else "" + print(f" ✅ {name}({code}) {price} {result['action']}{rr_str}") + + # 记录所有股票的决策日志(含变更追踪) + status_display = result.get("status", "active") + # 构建行业上下文 + sector_ctx_str = "" + sec_name = sector_adj.get("sector_name", "") + sec_chg = sector_adj.get("sector_change", 0) + if sec_name: + sector_ctx_str = f"行业{sec_name}{sec_chg:+.1f}%" + if sector_adj.get("note"): + # note 已包含大盘宽度信息 + sector_ctx_str = sector_adj["note"] + elif market_breadth < 40: + # 无行业映射时至少记录大盘宽度 + sector_ctx_str = f"大盘上涨比{market_breadth}%" + new_entry = { + "code": code, "name": name, "price": price, + "cost": old_entry.get("cost", cost) if old_entry else cost, # 优先保留旧成本(holding.xls权威) + "shares": shares, # 当前实际持仓股数(不继承旧决策的可能为0的值) + "avg_price": old_entry.get("avg_price", 0), # 保留持仓均价 + "currency": "HKD" if is_hk_stock(str(code)) else "CNY", + "action": result["action"], + "stop_loss": result.get("stop_loss"), + "entry_low": result["entry_low"], + "entry_high": result["entry_high"], + "tech_snapshot": result.get("tech_snapshot", ""), + "timing_signal": result.get("timing_signal", ""), + "rr_ratio": result.get("rr_ratio", 0), + "status": status_display, + "note": result.get("action_note", ""), + "timestamp": result["reassessed_at"], + "updated_at": result["reassessed_at"], + "type": "自选策略" if is_wl else "持仓策略", + "source": old_entry.get("source", "auto"), # manual/auto,继承旧标记 + "sector_context": sector_ctx_str, # 市场上下文:行业表现+大盘宽度 + "stock_category": result.get("stock_category", "中短线"), # 组合监测用 + "position_advice": result.get("position_advice", "中等仓位"), + "time_horizon": result.get("time_horizon", "2周~3月"), + } + new_entry["trigger"] = trig + # created_at: 首次创建时设置,后续 preserve + old_entry = existing_decisions.get(code, {}) + if old_entry.get("created_at"): + new_entry["created_at"] = old_entry["created_at"] + else: + new_entry["created_at"] = result["reassessed_at"] + # 保留 last_reassessed_price(per_stock_reassess 维护的防抖字段) + if old_entry.get("last_reassessed_price"): + new_entry["last_reassessed_price"] = old_entry["last_reassessed_price"] + # 自选股也写止盈位(用于RR校验),但标签用"目标参考"非"止盈" + new_entry["take_profit"] = result.get("take_profit") + + # --- 变更追踪 --- + old_action = old_entry.get("action", "") + old_stop = old_entry.get("stop_loss") + old_target = old_entry.get("take_profit") + + # 构建旧策略摘要和变更理由 + update_reason = "" + changelog_entry = None + + if old_action and old_action != result["action"]: + # 策略有变化 → 记录变更 + old_summary = old_action + new_summary = result["action"] + + # 判断触发原因 + if abs(price - old_entry.get("price", price)) / max(price, 0.01) > 0.03: + trigger = f"价格变动({old_entry.get('price','?')}→{price})" + elif result.get("timing_signal") and result["timing_signal"] != old_entry.get("timing_signal", ""): + trigger = f"技术信号变化: {result['timing_signal']}" + else: + trigger = "技术面重评" + + # 格式化的变更理由(自选股只看止损,不看止盈) + diff_parts = [] + if old_stop and result["stop_loss"] != old_stop: + diff_parts.append(f"止损{old_stop}→{result['stop_loss']}") + if not is_wl and old_target and result.get("take_profit") and result["take_profit"] != old_target: + diff_parts.append(f"止盈{old_target}→{result['take_profit']}") + if diff_parts: + update_reason = f"{trigger}: {', '.join(diff_parts)} | {result.get('tech_snapshot','')[:60]}" + else: + update_reason = f"{trigger}: 策略文字调整" + + changelog_entry = { + "date": result["reassessed_at"], + "old_action": old_action, + "new_action": result["action"], + "reason": update_reason, + "trigger": trigger, + } + new_entry["updated_reason"] = update_reason + + elif not old_action: + # 首次创建策略 + update_reason = f"初始策略创建 | {result.get('tech_snapshot','')[:60]}" + changelog_entry = { + "date": result["reassessed_at"], + "old_action": "", + "new_action": result["action"], + "reason": update_reason, + "trigger": "初始创建", + } + + # 合并changelog + old_changelog = old_entry.get("changelog", []) if old_entry else [] + if changelog_entry: + new_entry["changelog"] = old_changelog + [changelog_entry] + else: + new_entry["changelog"] = old_changelog + + # 保留执行记录 + if old_entry and old_entry.get("execution"): + new_entry["execution"] = old_entry["execution"] + elif stock.get("analysis", {}).get("status") == "executing": + new_entry["execution"] = { + "status": "executing", + "entry_price": cost if cost else 0, + "shares": shares, + "notes": "", + } + + # --- 自动标记 current_recommend --- + # 只在真正执行中的持仓才自动推荐:execution.status 为 executing 或 partial_exit + exec_status = old_entry.get("execution", {}).get("status", "") if old_entry else "" + is_active = exec_status in ("executing", "partial_exit") + + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_deep_loss_stock = profit_pct < -20 + rr = result.get("rr_ratio", 0) + ts = result.get("timing_signal", "") + note = result.get("action_note", "") + + # 计算是否在/接近买入区 + entry_low_val = result.get("entry_low", 0) + entry_high_val = result.get("entry_high", 0) + in_buy_zone = (entry_low_val > 0 and entry_high_val > 0 and + entry_low_val <= price <= entry_high_val) + near_buy_zone_low = (entry_low_val > 0 and + price >= entry_low_val * 0.98 and + price <= entry_high_val) + + # 推荐条件:必须是执行中的持仓 + 基本面条件达标 + is_recommendable = ( + is_active + and not is_deep_loss_stock + and rr >= 1.5 + and ts != "neutral" + and "不建议" not in note + ) + if is_recommendable: + new_entry["tag"] = "current_recommend" + else: + # 不清除 active_manual(用户手动标记),只清除自动推荐的 + old_tag = old_entry.get("tag", "") if old_entry else "" + if old_tag != "active_manual": + new_entry.pop("tag", None) + + decisions.append(new_entry) + + except Exception as e: + results.append({"code": code, "name": name, "error": str(e)}) + errors += 1 + if stdout: + print(f" ❌ {name}({code}): {e}") + + # 写回数据文件 — 保留现有字段(现金、总资产等)不丢 + try: + existing_pf = read_portfolio() + except Exception: + existing_pf = {} + # 保留 price/change_pct — price_monitor 维护的实时价,regenerate_all 不应清除 + _existing_holdings_map = {} + for _h in existing_pf.get('holdings', []): + if _h.get('code'): + _existing_holdings_map[_h['code']] = _h + _new_holdings = pf.get("holdings", []) + for _h in _new_holdings: + _code = _h.get('code') + if _code and _code in _existing_holdings_map: + _old = _existing_holdings_map[_code] + _h['price'] = _old.get('price', 0) + _h['change_pct'] = _old.get('change_pct', 0) + existing_pf["holdings"] = _new_holdings + existing_pf["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M') + + # ── Watchlist ↔ Holdings 双向自动迁移(2026-06-27 Dad要求)── + # ① 持仓已有 → 从自选移除(买入自动清除) + wl_codes = {s.get("code") for s in wl.get("stocks", []) if s.get("code")} + pf_codes = {h.get("code") for h in _new_holdings if h.get("code") and h.get("shares", 0) > 0} + removed_from_wl = [] + for h_code in wl_codes & pf_codes: + # 持仓>0且量够 → 自选移除 + wl["stocks"] = [s for s in wl.get("stocks", []) if s.get("code") != h_code] + removed_from_wl.append(h_code) + if removed_from_wl and stdout: + print(f" 自选→持仓自动移除: {', '.join(removed_from_wl)}") + + # ② 清仓/卖光 → 加回自选(只要仍有关注价值) + added_to_wl = [] + old_pf_codes = {_h.get("code") for _h in existing_pf.get("holdings", []) if _h.get("code")} + sold_codes = old_pf_codes - pf_codes # 曾持仓但现在没有(或不在了) + for sc in sold_codes: + # 已有自选就不重复加 + if sc in wl_codes: + continue + # 从现有decisions看是否有关注价值 + for d in decisions: + if d.get("code") == sc and d.get("entry_low") and d.get("entry_high"): + wl["stocks"].append({ + "code": sc, "name": d.get("name", sc), + "entry_low": d.get("entry_low"), "entry_high": d.get("entry_high"), + "stop_loss": d.get("stop_loss", 0), + "analysis": {"action": d.get("action", ""), "tech_snapshot": d.get("tech_snapshot", "")} + }) + added_to_wl.append(sc) + break + if added_to_wl and stdout: + print(f" 清仓→自选自动加入: {', '.join(added_to_wl)}") + + # 重新计算 portfolio 汇总(保留已存在的 cash,用最新价格算市值) + try: + total_mv = 0.0 + total_cost = 0.0 + for h in existing_pf.get('holdings', []): + p = h.get('price') or 0 + s = h.get('shares') or 0 + c = h.get('cost') or 0 + total_mv += p * s + total_cost += c * s + if p and s and total_mv > 0: + h['market_value'] = round(p * s, 2) + old_cash = existing_pf.get('cash') or 80476 # fallback 6/23 backup + frozen_cash = existing_pf.get('frozen_cash') or 0 + existing_pf['cash'] = old_cash + existing_pf['total_mv'] = round(total_mv, 2) + existing_pf['total_assets'] = round(total_mv + old_cash + frozen_cash, 2) + existing_pf['total_pnl'] = round(total_mv - total_cost, 2) + existing_pf['position_pct'] = round(total_mv / (total_mv + old_cash + frozen_cash) * 100, 2) if (total_mv + old_cash + frozen_cash) > 0 else 0 + except Exception as e: + print(f" [汇总计算失败] {e}", flush=True) + + # DB 写入(替代 JSON dump — 强制币种约束) + try: + from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock, write_holding_strategy + conn = get_conn() + write_holdings_batch(conn, existing_pf.get('holdings', [])) + write_portfolio_summary(conn, existing_pf) + for s in wl.get('stocks', []): + s.setdefault('currency', 'CNY') + write_watchlist_stock(conn, s) + for d in decisions: + # ── 策略质量门禁 ── + code = d.get('code', '') + name = d.get('name', '') + enforce_strategy_quality(code, name, d) + write_holding_strategy(conn, code, name, d) + conn.close() + except Exception as e: + print(f" [DB写入失败] {e}", flush=True) + + # 记录策略→提示词版本关联 + if HAS_PROMPT_TRACKING: + try: + for d in decisions: + if d.get("code") and d.get("action"): + record_strategy_generation( + d["code"], d.get("name", ""), d.get("action", "") + ) + except Exception as e: + if stdout: + print(f" ⚠️ 提示词版本追踪失败: {e}", file=sys.stderr) + + # 刷新多周期缓存到磁盘 + try: + import multi_timeframe as _mtf + _mtf.flush_mtf_cache() + except Exception: + pass + + summary = {"total": total, "ok": ok, "errors": errors} + if stdout: + print(f"\n✅ 全量重评完成: {ok}/{total}成功, {errors}错误") + return summary + + +if __name__ == "__main__": + regenerate_all() diff --git a/scripts/strategy_tree.py b/scripts/strategy_tree.py new file mode 100644 index 0000000..3a8edfc --- /dev/null +++ b/scripts/strategy_tree.py @@ -0,0 +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 +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/scripts/sync_dashboard.py b/scripts/sync_dashboard.py new file mode 100644 index 0000000..263554a --- /dev/null +++ b/scripts/sync_dashboard.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""sync_dashboard.py — 数据同步包装脚本 +1. 运行 update_data.py +2. 检查 server 是否正常 +3. server 挂了就重启 +""" +import subprocess +import sys +import os + +DASHBOARD_DIR = "/home/hmo/web-dashboard" +SERVER_PORT = 8899 + +def run_update(): + """运行 update_data.py,返回 stdout""" + try: + r = subprocess.run( + ["python3", "update_data.py"], + cwd=DASHBOARD_DIR, + capture_output=True, text=True, timeout=60, + ) + return r.stdout.strip() if r.returncode == 0 else None + except Exception as e: + print(f"❌ update_data.py 执行失败: {e}", file=sys.stderr) + return None + +def check_server(): + """检查 server 是否正常""" + import urllib.request + try: + req = urllib.request.Request(f"http://localhost:{SERVER_PORT}", + headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=5) as r: + return r.status == 200 + except: + return False + +def restart_server(): + """重启 server""" + try: + subprocess.run( + ["python3", "server.py", str(SERVER_PORT)], + cwd=DASHBOARD_DIR, + capture_output=True, timeout=10, + ) + return True + except Exception as e: + print(f"❌ server 重启失败: {e}", file=sys.stderr) + return False + +def main(): + output = run_update() + server_ok = check_server() + + if output: + # update_data.py 有输出 → 转发 + print(output) + if not server_ok: + print("⚠️ 注意:server 似乎未响应,但数据已更新") + else: + if server_ok: + # 无输出 + server 正常 → SILENT + print("[SILENT] 数据无更新,server运行正常") + else: + # server 挂了 → 重启 + print("⚠️ server 无响应,尝试重启...") + if restart_server(): + print("✅ server 已重启") + # 再检查一次 + if check_server(): + print("✅ server 恢复运行") + else: + print("❌ server 仍未响应,需人工检查") + else: + print("❌ server 重启失败") + +if __name__ == "__main__": + main() diff --git a/scripts/system_audit.py b/scripts/system_audit.py new file mode 100644 index 0000000..c0c6012 --- /dev/null +++ b/scripts/system_audit.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""system_audit.py — MoFin 全局系统审计 + +每日收盘后运行,遍历所有对象生命周期,发现缺口→自动修复/记录。 + +审计维度: + 1. 信号管道 — 今日signal_news产出vs处理量,有积压则预警 + 2. 股票生命周期 — 关注列表是否有条件触发的、自选是否有策略缺失的 + 3. 策略状态 — 过期/偏离/无止损等异常策略 + 4. 建议闭环 — pending超过7天的未执行建议 + 5. 组合健康 — 弱势占比、仓位集中度、现金水位 + 6. 数据管道 — 今日采集是否正常、有无cron报错 + 7. 系统服务 — Dashboard/XMPP/小果API在线状态 + +输出:JSON + 摘要文本,推送给老爸。 +""" + +import json, sqlite3, subprocess, sys, time +from pathlib import Path +from datetime import datetime, timedelta +from mo_data import read_portfolio, read_decisions, read_watchlist + +DATA_DIR = Path("/home/hmo/MoFin/data") +WEB_DATA = Path("/home/hmo/web-dashboard/data") +REPORT = {"timestamp": datetime.now().isoformat(), "issues": [], "fixes": [], "ok": []} + + +def log_issue(area, severity, desc, fix=None): + REPORT["issues"].append({"area": area, "severity": severity, "desc": desc, "suggested_fix": fix}) + + +def log_fix(area, desc): + REPORT["fixes"].append({"area": area, "desc": desc}) + + +def log_ok(area, desc): + REPORT["ok"].append({"area": area, "desc": desc}) + + +# ── 1. 信号管道审计 ── +def audit_signals(conn): + try: + total = conn.execute("SELECT COUNT(*) FROM signal_news").fetchone()[0] + unproc = conn.execute("SELECT COUNT(*) FROM signal_news WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL)").fetchone()[0] + today = conn.execute("SELECT COUNT(*) FROM signal_news WHERE created_at > datetime('now','-1 day')").fetchone()[0] + log_ok("信号管道", f"信号库{total}条,今日{today}条,未处理{unproc}条") + if unproc > 30: + log_issue("信号管道", "HIGH", f"未处理信号堆积{unproc}条,可能处理速度跟不上") + except Exception as e: + log_issue("信号管道", "HIGH", f"查询失败: {e}") + + +# ── 2. 股票生命周期审计 ── +def audit_stocks(conn): + # 关注列表 + try: + wl = read_watchlist() + watching = [s for s in wl.get("stocks", []) if s.get("status") == "watching"] + formal = [s for s in wl.get("stocks", []) if s.get("status") != "watching"] + log_ok("股票池", f"正式自选{len(formal)}只, 关注列表{len(watching)}只") + + # 检查持仓中是否有已关闭但未标记的 + closed_holdings = conn.execute("SELECT COUNT(*) FROM holdings WHERE is_active=0").fetchone()[0] + active_holdings = conn.execute("SELECT COUNT(*) FROM holdings WHERE is_active=1").fetchone()[0] + if closed_holdings > 0: + log_ok("股票池", f"持有中{active_holdings}只活跃, {closed_holdings}只已关闭") + except Exception as e: + log_issue("股票池", "MEDIUM", f"查询失败: {e}") + + +# ── 3. 策略状态审计 ── +def audit_strategies(conn): + try: + dec = read_decisions() + active = [d for d in dec.get("decisions", []) if d.get("status") in ("active", "updated")] + stale_count = 0 + no_stop = 0 + for d in active: + # 检查是否有止损 + if not d.get("stop_loss"): + no_stop += 1 + # 检查是否过期(>14天) + ts = d.get("timestamp", "") + if ts: + try: + dt = datetime.fromisoformat(ts) + if (datetime.now() - dt).days > 14: + stale_count += 1 + except: + pass + log_ok("策略", f"活跃策略{len(active)}条") + if stale_count > 0: + log_issue("策略", "MEDIUM", f"{stale_count}条策略超过14天未更新", "运行 stale_detector 触发重评") + if no_stop > 0: + log_issue("策略", "HIGH", f"{no_stop}条活跃策略缺少止损位") + except Exception as e: + log_issue("策略", "HIGH", f"查询失败: {e}") + + +# ── 4. 建议闭环审计 ── +def audit_advice(conn): + try: + dec = read_decisions() + pending = 0 + for d in dec.get("decisions", []): + for a in d.get("advice_timeline", []): + if a.get("status") == "pending": + pending += 1 + if pending > 0: + log_issue("建议", "LOW", f"{pending}条建议待确认/执行", "检查advice_timeline确认是否已执行") + else: + log_ok("建议", "无待处理建议") + except Exception as e: + log_issue("建议", "MEDIUM", f"查询失败: {e}") + + +# ── 5. 组合健康 ── +def audit_portfolio(conn): + try: + pj = read_portfolio() + pos = pj.get("position_pct", 0) + cash = pj.get("cash", 0) + available = pj.get("available_cash", cash) + + log_ok("组合", f"总仓位{pos:.1f}%") + if pos > 90: + log_issue("组合", "MEDIUM", f"仓位{pos:.1f}%超过90%,现金紧张") + elif pos < 30: + log_issue("组合", "LOW", f"仓位仅{pos:.1f}%,现金过多") + except Exception as e: + log_issue("组合", "MEDIUM", f"查询失败: {e}") + + +# ── 8. 编译缓存审计 ── +def audit_cache(): + """检查 __pycache__ 中是否有比 .py 源文件更老的 .pyc(陈旧缓存)。""" + try: + base = Path(__file__).resolve().parent + stale = [] + for pyc in base.rglob("__pycache__/*.pyc"): + py = pyc.with_suffix("") # remove .cpython-*.pyc extension + # The .py file is at parent_of___pycache__ / stem_without_cpython_suffix + # e.g., __pycache__/foo.cpython-312.pyc -> ../foo.py + stem = pyc.stem # e.g. "foo.cpython-312" + # Remove the .cpython-NNN suffix to get original module name + import re + m = re.match(r"^(.*?)\.cpython-\d+", stem) + if not m: + continue + py_path = pyc.parent.parent / f"{m.group(1)}.py" + if py_path.exists() and pyc.stat().st_mtime < py_path.stat().st_mtime: + stale.append(str(py_path.name)) + if stale: + log_issue("编译缓存", "MEDIUM", f"{len(stale)}个陈旧.pyc:{', '.join(stale)}", "删除对应__pycache__/.pyc") + else: + log_ok("编译缓存", "所有.pyc文件与源文件一致") + except Exception as e: + log_issue("编译缓存", "LOW", f"检查失败: {e}") + + +# ── 6. 数据管道审计 ── +def audit_pipeline(): + # 检查DB市场数据是否今天更新 + try: + conn = sqlite3.connect(str(DATA_DIR / "mofin.db")) + row = conn.execute( + "SELECT created_at FROM market_snapshots ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + if row and row[0][:10] == datetime.now().strftime("%Y-%m-%d"): + log_ok("数据管道", f"市场数据今天更新({row[0]})") + else: + log_issue("数据管道", "HIGH", f"市场数据未更新(DB), 最后{row[0] if row else '无数据'}") + except Exception as e: + log_issue("数据管道", "HIGH", f"DB检查失败: {e}") + + +# ── 7. 系统服务 ── +def audit_services(): + services = [ + ("Dashboard", "http://127.0.0.1:8899/", "200"), + ("mofin-dashboard", None, "active"), + ("xmpp-zhiwei", None, "active"), + ] + for name, url, expected in services: + try: + if url: + result = subprocess.run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", url], + capture_output=True, text=True, timeout=5) + if result.stdout.strip() == expected: + log_ok("系统服务", f"{name} 正常") + else: + log_issue("系统服务", "HIGH", f"{name} 返回 {result.stdout.strip()} (期望{expected})") + else: + result = subprocess.run(["systemctl", "is-active", name], + capture_output=True, text=True, timeout=5) + if result.stdout.strip() == expected: + log_ok("系统服务", f"{name} 正常") + else: + log_issue("系统服务", "HIGH", f"{name} 状态 {result.stdout.strip()} (期望{expected})") + except Exception as e: + log_issue("系统服务", "HIGH", f"{name} 检查失败: {e}") + + +# ── 执行 ── +def main(): + start = time.time() + conn = sqlite3.connect(str(DATA_DIR / "mofin.db")) + + audit_signals(conn) + audit_stocks(conn) + audit_strategies(conn) + audit_advice(conn) + audit_portfolio(conn) + audit_pipeline() + audit_services() + audit_cache() + + conn.close() + + REPORT["duration"] = f"{time.time()-start:.0f}s" + REPORT["summary"] = f"审计完成: {len(REPORT['issues'])}个问题, {len(REPORT['fixes'])}个已修复, {len(REPORT['ok'])}项正常" + + # 写入文件 + (WEB_DATA / "system_audit_report.json").write_text(json.dumps(REPORT, ensure_ascii=False, indent=2)) + + # 输出摘要(给cron推送用) + print(f"【系统审计】{REPORT['summary']}") + for i in REPORT["issues"]: + print(f" [{i['severity']}] {i['area']}: {i['desc']}") + if REPORT["fixes"]: + for f in REPORT["fixes"]: + print(f" ✅ 已修复: {f['area']}: {f['desc']}") + for o in REPORT["ok"]: + print(f" ✅ {o['area']}: {o['desc']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/system_health_check.py b/scripts/system_health_check.py new file mode 100644 index 0000000..64ade3d --- /dev/null +++ b/scripts/system_health_check.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""system_health_check.py — MoFin 系统健康检查 + +每日运行,检查所有组件是否正常工作。 +输出报告,有问题才推送。 +""" +import json, os, sys, subprocess +from datetime import datetime, timedelta +from pathlib import Path + +DATA_DIR = Path("/home/hmo/web-dashboard/data") +EVENTS_PATH = DATA_DIR / "price_events.json" +EVALUATION_PATH = DATA_DIR / "evaluation.json" +ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" +CRON_JOBS = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" +POSITION_CRON = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" + +def check(ok, msg): + icon = "✅" if ok else "⚠️" + return f" {icon} {msg}" + +def load_json(path, default=None): + try: + with open(path) as f: + return json.load(f) + except: + return {} if default is None else default + +def check_cron_jobs(path, label): + issues = [] + try: + d = load_json(path, {"jobs": []}) + for j in d.get("jobs", []): + name = j.get("name", "?") + enabled = j.get("enabled", True) + last = j.get("last_run_at", "") + status = j.get("last_status", "") + if not enabled: + issues.append(f"{name} 已禁用") + elif not last: + issues.append(f"{name} 从未运行") + elif status != "ok": + issues.append(f"{name} 上次状态={status}") + return len(d.get("jobs", [])), issues + except: + return 0, ["无法读取"] + +def run(): + now = datetime.now() + issues = [] + ok_count = 0 + warn_count = 0 + + lines = [f"MoFin 系统健康检查 | {now.strftime('%Y-%m-%d %H:%M')}"] + lines.append("") + + # 1. 进程检查 + lines.append("【进程】") + procs = { + "mofin-dashboard": "mofin-dashboard", + "xmpp-zhiwei": "xmpp_zhiwei_bot", + "ejabberd": "ejabberd", + } + for name, pattern in procs.items(): + # 先查 systemd,再查 pgrep + r = subprocess.run(["systemctl", "is-active", f"{pattern}.service"], capture_output=True, text=True, timeout=5) + alive = r.stdout.strip() == "active" + if not alive: + r2 = subprocess.run(["pgrep", "-f", pattern], capture_output=True, timeout=5) + alive = r2.returncode == 0 + lines.append(check(alive, f"{name} {'运行中' if alive else '已停止'}")) + if not alive: issues.append(f"{name} 进程不存在"); warn_count += 1 + else: ok_count += 1 + + # 2. 端口检查 + lines.append("") + lines.append("【端口】") + ports = {"8899": "Dashboard", "5222": "ejabberd", "8643": "知微Gateway"} + for port, name in ports.items(): + r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) + listening = f":{port}" in r.stdout + lines.append(check(listening, f"{name} :{port} {'监听中' if listening else '未监听'}")) + if not listening: issues.append(f"{name} 端口{port}未监听"); warn_count += 1 + else: ok_count += 1 + + # 3. 数据文件检查 + lines.append("") + lines.append("【数据文件】") + # DB 优先:从 SQLite 查询代替 JSON 文件检查 + try: + from mo_data import read_portfolio, read_decisions, read_watchlist + pf = read_portfolio() + lines.append(check(len(pf.get("holdings", [])) > 0, f"portfolio.json DB记录: {len(pf.get('holdings', []))}条")) + ok_count += 1 + wl = read_watchlist() + lines.append(check(len(wl.get("stocks", [])) > 0, f"watchlist.json DB记录: {len(wl.get('stocks', []))}条")) + ok_count += 1 + dec = read_decisions() + lines.append(check(len(dec.get("decisions", [])) > 0, f"decisions.json DB记录: {len(dec.get('decisions', []))}条")) + ok_count += 1 + except Exception: + lines.append(check(False, "MoFin DB 数据读取失败")) + warn_count += 3 + + # 仍为 JSON 文件的检查 + files = { + "market.json": DATA_DIR / "market.json", + "price_events.json": EVENTS_PATH, + "evaluation.json": EVALUATION_PATH, + "accuracy_stats.json": ACCURACY_PATH, + } + for name, path in files.items(): + exists = path.exists() + size = path.stat().st_size if exists else 0 + lines.append(check(exists and size > 10, f"{name} {'存在' if exists else '缺失'} ({size}B)")) + if not exists or size < 10: + issues.append(f"{name} 缺失或为空") + warn_count += 1 + else: + ok_count += 1 + + # 4. 价格事件统计 + lines.append("") + lines.append("【价格事件】") + try: + from mofin_db import get_conn, query_price_events, query_price_events_by_date + conn = get_conn() + ev_list = query_price_events(conn, limit=50000) + today_events = query_price_events_by_date(conn, now.strftime("%Y-%m-%d")) + conn.close() + except Exception: + events = load_json(EVENTS_PATH, {"events": []}) + ev_list = events.get("events", []) + today_events = [e for e in ev_list if e.get("date") == now.strftime("%Y-%m-%d")] + lines.append(check(len(ev_list) > 0, f"历史事件: {len(ev_list)}条")) + lines.append(check(len(today_events) > 0, f"今日事件: {len(today_events)}条")) + if len(ev_list) == 0: + issues.append("price_events 无事件记录,price_monitor可能未触发过") + warn_count += 1 + else: + ok_count += 1 + + # 5. 策略评估统计 + lines.append("") + lines.append("【策略评估】") + evals = load_json(EVALUATION_PATH, {"strategies": []}) + s_list = evals.get("strategies", []) + lines.append(check(len(s_list) > 0, f"已评估策略: {len(s_list)}条")) + if len(s_list) > 0: + avg = sum(s.get("score", 0) for s in s_list) / len(s_list) + lines.append(check(avg > 0, f"平均评分: {avg:.1f}/10")) + ok_count += 1 + else: + issues.append("evaluation.json 无评估数据") + warn_count += 1 + + # 6. 建议记录统计 + lines.append("") + lines.append("【建议记录】") + try: + from mo_data import read_decisions + dec = read_decisions() + total_advice = sum(len(d.get("advice_timeline", [])) for d in dec.get("decisions", [])) + except Exception: + dec = {"decisions": []} + total_advice = 0 + lines.append(check(total_advice > 0, f"建议记录: {total_advice}条")) + if total_advice == 0: + issues.append("所有策略建议记录为空") + warn_count += 1 + else: + ok_count += 1 + + # 7. Cron jobs + lines.append("") + lines.append("【Cron Jobs】") + cnt, cron_issues = check_cron_jobs(CRON_JOBS, "default") + lines.append(check(cnt > 0, f"default profile: {cnt}个job")) + for ci in cron_issues: + lines.append(f" ⚠️ {ci}") + warn_count += 1 + if cnt == 0: warn_count += 1 + cnt2, cron_issues2 = check_cron_jobs(POSITION_CRON, "position-analyst") + lines.append(check(cnt2 > 0, f"position-analyst: {cnt2}个job")) + for ci in cron_issues2: + lines.append(f" ⚠️ {ci}") + warn_count += 1 + if cnt2 == 0: warn_count += 1 + + # 8. 数据新鲜度 + lines.append("") + lines.append("【数据新鲜度】") + # DB 优先:从 SQLite 查最新更新时间 + try: + from mofin_db import get_conn + conn = get_conn() + db_checks = { + "portfolio (DB)": ("SELECT MAX(updated_at) FROM holdings", 24), + "decisions (DB)": ("SELECT MAX(updated_at) FROM holding_strategies WHERE status IN ('active','updated')", 48), + } + for name, (sql, threshold) in db_checks.items(): + row = conn.execute(sql).fetchone() + ts = row[0] if row and row[0] else None + if not ts: + lines.append(check(False, f"{name} 无更新时间戳")) + issues.append(f"{name} 无更新时间戳") + warn_count += 1 + continue + mtime = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S") if len(ts) >= 19 else datetime.strptime(ts[:10], "%Y-%m-%d") + hours_ago = (now - mtime).total_seconds() / 3600 + fresh = hours_ago < threshold + time_str = f"{hours_ago:.0f}h前" if hours_ago >= 1 else f"{hours_ago*60:.0f}分钟前" + lines.append(check(fresh, f"{name} 更新于 {time_str} (阈值{threshold}h)")) + if not fresh: + issues.append(f"{name} 超过{threshold}h未更新(最近更新:{time_str})") + warn_count += 1 + else: + ok_count += 1 + conn.close() + except Exception: + lines.append(check(False, "DB 数据新鲜度检查失败")) + + # JSON 文件新鲜度(仅限尚未迁移到 DB 的) + freshness_thresholds = { + "multi_tf_cache.json": 24, # K线缓存每日更新 + "macro_context.json": 24, # 宏观数据每日2次 + "market.json": 48, # 行业数据每日更新 + "strategy_staleness_report.json": 24, # 时效性报告每日生成 + } + data_files = { + "multi_tf_cache.json": DATA_DIR / "multi_tf_cache.json", + "macro_context.json": DATA_DIR / "macro_context.json", + "market.json": DATA_DIR / "market.json", + "strategy_staleness_report.json": DATA_DIR / "strategy_staleness_report.json", + } + for name, path in data_files.items(): + if not path.exists(): + lines.append(check(False, f"{name} 缺失")) + issues.append(f"{name} 文件缺失") + warn_count += 1 + continue + mtime = datetime.fromtimestamp(path.stat().st_mtime) + hours_ago = (now - mtime).total_seconds() / 3600 + threshold = freshness_thresholds.get(name, 24) + fresh = hours_ago < threshold + time_str = f"{hours_ago:.0f}h前" if hours_ago >= 1 else f"{hours_ago*60:.0f}分钟前" + lines.append(check(fresh, f"{name} 更新于 {time_str} (阈值{threshold}h)")) + if not fresh: + issues.append(f"{name} 超过{threshold}h未更新(最近更新:{time_str})") + warn_count += 1 + else: + ok_count += 1 + + # 数据管道组件检查 + lines.append("") + lines.append("【数据管道】") + pipe_checks = [ + ("再生器(regenerate_all)", r"strategy_lifecycle\.py"), + ("市场采集(market_watch)", r"market_watch\.py"), + ("宏观采集(macro)", r"macro_context_collector\.py"), + ] + for pname, ppattern in pipe_checks: + r = subprocess.run(["pgrep", "-f", ppattern], capture_output=True, timeout=5) + if r.returncode == 0: + lines.append(check(True, f"{pname} 进程存在")) + ok_count += 1 + else: + # no_agent脚本不常驻,不报warn + lines.append(" 📎 {} 无常驻进程(no_agent脚本按cron调度运行)".format(pname)) + + # 价格数据更新时间检查(盘中应有当日数据) + is_trading_day = now.weekday() < 5 # 周一到周五 + if is_trading_day and now.hour >= 9 and now.hour < 16: + try: + from mofin_db import get_conn + conn = get_conn() + row = conn.execute("SELECT MAX(updated_at) FROM holdings WHERE is_active=1").fetchone() + conn.close() + ts = row[0] if row and row[0] else None + if ts: + mtime = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S") if len(ts) >= 19 else datetime.strptime(ts[:10], "%Y-%m-%d") + has_intraday_data = mtime.date() == now.date() + lines.append(check(has_intraday_data, f"盘中有当日价格数据 {'是' if has_intraday_data else '否'}(最近{mtime.strftime('%H:%M')})")) + if not has_intraday_data: + issues.append(f"盘中交易时段但DB holdings无今日数据(最近更新{mtime.strftime('%m-%d %H:%M')})") + warn_count += 1 + else: + ok_count += 1 + else: + lines.append(check(False, "盘中DB holdings无价格更新记录")) + warn_count += 1 + except Exception: + lines.append(check(False, "盘中DB价格数据检查失败")) + warn_count += 1 + + # 汇总 + total = ok_count + warn_count + lines.append("") + lines.append(f"总计: ✅ {ok_count}/{total} 正常 | ⚠️ {warn_count}/{total} 需关注") + if issues: + lines.append("") + lines.append("需关注项:") + for i, issue in enumerate(issues[:10], 1): + lines.append(f" {i}. {issue}") + + report = "\n".join(lines) + print(report) + + # 如果有问题,写入报告文件供推送 + if warn_count > 0: + report_path = Path("/home/hmo/.hermes/profiles/position-analyst/cron/output/health") + report_path.mkdir(parents=True, exist_ok=True) + report_file = report_path / f"health_{now.strftime('%Y%m%d_%H%M')}.md" + report_file.write_text(f"# MoFin 系统健康检查\n\n{report}") + print(f"\n报告已写入 {report_file}") + else: + print("\n[SILENT] 一切正常") + + +if __name__ == "__main__": + run() diff --git a/scripts/technical_analysis.py b/scripts/technical_analysis.py new file mode 100644 index 0000000..b512857 --- /dev/null +++ b/scripts/technical_analysis.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""technical_analysis.py — 技术面分析模块 v2 + +基于多日价格数据计算支撑位/压力位: +1. 缓存每日 HLC 到 price_history.json +2. 使用 5 日最高/最低计算枢轴点 +3. 结合振幅自动调整区间宽度 + +使用方式: + from technical_analysis import full_analysis + result = full_analysis("603259") # 自动识别A股/港股 +""" + +import json +import os +import urllib.request +from datetime import datetime, date + +# 腾讯API字段索引 +F = { + "name": 1, "code": 2, "price": 3, "close_yest": 4, "open": 5, + "volume": 6, "timestamp": 30, "change": 31, "change_pct": 32, + "high": 33, "low": 34, "amplitude": 43, + "turnover": 38, "pe": 39, "pb": 46, + "limit_up": 47, "limit_down": 48, + "avg_price": 51, "inner_vol": 52, "outer_vol": 53, +} + +HISTORY_PATH = "/home/hmo/web-dashboard/data/price_history.json" +HISTORY_DAYS = 60 # 使用最近 N 天的 HLC 数据 + + +def _load_history(): + """读取价格历史缓存""" + try: + return json.load(open(HISTORY_PATH)) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_history(h): + json.dump(h, open(HISTORY_PATH, "w"), ensure_ascii=False, indent=2) + + +def _market_prefix(code): + """根据代码确定腾讯API前缀""" + if code.startswith("sh") or code.startswith("sz") or code.startswith("hk"): + code = code[2:] if code[2:].isdigit() else code + raw = str(code).split("_")[0] + if len(raw) == 5 and raw.isdigit(): + return "hk" + if raw.startswith("6") or raw.startswith("5"): + return "sh" + return "sz" + + +def get_quote(code): + """获取行情数据。DB 优先(price_monitor 维护),腾讯 API fallback""" + # DB 优先 + try: + from mofin_db import get_price_from_db + p, chg = get_price_from_db(code) + if p: return {"code": code, "price": p, "change_pct": chg or 0} + except: pass + # Fallback: 腾讯 API + import time + _cache = get_quote.__dict__.get("_cache", {}) + now = time.time() + cached = _cache.get(code) + if cached and (now - cached["ts"]) < 60: + return cached["data"] + + raw = str(code).split("_")[0] + prefix = _market_prefix(code) + url = f"http://qt.gtimg.cn/q={prefix}{raw}" + try: + r = urllib.request.urlopen(url, timeout=5) + fields = r.read().decode("gbk").split('"')[1].split("~") + except Exception as e: + return {"code": code, "error": str(e)} + + def get(i): + try: + return float(fields[i]) if fields[i].strip() else None + except (IndexError, ValueError): + return None + + today_str = date.today().isoformat() + q = { + "code": raw, + "market": prefix, + "name": fields[F["name"]] if len(fields) > F["name"] else code, + "price": get(3), + "close_yest": get(4), + "open": get(5), + "high": get(33), + "low": get(34), + "volume": get(6), + "amount": get(37), + "change": get(31), + "change_pct": get(32), + "amplitude": get(43), + "turnover_rate": get(38), + "pe": get(39), + "pb": get(46), + "limit_up": get(47), + "limit_down": get(48), + "avg_price": get(51), + "inner_vol": get(52), + "outer_vol": get(53), + "timestamp": fields[F["timestamp"]] if len(fields) > F["timestamp"] else "", + "_date": today_str, + } + + # 写入价格历史缓存(每日一次) + h = get(33) # high + l = get(34) # low + c = get(3) # price / close + if h and l and c: + history = _load_history() + if raw not in history: + history[raw] = [] + days = history[raw] + # 如果今天已有记录,更新(盘中数据更精确) + if days and len(days) > 0 and days[-1].get("date") == today_str: + days[-1]["high"] = max(days[-1]["high"], h) + days[-1]["low"] = min(days[-1]["low"], l) + days[-1]["close"] = c # 盘中用最新价,收盘后是收盘价 + else: + days.append({"date": today_str, "high": h, "low": l, "close": c}) + # 只保留最近 HISTORY_DAYS 天 + history[raw] = days[-HISTORY_DAYS:] + _save_history(history) + + # 写入60秒缓存 + get_quote.__dict__["_cache"] = {**get_quote.__dict__.get("_cache", {}), code: {"ts": now, "data": q}} + + return q + + +def calc_support_resistance(q): + """计算技术支撑位和压力位 — 多日枢轴点算法 + + 使用多个数据源确定有效区间: + 1. 当日波幅(H-L) + 2. 最近 N 日的最高/最低(从 price_history.json 读取) + 3. 价格基数的百分比(对大市值低波动股票有效) + """ + h = q.get("high") + l = q.get("low") + c = q.get("price") + yc = q.get("close_yest") + amplitude = q.get("amplitude") # 当日振幅% + code = q.get("code", "") + + if not all([h, l, c]): + return {"error": "数据不足"} + + # 多日最高/最低(从历史缓存读取) + history = _load_history() + hist_days = history.get(code, []) + multi_high = max(d["high"] for d in hist_days) if hist_days else h + multi_low = min(d["low"] for d in hist_days) if hist_days else l + + # 有效区间 = max(当日波幅, 多日波幅, 价格×5%) + daily_range = h - l + multi_range = multi_high - multi_low + min_range = c * 0.05 # 5%价格基数 + + effective_range = max(daily_range, multi_range, min_range) + + # 如果股价接近多日高点(>80%分位),说明在上升趋势中,扩大区间 + trend_position = (c - multi_low) / (multi_high - multi_low) if multi_high > multi_low else 0.5 + if trend_position > 0.8: + # 高位运行,扩大有效区间到价格的8%确保合理空间 + effective_range = max(effective_range, c * 0.08) + elif trend_position < 0.2: + # 低位运行,同样扩大 + effective_range = max(effective_range, c * 0.08) + + # 如果振幅数据可用且振幅较小(<3%),进一步扩大区间确保有效性 + if amplitude and amplitude > 0 and amplitude < 3: + # 低波动股票用 振幅×3 作为最小范围 + amp_based = c * amplitude / 100 * 3 + effective_range = max(effective_range, amp_based) + + # 枢轴点 (Pivot Point) + pp = (h + l + c) / 3 + + # 支撑位 + s1 = 2 * pp - h # 弱支撑 + s2 = pp - effective_range # 强支撑 + + # 压力位 + r1 = 2 * pp - l # 弱压力 + r2 = pp + effective_range # 强压力 + + # 参考昨收调整 + if yc: + if yc < s1: + s1 = yc + if yc > r1: + r1 = yc + + # A股涨停/跌停价作为极端边界 + limit_up = q.get("limit_up") + limit_down = q.get("limit_down") + market = q.get("market", "hk") + if market != "hk" and limit_up and limit_down: + # 注意:当现价逼近涨停/跌停时,limit不再是有效边界 + # 用有效区间判断:如果自然计算的r2/s2在合理范围内不截断 + natural_r2 = r2 + natural_s2 = s2 + # 涨停限制只对距离现价超过2%的强压位生效 + if limit_up < r2 and (limit_up - c) / c < 0.02: + # 涨停价离现价<2%,说明可能封板,不截断 + pass # 使用自然计算的r2 + elif limit_up < r2: + r2 = limit_up + if limit_down > s2 and (c - limit_down) / c < 0.02: + pass # 接近跌停,不截断 + elif limit_down > s2: + s2 = limit_down + + return { + "strong_support": round(s2, 2), + "weak_support": round(s1, 2), + "pivot": round(pp, 2), + "weak_resist": round(r1, 2), + "strong_resist": round(r2, 2), + "today_high": h, + "today_low": l, + "multi_high": multi_high, + "multi_low": multi_low, + "effective_range": round(effective_range, 2), + } + + +def analyze_candlestick(q): + """判断K线形态""" + o = q.get("open") + c = q.get("price") + h = q.get("high") + l = q.get("low") + yc = q.get("close_yest") + + if not all([o, c, h, l]): + return {"pattern": "unknown", "sentiment": "neutral"} + + if c >= o: + body = c - o + upper = h - c + lower = o - l + is_green = True + else: + body = o - c + upper = h - o + lower = c - l + is_green = False + + total_range = h - l + if total_range == 0: + return {"pattern": "平盘", "sentiment": "neutral"} + + body_pct = body / total_range * 100 + upper_pct = upper / total_range * 100 + lower_pct = lower / total_range * 100 + + if body_pct < 5: + if upper_pct > 60: + pattern = "倒T线/射击之星" + sentiment = "bearish" + elif lower_pct > 60: + pattern = "锤子线/T字线" + sentiment = "bullish" + else: + pattern = "十字星" + sentiment = "neutral" + elif body_pct < 30: + if upper_pct > 40 and lower_pct > 40: + pattern = "长影星线" + sentiment = "neutral" + elif upper_pct > 40: + pattern = "倒T线/射击之星" + sentiment = "bearish" if is_green else "bearish" + elif lower_pct > 40: + pattern = "锤子线/T字线" + sentiment = "bullish" if is_green else "bullish" + else: + pattern = "小阳线" if is_green else "小阴线" + sentiment = "bullish" if is_green else "bearish" + else: + if upper_pct > 30: + pattern = "带上影阳线" if is_green else "带上影阴线" + sentiment = "neutral" if is_green else "bearish" + elif lower_pct > 30: + pattern = "带下影阳线" if is_green else "带下影阴线" + sentiment = "bullish" if is_green else "neutral" + else: + pattern = "光头光脚阳线" if is_green else "光头光脚阴线" + sentiment = "bullish" if is_green else "bearish" + + gap_up = "" + gap_down = "" + if yc: + if o > yc * 1.01: + gap_up = "跳空高开" + if not is_green: + sentiment = "neutral" + elif o < yc * 0.99: + gap_down = "跳空低开" + if is_green: + sentiment = "neutral" + + return { + "pattern": pattern, + "sentiment": sentiment, + "body_pct": round(body_pct, 1), + "upper_shadow_pct": round(upper_pct, 1), + "lower_shadow_pct": round(lower_pct, 1), + "is_green": is_green, + "gap": gap_up or gap_down or "无跳空", + } + + +def analyze_volume(q): + """量价分析""" + outer = q.get("outer_vol") + inner = q.get("inner_vol") + turnover = q.get("turnover_rate") + + result = {} + if outer and inner and (outer + inner) > 0: + ratio = outer / (outer + inner) + result["buy_sell_ratio"] = round(ratio, 2) + if ratio > 0.55: + result["volume_signal"] = "主动买盘占优" + elif ratio < 0.45: + result["volume_signal"] = "主动卖盘占优" + else: + result["volume_signal"] = "买卖均衡" + else: + result["volume_signal"] = "数据不足" + + if turnover: + result["turnover_rate"] = turnover + + return result + + +def full_analysis(code): + """完整技术分析(带30秒缓存,避免分钟级波动)""" + import time + _cache = full_analysis.__dict__.get("_cache", {}) + now = time.time() + cached = _cache.get(code) + if cached and (now - cached["ts"]) < 30: + return cached["data"] + + q = get_quote(code) + if not q or "error" in q: + return q + + sr = calc_support_resistance(q) + candle = analyze_candlestick(q) + vol = analyze_volume(q) + + # 多周期+均线分析(整合 multi_timeframe) + mtf = {} + try: + from multi_timeframe import full_multi_tf_analysis as _mtf + mtf_raw = _mtf(code) + if mtf_raw and 'daily' in mtf_raw: + d = mtf_raw['daily'] + mtf = { + 'mas': d.get('mas', {}), + 'multi_tf_sr': d.get('support_resistance', {}), + 'trend': d.get('trend', {}), + } + # 周线弱压/弱撑作为中周期参考 + if 'weekly' in mtf_raw: + w = mtf_raw['weekly'] + ws = w.get('support_resistance', {}) + mtf['weekly_sr'] = { + 'weak_resist': ws.get('weak_resist'), + 'weak_support': ws.get('weak_support'), + } + except Exception: + pass # non-critical, graceful degradation + + result = { + "quote": { + "name": q.get("name", code), + "price": q["price"], + "change_pct": q.get("change_pct", 0), + "open": q.get("open", 0), + "high": q.get("high", 0), + "low": q.get("low", 0), + "close_yest": q.get("close_yest", 0), + "volume": q.get("volume", 0), + "amplitude": q.get("amplitude", 0), + }, + "support_resistance": sr, + "candlestick": candle, + "volume": vol, + "multi_tf": mtf, + "analyzed_at": datetime.now().strftime("%H:%M"), + } + + # 写入缓存 + _cache[code] = {"ts": now, "data": result} + full_analysis.__dict__["_cache"] = _cache + return result + + +if __name__ == "__main__": + import sys + codes = sys.argv[1:] or ["603259", "002594", "00700"] + for c in codes: + r = full_analysis(c) + print(json.dumps(r, ensure_ascii=False, indent=2)) + print() diff --git a/scripts/trend_detector.py b/scripts/trend_detector.py new file mode 100644 index 0000000..1492dcc --- /dev/null +++ b/scripts/trend_detector.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""trend_detector.py — 板块异常信号检测 + +配合 market_watch(每30分)运行,从最新 snapshot 中检测6类信号: + 1. 资金异动 — 净流入/出远超近期均值 + 2. 涨跌比反转 — 板块内涨跌家数比例突变 + 3. 领涨股更替 — 领涨股换人 + 4. 趋势拐点 — 连续流入→转流出 或 连续流出→转入流 + 5. 量价背离 — 涨但资金流出 / 跌但资金流入 + 6. 普涨背离 — 板块大涨但上涨家数<50% + +检测到信号后写入 sector_signals 表。 +""" + +import json +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" +DB_PATH = DATA_DIR / "mofin.db" + + +def get_conn(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def get_recent_snapshots(conn, n=20): + """取最近 n 次 market_snapshots""" + return conn.execute( + "SELECT id, timestamp, up_ratio, mood FROM market_snapshots ORDER BY id DESC LIMIT ?", + (n,) + ).fetchall() + + +def get_sectors_for_snapshot(conn, snapshot_id): + """取指定 snapshot 的全部板块数据""" + rows = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ?", (snapshot_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def get_sector_history(conn, sector_name, n=20): + """取某板块最近 n 次采集记录""" + return conn.execute(""" + SELECT ss.*, ms.timestamp + FROM sector_snapshots ss + JOIN market_snapshots ms ON ss.snapshot_id = ms.id + WHERE ss.name = ? + ORDER BY ms.id DESC LIMIT ? + """, (sector_name, n)).fetchall() + + +def get_holdings(conn): + """取活跃持仓""" + return conn.execute("SELECT code, name FROM holdings WHERE is_active=1").fetchall() + + +def get_watchlist(conn): + """取活跃自选""" + return conn.execute("SELECT code, name FROM watchlist_stocks WHERE is_active=1").fetchall() + + +def get_sector_for_stock(conn, code): + """查个股对应的板块""" + row = conn.execute( + "SELECT sector_name FROM stock_sectors WHERE code = ?", (code,) + ).fetchone() + return row[0] if row else None + + +def write_signal(conn, signal_type, sector, severity, related_stocks, + holdings_list, watchlist_list, trigger_reason, snapshot_id): + """写入信号到 sector_signals""" + # 同板块同类型24小时内已有信号则跳过 + existing = conn.execute(""" + SELECT id FROM sector_signals + WHERE sector = ? AND signal_type = ? + AND datetime(detected_at) >= datetime('now', '-1 day') + LIMIT 1 + """, (sector, signal_type)).fetchone() + if existing: + return False + + conn.execute(""" + INSERT INTO sector_signals + (signal_type, sector, severity, related_stocks, + holdings_in_sector, watchlist_in_sector, + trigger_reason, snapshot_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + signal_type, sector, severity, + json.dumps(related_stocks, ensure_ascii=False), + json.dumps(holdings_list, ensure_ascii=False) if holdings_list else None, + json.dumps(watchlist_list, ensure_ascii=False) if watchlist_list else None, + trigger_reason, snapshot_id + )) + conn.commit() + return True + + +def check_signals(conn, latest, prev_snapshots): + """对最新 snapshot 检测6类信号""" + latest_id = latest["id"] + sectors = get_sectors_for_snapshot(conn, latest_id) + if not sectors: + return + + # 取持仓和自选 + holdings = {r["code"]: r["name"] for r in get_holdings(conn)} + watchlist = {r["code"]: r["name"] for r in get_watchlist(conn)} + + # 取上一次 snapshot 用于对比(如果有) + prev_id = None + if len(prev_snapshots) >= 2: + prev_id = prev_snapshots[1]["id"] + prev_sectors = get_sectors_for_snapshot(conn, prev_id) if prev_id else [] + + # 构建 name→sector 映射 + prev_map = {s["name"]: s for s in prev_sectors} + + for s in sectors: + name = s["name"] + change = s["change_pct"] or 0 + net_inflow = s["net_inflow"] or 0 + up_count = s["up_count"] or 0 + down_count = s["down_count"] or 0 + total = up_count + down_count + up_ratio = up_count / total if total > 0 else None + lead_stock = s["lead_stock"] or "" + + # 近期历史 + history = get_sector_history(conn, name, 20) + + # 计算近期均值(后续多重信号共用) + mean = 0 + if len(history) >= 3: + recent_inflows = [abs(h["net_inflow"] or 0) for h in history[:10]] + mean = sum(recent_inflows) / len(recent_inflows) if recent_inflows else 0 + + # 信号1:资金异动 + if net_inflow and len(history) >= 5 and mean > 0: + recent_inflows = [abs(h["net_inflow"] or 0) for h in history[:20]] + new_mean = sum(recent_inflows) / len(recent_inflows) + std = (sum((x - new_mean) ** 2 for x in recent_inflows) / len(recent_inflows)) ** 0.5 + if std > 0 and abs(net_inflow) > mean + 3 * std: + direction = "净流入" if net_inflow > 0 else "净流出" + sev = "high" if abs(net_inflow) > mean + 5 * std else "medium" + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "资金异动", name, sev, related, + holdings_list, watchlist_list, + f"{direction}{abs(net_inflow):.0f}亿(均值{mean:.0f}亿,超{abs(net_inflow)/max(mean,0.01):.0f}倍)", + latest_id) + if ok: + print(f" ⚠️ 资金异动 [{sev}] {name}: {direction}{abs(net_inflow):.0f}亿", flush=True) + + # 信号2:涨跌比反转(相比上一次) + prev = dict(prev_map[name]) if name in prev_map else {} + if prev and up_ratio is not None and prev["up_count"] and prev["down_count"]: + prev_total = prev["up_count"] + prev["down_count"] + prev_ratio = prev["up_count"] / prev_total if prev_total > 0 else 0 + if abs(up_ratio - prev_ratio) > 0.3: # 涨跌比变化超过30个百分点 + direction = "转强" if up_ratio > prev_ratio else "转弱" + sev = "high" if abs(up_ratio - prev_ratio) > 0.5 else "medium" + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "涨跌比反转", name, sev, related, + holdings_list, watchlist_list, + f"上涨占比{prev_ratio*100:.0f}%→{up_ratio*100:.0f}%,{direction}", + latest_id) + if ok: + print(f" ⚠️ 涨跌比反转 [{sev}] {name}: {prev_ratio*100:.0f}%→{up_ratio*100:.0f}% {direction}", flush=True) + + # 信号3:领涨股更替 + prev_lead = prev.get("lead_stock", "") if prev else "" + if lead_stock and prev_lead and lead_stock != prev_lead: + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "领涨股更替", name, "medium", related, + holdings_list, watchlist_list, + f"领涨股从「{prev_lead}」换成「{lead_stock}」", + latest_id) + if ok: + print(f" ⚠️ 领涨股更替 [{name}] {prev_lead} → {lead_stock}", flush=True) + + # 信号4:趋势拐点(连续净流入突然转流出,反之亦然) + if net_inflow and len(history) >= 4: + recent = [h["net_inflow"] or 0 for h in history[:4]] + all_positive = all(r > 0 for r in recent[:3]) + all_negative = all(r < 0 for r in recent[:3]) + if all_positive and net_inflow < 0 and abs(net_inflow) > mean * 0.5: + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "趋势拐点", name, "high", related, + holdings_list, watchlist_list, + f"连续3次净流入后转流出{abs(net_inflow):.0f}亿", + latest_id) + if ok: + print(f" ⚠️ 趋势拐点 [high] {name}: 连续流入→转流出{abs(net_inflow):.0f}亿", flush=True) + elif all_negative and net_inflow > 0 and net_inflow > abs(sum(recent[:3])) * 0.5: + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "趋势拐点", name, "high", related, + holdings_list, watchlist_list, + f"连续3次净流出后转入流{net_inflow:.0f}亿", + latest_id) + if ok: + print(f" ⚠️ 趋势拐点 [high] {name}: 连续流出→转入流{net_inflow:.0f}亿", flush=True) + + # 信号5:量价背离 + if net_inflow and change and abs(change) > 2: + if change > 0 and net_inflow < -abs(mean or 1): + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "量价背离", name, "medium", related, + holdings_list, watchlist_list, + f"板块涨{change:+.2f}%但资金净流出{abs(net_inflow):.0f}亿", + latest_id) + if ok: + print(f" ⚠️ 量价背离 [{name}] 涨{change:+.2f}%但流出{abs(net_inflow):.0f}亿", flush=True) + elif change < 0 and net_inflow > abs(mean or 1): + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "量价背离", name, "medium", related, + holdings_list, watchlist_list, + f"板块跌{change:+.2f}%但资金净流入{net_inflow:.0f}亿(吸筹信号)", + latest_id) + if ok: + print(f" ⚠️ 量价背离 [{name}] 跌{change:+.2f}%但流入{net_inflow:.0f}亿(吸筹)", flush=True) + + # 信号6:普涨背离 + if up_ratio is not None and change > 3 and up_ratio < 0.5: + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "普涨背离", name, "medium", related, + holdings_list, watchlist_list, + f"板块涨{change:+.2f}%但仅{up_count}/{total}家上涨(分化严重)", + latest_id) + if ok: + print(f" ⚠️ 普涨背离 [{name}] 涨{change:+.2f}%但仅{up_count}/{total}家上涨", flush=True) + + +def _get_related_stocks(conn, sector_name, lead_stock, change_pct): + """获取板块相关个股(领涨股 + board 成分股)""" + stocks = [] + if lead_stock: + stocks.append({"name": lead_stock, "code": "", "change_pct": 0, "role": "领涨"}) + # 从 stock_sectors 表取成分股 + members = conn.execute( + "SELECT s.code, s.name FROM stocks s " + "JOIN stock_sectors ss ON s.code = ss.code " + "WHERE ss.sector_name = ? LIMIT 5", + (sector_name,) + ).fetchall() + for m in members: + if not any(s.get("name") == m["name"] for s in stocks): + stocks.append({"name": m["name"], "code": m["code"], "change_pct": 0, "role": "成分"}) + return stocks + + +def _match_holdings(stocks, holding_dict): + """匹配相关个股中的持仓/自选""" + matched = [] + for s in stocks: + code = s.get("code", "") + if code in holding_dict: + matched.append({"code": code, "name": holding_dict[code]}) + return matched + + +def main(): + conn = get_conn() + + # 取最近 snapshots + snapshots = get_recent_snapshots(conn, 5) + if len(snapshots) < 2: + print(f"数据不足: 只有 {len(snapshots)} 次采集,需要至少2次", flush=True) + conn.close() + return + + latest = dict(snapshots[0]) + print(f"检测最新 snapshot: {latest['timestamp']} (id={latest['id']})", flush=True) + + check_signals(conn, latest, snapshots) + conn.close() + print("检测完成", flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/update_data.py b/scripts/update_data.py new file mode 100644 index 0000000..97eb910 --- /dev/null +++ b/scripts/update_data.py @@ -0,0 +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 +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 diff --git a/scripts/xiaoguo_news_processor.py b/scripts/xiaoguo_news_processor.py new file mode 100644 index 0000000..44c1ae8 --- /dev/null +++ b/scripts/xiaoguo_news_processor.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""xiaoguo_news_processor.py — 小果新闻情报处理 + +配合 trend_detector(每30分)运行,处理未处理的 sector_signals。 + +流程: + 1. 读未 processed 的 signals(每次1条) + 2. akshare 搜新闻(板块相关个股 + 持仓 + 自选) + 3. 调小果 LLM 逐批分析(每批3-5篇,给摘要+情感) + 4. 写入 signal_news + 5. 标记 signal.processed = true +""" + +import json +import os +import urllib.request +import re +from pathlib import Path + +try: + import akshare as ak + HAS_AKSHARE = True +except ImportError: + HAS_AKSHARE = False + +DATA_DIR = Path(__file__).parent / "data" +DB_PATH = DATA_DIR / "mofin.db" +XIAOGUO_API = "http://node122:18003/v1/chat/completions" # fallback, /etc/hosts resolves to LAN or EasyTier + +def _get_xiaoguo_url(): + try: + from mo_config import get_config + return get_config().xiaoguo_api_url + except Exception: + return XIAOGUO_API +XIAOGUO_MODEL = "Qwen3.6-27B-MTPLX-Optimized-Speed" +MAX_ARTICLES = 5 # 每次最多分析篇数(实测5篇12s) + + +def clean_proxy(): + for k in ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY']: + os.environ.pop(k, None) + + +def get_conn(): + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def search_akshare_news(code, max_results=3): + """用 akshare 搜个股新闻(含全文)""" + articles = [] + if not HAS_AKSHARE: + return articles + try: + clean_proxy() + df = ak.stock_news_em(symbol=code) + for _, r in df.head(max_results).iterrows(): + title = r.get('新闻标题', '') + content = r.get('新闻内容', '') + if title and len(title) > 5: + articles.append({ + "title": title, + "content": content, + "url": r.get('新闻链接', '') + }) + except: + pass + return articles + + +def extract_json(text): + """从回复中提取JSON数组或对象""" + # 先找 ```json ... ``` 代码块 + m = re.search(r'```(?:json)?\s*(\[[\s\S]*?\]|\{[\s\S]*?\})\s*```', text) + if m: + try: + return json.loads(m.group(1)) + except: + pass + # 找第一个 [ 或 { 到最后一个 ] 或 } + for start_ch, end_ch in [('[', ']'), ('{', '}')]: + s = text.find(start_ch) + if s >= 0: + depth = 0 + for i in range(s, len(text)): + if text[i] == start_ch: + depth += 1 + elif text[i] == end_ch: + depth -= 1 + if depth == 0: + try: + return json.loads(text[s:i+1]) + except: + break + return None + + +def call_xiaoguo(articles): + """调小果LLM:给摘要+情感""" + lines = [] + for a in articles: + title = re.sub(r'\b\d{6}\b', '', a['title']).strip() + title = re.sub(r'\s+', ' ', title) + content = a.get('content') or '' + # 给正文加标点分隔(akshare正文无标点,模型推理会卡) + if content and not any(c in content for c in '。,!?;'): + content = '。'.join([content[i:i+20] for i in range(0, len(content), 20)]) + if content: + lines.append(f"{len(lines)+1}. {title}\n {content}") + else: + lines.append(f"{len(lines)+1}. {title}") + prompt = "\n".join(lines) + "\n\n逐篇分析:给摘要(概括核心内容)和情感(positive/negative/neutral)。JSON数组。" + + payload = json.dumps({ + "model": XIAOGUO_MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, + "max_tokens": 2048, + }).encode() + + clean_proxy() + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + req = urllib.request.Request( + _get_xiaoguo_url(), data=payload, + headers={"Content-Type": "application/json"}, method="POST" + ) + try: + resp = opener.open(req, timeout=60) + data = json.loads(resp.read()) + content = data["choices"][0]["message"]["content"] + result = extract_json(content) + if isinstance(result, list): + return result + except Exception as e: + print(f" 小果调用失败: {e}", flush=True) + return None + + +def translate_sentiment(s): + """将英文情感转中文""" + m = {"positive": "利好", "negative": "利空", "neutral": "中性"} + return m.get(s.lower() if isinstance(s, str) else "", s) + + +def fallback_classify(batch): + """关键词降级分类(小果API不可用时)""" + positive_kw = ['突破', '增长', '利好', '加单', '订单', '放量', '新高', '获批', '量产', + '超预期', '投产', '融资', '增持', '回购', '降息', '减税', '补贴', + '国产替代', '自主可控', '准入'] + negative_kw = ['管制', '限制', '制裁', '利空', '减持', '抛售', '下跌', '跌停', + '风险', '违约', '调查', '暂停', '取消', '下滑', '亏损', '裁员', + '诉讼', '退市', '做空', '关税', '禁令'] + + for a in batch: + text = a['title'] + (a.get('content') or '') + pos = sum(1 for kw in positive_kw if kw in text) + neg = sum(1 for kw in negative_kw if kw in text) + if pos > neg: + a['sentiment'] = '利好' + elif neg > pos: + a['sentiment'] = '利空' + else: + a['sentiment'] = '中性' + a['summary'] = a['title'][:80] + return batch + + +def main(): + conn = get_conn() + signals = conn.execute( + "SELECT * FROM sector_signals WHERE processed = 0 ORDER BY severity DESC, id ASC LIMIT 1" + ).fetchall() + + if not signals: + print("无未处理的信号", flush=True) + conn.close() + return + + signal = dict(signals[0]) + sector = signal["sector"] + related = json.loads(signal["related_stocks"] or "[]") + holdings = json.loads(signal["holdings_in_sector"] or "[]") + watchlist = json.loads(signal["watchlist_in_sector"] or "[]") + + print(f"处理信号: [{signal['severity']}] {signal['signal_type']} {sector}", flush=True) + + codes = {} + for item in related + holdings + watchlist: + if item.get("code"): + codes[item["code"]] = item.get("name", "") + + members = conn.execute( + "SELECT s.code, s.name FROM stocks s JOIN stock_sectors ss ON s.code=ss.code WHERE ss.sector_name=? LIMIT 5", + (sector,) + ).fetchall() + for m in members: + if m["code"] not in codes: + codes[m["code"]] = m["name"] + + all_articles = [] + for code, name in codes.items(): + arts = search_akshare_news(code, 3) + for a in arts: + if a["title"] not in [x["title"] for x in all_articles]: + all_articles.append(a) + print(f" 搜 {name}({code}): {len(arts)} 篇", flush=True) + + if not all_articles: + print(" 未搜到新闻", flush=True) + conn.execute("UPDATE sector_signals SET processed=1 WHERE id=?", (signal["id"],)) + conn.commit() + conn.close() + return + + # 只取前5篇,跳过含有表格数据的脏内容 + filtered = [] + for a in all_articles: + c = a.get('content', '') or '' + if any(kw in c for kw in ['主力资金', '资金净流入', '代码', '简称']): + continue + filtered.append(a) + if len(filtered) >= MAX_ARTICLES: + break + batch = filtered[:MAX_ARTICLES] + print(f" 共{len(all_articles)}篇,送小果分析{len(batch)}篇", flush=True) + + results = call_xiaoguo(batch) + if not results: + print(" 小果API不可用,降级到关键词分类", flush=True) + fallback_classify(batch) + results = None # batch already has sentiment/summary set + + if results and isinstance(results, list): + # 小果LLM返回结果,按索引匹配 + for i, r in enumerate(results): + if i < len(batch): + batch[i]["sentiment"] = translate_sentiment(r.get("sentiment", r.get("情感", ""))) + batch[i]["summary"] = r.get("summary", r.get("摘要", "")) + else: + break + + # 汇总情感 + sentiments = [a.get("sentiment", "中性") for a in batch if a.get("sentiment")] + pos = sentiments.count("利好") + neg = sentiments.count("利空") + overall = "利好" if pos > neg * 1.5 else "利空" if neg > pos * 1.5 else "中性" + summaries = [a.get("summary", "") for a in batch if a.get("summary")] + combined = f"{sector}板块信号:{'|'.join(summaries[:3])}。总体{overall}。" + + searched_names = list(set(codes.values())) + conn.execute( + "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks) VALUES (?, ?, ?, ?, ?, ?)", + (signal["id"], sector, overall, combined, json.dumps(batch, ensure_ascii=False), json.dumps(searched_names, ensure_ascii=False)) + ) + conn.execute("UPDATE sector_signals SET processed=1 WHERE id=?", (signal["id"],)) + conn.commit() + + print(f" 完成: {overall} — {combined[:100]}", flush=True) + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/xiaoguo_sentiment_bridge.py b/scripts/xiaoguo_sentiment_bridge.py new file mode 100644 index 0000000..f896e3f --- /dev/null +++ b/scripts/xiaoguo_sentiment_bridge.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""xiaoguo_sentiment_bridge.py — 小果情感分析数据 → 策略引擎桥接 + +小果情感分析 cron (3da7ad4ff3a6) 每日16:00运行,输出到指定格式。 +本脚本读取小果的输出,格式化为 strategy_lifecycle 可读取的格式, +写入 /home/hmo/web-dashboard/data/xiaoguo_sentiment.json + +格式: +{ + "updated_at": "2026-06-18T16:00:00", + "stocks": { + "00700": { + "name": "腾讯控股", + "sentiment": "positive" | "negative" | "neutral", + "confidence": 0.85, + "keywords": ["游戏", "增长"], + "summary": "腾讯游戏业务Q2增长超预期", + "source": "xiaoguo" + } + } +} +""" + +import json +import os +import sys +from datetime import datetime + +OUTPUT_PATH = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" +XIAOGUO_INSIGHTS_PATH = "/home/hmo/web-dashboard/data/xiaoguo_insights.json" + + +def load_xiaoguo_output(): + """读取小果情感分析的最新输出""" + try: + if os.path.exists(XIAOGUO_INSIGHTS_PATH): + with open(XIAOGUO_INSIGHTS_PATH) as f: + data = json.load(f) + return data + except Exception: + pass + return None + + +def main(): + data = load_xiaoguo_output() + if data is None: + data = {"updated_at": datetime.now().isoformat(), "stocks": {}} + else: + # 转换为 {code: {sentiment, confidence, keywords, summary}} 格式 + formatted = {"updated_at": datetime.now().isoformat(), "stocks": {}} + for item in data.get("analyses", []): + code = item.get("code", "") + if code: + formatted["stocks"][code] = { + "name": item.get("name", ""), + "sentiment": item.get("sentiment", "neutral"), + "confidence": item.get("confidence", 0), + "keywords": item.get("keywords", []), + "summary": item.get("brief", ""), + "source": "xiaoguo", + } + data = formatted + + os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) + with open(OUTPUT_PATH, "w") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + stock_count = len(data.get("stocks", {})) + print(f"[xiaoguo_bridge] {stock_count} stocks synced", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/stock_analysis.db b/stock_analysis.db new file mode 100644 index 0000000..e69de29