diff --git a/__pycache__/multi_timeframe.cpython-312.pyc b/__pycache__/multi_timeframe.cpython-312.pyc index 18d42c6..6b98303 100644 Binary files a/__pycache__/multi_timeframe.cpython-312.pyc and b/__pycache__/multi_timeframe.cpython-312.pyc differ diff --git a/__pycache__/price_monitor.cpython-312.pyc b/__pycache__/price_monitor.cpython-312.pyc deleted file mode 100644 index 8926ee0..0000000 Binary files a/__pycache__/price_monitor.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/strategy_lifecycle.cpython-312.pyc b/__pycache__/strategy_lifecycle.cpython-312.pyc index bda4403..bc5d3db 100644 Binary files a/__pycache__/strategy_lifecycle.cpython-312.pyc and b/__pycache__/strategy_lifecycle.cpython-312.pyc differ diff --git a/_watchdog_report.py b/_watchdog_report.py new file mode 100644 index 0000000..f825265 --- /dev/null +++ b/_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/data/candidate_pool.json b/data/candidate_pool.json index 8a2d11a..48887ca 100644 --- a/data/candidate_pool.json +++ b/data/candidate_pool.json @@ -1,6 +1,6 @@ { - "last_updated": "2026-07-03 11:32", - "total_candidates": 5, + "last_updated": "2026-07-03 13:42", + "total_candidates": 11, "sectors_analyzed_today": [ "半导体", "金属新材料", @@ -503,6 +503,198 @@ "drop_reason": null, "trend_warning": false, "trend_note": "" + }, + { + "code": "300124", + "name": "汇川技术", + "sector": "自动化设备", + "xiaoguo_score": 8.5, + "xiaoguo_reason": "工控龙头,PLC/伺服/机器人核心部件全覆盖,受益设备更新与新能源自动化需求,业绩确定性强,机构持仓集中。", + "xiaoguo_strategy": { + "entry_range": "58.00-62.00元", + "stop_loss": "54.00元", + "target": "72.00-78.00元" + }, + "verified_price": 72.94, + "verified_change": 6.64, + "added_at": "2026-07-03 13:37", + "last_updated": "2026-07-03 13:37", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-03 13:37", + "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": "002747", + "name": "埃斯顿", + "sector": "自动化设备", + "xiaoguo_score": 7.5, + "xiaoguo_reason": "国产工业机器人第一梯队,核心零部件自研率高,政策扶持明确,但短期毛利率承压,适合波段操作。", + "xiaoguo_strategy": { + "entry_range": "18.00-20.00元", + "stop_loss": "16.50元", + "target": "24.00-26.00元" + }, + "verified_price": 44.77, + "verified_change": 10.0, + "added_at": "2026-07-03 13:37", + "last_updated": "2026-07-03 13:37", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-03 13:37", + "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": "" + }, + { + "code": "300607", + "name": "拓斯达", + "sector": "自动化设备", + "xiaoguo_score": 7.0, + "xiaoguo_reason": "自动化产线集成+机器人双轮驱动,市值适中弹性大,AI视觉与协作机器人布局进展快,但业绩波动性较高。", + "xiaoguo_strategy": { + "entry_range": "12.00-13.50元", + "stop_loss": "11.00元", + "target": "16.00-18.00元" + }, + "verified_price": 52.92, + "verified_change": 13.46, + "added_at": "2026-07-03 13:37", + "last_updated": "2026-07-03 13:37", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-03 13:37", + "score": 7.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": "601689", + "name": "拓普集团", + "sector": "汽车零部件", + "xiaoguo_score": 8.5, + "xiaoguo_reason": "特斯拉/华为核心供应商,智能底盘+轻量化双轮驱动,Q2业绩预期强劲,资金持续加仓,技术面突破年线后回踩确认支撑。", + "xiaoguo_strategy": { + "entry_range": "58.5-60.2", + "stop_loss": "56.0", + "target": "68.0-72.0" + }, + "verified_price": 62.43, + "verified_change": 10.01, + "added_at": "2026-07-03 13:40", + "last_updated": "2026-07-03 13:40", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-03 13:40", + "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": "002126", + "name": "银轮股份", + "sector": "汽车零部件", + "xiaoguo_score": 8.0, + "xiaoguo_reason": "热管理龙头,绑定比亚迪/理想/海外车企,订单饱满,估值合理,趋势稳健,机构持仓集中,适合中线波段。", + "xiaoguo_strategy": { + "entry_range": "24.8-25.5", + "stop_loss": "23.5", + "target": "29.0-31.0" + }, + "verified_price": 53.39, + "verified_change": 5.76, + "added_at": "2026-07-03 13:40", + "last_updated": "2026-07-03 13:40", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-03 13:40", + "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": "603596", + "name": "伯特利", + "sector": "汽车零部件", + "xiaoguo_score": 8.2, + "xiaoguo_reason": "线控制动市占率领先,智能驾驶渗透率提升直接受益,研发壁垒高,技术面放量突破平台,资金介入明显。", + "xiaoguo_strategy": { + "entry_range": "42.0-43.5", + "stop_loss": "40.0", + "target": "48.0-52.0" + }, + "verified_price": 27.65, + "verified_change": 9.98, + "added_at": "2026-07-03 13:40", + "last_updated": "2026-07-03 13:40", + "num_observations": 1, + "score_history": [ + { + "date": "2026-07-03 13:40", + "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": "" } ] } \ No newline at end of file diff --git a/data/mofin.db-shm b/data/mofin.db-shm deleted file mode 100644 index 98b1a5d..0000000 Binary files a/data/mofin.db-shm and /dev/null differ diff --git a/data/mofin.db-wal b/data/mofin.db-wal deleted file mode 100644 index 382dc1a..0000000 Binary files a/data/mofin.db-wal and /dev/null differ diff --git a/data/multi_tf_cache.json b/data/multi_tf_cache.json index 6fe1f90..b82def6 100644 --- a/data/multi_tf_cache.json +++ b/data/multi_tf_cache.json @@ -960,6 +960,14 @@ "high": 151.69, "low": 141.26, "volume": 94902364.0 + }, + { + "date": "2026-07-03", + "open": 144.89, + "close": 143.0, + "high": 146.45, + "low": 140.4, + "volume": 56102965.0 } ], "weekly": [ @@ -1254,7 +1262,7 @@ "volume": 147766189.0 } ], - "updated_at": 1783051747.536662 + "updated_at": 1783056811.510267 }, "688795": { "daily": [ @@ -2217,6 +2225,14 @@ "high": 687.04, "low": 639.4, "volume": 3085878.0 + }, + { + "date": "2026-07-03", + "open": 643.88, + "close": 649.03, + "high": 664.48, + "low": 633.01, + "volume": 1568495.0 } ], "weekly": [ @@ -2479,7 +2495,7 @@ "volume": 4788252.0 } ], - "updated_at": 1783051740.058768 + "updated_at": 1783056712.9785304 }, "000657": { "daily": [ @@ -3442,6 +3458,14 @@ "high": 101.5, "low": 90.68, "volume": 1054773.0 + }, + { + "date": "2026-07-03", + "open": 95.0, + "close": 92.06, + "high": 96.53, + "low": 87.88, + "volume": 721833.0 } ], "weekly": [ @@ -3736,7 +3760,7 @@ "volume": 1051508.0 } ], - "updated_at": 1783051278.4453125 + "updated_at": 1783056502.3469434 }, "000700": { "daily": [ @@ -4699,6 +4723,14 @@ "high": 16.87, "low": 15.59, "volume": 1137587.0 + }, + { + "date": "2026-07-03", + "open": 16.08, + "close": 17.7, + "high": 17.88, + "low": 16.08, + "volume": 1515920.0 } ], "weekly": [ @@ -4993,7 +5025,7 @@ "volume": 1265397.0 } ], - "updated_at": 1783051627.982171 + "updated_at": 1783056719.8422928 }, "000711": { "daily": [ @@ -5956,6 +5988,14 @@ "high": 5.01, "low": 4.87, "volume": 443973.0 + }, + { + "date": "2026-07-03", + "open": 5.18, + "close": 5.26, + "high": 5.26, + "low": 5.1, + "volume": 1131235.0 } ], "weekly": [ @@ -6250,7 +6290,7 @@ "volume": 496248.0 } ], - "updated_at": 1783051282.8847363 + "updated_at": 1783056503.0905344 }, "001309": { "daily": [ @@ -7213,6 +7253,14 @@ "high": 872.83, "low": 806.0, "volume": 160378.0 + }, + { + "date": "2026-07-03", + "open": 804.75, + "close": 888.1, + "high": 892.1, + "low": 795.0, + "volume": 144171.0 } ], "weekly": [ @@ -7507,7 +7555,7 @@ "volume": 216663.0 } ], - "updated_at": 1783051286.5401492 + "updated_at": 1783056668.8085573 }, "002594": { "daily": [ @@ -8470,6 +8518,14 @@ "high": 85.67, "low": 81.9, "volume": 825046.0 + }, + { + "date": "2026-07-03", + "open": 83.73, + "close": 86.66, + "high": 87.28, + "low": 83.6, + "volume": 477402.0 } ], "weekly": [ @@ -8764,7 +8820,7 @@ "volume": 934285.0 } ], - "updated_at": 1783051286.9981902 + "updated_at": 1783056675.2141986 }, "00700": { "daily": [ @@ -9727,6 +9783,14 @@ "high": 447.0, "low": 429.4, "volume": 40905100.0 + }, + { + "date": "2026-07-03", + "open": 433.0, + "close": 432.6, + "high": 445.8, + "low": 432.4, + "volume": 15135668.0 } ], "weekly": [ @@ -10029,7 +10093,7 @@ "volume": 13032847.0 } ], - "updated_at": 1783051633.4890287 + "updated_at": 1783056724.989126 }, "00968": { "daily": [ @@ -12257,6 +12321,14 @@ "high": 84.2, "low": 78.55, "volume": 178219425.0 + }, + { + "date": "2026-07-03", + "open": 80.0, + "close": 78.95, + "high": 81.45, + "low": 77.35, + "volume": 71251123.0 } ], "weekly": [ @@ -12559,7 +12631,7 @@ "volume": 60114819.0 } ], - "updated_at": 1783051638.5061805 + "updated_at": 1783056730.0097694 }, "01070": { "daily": [ @@ -14787,6 +14859,14 @@ "high": 40.4, "low": 39.16, "volume": 16452660.0 + }, + { + "date": "2026-07-03", + "open": 39.7, + "close": 39.9, + "high": 40.5, + "low": 39.62, + "volume": 6092471.0 } ], "weekly": [ @@ -15089,7 +15169,7 @@ "volume": 2870057.0 } ], - "updated_at": 1783051643.521601 + "updated_at": 1783056735.0304337 }, "01211": { "daily": [ @@ -16052,6 +16132,14 @@ "high": 79.6, "low": 74.95, "volume": 54549471.0 + }, + { + "date": "2026-07-03", + "open": 81.2, + "close": 82.85, + "high": 83.35, + "low": 80.0, + "volume": 25755799.0 } ], "weekly": [ @@ -16354,7 +16442,7 @@ "volume": 13286402.0 } ], - "updated_at": 1783051648.5394585 + "updated_at": 1783056740.0491588 }, "01478": { "daily": [ @@ -17317,6 +17405,14 @@ "high": 7.08, "low": 6.72, "volume": 6257000.0 + }, + { + "date": "2026-07-03", + "open": 6.72, + "close": 7.04, + "high": 7.1, + "low": 6.72, + "volume": 2981000.0 } ], "weekly": [ @@ -17619,7 +17715,7 @@ "volume": 1618000.0 } ], - "updated_at": 1783051653.5516405 + "updated_at": 1783056745.0709894 }, "01888": { "daily": [ @@ -18582,6 +18678,14 @@ "high": 90.1, "low": 79.85, "volume": 92637274.0 + }, + { + "date": "2026-07-03", + "open": 81.0, + "close": 85.6, + "high": 88.9, + "low": 80.6, + "volume": 57017840.0 } ], "weekly": [ @@ -18884,7 +18988,7 @@ "volume": 29941901.0 } ], - "updated_at": 1783051658.5684023 + "updated_at": 1783056750.0898428 }, "02202": { "daily": [ @@ -19847,6 +19951,14 @@ "high": 2.27, "low": 2.18, "volume": 39512590.0 + }, + { + "date": "2026-07-03", + "open": 2.25, + "close": 2.31, + "high": 2.35, + "low": 2.23, + "volume": 18235400.0 } ], "weekly": [ @@ -20149,7 +20261,7 @@ "volume": 19786580.0 } ], - "updated_at": 1783051663.581557 + "updated_at": 1783056755.496929 }, "02318": { "daily": [ @@ -27437,6 +27549,14 @@ "high": 229.2, "low": 198.0, "volume": 40078361.0 + }, + { + "date": "2026-07-03", + "open": 191.2, + "close": 207.2, + "high": 210.6, + "low": 190.0, + "volume": 14255822.0 } ], "weekly": [ @@ -27739,7 +27859,7 @@ "volume": 15066251.0 } ], - "updated_at": 1783051668.878092 + "updated_at": 1783056760.5189984 }, "09868": { "daily": [ @@ -31232,6 +31352,14 @@ "high": 14.6, "low": 14.1, "volume": 155998.0 + }, + { + "date": "2026-07-03", + "open": 14.18, + "close": 14.4, + "high": 14.53, + "low": 14.16, + "volume": 102393.0 } ], "weekly": [ @@ -31526,7 +31654,7 @@ "volume": 230937.0 } ], - "updated_at": 1783051674.5847087 + "updated_at": 1783056765.5414958 }, "300124": { "daily": [ @@ -32489,6 +32617,14 @@ "high": 71.79, "low": 67.31, "volume": 703358.0 + }, + { + "date": "2026-07-03", + "open": 67.5, + "close": 74.14, + "high": 74.23, + "low": 67.4, + "volume": 691416.0 } ], "weekly": [ @@ -32783,7 +32919,7 @@ "volume": 722493.0 } ], - "updated_at": 1783051344.573262 + "updated_at": 1783056692.3502543 }, "300308": { "daily": [ @@ -33746,6 +33882,14 @@ "high": 1198.0, "low": 1127.4, "volume": 317620.0 + }, + { + "date": "2026-07-03", + "open": 1130.0, + "close": 1158.88, + "high": 1188.01, + "low": 1124.0, + "volume": 230014.0 } ], "weekly": [ @@ -34040,7 +34184,7 @@ "volume": 389058.0 } ], - "updated_at": 1783051680.1109805 + "updated_at": 1783056770.5614996 }, "300548": { "daily": [ @@ -35003,6 +35147,14 @@ "high": 245.0, "low": 220.0, "volume": 174041.0 + }, + { + "date": "2026-07-03", + "open": 220.0, + "close": 228.99, + "high": 231.55, + "low": 218.99, + "volume": 102806.0 } ], "weekly": [ @@ -35297,7 +35449,7 @@ "volume": 242727.0 } ], - "updated_at": 1783051685.6375031 + "updated_at": 1783056775.5768287 }, "300750": { "daily": [ @@ -36260,6 +36412,14 @@ "high": 390.99, "low": 380.39, "volume": 340754.0 + }, + { + "date": "2026-07-03", + "open": 381.96, + "close": 381.17, + "high": 387.95, + "low": 380.55, + "volume": 157731.0 } ], "weekly": [ @@ -36554,7 +36714,7 @@ "volume": 551212.0 } ], - "updated_at": 1783051690.893728 + "updated_at": 1783056780.5942247 }, "301308": { "daily": [ @@ -37517,6 +37677,14 @@ "high": 636.99, "low": 592.0, "volume": 208005.0 + }, + { + "date": "2026-07-03", + "open": 589.0, + "close": 634.0, + "high": 646.85, + "low": 574.1, + "volume": 153435.0 } ], "weekly": [ @@ -37811,7 +37979,7 @@ "volume": 296230.0 } ], - "updated_at": 1783051380.6922731 + "updated_at": 1783056696.8459098 }, "518880": { "daily": [ @@ -38774,6 +38942,14 @@ "high": 8.484, "low": 8.397, "volume": 5149790.0 + }, + { + "date": "2026-07-03", + "open": 8.71, + "close": 8.68, + "high": 8.73, + "low": 8.68, + "volume": 2689807.0 } ], "weekly": [ @@ -39068,7 +39244,7 @@ "volume": 3915247.0 } ], - "updated_at": 1783051695.915503 + "updated_at": 1783056785.6140218 }, "600519": { "daily": [ @@ -40031,6 +40207,14 @@ "high": 1215.52, "low": 1190.51, "volume": 50870.0 + }, + { + "date": "2026-07-03", + "open": 1205.24, + "close": 1189.52, + "high": 1210.14, + "low": 1188.0, + "volume": 23088.0 } ], "weekly": [ @@ -40325,7 +40509,7 @@ "volume": 64803.0 } ], - "updated_at": 1783051561.7579694 + "updated_at": 1783056699.260756 }, "600563": { "daily": [ @@ -41288,6 +41472,14 @@ "high": 173.3, "low": 164.3, "volume": 112859.0 + }, + { + "date": "2026-07-03", + "open": 160.27, + "close": 159.59, + "high": 163.68, + "low": 153.52, + "volume": 75944.0 } ], "weekly": [ @@ -41582,7 +41774,7 @@ "volume": 180947.0 } ], - "updated_at": 1783051700.9725504 + "updated_at": 1783056791.4344049 }, "601318": { "daily": [ @@ -42545,6 +42737,14 @@ "high": 50.2, "low": 48.8, "volume": 920130.0 + }, + { + "date": "2026-07-03", + "open": 49.5, + "close": 48.43, + "high": 49.78, + "low": 48.41, + "volume": 556535.0 } ], "weekly": [ @@ -42839,7 +43039,7 @@ "volume": 1746202.0 } ], - "updated_at": 1783051575.149798 + "updated_at": 1783056700.660319 }, "601899": { "daily": [ @@ -43802,6 +44002,14 @@ "high": 26.96, "low": 25.52, "volume": 5067417.0 + }, + { + "date": "2026-07-03", + "open": 27.5, + "close": 27.93, + "high": 28.3, + "low": 27.41, + "volume": 4183914.0 } ], "weekly": [ @@ -44096,7 +44304,7 @@ "volume": 4780454.0 } ], - "updated_at": 1783051705.9927075 + "updated_at": 1783056796.4605315 }, "688411": { "daily": [ @@ -45059,6 +45267,14 @@ "high": 272.99, "low": 254.8, "volume": 6368531.0 + }, + { + "date": "2026-07-03", + "open": 255.8, + "close": 255.99, + "high": 261.48, + "low": 251.0, + "volume": 2572727.0 } ], "weekly": [ @@ -45353,7 +45569,7 @@ "volume": 13672788.0 } ], - "updated_at": 1783051711.208435 + "updated_at": 1783056801.4764674 }, "688630": { "daily": [ @@ -46316,6 +46532,14 @@ "high": 499.95, "low": 464.8, "volume": 5841815.0 + }, + { + "date": "2026-07-03", + "open": 467.98, + "close": 497.0, + "high": 502.0, + "low": 444.55, + "volume": 4259432.0 } ], "weekly": [ @@ -46610,7 +46834,7 @@ "volume": 9660790.0 } ], - "updated_at": 1783051587.7339969 + "updated_at": 1783056704.6600807 }, "688639": { "daily": [ @@ -47573,6 +47797,14 @@ "high": 17.4, "low": 15.98, "volume": 9065955.0 + }, + { + "date": "2026-07-03", + "open": 17.06, + "close": 16.71, + "high": 17.2, + "low": 16.52, + "volume": 4669797.0 } ], "weekly": [ @@ -47867,7 +48099,7 @@ "volume": 13996588.0 } ], - "updated_at": 1783051733.2346125 + "updated_at": 1783056806.4868052 }, "688802": { "daily": [ @@ -48830,6 +49062,14 @@ "high": 784.0, "low": 721.0, "volume": 2036024.0 + }, + { + "date": "2026-07-03", + "open": 731.34, + "close": 741.27, + "high": 757.88, + "low": 713.0, + "volume": 1323959.0 } ], "weekly": [ @@ -49092,6 +49332,6 @@ "volume": 3202146.0 } ], - "updated_at": 1783051743.6900556 + "updated_at": 1783056714.5052435 } } \ No newline at end of file diff --git a/data/portfolio.json b/data/portfolio.json index ea2a9d3..d22799d 100644 --- a/data/portfolio.json +++ b/data/portfolio.json @@ -5,9 +5,9 @@ "name": "中际旭创", "shares": 100, "cost": 1316.53, - "price": 1157.97, - "market_value": 115797.0, - "change_pct": 1.31, + "price": 1145.0, + "market_value": 114101.0, + "change_pct": 0.17, "currency": "CNY", "position_pct": 15.27, "_currency": "CNY" @@ -18,32 +18,32 @@ "shares": 500, "cost": 228.65, "price": 178.26, - "market_value": 102700.0, + "market_value": 89910.0, "change_pct": 3.859, - "currency": "HKD", + "currency": "CNY", "position_pct": 13.47, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "01478", "name": "丘钛科技", "shares": 11000, "cost": 11.68, - "price": 6.11, - "market_value": 77550.0, - "change_pct": 4.911, - "currency": "HKD", + "price": 6.1, + "market_value": 67210.0, + "change_pct": 4.762, + "currency": "CNY", "position_pct": 7.97, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "601899", "name": "紫金矿业", "shares": 2400, "cost": 39.89, - "price": 28.04, - "market_value": 67296.0, - "change_pct": 6.62, + "price": 27.8, + "market_value": 66864.0, + "change_pct": 5.7, "currency": "CNY", "position_pct": 7.34, "_currency": "CNY" @@ -53,9 +53,9 @@ "name": "海博思创", "shares": 200, "cost": 266.95, - "price": 257.52, - "market_value": 51504.0, - "change_pct": 0.71, + "price": 254.75, + "market_value": 50842.0, + "change_pct": -0.38, "currency": "CNY", "position_pct": 6.31, "_currency": "CNY" @@ -65,9 +65,9 @@ "name": "中芯国际", "shares": 300, "cost": 126.07, - "price": 143.65, - "market_value": 43095.0, - "change_pct": -0.31, + "price": 142.97, + "market_value": 42600.0, + "change_pct": -0.78, "currency": "CNY", "position_pct": 5.44, "_currency": "CNY" @@ -78,20 +78,20 @@ "shares": 500, "cost": 76.5, "price": 74.17, - "market_value": 42600.0, + "market_value": 37325.0, "change_pct": 2.088, - "currency": "HKD", + "currency": "CNY", "position_pct": 5.28, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "688639", "name": "华恒生物", "shares": 2800, "cost": 21.51, - "price": 16.69, - "market_value": 46732.0, - "change_pct": -1.53, + "price": 16.72, + "market_value": 46900.0, + "change_pct": -1.36, "currency": "CNY", "position_pct": 5.25, "_currency": "CNY" @@ -101,9 +101,9 @@ "name": "宁德时代", "shares": 100, "cost": 401.78, - "price": 384.16, - "market_value": 38416.0, - "change_pct": 0.47, + "price": 380.24, + "market_value": 38032.0, + "change_pct": -0.55, "currency": "CNY", "position_pct": 4.64, "_currency": "CNY" @@ -113,57 +113,57 @@ "name": "比亚迪股份", "shares": 600, "cost": 90.92, - "price": 71.66, - "market_value": 49560.0, - "change_pct": 5.556, - "currency": "HKD", + "price": 71.83, + "market_value": 43044.0, + "change_pct": 5.811, + "currency": "CNY", "position_pct": 4.62, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "02202", "name": "万科企业", "shares": 19700, "cost": 4.05, - "price": 2.02, - "market_value": 45704.0, - "change_pct": 4.484, - "currency": "HKD", + "price": 1.99, + "market_value": 39203.0, + "change_pct": 2.69, + "currency": "CNY", "position_pct": 4.6, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "00700", "name": "腾讯", "shares": 100, "cost": null, - "price": 379.05, - "market_value": 43700.0, - "change_pct": 1.627, - "currency": "HKD", + "price": 376.8, + "market_value": 37541.0, + "change_pct": 1.023, + "currency": "CNY", "position_pct": null, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "00981", "name": "中芯国际", "shares": 500, "cost": 65.84, - "price": 69.1, - "market_value": 39825.0, - "change_pct": -0.871, - "currency": "HKD", + "price": 68.41, + "market_value": 34440.0, + "change_pct": -1.866, + "currency": "CNY", "position_pct": 4.2, - "_currency": "HKD" + "_currency": "CNY" }, { "code": "300548", "name": "长芯博创", "shares": 100, "cost": 231.46, - "price": 227.0, - "market_value": 22700.0, - "change_pct": 2.25, + "price": 226.5, + "market_value": 22599.0, + "change_pct": 2.03, "currency": "CNY", "position_pct": 3.2, "_currency": "CNY" @@ -173,9 +173,9 @@ "name": "黄金ETF华安", "shares": 2400, "cost": 12.19, - "price": 8.69, - "market_value": 20856.0, - "change_pct": 2.49, + "price": 8.67, + "market_value": 20832.0, + "change_pct": 2.32, "currency": "CNY", "position_pct": 2.45, "_currency": "CNY" @@ -185,9 +185,9 @@ "name": "中科电气", "shares": 1400, "cost": 22.29, - "price": 14.3, - "market_value": 20020.0, - "change_pct": 0.92, + "price": 14.38, + "market_value": 20062.0, + "change_pct": 1.48, "currency": "CNY", "position_pct": 2.42, "_currency": "CNY" @@ -197,9 +197,9 @@ "name": "模塑科技", "shares": 1400, "cost": 14.83, - "price": 17.37, - "market_value": 24318.0, - "change_pct": 2.96, + "price": 17.62, + "market_value": 25088.0, + "change_pct": 4.45, "currency": "CNY", "position_pct": 2.41, "_currency": "CNY" @@ -209,9 +209,9 @@ "name": "法拉电子", "shares": 100, "cost": 147.18, - "price": 161.71, - "market_value": 16171.0, - "change_pct": -1.58, + "price": 160.0, + "market_value": 15900.0, + "change_pct": -2.62, "currency": "CNY", "position_pct": 2.3, "_currency": "CNY" @@ -221,20 +221,20 @@ "name": "中国神华", "shares": 500, "cost": 39.79, - "price": 34.84, - "market_value": 20090.0, - "change_pct": 1.465, - "currency": "HKD", + "price": 34.59, + "market_value": 17305.0, + "change_pct": 0.758, + "currency": "CNY", "position_pct": 2.14, - "_currency": "HKD" + "_currency": "CNY" } ], - "total_assets": 864781.03, - "total_mv": 784305.03, + "total_assets": 860913.13, + "total_mv": 780437.13, "stock_value": null, "cash": 80476.0, "frozen_cash": 0.0, - "position_pct": 90.69, + "position_pct": 90.65, "currency": "CNY", - "updated_at": "2026-07-03 12:10" + "updated_at": "2026-07-03 13:42" } \ No newline at end of file diff --git a/data/price_events.json b/data/price_events.json index 343299c..f6cc9e9 100644 --- a/data/price_events.json +++ b/data/price_events.json @@ -9109,6 +9109,26 @@ "event_label": "止盈区间", "timestamp": "2026-07-03T11:54:31.001389", "date": "2026-07-03" + }, + { + "code": "000700", + "name": "模塑科技", + "event_type": "entry_zone", + "price": 17.92, + "trigger_value": "15.5~18.08", + "event_label": "买入区间", + "timestamp": "2026-07-03T13:28:17.259162", + "date": "2026-07-03" + }, + { + "code": "000700", + "name": "模塑科技", + "event_type": "entry_zone", + "price": 17.88, + "trigger_value": "15.5~18.08", + "event_label": "买入区间", + "timestamp": "2026-07-03T13:30:30.458142", + "date": "2026-07-03" } ] } \ No newline at end of file diff --git a/data/price_history.json b/data/price_history.json index 404e7e2..826fe60 100644 --- a/data/price_history.json +++ b/data/price_history.json @@ -25,8 +25,8 @@ { "date": "2026-07-03", "high": 1215.52, - "low": 1190.5, - "close": 1191.31 + "low": 1188.0, + "close": 1188.7 } ], "02202": [ @@ -61,8 +61,8 @@ { "date": "2026-07-03", "high": 50.2, - "low": 48.6, - "close": 48.7 + "low": 48.4, + "close": 48.44 } ], "02359": [ @@ -118,7 +118,7 @@ "date": "2026-07-03", "high": 502.0, "low": 444.55, - "close": 495.95 + "close": 494.57 } ], "06160": [ @@ -154,7 +154,7 @@ "date": "2026-07-03", "high": 687.04, "low": 633.01, - "close": 654.0 + "close": 646.83 } ], "09868": [ @@ -194,9 +194,9 @@ }, { "date": "2026-07-03", - "high": 753.88, + "high": 757.88, "low": 713.0, - "close": 744.0 + "close": 733.9 } ], "300124": [ @@ -208,9 +208,9 @@ }, { "date": "2026-07-03", - "high": 72.38, + "high": 74.63, "low": 67.31, - "close": 72.29 + "close": 73.13 } ], "000657": [ @@ -224,7 +224,7 @@ "date": "2026-07-03", "high": 101.5, "low": 87.88, - "close": 91.88 + "close": 91.78 } ], "000711": [ @@ -250,9 +250,9 @@ }, { "date": "2026-07-03", - "high": 882.5, + "high": 892.1, "low": 795.0, - "close": 874.23 + "close": 887.87 } ], "002594": [ @@ -266,7 +266,7 @@ "date": "2026-07-03", "high": 87.28, "low": 81.9, - "close": 86.56 + "close": 86.45 } ], "00700": [ @@ -332,9 +332,9 @@ "301308": [ { "date": "2026-07-03", - "high": 631.56, + "high": 646.85, "low": 574.1, - "close": 620.0 + "close": 635.0 } ] } \ No newline at end of file diff --git a/data_freshness.py b/data_freshness.py index 0d6eb1d..99808a7 100644 --- a/data_freshness.py +++ b/data_freshness.py @@ -1,81 +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_PATH = "/home/hmo/web-dashboard/data/live_prices.json" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.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 - - # 主指标:live_prices.json - if os.path.exists(LIVE_PRICES_PATH): - try: - lp = json.load(open(LIVE_PRICES_PATH)) - lp_time = lp.get("updated_at", "") - if not lp_time: - return False, "live_prices.json updated_at 为空" - lp_dt = datetime.fromisoformat(lp_time) - age = (now - lp_dt).total_seconds() / 60 - if age > max_age_min: - return False, f"live_prices.json 已 {age:.0f} 分钟未更新(阈值 {max_age_min} 分钟)" - return True, f"数据新鲜({age:.0f} 分钟前)" - except Exception as e: - return False, f"live_prices.json 读取失败: {e}" - else: - # fallback: portfolio.json - if os.path.exists(PORTFOLIO_PATH): - try: - pf = mo_data.read_portfolio() - pf_time = pf.get("updated_at", "") - if not pf_time: - return False, "portfolio.json updated_at 为空" - pf_dt = datetime.fromisoformat(pf_time) - age = (now - pf_dt).total_seconds() / 60 - if age > max_age_min: - return False, f"portfolio.json 已 {age:.0f} 分钟未更新(阈值 {max_age_min} 分钟)" - return True, f"数据新鲜(portfolio.json {age:.0f} 分钟前)" - except Exception as e: - return False, f"portfolio.json 读取失败: {e}" - return False, "live_prices.json 和 portfolio.json 均不存在" - - -if __name__ == "__main__": - ok, msg = check_fresh() - print(f"{'✅' if ok else '❌'} {msg}") +#!/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/market_watch.py b/market_watch.py index 1b705ee..7c80389 100644 --- a/market_watch.py +++ b/market_watch.py @@ -1,223 +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) - with open(DATA_DIR / "market.json", "w", encoding="utf-8") as f: - json.dump(market_data, f, ensure_ascii=False, indent=2) - - # ── SQLite 双写 ── - 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() +#!/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/mofin_db.py b/mofin_db.py index f746234..b298e66 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -387,6 +387,28 @@ def init_all_tables(conn: sqlite3.Connection): 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')) + ); """) conn.commit() @@ -1120,3 +1142,48 @@ def query_cash_log(conn, limit: int = 20) -> list[dict]: "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/multi_timeframe.py b/multi_timeframe.py index d619adb..69f4291 100644 --- a/multi_timeframe.py +++ b/multi_timeframe.py @@ -1,640 +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") -MTF_CACHE_PATH = os.path.join(DATA_DIR, "multi_tf_cache.json") # 多周期缓存独立存储 - -# 腾讯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都重新读/写大文件 -_MTF_CACHE_DATA = None # {code: {daily:[], weekly:[], monthly:[], updated_at: float, fundamentals:{}}} -_MTF_CACHE_MTIME = 0 # 文件最后修改时间 - - -def _load_mtf_cache(): - """加载多周期缓存(带模块级缓存,避免频繁读盘)""" - global _MTF_CACHE_DATA, _MTF_CACHE_MTIME - import time - try: - current_mtime = os.path.getmtime(MTF_CACHE_PATH) - if _MTF_CACHE_DATA is not None and current_mtime == _MTF_CACHE_MTIME: - return _MTF_CACHE_DATA - with open(MTF_CACHE_PATH) as f: - _MTF_CACHE_DATA = json.load(f) - _MTF_CACHE_MTIME = current_mtime - return _MTF_CACHE_DATA - except (FileNotFoundError, json.JSONDecodeError, OSError): - _MTF_CACHE_DATA = {} - _MTF_CACHE_MTIME = 0 - return {} - - -def _save_mtf_cache(): - """将模块级缓存写回磁盘""" - global _MTF_CACHE_DATA, _MTF_CACHE_MTIME - if _MTF_CACHE_DATA is None: - return - try: - os.makedirs(os.path.dirname(MTF_CACHE_PATH), exist_ok=True) - with open(MTF_CACHE_PATH, "w") as f: - json.dump(_MTF_CACHE_DATA, f, ensure_ascii=False, indent=2) - import time - _MTF_CACHE_MTIME = os.path.getmtime(MTF_CACHE_PATH) if os.path.exists(MTF_CACHE_PATH) else time.time() - 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: - """从本地多周期缓存读取历史数据,不修改 price_history.json""" - try: - with open(MTF_CACHE_PATH) as f: - data = json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return [] - - 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) +#!/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/price_monitor.py b/price_monitor.py index f29f4d9..0df65d9 100644 --- a/price_monitor.py +++ b/price_monitor.py @@ -12,7 +12,7 @@ from datetime import datetime # ── MoFin unified model ────────────────────────────────────────────── from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct -from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock, write_holding_strategy +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock, write_live_prices, read_capital_flow_cache, write_holding_strategy from mo_data import read_portfolio, read_decisions, read_watchlist BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" @@ -231,14 +231,16 @@ def refresh_data_prices(): prices.update(hk_prices) updated = 0 - # 保存全量实时价快照(供报告管道消费,确保分析用最新数据) + # 保存全量实时价快照到 DB(替代 live_prices.json) try: - live = {"updated_at": datetime.now().isoformat(), "prices": {}} + live = {} 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) + live[code] = {"price": p, "change_pct": chg} + conn = get_conn() + write_live_prices(conn, live) + conn.close() except Exception: pass @@ -663,7 +665,7 @@ def run_once(round_label=""): # === 3.5 资金流异常检测(2026-06-27 新增)=== try: - cf = json.load(open("/home/hmo/web-dashboard/data/capital_flow_cache.json")) + cf = read_capital_flow_cache(get_conn()) # 检查所有 active decision 中的资金流异常 for d in active: code = d["code"] diff --git a/scripts/capital_flow_collector.py b/scripts/capital_flow_collector.py index 7bf450a..06a2a4a 100644 --- a/scripts/capital_flow_collector.py +++ b/scripts/capital_flow_collector.py @@ -1,182 +1,186 @@ -#!/usr/bin/env python3 -"""capital_flow_collector.py — 个股资金流数据采集器 - -每30分钟拉一次持仓+自选的超大单/大单/中单/小单资金流向。 -输出到 capital_flow_cache.json 供 price_monitor 和报告使用。 - -API: push2his.eastmoney.com 个股资金流日线 -""" -import json, os, sys, time, urllib.request -from datetime import datetime -from urllib.request import urlopen, Request -from concurrent.futures import ThreadPoolExecutor, as_completed -from threading import Semaphore -from mo_data import read_portfolio, read_decisions, read_watchlist - -DATA_DIR = "/home/hmo/web-dashboard/data" -DECISIONS_PATH = f"{DATA_DIR}/decisions.json" -CACHE_PATH = f"{DATA_DIR}/capital_flow_cache.json" - -UA = "Mozilla/5.0" -# 限速器:最多5个并发,每请求后强制间隔0.3s -RATE_LIMIT = Semaphore(5) -MIN_INTERVAL = 0.3 -_last_req = 0 - -def _rate_limited_request(url): - """带速率限制的HTTP GET,用Semaphore控制并发数""" - global _last_req - with RATE_LIMIT: - elapsed = time.time() - _last_req - if elapsed < MIN_INTERVAL: - time.sleep(MIN_INTERVAL - elapsed) - proxy_handler = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy_handler) - req = Request(url, headers={"User-Agent": UA, "Referer": "https://data.eastmoney.com/"}) - try: - resp = opener.open(req, timeout=8) - _last_req = time.time() - return json.loads(resp.read().decode("utf-8")) - except Exception: - return None - -# eastmoney secid: 1=上海 0=深圳 -def secid(code): - code = str(code).strip() - if code.startswith(("6", "9")): - return f"1.{code}" - return f"0.{code}" - -def fetch_flow(code, days=5): - """拉取个股近N日资金流(带限速+代理绕过)""" - sid = secid(code) - url = f"http://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&lmt={days}" - data = _rate_limited_request(url) - if not data: - return None - klines = data.get("data", {}).get("klines", []) - if not klines: - return None - result = [] - for k in klines: - p = k.split(",") - if len(p) >= 7: - result.append({ - "date": p[0], - "main_net": float(p[1]), # 主力净流入(元) - "super_large": float(p[2]), # 超大单净流入(元) - "large": float(p[3]), # 大单净流入(元) - "medium": float(p[4]), # 中单净流入(元) - "small": float(p[5]), # 小单净流入(元) - }) - return result - -def fetch_flow_intraday(code): - """拉取当日分时资金流(用于盘中判断)""" - sid = secid(code) - url = f"http://push2.eastmoney.com/api/qt/stock/fflow/kline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&klt=1&lmt=120" - try: - resp = urlopen(url, timeout=5) - data = json.loads(resp.read().decode("utf-8")) - klines = data.get("data", {}).get("klines", []) - if not klines: - return None - latest = klines[-1].split(",") - return { - "main_net": float(latest[1]), - "super_large": float(latest[2]), - "large": float(latest[3]), - } - except: - return None - -def analyze_flow(flow_data): - """分析资金流模式""" - if not flow_data or len(flow_data) < 2: - return {} - - result = {"alerts": [], "pattern": ""} - - # 最近两日对比 - d1 = flow_data[-1] # 最新日 - d2 = flow_data[-2] # 前一日 - - # 超大单信号 - sl1 = d1["super_large"] - sl2 = d2["super_large"] - - # 连续形态判断 - main_trend = sum(d["main_net"] for d in flow_data[-3:]) - sl_trend = sum(d["super_large"] for d in flow_data[-3:]) - - # 1. 主力连续流入 - if main_trend > 50000000 and sl1 > 0 and sl2 > 0: - result["pattern"] = "主力持续流入" - result["alerts"].append("主力连续3日净流入") - - # 2. 超大单突然转向(连续流入→流出 或 流出→流入) - if sl1 * sl2 < 0: # 方向反转 - if sl1 > 0 and sl2 < 0: - result["pattern"] = "超大单由出转入" - result["alerts"].append("超大单转为净买入(暗示消息即将落地)") - elif sl1 < 0 and sl2 > 0: - result["pattern"] = "超大单由入转出" - result["alerts"].append("超大单转为净卖出(利好出货嫌疑)") - - # 3. 价格与资金流背离(缺当前价格作比较,在主脚本中完成) - # 4. 单日暴量 - max_sl = max(abs(d["super_large"]) for d in flow_data) - if max_sl == abs(sl1) and abs(sl1) > 100000000: - result["pattern"] = "单日资金暴量" - result["alerts"].append(f"今日超大单异常: {sl1/100000000:.2f}亿") - - return result - -def main(): - codes = set() - # 读取持仓+自选 - try: - dec = mo_data.read_decisions() - for d in dec.get("decisions", []): - c = d.get("code", "") - if c: - codes.add(c) - except: - pass - - all_flows = {} - - # 并行抓取:ThreadPoolExecutor + 内置限速器(Semaphore 5 + 0.3s间隔) - code_list = sorted(codes) - if not code_list: - print("[capital_flow] 无代码需要采集") - return - - def fetch_one(code): - flow = fetch_flow(code, days=5) - if flow: - analysis = analyze_flow(flow) - return (code, { - "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "flow": flow, - "analysis": analysis, - }) - return (code, None) - - with ThreadPoolExecutor(max_workers=5) as pool: - futures = {pool.submit(fetch_one, c): c for c in code_list} - for f in as_completed(futures): - code, result = f.result() - if result: - all_flows[code] = result - - # 写缓存 - cache = { - "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "stocks": all_flows, - } - json.dump(cache, open(CACHE_PATH, "w"), indent=2, ensure_ascii=False) - print(f"[capital_flow] {len(all_flows)}/{len(code_list)}只更新完成") - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""capital_flow_collector.py — 个股资金流数据采集器 + +每30分钟拉一次持仓+自选的超大单/大单/中单/小单资金流向。 +输出到 capital_flow_cache.json 供 price_monitor 和报告使用。 + +API: push2his.eastmoney.com 个股资金流日线 +""" +import json, os, sys, time, urllib.request +from datetime import datetime +from urllib.request import urlopen, Request +from concurrent.futures import ThreadPoolExecutor, as_completed +from threading import Semaphore +from mo_data import read_portfolio, read_decisions, read_watchlist +from mofin_db import get_conn, write_capital_flow_cache + +DATA_DIR = "/home/hmo/web-dashboard/data" +DECISIONS_PATH = f"{DATA_DIR}/decisions.json" +CACHE_PATH = f"{DATA_DIR}/capital_flow_cache.json" + +UA = "Mozilla/5.0" +# 限速器:最多5个并发,每请求后强制间隔0.3s +RATE_LIMIT = Semaphore(5) +MIN_INTERVAL = 0.3 +_last_req = 0 + +def _rate_limited_request(url): + """带速率限制的HTTP GET,用Semaphore控制并发数""" + global _last_req + with RATE_LIMIT: + elapsed = time.time() - _last_req + if elapsed < MIN_INTERVAL: + time.sleep(MIN_INTERVAL - elapsed) + proxy_handler = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy_handler) + req = Request(url, headers={"User-Agent": UA, "Referer": "https://data.eastmoney.com/"}) + try: + resp = opener.open(req, timeout=8) + _last_req = time.time() + return json.loads(resp.read().decode("utf-8")) + except Exception: + return None + +# eastmoney secid: 1=上海 0=深圳 +def secid(code): + code = str(code).strip() + if code.startswith(("6", "9")): + return f"1.{code}" + return f"0.{code}" + +def fetch_flow(code, days=5): + """拉取个股近N日资金流(带限速+代理绕过)""" + sid = secid(code) + url = f"http://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&lmt={days}" + data = _rate_limited_request(url) + if not data: + return None + klines = data.get("data", {}).get("klines", []) + if not klines: + return None + result = [] + for k in klines: + p = k.split(",") + if len(p) >= 7: + result.append({ + "date": p[0], + "main_net": float(p[1]), # 主力净流入(元) + "super_large": float(p[2]), # 超大单净流入(元) + "large": float(p[3]), # 大单净流入(元) + "medium": float(p[4]), # 中单净流入(元) + "small": float(p[5]), # 小单净流入(元) + }) + return result + +def fetch_flow_intraday(code): + """拉取当日分时资金流(用于盘中判断)""" + sid = secid(code) + url = f"http://push2.eastmoney.com/api/qt/stock/fflow/kline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&klt=1&lmt=120" + try: + resp = urlopen(url, timeout=5) + data = json.loads(resp.read().decode("utf-8")) + klines = data.get("data", {}).get("klines", []) + if not klines: + return None + latest = klines[-1].split(",") + return { + "main_net": float(latest[1]), + "super_large": float(latest[2]), + "large": float(latest[3]), + } + except: + return None + +def analyze_flow(flow_data): + """分析资金流模式""" + if not flow_data or len(flow_data) < 2: + return {} + + result = {"alerts": [], "pattern": ""} + + # 最近两日对比 + d1 = flow_data[-1] # 最新日 + d2 = flow_data[-2] # 前一日 + + # 超大单信号 + sl1 = d1["super_large"] + sl2 = d2["super_large"] + + # 连续形态判断 + main_trend = sum(d["main_net"] for d in flow_data[-3:]) + sl_trend = sum(d["super_large"] for d in flow_data[-3:]) + + # 1. 主力连续流入 + if main_trend > 50000000 and sl1 > 0 and sl2 > 0: + result["pattern"] = "主力持续流入" + result["alerts"].append("主力连续3日净流入") + + # 2. 超大单突然转向(连续流入→流出 或 流出→流入) + if sl1 * sl2 < 0: # 方向反转 + if sl1 > 0 and sl2 < 0: + result["pattern"] = "超大单由出转入" + result["alerts"].append("超大单转为净买入(暗示消息即将落地)") + elif sl1 < 0 and sl2 > 0: + result["pattern"] = "超大单由入转出" + result["alerts"].append("超大单转为净卖出(利好出货嫌疑)") + + # 3. 价格与资金流背离(缺当前价格作比较,在主脚本中完成) + # 4. 单日暴量 + max_sl = max(abs(d["super_large"]) for d in flow_data) + if max_sl == abs(sl1) and abs(sl1) > 100000000: + result["pattern"] = "单日资金暴量" + result["alerts"].append(f"今日超大单异常: {sl1/100000000:.2f}亿") + + return result + +def main(): + codes = set() + # 读取持仓+自选 + try: + dec = mo_data.read_decisions() + for d in dec.get("decisions", []): + c = d.get("code", "") + if c: + codes.add(c) + except: + pass + + all_flows = {} + + # 并行抓取:ThreadPoolExecutor + 内置限速器(Semaphore 5 + 0.3s间隔) + code_list = sorted(codes) + if not code_list: + print("[capital_flow] 无代码需要采集") + return + + def fetch_one(code): + flow = fetch_flow(code, days=5) + if flow: + analysis = analyze_flow(flow) + return (code, { + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "flow": flow, + "analysis": analysis, + }) + return (code, None) + + with ThreadPoolExecutor(max_workers=5) as pool: + futures = {pool.submit(fetch_one, c): c for c in code_list} + for f in as_completed(futures): + code, result = f.result() + if result: + all_flows[code] = result + + # 写缓存 + cache = { + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "stocks": all_flows, + } + # 写 DB(替代 capital_flow_cache.json) + conn = get_conn() + write_capital_flow_cache(conn, cache) + conn.close() + print(f"[capital_flow] {len(all_flows)}/{len(code_list)}只更新完成") + +if __name__ == "__main__": + main() diff --git a/scripts/check_imports.py b/scripts/check_imports.py new file mode 100644 index 0000000..1bf4bdc --- /dev/null +++ b/scripts/check_imports.py @@ -0,0 +1,33 @@ +import sys, traceback +sys.path.insert(0, '/home/hmo/MoFin') + +errors = [] +for mod, name in [ + ('mo_data', 'read_portfolio'), + ('mo_data', 'read_decisions'), + ('mo_data', 'read_watchlist'), + ('mofin_db', 'get_conn'), + ('mofin_db', 'write_holdings_batch'), + ('mofin_db', 'write_portfolio_summary'), + ('mofin_db', 'write_watchlist_stock'), + ('mofin_db', 'write_holding_strategy'), + ('mo_models', 'is_hk_stock'), + ('mo_models', 'get_hk_rate'), +]: + try: + m = __import__(mod, fromlist=[name]) + getattr(m, name) + print(f"OK: {mod}.{name}") + except Exception as e: + print(f"FAIL: {mod}.{name} -> {e}") + errors.append(str(e)) + +print(f"\n=== price_monitor.py import test ===") +try: + import price_monitor + print("price_monitor imported OK") +except Exception as e: + print(f"FAIL: {traceback.format_exc()}") + +if errors: + print(f"\n{len(errors)} import errors!") diff --git a/scripts/check_prices.py b/scripts/check_prices.py new file mode 100644 index 0000000..d4a2f08 --- /dev/null +++ b/scripts/check_prices.py @@ -0,0 +1,15 @@ +import sqlite3 +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + +# Check when portfolio was last updated +r = db.execute("SELECT updated_at, total_assets, total_mv, cash FROM portfolio_summary WHERE id=1").fetchone() +print(f"Portfolio last updated: {r[0]}") +print(f"total_assets={r[1]} total_mv={r[2]} cash={r[3]}") + +# Check hold prices +print("\nAll holdings:") +for r in db.execute("SELECT code, name, price, change_pct, cost, shares FROM holdings WHERE is_active=1 ORDER BY code"): + mv = (r[2] or 0) * (r[5] or 0) + print(f" {r[0]} {r[1]}: price={r[2]} chg={r[3]} cost={r[4]} shares={r[5]} mv={mv}") + +db.close() diff --git a/scripts/clean_watchlist.py b/scripts/clean_watchlist.py index ab89f13..be8d04e 100644 --- a/scripts/clean_watchlist.py +++ b/scripts/clean_watchlist.py @@ -1,7 +1,11 @@ #!/usr/bin/env python3 """Remove held stocks from watchlist""" -import json, os +import json, os, sys + +# 确保 MoFin 根目录在模块搜索路径中(兼容 cron 环境) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from mo_data import read_portfolio, read_decisions, read_watchlist from mofin_db import get_conn, write_watchlist_stock, write_holding_strategy diff --git a/scripts/diagnose_assets.py b/scripts/diagnose_assets.py new file mode 100644 index 0000000..e15941e --- /dev/null +++ b/scripts/diagnose_assets.py @@ -0,0 +1,35 @@ +"""Diagnose: check HK stock costs, prices, total assets calculation""" +import sys +sys.path.insert(0, '/home/hmo/MoFin') +from mo_models import calc_total_assets, calc_total_mv, is_hk_stock, to_cny, get_hk_rate +from mo_data import read_portfolio +import json + +pf = read_portfolio() +holdings = pf.get('holdings', []) +rate = get_hk_rate() + +print(f"HK_RATE: {rate}") +print(f"total_assets (from DB): {pf.get('total_assets')}") +print(f"total_mv (from DB): {pf.get('total_mv')}") +print(f"cash: {pf.get('cash')}") +print(f"frozen_cash: {pf.get('frozen_cash')}") +print(f"position_pct: {pf.get('position_pct')}") + +total_mv_calc = calc_total_mv(holdings) +total_assets_calc = calc_total_assets(pf) +print(f"\ncalc_total_mv: {total_mv_calc}") +print(f"calc_total_assets: {total_assets_calc}") + +print(f"\n=== HK stocks ===") +for h in holdings: + code = h.get('code', '') + if is_hk_stock(str(code)): + cost = h.get('cost', 0) or 0 + price = h.get('price', 0) or 0 + shares = h.get('shares', 0) or 0 + mv = price * shares + cost_calc = cost * shares + pnl = (price - cost) * shares if cost > 0 else 0 + pnl_pct = (price - cost) / cost * 100 if cost > 0 else 0 + print(f" {code} {h.get('name')}: cost={cost} price={price} shares={shares} mv={mv} pnl={pnl:.1f} ({pnl_pct:+.1f}%)") diff --git a/scripts/quick_verify.py b/scripts/quick_verify.py new file mode 100644 index 0000000..de52a94 --- /dev/null +++ b/scripts/quick_verify.py @@ -0,0 +1,25 @@ +"""Quick verification after JSON→DB migration""" +import sys +sys.path.insert(0, '/home/hmo/MoFin') + +from mo_data import read_portfolio, read_decisions, read_watchlist + +ok = 0 +err = 0 + +for name, fn in [("portfolio", read_portfolio), ("decisions", read_decisions), ("watchlist", read_watchlist)]: + try: + r = fn() + if name == "portfolio": + n = len(r.get('holdings', [])) + elif name == "decisions": + n = len(r.get('decisions', [])) + else: + n = len(r.get('stocks', [])) + print(f" {name}: {n} records OK") + ok += 1 + except Exception as e: + print(f" {name}: ERROR -> {e}") + err += 1 + +print(f"\n{ok}/3 passed, {err} errors") diff --git a/scripts/test_api.py b/scripts/test_api.py new file mode 100644 index 0000000..b18e7a9 --- /dev/null +++ b/scripts/test_api.py @@ -0,0 +1,36 @@ +"""Test Eastmoney API response time for HK stocks""" +import urllib.request, json, time + +codes = ['00700', '01888', '00981'] +UA = 'Mozilla/5.0' + +for code in codes: + url = f"https://push2.eastmoney.com/api/qt/stock/get?secid=116.{code}&fields=f43,f170&fltt=2" + start = time.time() + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=30) as r: + resp = json.loads(r.read().decode("utf-8")) + elapsed = time.time() - start + price = resp.get('data', {}).get('f43', '?') + print(f"{code}: {elapsed:.1f}s, price={price}, rc={resp.get('rc')}") + except Exception as e: + elapsed = time.time() - start + print(f"{code}: {elapsed:.1f}s, ERROR: {type(e).__name__}: {e}") + +# Also test Tencent fallback +print("\nTencent fallback:") +url = "http://qt.gtimg.cn/q=hk00700,hk01888,hk00981" +start = time.time() +try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=10) as r: + text = r.read().decode("gbk") + elapsed = time.time() - start + print(f"Tencent: {elapsed:.1f}s, {len(text)} bytes") + # Parse first line + line = text.strip().split('\n')[0] + print(f" sample: {line[:80]}...") +except Exception as e: + elapsed = time.time() - start + print(f"Tencent: {elapsed:.1f}s, ERROR: {e}") diff --git a/scripts/test_interval.py b/scripts/test_interval.py new file mode 100644 index 0000000..15b7b9d --- /dev/null +++ b/scripts/test_interval.py @@ -0,0 +1,22 @@ +"""Test if steady 5s interval avoids rate limit""" +import urllib.request, json, time + +UA = 'Mozilla/5.0' +codes = ['00700', '01888', '00981'] + +for i, code in enumerate(codes): + url = f"https://push2.eastmoney.com/api/qt/stock/get?secid=116.{code}&fields=f43,f170&fltt=2" + start = time.time() + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=5) as r: + resp = json.loads(r.read().decode("utf-8")) + elapsed = time.time() - start + price = resp.get('data', {}).get('f43', '?') + print(f"#{i+1} {code}: OK in {elapsed:.1f}s, price={price}") + except Exception as e: + elapsed = time.time() - start + print(f"#{i+1} {code}: FAIL in {elapsed:.1f}s — {type(e).__name__}") + break + if i < len(codes) - 1: + time.sleep(5) diff --git a/scripts/test_ratelimit.py b/scripts/test_ratelimit.py new file mode 100644 index 0000000..c06f083 --- /dev/null +++ b/scripts/test_ratelimit.py @@ -0,0 +1,49 @@ +"""Test Eastmoney rate limit — find safe interval between requests""" +import urllib.request, json, time + +UA = 'Mozilla/5.0' +CODE = '00700' +url = f"https://push2.eastmoney.com/api/qt/stock/get?secid=116.{CODE}&fields=f43,f170&fltt=2" + +def try_fetch(): + start = time.time() + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=5) as r: + resp = json.loads(r.read().decode("utf-8")) + elapsed = time.time() - start + return True, elapsed, resp.get('data', {}).get('f43', '?'), '' + except Exception as e: + elapsed = time.time() - start + return False, elapsed, 0, str(e)[:50] + +# Test 1: single request +ok, t, price, err = try_fetch() +print(f"Single: {'OK' if ok else 'FAIL'} in {t:.2f}s price={price} {err}") + +if ok: + # If it works, test minimal interval + for delay in [0.5, 1, 2, 3, 5]: + time.sleep(delay) + ok2, t2, p2, e2 = try_fetch() + print(f"After {delay:.1f}s: {'OK' if ok2 else 'FAIL'} in {t2:.2f}s price={p2} {e2}") + if not ok2: + print(f" -> Rate limited! Need > {delay}s between requests") +else: + # Currently blocked, wait and retry + print("Currently blocked. Waiting 10s then retry...") + time.sleep(10) + ok, t, price, err = try_fetch() + print(f"After 10s: {'OK' if ok else 'FAIL'} in {t:.2f}s price={price} {err}") + if ok: + time.sleep(2) + ok2, t2, p2, e2 = try_fetch() + print(f"After +2s: {'OK' if ok2 else 'FAIL'} in {t2:.2f}s price={p2} {e2}") + else: + for w in [20, 30, 60]: + print(f"Waiting {w}s...") + time.sleep(w) + ok, t, price, err = try_fetch() + print(f"After {w}s: {'OK' if ok else 'FAIL'} in {t:.2f}s price={price} {err}") + if ok: + break diff --git a/scripts/verify_pnl.py b/scripts/verify_pnl.py new file mode 100644 index 0000000..d377d5a --- /dev/null +++ b/scripts/verify_pnl.py @@ -0,0 +1,38 @@ +"""Verify total assets and P&L calculation""" +import sqlite3, sys +sys.path.insert(0, '/home/hmo/MoFin') +from mo_models import calc_total_assets, calc_total_mv + +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') +r = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone() +keys = [d[0] for d in db.execute("SELECT * FROM portfolio_summary LIMIT 0").description] +summary = dict(zip(keys, r)) + +print(f"total_assets (stored): {summary.get('total_assets')}") +print(f"total_mv (stored): {summary.get('total_mv')}") +print(f"total_pnl (stored): {summary.get('total_pnl')}") +print(f"cash: {summary.get('cash')}") +print(f"frozen_cash: {summary.get('frozen_cash')}") + +holdings = [] +for r in db.execute("SELECT code, name, cost, price, shares FROM holdings WHERE is_active=1"): + holdings.append({'code': r[0], 'name': r[1], 'cost': r[2] or 0, 'price': r[3] or 0, 'shares': r[4] or 0}) + +mv = sum(h['price'] * h['shares'] for h in holdings) +total_cost = sum(h['cost'] * h['shares'] for h in holdings) +pnl = mv - total_cost +ta = mv + (summary.get('cash') or 0) + (summary.get('frozen_cash') or 0) + +print(f"\nCalculated:") +print(f"total_mv = {mv:.2f}") +print(f"total_cost = {total_cost:.2f}") +print(f"total_pnl = {pnl:.2f}") +print(f"total_assets = {ta:.2f}") + +# Check for HK stocks with cost=0 (never converted) +print("\nStocks with cost=0 or None:") +for h in holdings: + if h['cost'] <= 0 and h['shares'] > 0: + print(f" {h['code']} {h['name']}: cost={h['cost']} shares={h['shares']} price={h['price']}") + +db.close() diff --git a/scripts/xiaoguo_signal_consumer.py b/scripts/xiaoguo_signal_consumer.py index dd5a898..47bd07f 100644 --- a/scripts/xiaoguo_signal_consumer.py +++ b/scripts/xiaoguo_signal_consumer.py @@ -13,6 +13,10 @@ no_agent模式:有发现→输出,无→静默 import json, os, sqlite3, sys, time, urllib.request from pathlib import Path from datetime import datetime + +# 确保 MoFin 根目录在模块搜索路径中(兼容 cron 环境) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from mo_data import read_watchlist from mofin_db import write_watchlist_stock @@ -34,8 +38,13 @@ def clean_proxy(): def fetch_quote(code): """拉行情。DB 优先,腾讯 fallback""" # DB 优先 - try: from mofin_db import get_price_from_db; p, chg = get_price_from_db(code); return {"name":"", "code":code, "price":p, "change_pct":chg or 0} if p else None - except: pass + try: + from mofin_db import get_price_from_db + p, chg = get_price_from_db(code) + if p: + return {"name":"", "code":code, "price":p, "change_pct":chg or 0} + except: + pass # Fallback: 腾讯 try: prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk"