migrate: last 4 JSON files — live_prices, market, mtf_cache, capital_flow → DB

This commit is contained in:
知微
2026-07-03 13:44:10 +08:00
parent 7cc0ea0ef3
commit bb9b3922c9
27 changed files with 2064 additions and 1256 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+35
View File
@@ -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}")
+194 -2
View File
@@ -1,6 +1,6 @@
{ {
"last_updated": "2026-07-03 11:32", "last_updated": "2026-07-03 13:42",
"total_candidates": 5, "total_candidates": 11,
"sectors_analyzed_today": [ "sectors_analyzed_today": [
"半导体", "半导体",
"金属新材料", "金属新材料",
@@ -503,6 +503,198 @@
"drop_reason": null, "drop_reason": null,
"trend_warning": false, "trend_warning": false,
"trend_note": "" "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": ""
} }
] ]
} }
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+270 -30
View File
@@ -960,6 +960,14 @@
"high": 151.69, "high": 151.69,
"low": 141.26, "low": 141.26,
"volume": 94902364.0 "volume": 94902364.0
},
{
"date": "2026-07-03",
"open": 144.89,
"close": 143.0,
"high": 146.45,
"low": 140.4,
"volume": 56102965.0
} }
], ],
"weekly": [ "weekly": [
@@ -1254,7 +1262,7 @@
"volume": 147766189.0 "volume": 147766189.0
} }
], ],
"updated_at": 1783051747.536662 "updated_at": 1783056811.510267
}, },
"688795": { "688795": {
"daily": [ "daily": [
@@ -2217,6 +2225,14 @@
"high": 687.04, "high": 687.04,
"low": 639.4, "low": 639.4,
"volume": 3085878.0 "volume": 3085878.0
},
{
"date": "2026-07-03",
"open": 643.88,
"close": 649.03,
"high": 664.48,
"low": 633.01,
"volume": 1568495.0
} }
], ],
"weekly": [ "weekly": [
@@ -2479,7 +2495,7 @@
"volume": 4788252.0 "volume": 4788252.0
} }
], ],
"updated_at": 1783051740.058768 "updated_at": 1783056712.9785304
}, },
"000657": { "000657": {
"daily": [ "daily": [
@@ -3442,6 +3458,14 @@
"high": 101.5, "high": 101.5,
"low": 90.68, "low": 90.68,
"volume": 1054773.0 "volume": 1054773.0
},
{
"date": "2026-07-03",
"open": 95.0,
"close": 92.06,
"high": 96.53,
"low": 87.88,
"volume": 721833.0
} }
], ],
"weekly": [ "weekly": [
@@ -3736,7 +3760,7 @@
"volume": 1051508.0 "volume": 1051508.0
} }
], ],
"updated_at": 1783051278.4453125 "updated_at": 1783056502.3469434
}, },
"000700": { "000700": {
"daily": [ "daily": [
@@ -4699,6 +4723,14 @@
"high": 16.87, "high": 16.87,
"low": 15.59, "low": 15.59,
"volume": 1137587.0 "volume": 1137587.0
},
{
"date": "2026-07-03",
"open": 16.08,
"close": 17.7,
"high": 17.88,
"low": 16.08,
"volume": 1515920.0
} }
], ],
"weekly": [ "weekly": [
@@ -4993,7 +5025,7 @@
"volume": 1265397.0 "volume": 1265397.0
} }
], ],
"updated_at": 1783051627.982171 "updated_at": 1783056719.8422928
}, },
"000711": { "000711": {
"daily": [ "daily": [
@@ -5956,6 +5988,14 @@
"high": 5.01, "high": 5.01,
"low": 4.87, "low": 4.87,
"volume": 443973.0 "volume": 443973.0
},
{
"date": "2026-07-03",
"open": 5.18,
"close": 5.26,
"high": 5.26,
"low": 5.1,
"volume": 1131235.0
} }
], ],
"weekly": [ "weekly": [
@@ -6250,7 +6290,7 @@
"volume": 496248.0 "volume": 496248.0
} }
], ],
"updated_at": 1783051282.8847363 "updated_at": 1783056503.0905344
}, },
"001309": { "001309": {
"daily": [ "daily": [
@@ -7213,6 +7253,14 @@
"high": 872.83, "high": 872.83,
"low": 806.0, "low": 806.0,
"volume": 160378.0 "volume": 160378.0
},
{
"date": "2026-07-03",
"open": 804.75,
"close": 888.1,
"high": 892.1,
"low": 795.0,
"volume": 144171.0
} }
], ],
"weekly": [ "weekly": [
@@ -7507,7 +7555,7 @@
"volume": 216663.0 "volume": 216663.0
} }
], ],
"updated_at": 1783051286.5401492 "updated_at": 1783056668.8085573
}, },
"002594": { "002594": {
"daily": [ "daily": [
@@ -8470,6 +8518,14 @@
"high": 85.67, "high": 85.67,
"low": 81.9, "low": 81.9,
"volume": 825046.0 "volume": 825046.0
},
{
"date": "2026-07-03",
"open": 83.73,
"close": 86.66,
"high": 87.28,
"low": 83.6,
"volume": 477402.0
} }
], ],
"weekly": [ "weekly": [
@@ -8764,7 +8820,7 @@
"volume": 934285.0 "volume": 934285.0
} }
], ],
"updated_at": 1783051286.9981902 "updated_at": 1783056675.2141986
}, },
"00700": { "00700": {
"daily": [ "daily": [
@@ -9727,6 +9783,14 @@
"high": 447.0, "high": 447.0,
"low": 429.4, "low": 429.4,
"volume": 40905100.0 "volume": 40905100.0
},
{
"date": "2026-07-03",
"open": 433.0,
"close": 432.6,
"high": 445.8,
"low": 432.4,
"volume": 15135668.0
} }
], ],
"weekly": [ "weekly": [
@@ -10029,7 +10093,7 @@
"volume": 13032847.0 "volume": 13032847.0
} }
], ],
"updated_at": 1783051633.4890287 "updated_at": 1783056724.989126
}, },
"00968": { "00968": {
"daily": [ "daily": [
@@ -12257,6 +12321,14 @@
"high": 84.2, "high": 84.2,
"low": 78.55, "low": 78.55,
"volume": 178219425.0 "volume": 178219425.0
},
{
"date": "2026-07-03",
"open": 80.0,
"close": 78.95,
"high": 81.45,
"low": 77.35,
"volume": 71251123.0
} }
], ],
"weekly": [ "weekly": [
@@ -12559,7 +12631,7 @@
"volume": 60114819.0 "volume": 60114819.0
} }
], ],
"updated_at": 1783051638.5061805 "updated_at": 1783056730.0097694
}, },
"01070": { "01070": {
"daily": [ "daily": [
@@ -14787,6 +14859,14 @@
"high": 40.4, "high": 40.4,
"low": 39.16, "low": 39.16,
"volume": 16452660.0 "volume": 16452660.0
},
{
"date": "2026-07-03",
"open": 39.7,
"close": 39.9,
"high": 40.5,
"low": 39.62,
"volume": 6092471.0
} }
], ],
"weekly": [ "weekly": [
@@ -15089,7 +15169,7 @@
"volume": 2870057.0 "volume": 2870057.0
} }
], ],
"updated_at": 1783051643.521601 "updated_at": 1783056735.0304337
}, },
"01211": { "01211": {
"daily": [ "daily": [
@@ -16052,6 +16132,14 @@
"high": 79.6, "high": 79.6,
"low": 74.95, "low": 74.95,
"volume": 54549471.0 "volume": 54549471.0
},
{
"date": "2026-07-03",
"open": 81.2,
"close": 82.85,
"high": 83.35,
"low": 80.0,
"volume": 25755799.0
} }
], ],
"weekly": [ "weekly": [
@@ -16354,7 +16442,7 @@
"volume": 13286402.0 "volume": 13286402.0
} }
], ],
"updated_at": 1783051648.5394585 "updated_at": 1783056740.0491588
}, },
"01478": { "01478": {
"daily": [ "daily": [
@@ -17317,6 +17405,14 @@
"high": 7.08, "high": 7.08,
"low": 6.72, "low": 6.72,
"volume": 6257000.0 "volume": 6257000.0
},
{
"date": "2026-07-03",
"open": 6.72,
"close": 7.04,
"high": 7.1,
"low": 6.72,
"volume": 2981000.0
} }
], ],
"weekly": [ "weekly": [
@@ -17619,7 +17715,7 @@
"volume": 1618000.0 "volume": 1618000.0
} }
], ],
"updated_at": 1783051653.5516405 "updated_at": 1783056745.0709894
}, },
"01888": { "01888": {
"daily": [ "daily": [
@@ -18582,6 +18678,14 @@
"high": 90.1, "high": 90.1,
"low": 79.85, "low": 79.85,
"volume": 92637274.0 "volume": 92637274.0
},
{
"date": "2026-07-03",
"open": 81.0,
"close": 85.6,
"high": 88.9,
"low": 80.6,
"volume": 57017840.0
} }
], ],
"weekly": [ "weekly": [
@@ -18884,7 +18988,7 @@
"volume": 29941901.0 "volume": 29941901.0
} }
], ],
"updated_at": 1783051658.5684023 "updated_at": 1783056750.0898428
}, },
"02202": { "02202": {
"daily": [ "daily": [
@@ -19847,6 +19951,14 @@
"high": 2.27, "high": 2.27,
"low": 2.18, "low": 2.18,
"volume": 39512590.0 "volume": 39512590.0
},
{
"date": "2026-07-03",
"open": 2.25,
"close": 2.31,
"high": 2.35,
"low": 2.23,
"volume": 18235400.0
} }
], ],
"weekly": [ "weekly": [
@@ -20149,7 +20261,7 @@
"volume": 19786580.0 "volume": 19786580.0
} }
], ],
"updated_at": 1783051663.581557 "updated_at": 1783056755.496929
}, },
"02318": { "02318": {
"daily": [ "daily": [
@@ -27437,6 +27549,14 @@
"high": 229.2, "high": 229.2,
"low": 198.0, "low": 198.0,
"volume": 40078361.0 "volume": 40078361.0
},
{
"date": "2026-07-03",
"open": 191.2,
"close": 207.2,
"high": 210.6,
"low": 190.0,
"volume": 14255822.0
} }
], ],
"weekly": [ "weekly": [
@@ -27739,7 +27859,7 @@
"volume": 15066251.0 "volume": 15066251.0
} }
], ],
"updated_at": 1783051668.878092 "updated_at": 1783056760.5189984
}, },
"09868": { "09868": {
"daily": [ "daily": [
@@ -31232,6 +31352,14 @@
"high": 14.6, "high": 14.6,
"low": 14.1, "low": 14.1,
"volume": 155998.0 "volume": 155998.0
},
{
"date": "2026-07-03",
"open": 14.18,
"close": 14.4,
"high": 14.53,
"low": 14.16,
"volume": 102393.0
} }
], ],
"weekly": [ "weekly": [
@@ -31526,7 +31654,7 @@
"volume": 230937.0 "volume": 230937.0
} }
], ],
"updated_at": 1783051674.5847087 "updated_at": 1783056765.5414958
}, },
"300124": { "300124": {
"daily": [ "daily": [
@@ -32489,6 +32617,14 @@
"high": 71.79, "high": 71.79,
"low": 67.31, "low": 67.31,
"volume": 703358.0 "volume": 703358.0
},
{
"date": "2026-07-03",
"open": 67.5,
"close": 74.14,
"high": 74.23,
"low": 67.4,
"volume": 691416.0
} }
], ],
"weekly": [ "weekly": [
@@ -32783,7 +32919,7 @@
"volume": 722493.0 "volume": 722493.0
} }
], ],
"updated_at": 1783051344.573262 "updated_at": 1783056692.3502543
}, },
"300308": { "300308": {
"daily": [ "daily": [
@@ -33746,6 +33882,14 @@
"high": 1198.0, "high": 1198.0,
"low": 1127.4, "low": 1127.4,
"volume": 317620.0 "volume": 317620.0
},
{
"date": "2026-07-03",
"open": 1130.0,
"close": 1158.88,
"high": 1188.01,
"low": 1124.0,
"volume": 230014.0
} }
], ],
"weekly": [ "weekly": [
@@ -34040,7 +34184,7 @@
"volume": 389058.0 "volume": 389058.0
} }
], ],
"updated_at": 1783051680.1109805 "updated_at": 1783056770.5614996
}, },
"300548": { "300548": {
"daily": [ "daily": [
@@ -35003,6 +35147,14 @@
"high": 245.0, "high": 245.0,
"low": 220.0, "low": 220.0,
"volume": 174041.0 "volume": 174041.0
},
{
"date": "2026-07-03",
"open": 220.0,
"close": 228.99,
"high": 231.55,
"low": 218.99,
"volume": 102806.0
} }
], ],
"weekly": [ "weekly": [
@@ -35297,7 +35449,7 @@
"volume": 242727.0 "volume": 242727.0
} }
], ],
"updated_at": 1783051685.6375031 "updated_at": 1783056775.5768287
}, },
"300750": { "300750": {
"daily": [ "daily": [
@@ -36260,6 +36412,14 @@
"high": 390.99, "high": 390.99,
"low": 380.39, "low": 380.39,
"volume": 340754.0 "volume": 340754.0
},
{
"date": "2026-07-03",
"open": 381.96,
"close": 381.17,
"high": 387.95,
"low": 380.55,
"volume": 157731.0
} }
], ],
"weekly": [ "weekly": [
@@ -36554,7 +36714,7 @@
"volume": 551212.0 "volume": 551212.0
} }
], ],
"updated_at": 1783051690.893728 "updated_at": 1783056780.5942247
}, },
"301308": { "301308": {
"daily": [ "daily": [
@@ -37517,6 +37677,14 @@
"high": 636.99, "high": 636.99,
"low": 592.0, "low": 592.0,
"volume": 208005.0 "volume": 208005.0
},
{
"date": "2026-07-03",
"open": 589.0,
"close": 634.0,
"high": 646.85,
"low": 574.1,
"volume": 153435.0
} }
], ],
"weekly": [ "weekly": [
@@ -37811,7 +37979,7 @@
"volume": 296230.0 "volume": 296230.0
} }
], ],
"updated_at": 1783051380.6922731 "updated_at": 1783056696.8459098
}, },
"518880": { "518880": {
"daily": [ "daily": [
@@ -38774,6 +38942,14 @@
"high": 8.484, "high": 8.484,
"low": 8.397, "low": 8.397,
"volume": 5149790.0 "volume": 5149790.0
},
{
"date": "2026-07-03",
"open": 8.71,
"close": 8.68,
"high": 8.73,
"low": 8.68,
"volume": 2689807.0
} }
], ],
"weekly": [ "weekly": [
@@ -39068,7 +39244,7 @@
"volume": 3915247.0 "volume": 3915247.0
} }
], ],
"updated_at": 1783051695.915503 "updated_at": 1783056785.6140218
}, },
"600519": { "600519": {
"daily": [ "daily": [
@@ -40031,6 +40207,14 @@
"high": 1215.52, "high": 1215.52,
"low": 1190.51, "low": 1190.51,
"volume": 50870.0 "volume": 50870.0
},
{
"date": "2026-07-03",
"open": 1205.24,
"close": 1189.52,
"high": 1210.14,
"low": 1188.0,
"volume": 23088.0
} }
], ],
"weekly": [ "weekly": [
@@ -40325,7 +40509,7 @@
"volume": 64803.0 "volume": 64803.0
} }
], ],
"updated_at": 1783051561.7579694 "updated_at": 1783056699.260756
}, },
"600563": { "600563": {
"daily": [ "daily": [
@@ -41288,6 +41472,14 @@
"high": 173.3, "high": 173.3,
"low": 164.3, "low": 164.3,
"volume": 112859.0 "volume": 112859.0
},
{
"date": "2026-07-03",
"open": 160.27,
"close": 159.59,
"high": 163.68,
"low": 153.52,
"volume": 75944.0
} }
], ],
"weekly": [ "weekly": [
@@ -41582,7 +41774,7 @@
"volume": 180947.0 "volume": 180947.0
} }
], ],
"updated_at": 1783051700.9725504 "updated_at": 1783056791.4344049
}, },
"601318": { "601318": {
"daily": [ "daily": [
@@ -42545,6 +42737,14 @@
"high": 50.2, "high": 50.2,
"low": 48.8, "low": 48.8,
"volume": 920130.0 "volume": 920130.0
},
{
"date": "2026-07-03",
"open": 49.5,
"close": 48.43,
"high": 49.78,
"low": 48.41,
"volume": 556535.0
} }
], ],
"weekly": [ "weekly": [
@@ -42839,7 +43039,7 @@
"volume": 1746202.0 "volume": 1746202.0
} }
], ],
"updated_at": 1783051575.149798 "updated_at": 1783056700.660319
}, },
"601899": { "601899": {
"daily": [ "daily": [
@@ -43802,6 +44002,14 @@
"high": 26.96, "high": 26.96,
"low": 25.52, "low": 25.52,
"volume": 5067417.0 "volume": 5067417.0
},
{
"date": "2026-07-03",
"open": 27.5,
"close": 27.93,
"high": 28.3,
"low": 27.41,
"volume": 4183914.0
} }
], ],
"weekly": [ "weekly": [
@@ -44096,7 +44304,7 @@
"volume": 4780454.0 "volume": 4780454.0
} }
], ],
"updated_at": 1783051705.9927075 "updated_at": 1783056796.4605315
}, },
"688411": { "688411": {
"daily": [ "daily": [
@@ -45059,6 +45267,14 @@
"high": 272.99, "high": 272.99,
"low": 254.8, "low": 254.8,
"volume": 6368531.0 "volume": 6368531.0
},
{
"date": "2026-07-03",
"open": 255.8,
"close": 255.99,
"high": 261.48,
"low": 251.0,
"volume": 2572727.0
} }
], ],
"weekly": [ "weekly": [
@@ -45353,7 +45569,7 @@
"volume": 13672788.0 "volume": 13672788.0
} }
], ],
"updated_at": 1783051711.208435 "updated_at": 1783056801.4764674
}, },
"688630": { "688630": {
"daily": [ "daily": [
@@ -46316,6 +46532,14 @@
"high": 499.95, "high": 499.95,
"low": 464.8, "low": 464.8,
"volume": 5841815.0 "volume": 5841815.0
},
{
"date": "2026-07-03",
"open": 467.98,
"close": 497.0,
"high": 502.0,
"low": 444.55,
"volume": 4259432.0
} }
], ],
"weekly": [ "weekly": [
@@ -46610,7 +46834,7 @@
"volume": 9660790.0 "volume": 9660790.0
} }
], ],
"updated_at": 1783051587.7339969 "updated_at": 1783056704.6600807
}, },
"688639": { "688639": {
"daily": [ "daily": [
@@ -47573,6 +47797,14 @@
"high": 17.4, "high": 17.4,
"low": 15.98, "low": 15.98,
"volume": 9065955.0 "volume": 9065955.0
},
{
"date": "2026-07-03",
"open": 17.06,
"close": 16.71,
"high": 17.2,
"low": 16.52,
"volume": 4669797.0
} }
], ],
"weekly": [ "weekly": [
@@ -47867,7 +48099,7 @@
"volume": 13996588.0 "volume": 13996588.0
} }
], ],
"updated_at": 1783051733.2346125 "updated_at": 1783056806.4868052
}, },
"688802": { "688802": {
"daily": [ "daily": [
@@ -48830,6 +49062,14 @@
"high": 784.0, "high": 784.0,
"low": 721.0, "low": 721.0,
"volume": 2036024.0 "volume": 2036024.0
},
{
"date": "2026-07-03",
"open": 731.34,
"close": 741.27,
"high": 757.88,
"low": 713.0,
"volume": 1323959.0
} }
], ],
"weekly": [ "weekly": [
@@ -49092,6 +49332,6 @@
"volume": 3202146.0 "volume": 3202146.0
} }
], ],
"updated_at": 1783051743.6900556 "updated_at": 1783056714.5052435
} }
} }
+73 -73
View File
@@ -5,9 +5,9 @@
"name": "中际旭创", "name": "中际旭创",
"shares": 100, "shares": 100,
"cost": 1316.53, "cost": 1316.53,
"price": 1157.97, "price": 1145.0,
"market_value": 115797.0, "market_value": 114101.0,
"change_pct": 1.31, "change_pct": 0.17,
"currency": "CNY", "currency": "CNY",
"position_pct": 15.27, "position_pct": 15.27,
"_currency": "CNY" "_currency": "CNY"
@@ -18,32 +18,32 @@
"shares": 500, "shares": 500,
"cost": 228.65, "cost": 228.65,
"price": 178.26, "price": 178.26,
"market_value": 102700.0, "market_value": 89910.0,
"change_pct": 3.859, "change_pct": 3.859,
"currency": "HKD", "currency": "CNY",
"position_pct": 13.47, "position_pct": 13.47,
"_currency": "HKD" "_currency": "CNY"
}, },
{ {
"code": "01478", "code": "01478",
"name": "丘钛科技", "name": "丘钛科技",
"shares": 11000, "shares": 11000,
"cost": 11.68, "cost": 11.68,
"price": 6.11, "price": 6.1,
"market_value": 77550.0, "market_value": 67210.0,
"change_pct": 4.911, "change_pct": 4.762,
"currency": "HKD", "currency": "CNY",
"position_pct": 7.97, "position_pct": 7.97,
"_currency": "HKD" "_currency": "CNY"
}, },
{ {
"code": "601899", "code": "601899",
"name": "紫金矿业", "name": "紫金矿业",
"shares": 2400, "shares": 2400,
"cost": 39.89, "cost": 39.89,
"price": 28.04, "price": 27.8,
"market_value": 67296.0, "market_value": 66864.0,
"change_pct": 6.62, "change_pct": 5.7,
"currency": "CNY", "currency": "CNY",
"position_pct": 7.34, "position_pct": 7.34,
"_currency": "CNY" "_currency": "CNY"
@@ -53,9 +53,9 @@
"name": "海博思创", "name": "海博思创",
"shares": 200, "shares": 200,
"cost": 266.95, "cost": 266.95,
"price": 257.52, "price": 254.75,
"market_value": 51504.0, "market_value": 50842.0,
"change_pct": 0.71, "change_pct": -0.38,
"currency": "CNY", "currency": "CNY",
"position_pct": 6.31, "position_pct": 6.31,
"_currency": "CNY" "_currency": "CNY"
@@ -65,9 +65,9 @@
"name": "中芯国际", "name": "中芯国际",
"shares": 300, "shares": 300,
"cost": 126.07, "cost": 126.07,
"price": 143.65, "price": 142.97,
"market_value": 43095.0, "market_value": 42600.0,
"change_pct": -0.31, "change_pct": -0.78,
"currency": "CNY", "currency": "CNY",
"position_pct": 5.44, "position_pct": 5.44,
"_currency": "CNY" "_currency": "CNY"
@@ -78,20 +78,20 @@
"shares": 500, "shares": 500,
"cost": 76.5, "cost": 76.5,
"price": 74.17, "price": 74.17,
"market_value": 42600.0, "market_value": 37325.0,
"change_pct": 2.088, "change_pct": 2.088,
"currency": "HKD", "currency": "CNY",
"position_pct": 5.28, "position_pct": 5.28,
"_currency": "HKD" "_currency": "CNY"
}, },
{ {
"code": "688639", "code": "688639",
"name": "华恒生物", "name": "华恒生物",
"shares": 2800, "shares": 2800,
"cost": 21.51, "cost": 21.51,
"price": 16.69, "price": 16.72,
"market_value": 46732.0, "market_value": 46900.0,
"change_pct": -1.53, "change_pct": -1.36,
"currency": "CNY", "currency": "CNY",
"position_pct": 5.25, "position_pct": 5.25,
"_currency": "CNY" "_currency": "CNY"
@@ -101,9 +101,9 @@
"name": "宁德时代", "name": "宁德时代",
"shares": 100, "shares": 100,
"cost": 401.78, "cost": 401.78,
"price": 384.16, "price": 380.24,
"market_value": 38416.0, "market_value": 38032.0,
"change_pct": 0.47, "change_pct": -0.55,
"currency": "CNY", "currency": "CNY",
"position_pct": 4.64, "position_pct": 4.64,
"_currency": "CNY" "_currency": "CNY"
@@ -113,57 +113,57 @@
"name": "比亚迪股份", "name": "比亚迪股份",
"shares": 600, "shares": 600,
"cost": 90.92, "cost": 90.92,
"price": 71.66, "price": 71.83,
"market_value": 49560.0, "market_value": 43044.0,
"change_pct": 5.556, "change_pct": 5.811,
"currency": "HKD", "currency": "CNY",
"position_pct": 4.62, "position_pct": 4.62,
"_currency": "HKD" "_currency": "CNY"
}, },
{ {
"code": "02202", "code": "02202",
"name": "万科企业", "name": "万科企业",
"shares": 19700, "shares": 19700,
"cost": 4.05, "cost": 4.05,
"price": 2.02, "price": 1.99,
"market_value": 45704.0, "market_value": 39203.0,
"change_pct": 4.484, "change_pct": 2.69,
"currency": "HKD", "currency": "CNY",
"position_pct": 4.6, "position_pct": 4.6,
"_currency": "HKD" "_currency": "CNY"
}, },
{ {
"code": "00700", "code": "00700",
"name": "腾讯", "name": "腾讯",
"shares": 100, "shares": 100,
"cost": null, "cost": null,
"price": 379.05, "price": 376.8,
"market_value": 43700.0, "market_value": 37541.0,
"change_pct": 1.627, "change_pct": 1.023,
"currency": "HKD", "currency": "CNY",
"position_pct": null, "position_pct": null,
"_currency": "HKD" "_currency": "CNY"
}, },
{ {
"code": "00981", "code": "00981",
"name": "中芯国际", "name": "中芯国际",
"shares": 500, "shares": 500,
"cost": 65.84, "cost": 65.84,
"price": 69.1, "price": 68.41,
"market_value": 39825.0, "market_value": 34440.0,
"change_pct": -0.871, "change_pct": -1.866,
"currency": "HKD", "currency": "CNY",
"position_pct": 4.2, "position_pct": 4.2,
"_currency": "HKD" "_currency": "CNY"
}, },
{ {
"code": "300548", "code": "300548",
"name": "长芯博创", "name": "长芯博创",
"shares": 100, "shares": 100,
"cost": 231.46, "cost": 231.46,
"price": 227.0, "price": 226.5,
"market_value": 22700.0, "market_value": 22599.0,
"change_pct": 2.25, "change_pct": 2.03,
"currency": "CNY", "currency": "CNY",
"position_pct": 3.2, "position_pct": 3.2,
"_currency": "CNY" "_currency": "CNY"
@@ -173,9 +173,9 @@
"name": "黄金ETF华安", "name": "黄金ETF华安",
"shares": 2400, "shares": 2400,
"cost": 12.19, "cost": 12.19,
"price": 8.69, "price": 8.67,
"market_value": 20856.0, "market_value": 20832.0,
"change_pct": 2.49, "change_pct": 2.32,
"currency": "CNY", "currency": "CNY",
"position_pct": 2.45, "position_pct": 2.45,
"_currency": "CNY" "_currency": "CNY"
@@ -185,9 +185,9 @@
"name": "中科电气", "name": "中科电气",
"shares": 1400, "shares": 1400,
"cost": 22.29, "cost": 22.29,
"price": 14.3, "price": 14.38,
"market_value": 20020.0, "market_value": 20062.0,
"change_pct": 0.92, "change_pct": 1.48,
"currency": "CNY", "currency": "CNY",
"position_pct": 2.42, "position_pct": 2.42,
"_currency": "CNY" "_currency": "CNY"
@@ -197,9 +197,9 @@
"name": "模塑科技", "name": "模塑科技",
"shares": 1400, "shares": 1400,
"cost": 14.83, "cost": 14.83,
"price": 17.37, "price": 17.62,
"market_value": 24318.0, "market_value": 25088.0,
"change_pct": 2.96, "change_pct": 4.45,
"currency": "CNY", "currency": "CNY",
"position_pct": 2.41, "position_pct": 2.41,
"_currency": "CNY" "_currency": "CNY"
@@ -209,9 +209,9 @@
"name": "法拉电子", "name": "法拉电子",
"shares": 100, "shares": 100,
"cost": 147.18, "cost": 147.18,
"price": 161.71, "price": 160.0,
"market_value": 16171.0, "market_value": 15900.0,
"change_pct": -1.58, "change_pct": -2.62,
"currency": "CNY", "currency": "CNY",
"position_pct": 2.3, "position_pct": 2.3,
"_currency": "CNY" "_currency": "CNY"
@@ -221,20 +221,20 @@
"name": "中国神华", "name": "中国神华",
"shares": 500, "shares": 500,
"cost": 39.79, "cost": 39.79,
"price": 34.84, "price": 34.59,
"market_value": 20090.0, "market_value": 17305.0,
"change_pct": 1.465, "change_pct": 0.758,
"currency": "HKD", "currency": "CNY",
"position_pct": 2.14, "position_pct": 2.14,
"_currency": "HKD" "_currency": "CNY"
} }
], ],
"total_assets": 864781.03, "total_assets": 860913.13,
"total_mv": 784305.03, "total_mv": 780437.13,
"stock_value": null, "stock_value": null,
"cash": 80476.0, "cash": 80476.0,
"frozen_cash": 0.0, "frozen_cash": 0.0,
"position_pct": 90.69, "position_pct": 90.65,
"currency": "CNY", "currency": "CNY",
"updated_at": "2026-07-03 12:10" "updated_at": "2026-07-03 13:42"
} }
+20
View File
@@ -9109,6 +9109,26 @@
"event_label": "止盈区间", "event_label": "止盈区间",
"timestamp": "2026-07-03T11:54:31.001389", "timestamp": "2026-07-03T11:54:31.001389",
"date": "2026-07-03" "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"
} }
] ]
} }
+16 -16
View File
@@ -25,8 +25,8 @@
{ {
"date": "2026-07-03", "date": "2026-07-03",
"high": 1215.52, "high": 1215.52,
"low": 1190.5, "low": 1188.0,
"close": 1191.31 "close": 1188.7
} }
], ],
"02202": [ "02202": [
@@ -61,8 +61,8 @@
{ {
"date": "2026-07-03", "date": "2026-07-03",
"high": 50.2, "high": 50.2,
"low": 48.6, "low": 48.4,
"close": 48.7 "close": 48.44
} }
], ],
"02359": [ "02359": [
@@ -118,7 +118,7 @@
"date": "2026-07-03", "date": "2026-07-03",
"high": 502.0, "high": 502.0,
"low": 444.55, "low": 444.55,
"close": 495.95 "close": 494.57
} }
], ],
"06160": [ "06160": [
@@ -154,7 +154,7 @@
"date": "2026-07-03", "date": "2026-07-03",
"high": 687.04, "high": 687.04,
"low": 633.01, "low": 633.01,
"close": 654.0 "close": 646.83
} }
], ],
"09868": [ "09868": [
@@ -194,9 +194,9 @@
}, },
{ {
"date": "2026-07-03", "date": "2026-07-03",
"high": 753.88, "high": 757.88,
"low": 713.0, "low": 713.0,
"close": 744.0 "close": 733.9
} }
], ],
"300124": [ "300124": [
@@ -208,9 +208,9 @@
}, },
{ {
"date": "2026-07-03", "date": "2026-07-03",
"high": 72.38, "high": 74.63,
"low": 67.31, "low": 67.31,
"close": 72.29 "close": 73.13
} }
], ],
"000657": [ "000657": [
@@ -224,7 +224,7 @@
"date": "2026-07-03", "date": "2026-07-03",
"high": 101.5, "high": 101.5,
"low": 87.88, "low": 87.88,
"close": 91.88 "close": 91.78
} }
], ],
"000711": [ "000711": [
@@ -250,9 +250,9 @@
}, },
{ {
"date": "2026-07-03", "date": "2026-07-03",
"high": 882.5, "high": 892.1,
"low": 795.0, "low": 795.0,
"close": 874.23 "close": 887.87
} }
], ],
"002594": [ "002594": [
@@ -266,7 +266,7 @@
"date": "2026-07-03", "date": "2026-07-03",
"high": 87.28, "high": 87.28,
"low": 81.9, "low": 81.9,
"close": 86.56 "close": 86.45
} }
], ],
"00700": [ "00700": [
@@ -332,9 +332,9 @@
"301308": [ "301308": [
{ {
"date": "2026-07-03", "date": "2026-07-03",
"high": 631.56, "high": 646.85,
"low": 574.1, "low": 574.1,
"close": 620.0 "close": 635.0
} }
] ]
} }
+63 -81
View File
@@ -1,81 +1,63 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""data_freshness.py — 数据新鲜度校验 """data_freshness.py — 数据新鲜度校验
所有报告管道在生成输出前必须调用 check_fresh()。 所有报告管道在生成输出前必须调用 check_fresh()。
返回 (pass: bool, details: str),如果数据过期则阻止生成操作建议。 返回 (pass: bool, details: str),如果数据过期则阻止生成操作建议。
用法: 用法:
from data_freshness import check_fresh from data_freshness import check_fresh
ok, msg = check_fresh() ok, msg = check_fresh()
if not ok: if not ok:
print(f"⚠️ 数据过期: {msg}") print(f"⚠️ 数据过期: {msg}")
sys.exit(0) # 不生成报告 sys.exit(0) # 不生成报告
校验规则: 校验规则:
- 盘中 (9:30~15:00)price/live_prices.json 必须在 5 分钟内刷新 - 盘中 (9:30~15:00)price/live_prices.json 必须在 5 分钟内刷新
- 盘后 (9:30以前/15:00以后):允许最长 120 分钟 - 盘后 (9:30以前/15:00以后):允许最长 120 分钟
- 周末/节假日:跳过校验 - 周末/节假日:跳过校验
""" """
import json, os import json, os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from mo_data import read_portfolio, read_decisions, read_watchlist 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" # live_prices.json 已废弃,所有数据走 DB
LIVE_PRICES_PATH = "/home/hmo/web-dashboard/data/live_prices.json"
def is_market_hours():
now = datetime.now() def is_market_hours():
if now.weekday() >= 5: # 周六日 now = datetime.now()
return False, "weekend" if now.weekday() >= 5:
t = now.hour * 60 + now.minute return False, "weekend"
if 9*60+30 <= t <= 15*60: t = now.hour * 60 + now.minute
return True, "trading" if 9*60+30 <= t <= 15*60:
return False, "closed" return True, "trading"
return False, "closed"
def check_fresh():
"""返回 (ok: bool, msg: str)""" def check_fresh():
now = datetime.now() """返回 (ok: bool, msg: str)"""
now = datetime.now()
# 先看是不是交易日 in_market, period = is_market_hours()
in_market, period = is_market_hours() max_age_min = 5 if in_market else 120
max_age_min = 5 if in_market else 120 # 主指标:DB portfolio_summary.updated_at
try:
# 主指标:live_prices.json pf = read_portfolio()
if os.path.exists(LIVE_PRICES_PATH): pf_time = pf.get("updated_at", "")
try: if pf_time:
lp = json.load(open(LIVE_PRICES_PATH)) pf_dt = datetime.fromisoformat(pf_time)
lp_time = lp.get("updated_at", "") age = (now - pf_dt).total_seconds() / 60
if not lp_time: if age > max_age_min:
return False, "live_prices.json updated_at 为空" return False, f"数据已 {age:.0f} 分钟未更新(阈值 {max_age_min} 分钟)"
lp_dt = datetime.fromisoformat(lp_time) return True, f"数据新鲜({age:.0f} 分钟前)"
age = (now - lp_dt).total_seconds() / 60 except Exception as e:
if age > max_age_min: return False, f"DB 读取失败: {e}"
return False, f"live_prices.json 已 {age:.0f} 分钟未更新(阈值 {max_age_min} 分钟)"
return True, f"数据新鲜({age:.0f} 分钟前)" return False, "无法获取数据新鲜度"
except Exception as e:
return False, f"live_prices.json 读取失败: {e}"
else: if __name__ == "__main__":
# fallback: portfolio.json ok, msg = check_fresh()
if os.path.exists(PORTFOLIO_PATH): print(f"{'' if ok else ''} {msg}")
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}")
+221 -223
View File
@@ -1,223 +1,221 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""market_watch.py — 行業熱點數據採集,寫入 dashboard data/market.json """market_watch.py — 行業熱點數據採集,寫入 dashboard data/market.json
數據源優先級: 數據源優先級:
後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數)
後端B:同花順 THS / akshare(降級,有漲跌家數+資金流向) 後端B:同花順 THS / akshare(降級,有漲跌家數+資金流向)
注意:當前服務器無法連通東方財富API(已被封禁/域名不可達), 注意:當前服務器無法連通東方財富API(已被封禁/域名不可達),
實際運行時自動降級到同花順 THS 後端。THS 提供90+行業板塊的 實際運行時自動降級到同花順 THS 後端。THS 提供90+行業板塊的
實時漲跌、上漲/下跌家數、淨流入資金等數據,足以滿足需求。 實時漲跌、上漲/下跌家數、淨流入資金等數據,足以滿足需求。
輸出:data/market.json → MoFin Dashboard 市場數據展示 輸出:data/market.json → MoFin Dashboard 市場數據展示
""" """
import json import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from mofin_db import get_conn, init_all_tables, write_market_snapshot from mofin_db import get_conn, init_all_tables, write_market_snapshot
DATA_DIR = Path(__file__).parent / "data" DATA_DIR = Path(__file__).parent / "data"
# ── 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) ── # ── 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) ──
def _fetch_em(url): def _fetch_em(url):
"""通用 EM API 請求""" """通用 EM API 請求"""
import urllib.request import urllib.request
req = urllib.request.Request( req = urllib.request.Request(
url, url,
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
) )
resp = urllib.request.urlopen(req, timeout=10) resp = urllib.request.urlopen(req, timeout=10)
return json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8"))
def fetch_sector_em(): def fetch_sector_em():
"""東方財富行業板塊""" """東方財富行業板塊"""
try: try:
data = _fetch_em( data = _fetch_em(
"https://push2.eastmoney.com/api/qt/clist/get?" "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" "pn=1&pz=60&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2"
) )
return [{ return [{
"name": i["f14"], "name": i["f14"],
"code": i["f12"], "code": i["f12"],
"price": i.get("f2", 0), "price": i.get("f2", 0),
"change": i.get("f3", 0), "change": i.get("f3", 0),
} for i in data.get("data", {}).get("diff", [])] } for i in data.get("data", {}).get("diff", [])]
except Exception: except Exception:
return None return None
def fetch_concept_em(): def fetch_concept_em():
"""東方財富概念板塊""" """東方財富概念板塊"""
try: try:
data = _fetch_em( data = _fetch_em(
"https://push2.eastmoney.com/api/qt/clist/get?" "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" "pn=1&pz=30&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3"
) )
return [{ return [{
"name": i["f14"], "name": i["f14"],
"code": i["f12"], "code": i["f12"],
"change": i.get("f3", 0), "change": i.get("f3", 0),
} for i in data.get("data", {}).get("diff", [])] } for i in data.get("data", {}).get("diff", [])]
except Exception: except Exception:
return None return None
# ── 後端B:同花順 THS / akshare(降級) ── # ── 後端B:同花順 THS / akshare(降級) ──
def fetch_sector_ths(): def fetch_sector_ths():
"""THS 行業板塊(含漲跌家數、資金流向、領漲股)""" """THS 行業板塊(含漲跌家數、資金流向、領漲股)"""
try: try:
import akshare as ak import akshare as ak
df = ak.stock_board_industry_summary_ths() df = ak.stock_board_industry_summary_ths()
return [{ return [{
"name": r["板块"], "name": r["板块"],
"code": "", "code": "",
"price": 0, "price": 0,
"change": float(r.get("涨跌幅", 0)), "change": float(r.get("涨跌幅", 0)),
"volume": float(r.get("总成交量", 0)), "volume": float(r.get("总成交量", 0)),
"turnover": float(r.get("总成交额", 0)), "turnover": float(r.get("总成交额", 0)),
"net_inflow": float(r.get("净流入", 0)), "net_inflow": float(r.get("净流入", 0)),
"up_count": int(r.get("上涨家数", 0)), "up_count": int(r.get("上涨家数", 0)),
"down_count": int(r.get("下跌家数", 0)), "down_count": int(r.get("下跌家数", 0)),
"avg_price": float(r.get("均价", 0)), "avg_price": float(r.get("均价", 0)),
"lead_stock": r.get("领涨股", ""), "lead_stock": r.get("领涨股", ""),
"lead_stock_change": float(r.get("领涨股-涨跌幅", 0)), "lead_stock_change": float(r.get("领涨股-涨跌幅", 0)),
} for _, r in df.iterrows()] } for _, r in df.iterrows()]
except Exception as e: except Exception as e:
print(f"THS行業失敗: {e}", flush=True) print(f"THS行業失敗: {e}", flush=True)
return [] return []
def fetch_concept_ths(): def fetch_concept_ths():
"""THS 概念板塊(僅名稱,無實時漲跌)""" """THS 概念板塊(僅名稱,無實時漲跌)"""
try: try:
import akshare as ak import akshare as ak
df = ak.stock_board_concept_name_ths() df = ak.stock_board_concept_name_ths()
return [{ return [{
"name": r["name"], "name": r["name"],
"code": str(r.get("code", "")), "code": str(r.get("code", "")),
"change": 0, "change": 0,
} for _, r in df.iterrows()] } for _, r in df.iterrows()]
except Exception as e: except Exception as e:
print(f"THS概念失敗: {e}", flush=True) print(f"THS概念失敗: {e}", flush=True)
return [] return []
# ── 輔助函數 ── # ── 輔助函數 ──
def get_market_mood(sectors): def get_market_mood(sectors):
if not sectors: if not sectors:
return "unknown" return "unknown"
ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors) 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" return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish"
def get_market_verdict(up_ratio, mood, sectors): def get_market_verdict(up_ratio, mood, sectors):
"""Return (verdict, reason) based on sector data.""" """Return (verdict, reason) based on sector data."""
if not sectors: if not sectors:
return "unknown", "数据不足" return "unknown", "数据不足"
if up_ratio < 25: if up_ratio < 25:
return "弱势", f"{up_ratio}%板块上涨,{mood}" return "弱势", f"{up_ratio}%板块上涨,{mood}"
elif up_ratio < 40: elif up_ratio < 40:
return "偏弱", f"{up_ratio}%板块上涨,结构分化" return "偏弱", f"{up_ratio}%板块上涨,结构分化"
elif up_ratio < 60: elif up_ratio < 60:
return "均衡", f"{up_ratio}%板块上涨,涨跌均衡" return "均衡", f"{up_ratio}%板块上涨,涨跌均衡"
else: else:
return "强势", f"{up_ratio}%板块上涨,整体走强" return "强势", f"{up_ratio}%板块上涨,整体走强"
def get_hot_sectors(sectors, top_n=3): def get_hot_sectors(sectors, top_n=3):
"""Return sectors with highest positive change as hot sectors.""" """Return sectors with highest positive change as hot sectors."""
hot = [s for s in sectors if s.get("change", 0) > 1.0] hot = [s for s in sectors if s.get("change", 0) > 1.0]
hot.sort(key=lambda s: s.get("change", 0), reverse=True) hot.sort(key=lambda s: s.get("change", 0), reverse=True)
return [{ return [{
"name": s["name"], "name": s["name"],
"change": s.get("change", 0), "change": s.get("change", 0),
"reason": f"板块涨{s.get('change',0):.1f}%" "reason": f"板块涨{s.get('change',0):.1f}%"
} for s in hot[:top_n]] } for s in hot[:top_n]]
def get_danger_sectors(sectors, top_n=3): def get_danger_sectors(sectors, top_n=3):
"""Return sectors with lowest (negative) change as danger sectors.""" """Return sectors with lowest (negative) change as danger sectors."""
danger = [s for s in sectors if s.get("change", 0) < -1.0] danger = [s for s in sectors if s.get("change", 0) < -1.0]
danger.sort(key=lambda s: s.get("change", 0)) danger.sort(key=lambda s: s.get("change", 0))
return [{ return [{
"name": s["name"], "name": s["name"],
"change": s.get("change", 0), "change": s.get("change", 0),
"reason": f"板块跌{s.get('change',0):.1f}%" "reason": f"板块跌{s.get('change',0):.1f}%"
} for s in danger[:top_n]] } for s in danger[:top_n]]
# ── 主流程 ── # ── 主流程 ──
def main(): def main():
# 行業板塊:EM → THS → 兜底 # 行業板塊:EM → THS → 兜底
sectors = fetch_sector_em() sectors = fetch_sector_em()
source = "eastmoney" source = "eastmoney"
if sectors is None: if sectors is None:
sectors = fetch_sector_ths() sectors = fetch_sector_ths()
source = "ths" source = "ths"
# 概念板塊:EM → THS → 空 # 概念板塊:EM → THS → 空
concepts = fetch_concept_em() concepts = fetch_concept_em()
concept_source = "eastmoney" concept_source = "eastmoney"
if concepts is None: if concepts is None:
concepts = fetch_concept_ths() concepts = fetch_concept_ths()
concept_source = "ths" concept_source = "ths"
if not concepts: if not concepts:
concepts = [] concepts = []
concept_source = "unavailable" concept_source = "unavailable"
# 排序 # 排序
sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True) 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_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] top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3]
# 计算大盘数据 # 计算大盘数据
up_ratio = round( up_ratio = round(
sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1 sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1
) )
mood = get_market_mood(sectors) mood = get_market_mood(sectors)
verdict, verdict_reason = get_market_verdict(up_ratio, mood, sectors) verdict, verdict_reason = get_market_verdict(up_ratio, mood, sectors)
market_data = { market_data = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
"source": source, "source": source,
"concept_source": concept_source, "concept_source": concept_source,
"total_sectors": len(sectors), "total_sectors": len(sectors),
"up_ratio": up_ratio, "up_ratio": up_ratio,
"mood": mood, "mood": mood,
"market_verdict": verdict, "market_verdict": verdict,
"verdict_reason": verdict_reason, "verdict_reason": verdict_reason,
"hot_sectors": get_hot_sectors(sectors), "hot_sectors": get_hot_sectors(sectors),
"danger_sectors": get_danger_sectors(sectors), "danger_sectors": get_danger_sectors(sectors),
"top_gainers": top_gainers, "top_gainers": top_gainers,
"top_losers": top_losers, "top_losers": top_losers,
"sectors": sectors, "sectors": sectors,
"concepts": concepts, "concepts": concepts,
} }
DATA_DIR.mkdir(parents=True, exist_ok=True) 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 写入(替代 market.json)──
conn = get_conn()
# ── SQLite 双写 ── init_all_tables(conn)
conn = get_conn() ok, msg, sid = write_market_snapshot(conn, market_data)
init_all_tables(conn) if ok:
ok, msg, sid = write_market_snapshot(conn, market_data) print(f"[DB] {msg}", flush=True)
if ok: else:
print(f"[DB] {msg}", flush=True) print(f"[DB] 写入失败(JSON 不受影响): {msg}", flush=True)
else: conn.close()
print(f"[DB] 写入失败(JSON 不受影响): {msg}", flush=True)
conn.close() # 靜默:只寫文件,不輸出到stdout,避免cron推送
# 靜默:只寫文件,不輸出到stdout,避免cron推送
if __name__ == "__main__":
main()
if __name__ == "__main__":
main()
+67
View File
@@ -387,6 +387,28 @@ def init_all_tables(conn: sqlite3.Connection):
last_scanned_at TEXT, last_scanned_at TEXT,
found_count INTEGER DEFAULT 0 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() 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,) "SELECT * FROM cash_log ORDER BY id DESC LIMIT ?", (limit,)
).fetchall() ).fetchall()
return [dict(r) for r in rows] 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 {}
+642 -640
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -12,7 +12,7 @@ from datetime import datetime
# ── MoFin unified model ────────────────────────────────────────────── # ── MoFin unified model ──────────────────────────────────────────────
from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct 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 from mo_data import read_portfolio, read_decisions, read_watchlist
BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" BREACH_PATH = "/home/hmo/.hermes/zone_breach.json"
@@ -231,14 +231,16 @@ def refresh_data_prices():
prices.update(hk_prices) prices.update(hk_prices)
updated = 0 updated = 0
# 保存全量实时价快照(供报告管道消费,确保分析用最新数据 # 保存全量实时价快照到 DB(替代 live_prices.json
try: try:
live = {"updated_at": datetime.now().isoformat(), "prices": {}} live = {}
for code in all_codes: for code in all_codes:
if code in prices: if code in prices:
p, c, chg = prices[code] p, c, chg = prices[code]
live["prices"][code] = {"price": p, "change_pct": chg} live[code] = {"price": p, "change_pct": chg}
json.dump(live, open("/home/hmo/web-dashboard/data/live_prices.json", "w"), indent=2) conn = get_conn()
write_live_prices(conn, live)
conn.close()
except Exception: except Exception:
pass pass
@@ -663,7 +665,7 @@ def run_once(round_label=""):
# === 3.5 资金流异常检测(2026-06-27 新增)=== # === 3.5 资金流异常检测(2026-06-27 新增)===
try: try:
cf = json.load(open("/home/hmo/web-dashboard/data/capital_flow_cache.json")) cf = read_capital_flow_cache(get_conn())
# 检查所有 active decision 中的资金流异常 # 检查所有 active decision 中的资金流异常
for d in active: for d in active:
code = d["code"] code = d["code"]
+186 -182
View File
@@ -1,182 +1,186 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""capital_flow_collector.py — 个股资金流数据采集器 """capital_flow_collector.py — 个股资金流数据采集器
每30分钟拉一次持仓+自选的超大单/大单/中单/小单资金流向。 每30分钟拉一次持仓+自选的超大单/大单/中单/小单资金流向。
输出到 capital_flow_cache.json 供 price_monitor 和报告使用。 输出到 capital_flow_cache.json 供 price_monitor 和报告使用。
API: push2his.eastmoney.com 个股资金流日线 API: push2his.eastmoney.com 个股资金流日线
""" """
import json, os, sys, time, urllib.request import json, os, sys, time, urllib.request
from datetime import datetime from datetime import datetime
from urllib.request import urlopen, Request from urllib.request import urlopen, Request
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Semaphore from threading import Semaphore
from mo_data import read_portfolio, read_decisions, read_watchlist 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" DATA_DIR = "/home/hmo/web-dashboard/data"
CACHE_PATH = f"{DATA_DIR}/capital_flow_cache.json" DECISIONS_PATH = f"{DATA_DIR}/decisions.json"
CACHE_PATH = f"{DATA_DIR}/capital_flow_cache.json"
UA = "Mozilla/5.0"
# 限速器:最多5个并发,每请求后强制间隔0.3s UA = "Mozilla/5.0"
RATE_LIMIT = Semaphore(5) # 限速器:最多5个并发,每请求后强制间隔0.3s
MIN_INTERVAL = 0.3 RATE_LIMIT = Semaphore(5)
_last_req = 0 MIN_INTERVAL = 0.3
_last_req = 0
def _rate_limited_request(url):
"""带速率限制的HTTP GET,用Semaphore控制并发数""" def _rate_limited_request(url):
global _last_req """带速率限制的HTTP GET,用Semaphore控制并发数"""
with RATE_LIMIT: global _last_req
elapsed = time.time() - _last_req with RATE_LIMIT:
if elapsed < MIN_INTERVAL: elapsed = time.time() - _last_req
time.sleep(MIN_INTERVAL - elapsed) if elapsed < MIN_INTERVAL:
proxy_handler = urllib.request.ProxyHandler({}) time.sleep(MIN_INTERVAL - elapsed)
opener = urllib.request.build_opener(proxy_handler) proxy_handler = urllib.request.ProxyHandler({})
req = Request(url, headers={"User-Agent": UA, "Referer": "https://data.eastmoney.com/"}) opener = urllib.request.build_opener(proxy_handler)
try: req = Request(url, headers={"User-Agent": UA, "Referer": "https://data.eastmoney.com/"})
resp = opener.open(req, timeout=8) try:
_last_req = time.time() resp = opener.open(req, timeout=8)
return json.loads(resp.read().decode("utf-8")) _last_req = time.time()
except Exception: return json.loads(resp.read().decode("utf-8"))
return None except Exception:
return None
# eastmoney secid: 1=上海 0=深圳
def secid(code): # eastmoney secid: 1=上海 0=深圳
code = str(code).strip() def secid(code):
if code.startswith(("6", "9")): code = str(code).strip()
return f"1.{code}" if code.startswith(("6", "9")):
return f"0.{code}" return f"1.{code}"
return f"0.{code}"
def fetch_flow(code, days=5):
"""拉取个股近N日资金流(带限速+代理绕过)""" def fetch_flow(code, days=5):
sid = secid(code) """拉取个股近N日资金流(带限速+代理绕过)"""
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}" sid = secid(code)
data = _rate_limited_request(url) 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}"
if not data: data = _rate_limited_request(url)
return None if not data:
klines = data.get("data", {}).get("klines", []) return None
if not klines: klines = data.get("data", {}).get("klines", [])
return None if not klines:
result = [] return None
for k in klines: result = []
p = k.split(",") for k in klines:
if len(p) >= 7: p = k.split(",")
result.append({ if len(p) >= 7:
"date": p[0], result.append({
"main_net": float(p[1]), # 主力净流入(元) "date": p[0],
"super_large": float(p[2]), # 超大单净流入(元) "main_net": float(p[1]), # 主力净流入(元)
"large": float(p[3]), # 大单净流入(元) "super_large": float(p[2]), # 大单净流入(元)
"medium": float(p[4]), # 单净流入(元) "large": float(p[3]), # 单净流入(元)
"small": float(p[5]), # 单净流入(元) "medium": float(p[4]), # 单净流入(元)
}) "small": float(p[5]), # 小单净流入(元)
return result })
return result
def fetch_flow_intraday(code):
"""拉取当日分时资金流(用于盘中判断)""" 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" sid = secid(code)
try: 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"
resp = urlopen(url, timeout=5) try:
data = json.loads(resp.read().decode("utf-8")) resp = urlopen(url, timeout=5)
klines = data.get("data", {}).get("klines", []) data = json.loads(resp.read().decode("utf-8"))
if not klines: klines = data.get("data", {}).get("klines", [])
return None if not klines:
latest = klines[-1].split(",") return None
return { latest = klines[-1].split(",")
"main_net": float(latest[1]), return {
"super_large": float(latest[2]), "main_net": float(latest[1]),
"large": float(latest[3]), "super_large": float(latest[2]),
} "large": float(latest[3]),
except: }
return None except:
return None
def analyze_flow(flow_data):
"""分析资金流模式""" def analyze_flow(flow_data):
if not flow_data or len(flow_data) < 2: """分析资金流模式"""
return {} if not flow_data or len(flow_data) < 2:
return {}
result = {"alerts": [], "pattern": ""}
result = {"alerts": [], "pattern": ""}
# 最近两日对比
d1 = flow_data[-1] # 最新日 # 最近两日对比
d2 = flow_data[-2] # 前一 d1 = flow_data[-1] # 最新
d2 = flow_data[-2] # 前一日
# 超大单信号
sl1 = d1["super_large"] # 超大单信号
sl2 = d2["super_large"] 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:]) 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: # 1. 主力连续流入
result["pattern"] = "主力持续流入" if main_trend > 50000000 and sl1 > 0 and sl2 > 0:
result["alerts"].append("主力连续3日净流入") result["pattern"] = "主力持续流入"
result["alerts"].append("主力连续3日净流入")
# 2. 超大单突然转向(连续流入→流出 或 流出→流入)
if sl1 * sl2 < 0: # 方向反转 # 2. 超大单突然转向(连续流入→流出 或 流出→流入)
if sl1 > 0 and sl2 < 0: if sl1 * sl2 < 0: # 方向反转
result["pattern"] = "超大单由出转入" if sl1 > 0 and sl2 < 0:
result["alerts"].append("超大单转为净买入(暗示消息即将落地)") result["pattern"] = "超大单由出转入"
elif sl1 < 0 and sl2 > 0: result["alerts"].append("超大单转为净买入(暗示消息即将落地)")
result["pattern"] = "超大单由入转出" elif sl1 < 0 and sl2 > 0:
result["alerts"].append("超大单转为净卖出(利好出货嫌疑)") result["pattern"] = "超大单由入转出"
result["alerts"].append("超大单转为净卖出(利好出货嫌疑)")
# 3. 价格与资金流背离(缺当前价格作比较,在主脚本中完成)
# 4. 单日暴量 # 3. 价格与资金流背离(缺当前价格作比较,在主脚本中完成)
max_sl = max(abs(d["super_large"]) for d in flow_data) # 4. 单日暴量
if max_sl == abs(sl1) and abs(sl1) > 100000000: max_sl = max(abs(d["super_large"]) for d in flow_data)
result["pattern"] = "单日资金暴量" if max_sl == abs(sl1) and abs(sl1) > 100000000:
result["alerts"].append(f"今日超大单异常: {sl1/100000000:.2f}亿") result["pattern"] = "单日资金暴量"
result["alerts"].append(f"今日超大单异常: {sl1/100000000:.2f}亿")
return result
return result
def main():
codes = set() def main():
# 读取持仓+自选 codes = set()
try: # 读取持仓+自选
dec = mo_data.read_decisions() try:
for d in dec.get("decisions", []): dec = mo_data.read_decisions()
c = d.get("code", "") for d in dec.get("decisions", []):
if c: c = d.get("code", "")
codes.add(c) if c:
except: codes.add(c)
pass except:
pass
all_flows = {}
all_flows = {}
# 并行抓取:ThreadPoolExecutor + 内置限速器(Semaphore 5 + 0.3s间隔)
code_list = sorted(codes) # 并行抓取:ThreadPoolExecutor + 内置限速器(Semaphore 5 + 0.3s间隔)
if not code_list: code_list = sorted(codes)
print("[capital_flow] 无代码需要采集") if not code_list:
return print("[capital_flow] 无代码需要采集")
return
def fetch_one(code):
flow = fetch_flow(code, days=5) def fetch_one(code):
if flow: flow = fetch_flow(code, days=5)
analysis = analyze_flow(flow) if flow:
return (code, { analysis = analyze_flow(flow)
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), return (code, {
"flow": flow, "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
"analysis": analysis, "flow": flow,
}) "analysis": analysis,
return (code, None) })
return (code, None)
with ThreadPoolExecutor(max_workers=5) as pool:
futures = {pool.submit(fetch_one, c): c for c in code_list} with ThreadPoolExecutor(max_workers=5) as pool:
for f in as_completed(futures): futures = {pool.submit(fetch_one, c): c for c in code_list}
code, result = f.result() for f in as_completed(futures):
if result: code, result = f.result()
all_flows[code] = result if result:
all_flows[code] = result
# 写缓存
cache = { # 写缓存
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), cache = {
"stocks": all_flows, "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)}只更新完成") # 写 DB(替代 capital_flow_cache.json
conn = get_conn()
if __name__ == "__main__": write_capital_flow_cache(conn, cache)
main() conn.close()
print(f"[capital_flow] {len(all_flows)}/{len(code_list)}只更新完成")
if __name__ == "__main__":
main()
+33
View File
@@ -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!")
+15
View File
@@ -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()
+5 -1
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Remove held stocks from watchlist""" """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 mo_data import read_portfolio, read_decisions, read_watchlist
from mofin_db import get_conn, write_watchlist_stock, write_holding_strategy from mofin_db import get_conn, write_watchlist_stock, write_holding_strategy
+35
View File
@@ -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}%)")
+25
View File
@@ -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")
+36
View File
@@ -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}")
+22
View File
@@ -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)
+49
View File
@@ -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
+38
View File
@@ -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()
+11 -2
View File
@@ -13,6 +13,10 @@ no_agent模式:有发现→输出,无→静默
import json, os, sqlite3, sys, time, urllib.request import json, os, sqlite3, sys, time, urllib.request
from pathlib import Path from pathlib import Path
from datetime import datetime 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 mo_data import read_watchlist
from mofin_db import write_watchlist_stock from mofin_db import write_watchlist_stock
@@ -34,8 +38,13 @@ def clean_proxy():
def fetch_quote(code): def fetch_quote(code):
"""拉行情。DB 优先,腾讯 fallback""" """拉行情。DB 优先,腾讯 fallback"""
# DB 优先 # 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 try:
except: pass 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: 腾讯 # Fallback: 腾讯
try: try:
prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk"