feat: DB-first architecture with lock-safe writes
- price_monitor: writes live prices to both JSON and mofin.db (holdings + live_prices + portfolio_summary) - mofin_db: added execute_with_retry/commit_with_retry with exponential backoff on 'database is locked' - mofin_db: increased timeout 5s->15s, added PRAGMA busy_timeout=15000 - price_monitor retry loop: fixed break-before-if-ok bug (was not retrying on write failure) - DB connection: WAL mode + retry decorator for all write operations - cash sync: preserves DB authoritative cash (JSON cash not pushed to DB) This is the DB-first version. JSON writes remain for dashboard compatibility. Next step: remove JSON writes entirely for full DB-only architecture.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+709
-1063
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
+147
-221
@@ -1,240 +1,166 @@
|
|||||||
{
|
{
|
||||||
"holdings": [
|
"holdings": [
|
||||||
{
|
|
||||||
"code": "300308",
|
|
||||||
"name": "中际旭创",
|
|
||||||
"shares": 100,
|
|
||||||
"cost": 1316.53,
|
|
||||||
"price": 1116.0,
|
|
||||||
"market_value": 111600.0,
|
|
||||||
"change_pct": -2.36,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 15.27,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "06869",
|
|
||||||
"name": "长飞光纤光缆",
|
|
||||||
"shares": 500,
|
|
||||||
"cost": 263.73,
|
|
||||||
"price": 201.2,
|
|
||||||
"market_value": 87220.0,
|
|
||||||
"change_pct": 1.64,
|
|
||||||
"currency": "HKD",
|
|
||||||
"position_pct": 13.47,
|
|
||||||
"_currency": "HKD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "01478",
|
|
||||||
"name": "丘钛科技",
|
|
||||||
"shares": 11000,
|
|
||||||
"cost": 13.47,
|
|
||||||
"price": 6.99,
|
|
||||||
"market_value": 66660.0,
|
|
||||||
"change_pct": 4.02,
|
|
||||||
"currency": "HKD",
|
|
||||||
"position_pct": 7.97,
|
|
||||||
"_currency": "HKD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "601899",
|
|
||||||
"name": "紫金矿业",
|
|
||||||
"shares": 2400,
|
|
||||||
"cost": 39.89,
|
|
||||||
"price": 27.82,
|
|
||||||
"market_value": 66768.0,
|
|
||||||
"change_pct": 5.78,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 7.34,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "688411",
|
|
||||||
"name": "海博思创",
|
|
||||||
"shares": 200,
|
|
||||||
"cost": 266.95,
|
|
||||||
"price": 251.15,
|
|
||||||
"market_value": 50230.0,
|
|
||||||
"change_pct": -1.78,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 6.31,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "688981",
|
|
||||||
"name": "中芯国际",
|
|
||||||
"shares": 300,
|
|
||||||
"cost": 126.07,
|
|
||||||
"price": 140.31,
|
|
||||||
"market_value": 42093.0,
|
|
||||||
"change_pct": -2.63,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 5.44,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "01888",
|
|
||||||
"name": "建滔积层板",
|
|
||||||
"shares": 500,
|
|
||||||
"cost": 88.24,
|
|
||||||
"price": 84.45,
|
|
||||||
"market_value": 36545.0,
|
|
||||||
"change_pct": 0.78,
|
|
||||||
"currency": "HKD",
|
|
||||||
"position_pct": 5.28,
|
|
||||||
"_currency": "HKD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "688639",
|
|
||||||
"name": "华恒生物",
|
|
||||||
"shares": 2800,
|
|
||||||
"cost": 21.51,
|
|
||||||
"price": 16.66,
|
|
||||||
"market_value": 46648.0,
|
|
||||||
"change_pct": -1.71,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 5.25,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "300750",
|
|
||||||
"name": "宁德时代",
|
|
||||||
"shares": 100,
|
|
||||||
"cost": 401.78,
|
|
||||||
"price": 380.0,
|
|
||||||
"market_value": 38000.0,
|
|
||||||
"change_pct": -0.61,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 4.64,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "01211",
|
|
||||||
"name": "比亚迪股份",
|
|
||||||
"shares": 600,
|
|
||||||
"cost": 104.87,
|
|
||||||
"price": 84.1,
|
|
||||||
"market_value": 43932.0,
|
|
||||||
"change_pct": 7.41,
|
|
||||||
"currency": "HKD",
|
|
||||||
"position_pct": 4.62,
|
|
||||||
"_currency": "HKD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "02202",
|
|
||||||
"name": "万科企业",
|
|
||||||
"shares": 19700,
|
|
||||||
"cost": 4.67,
|
|
||||||
"price": 2.34,
|
|
||||||
"market_value": 40188.0,
|
|
||||||
"change_pct": 4.93,
|
|
||||||
"currency": "HKD",
|
|
||||||
"position_pct": 4.6,
|
|
||||||
"_currency": "HKD"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "00700",
|
"code": "00700",
|
||||||
"name": "腾讯",
|
"name": "腾讯",
|
||||||
"shares": 100,
|
"shares": 100,
|
||||||
"cost": 443.13,
|
"cost": 443.13,
|
||||||
"price": 431.2,
|
"price": 447.0,
|
||||||
"market_value": 37437.0,
|
"market_value": 39063.0,
|
||||||
"change_pct": 0.23,
|
"change_pct": 3.66,
|
||||||
"currency": "HKD",
|
"currency": "HKD",
|
||||||
"position_pct": null,
|
"position_pct": null
|
||||||
"_currency": "HKD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "00981",
|
|
||||||
"name": "中芯国际",
|
|
||||||
"shares": 500,
|
|
||||||
"cost": 75.94,
|
|
||||||
"price": 77.6,
|
|
||||||
"market_value": 33530.0,
|
|
||||||
"change_pct": -3.48,
|
|
||||||
"currency": "HKD",
|
|
||||||
"position_pct": 4.2,
|
|
||||||
"_currency": "HKD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "300548",
|
|
||||||
"name": "长芯博创",
|
|
||||||
"shares": 100,
|
|
||||||
"cost": 231.46,
|
|
||||||
"price": 221.01,
|
|
||||||
"market_value": 22101.0,
|
|
||||||
"change_pct": -0.45,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 3.2,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "518880",
|
|
||||||
"name": "黄金ETF华安",
|
|
||||||
"shares": 2400,
|
|
||||||
"cost": 12.19,
|
|
||||||
"price": 8.67,
|
|
||||||
"market_value": 20808.0,
|
|
||||||
"change_pct": 2.32,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 2.45,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "300035",
|
|
||||||
"name": "中科电气",
|
|
||||||
"shares": 1400,
|
|
||||||
"cost": 22.29,
|
|
||||||
"price": 14.29,
|
|
||||||
"market_value": 20006.0,
|
|
||||||
"change_pct": 0.85,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 2.42,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "000700",
|
|
||||||
"name": "模塑科技",
|
|
||||||
"shares": 1400,
|
|
||||||
"cost": 14.83,
|
|
||||||
"price": 17.6,
|
|
||||||
"market_value": 24640.0,
|
|
||||||
"change_pct": 4.33,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 2.41,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "600563",
|
|
||||||
"name": "法拉电子",
|
|
||||||
"shares": 100,
|
|
||||||
"cost": 147.18,
|
|
||||||
"price": 157.06,
|
|
||||||
"market_value": 15706.0,
|
|
||||||
"change_pct": -4.41,
|
|
||||||
"currency": "CNY",
|
|
||||||
"position_pct": 2.3,
|
|
||||||
"_currency": "CNY"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "01088",
|
"code": "01088",
|
||||||
"name": "中国神华",
|
"name": "中国神华",
|
||||||
"shares": 500,
|
"shares": 500,
|
||||||
"cost": 45.89,
|
"cost": 45.89,
|
||||||
"price": 40.0,
|
"price": 40.68,
|
||||||
"market_value": 17355.0,
|
"market_value": 17575.0,
|
||||||
"change_pct": 1.01,
|
"change_pct": 1.7,
|
||||||
"currency": "HKD",
|
"currency": "HKD",
|
||||||
"position_pct": 2.14,
|
"position_pct": 2.14
|
||||||
"_currency": "HKD"
|
},
|
||||||
|
{
|
||||||
|
"code": "01211",
|
||||||
|
"name": "比亚迪股份",
|
||||||
|
"shares": 600,
|
||||||
|
"cost": 104.87,
|
||||||
|
"price": 84.2,
|
||||||
|
"market_value": 44376.0,
|
||||||
|
"change_pct": 0.12,
|
||||||
|
"currency": "HKD",
|
||||||
|
"position_pct": 4.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "01478",
|
||||||
|
"name": "丘钛科技",
|
||||||
|
"shares": 11000,
|
||||||
|
"cost": 13.47,
|
||||||
|
"price": 6.77,
|
||||||
|
"market_value": 64460.0,
|
||||||
|
"change_pct": -3.15,
|
||||||
|
"currency": "HKD",
|
||||||
|
"position_pct": 7.97
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "02202",
|
||||||
|
"name": "万科企业",
|
||||||
|
"shares": 19700,
|
||||||
|
"cost": 4.67,
|
||||||
|
"price": 2.33,
|
||||||
|
"market_value": 40385.0,
|
||||||
|
"change_pct": -0.43,
|
||||||
|
"currency": "HKD",
|
||||||
|
"position_pct": 4.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "300035",
|
||||||
|
"name": "中科电气",
|
||||||
|
"shares": 1400,
|
||||||
|
"cost": 22.29,
|
||||||
|
"price": 14.02,
|
||||||
|
"market_value": 19712.0,
|
||||||
|
"change_pct": -1.89,
|
||||||
|
"currency": "CNY",
|
||||||
|
"position_pct": 2.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "300308",
|
||||||
|
"name": "中际旭创",
|
||||||
|
"shares": 100,
|
||||||
|
"cost": 1316.53,
|
||||||
|
"price": 1116.03,
|
||||||
|
"market_value": 106181.0,
|
||||||
|
"change_pct": 0.0,
|
||||||
|
"currency": "CNY",
|
||||||
|
"position_pct": 15.27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "300750",
|
||||||
|
"name": "宁德时代",
|
||||||
|
"shares": 100,
|
||||||
|
"cost": 401.78,
|
||||||
|
"price": 377.6,
|
||||||
|
"market_value": 37726.0,
|
||||||
|
"change_pct": -0.63,
|
||||||
|
"currency": "CNY",
|
||||||
|
"position_pct": 4.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "518880",
|
||||||
|
"name": "黄金ETF华安",
|
||||||
|
"shares": 2400,
|
||||||
|
"cost": 12.19,
|
||||||
|
"price": 8.66,
|
||||||
|
"market_value": 20832.0,
|
||||||
|
"change_pct": -0.08,
|
||||||
|
"currency": "CNY",
|
||||||
|
"position_pct": 2.45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "600563",
|
||||||
|
"name": "法拉电子",
|
||||||
|
"shares": 100,
|
||||||
|
"cost": 147.18,
|
||||||
|
"price": 153.42,
|
||||||
|
"market_value": 15021.0,
|
||||||
|
"change_pct": -2.32,
|
||||||
|
"currency": "CNY",
|
||||||
|
"position_pct": 2.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "601899",
|
||||||
|
"name": "紫金矿业",
|
||||||
|
"shares": 2400,
|
||||||
|
"cost": 39.89,
|
||||||
|
"price": 28.41,
|
||||||
|
"market_value": 68784.0,
|
||||||
|
"change_pct": 2.12,
|
||||||
|
"currency": "CNY",
|
||||||
|
"position_pct": 7.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "688411",
|
||||||
|
"name": "海博思创",
|
||||||
|
"shares": 200,
|
||||||
|
"cost": 266.95,
|
||||||
|
"price": 262.98,
|
||||||
|
"market_value": 52064.0,
|
||||||
|
"change_pct": 4.71,
|
||||||
|
"currency": "CNY",
|
||||||
|
"position_pct": 6.31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "688639",
|
||||||
|
"name": "华恒生物",
|
||||||
|
"shares": 2800,
|
||||||
|
"cost": 21.51,
|
||||||
|
"price": 16.61,
|
||||||
|
"market_value": 46480.0,
|
||||||
|
"change_pct": -0.3,
|
||||||
|
"currency": "CNY",
|
||||||
|
"position_pct": 5.25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "688981",
|
||||||
|
"name": "中芯国际",
|
||||||
|
"shares": 300,
|
||||||
|
"cost": 126.07,
|
||||||
|
"price": 147.18,
|
||||||
|
"market_value": 40599.0,
|
||||||
|
"change_pct": 4.9,
|
||||||
|
"currency": "CNY",
|
||||||
|
"position_pct": 5.44
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"total_assets": 952877.07,
|
"cash": 289196.0,
|
||||||
"total_mv": 820755.14,
|
"frozen_cash": 0.0,
|
||||||
"stock_value": 820755.14,
|
"total_mv": 619713.76,
|
||||||
"cash": 132121.93,
|
"total_assets": 908909.76,
|
||||||
"frozen_cash": 0,
|
"position_pct": 68.18,
|
||||||
"position_pct": 86.13,
|
|
||||||
"currency": "CNY",
|
"currency": "CNY",
|
||||||
"updated_at": "2026-07-04 09:51"
|
"updated_at": "2026-07-06 11:35:19",
|
||||||
|
"stock_value": 619713.76
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,12 @@
|
|||||||
"high": 1215.52,
|
"high": 1215.52,
|
||||||
"low": 1185.0,
|
"low": 1185.0,
|
||||||
"close": 1194.45
|
"close": 1194.45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 1215.0,
|
||||||
|
"low": 1180.0,
|
||||||
|
"close": 1203.23
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"02202": [
|
"02202": [
|
||||||
@@ -63,6 +69,12 @@
|
|||||||
"high": 50.2,
|
"high": 50.2,
|
||||||
"low": 48.31,
|
"low": 48.31,
|
||||||
"close": 49.09
|
"close": 49.09
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 49.96,
|
||||||
|
"low": 48.81,
|
||||||
|
"close": 49.38
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"02359": [
|
"02359": [
|
||||||
@@ -119,6 +131,12 @@
|
|||||||
"high": 502.0,
|
"high": 502.0,
|
||||||
"low": 444.55,
|
"low": 444.55,
|
||||||
"close": 480.32
|
"close": 480.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 499.0,
|
||||||
|
"low": 448.0,
|
||||||
|
"close": 476.72
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"06160": [
|
"06160": [
|
||||||
@@ -155,6 +173,12 @@
|
|||||||
"high": 687.04,
|
"high": 687.04,
|
||||||
"low": 633.01,
|
"low": 633.01,
|
||||||
"close": 643.81
|
"close": 643.81
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 649.88,
|
||||||
|
"low": 615.0,
|
||||||
|
"close": 649.01
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"09868": [
|
"09868": [
|
||||||
@@ -197,6 +221,12 @@
|
|||||||
"high": 757.88,
|
"high": 757.88,
|
||||||
"low": 713.0,
|
"low": 713.0,
|
||||||
"close": 738.38
|
"close": 738.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 745.0,
|
||||||
|
"low": 690.11,
|
||||||
|
"close": 743.0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"300124": [
|
"300124": [
|
||||||
@@ -211,6 +241,12 @@
|
|||||||
"high": 74.63,
|
"high": 74.63,
|
||||||
"low": 67.31,
|
"low": 67.31,
|
||||||
"close": 72.15
|
"close": 72.15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 71.94,
|
||||||
|
"low": 68.6,
|
||||||
|
"close": 69.55
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"000657": [
|
"000657": [
|
||||||
@@ -225,6 +261,12 @@
|
|||||||
"high": 101.5,
|
"high": 101.5,
|
||||||
"low": 87.88,
|
"low": 87.88,
|
||||||
"close": 89.63
|
"close": 89.63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 90.58,
|
||||||
|
"low": 80.46,
|
||||||
|
"close": 81.83
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"000711": [
|
"000711": [
|
||||||
@@ -239,6 +281,12 @@
|
|||||||
"high": 5.26,
|
"high": 5.26,
|
||||||
"low": 4.87,
|
"low": 4.87,
|
||||||
"close": 5.26
|
"close": 5.26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 5.65,
|
||||||
|
"low": 5.0,
|
||||||
|
"close": 5.29
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"001309": [
|
"001309": [
|
||||||
@@ -253,6 +301,12 @@
|
|||||||
"high": 892.1,
|
"high": 892.1,
|
||||||
"low": 795.0,
|
"low": 795.0,
|
||||||
"close": 881.91
|
"close": 881.91
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 918.98,
|
||||||
|
"low": 860.0,
|
||||||
|
"close": 916.6
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"002594": [
|
"002594": [
|
||||||
@@ -267,6 +321,12 @@
|
|||||||
"high": 88.88,
|
"high": 88.88,
|
||||||
"low": 81.9,
|
"low": 81.9,
|
||||||
"close": 88.47
|
"close": 88.47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 88.95,
|
||||||
|
"low": 86.61,
|
||||||
|
"close": 87.68
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"00700": [
|
"00700": [
|
||||||
@@ -335,6 +395,12 @@
|
|||||||
"high": 646.85,
|
"high": 646.85,
|
||||||
"low": 574.1,
|
"low": 574.1,
|
||||||
"close": 618.02
|
"close": 618.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-06",
|
||||||
|
"high": 710.0,
|
||||||
|
"low": 659.16,
|
||||||
|
"close": 695.26
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -165,6 +165,11 @@
|
|||||||
"time": "2026-07-01T08:55:24.130605",
|
"time": "2026-07-01T08:55:24.130605",
|
||||||
"content": "**港股(恒指-0.63%):** 中芯国际H(00981) 89.40+5.42%浮盈+17%持有 | 腾讯控股(00700) 429.80+2.28%浮亏-3%距止损3.4% | 丘钛科技(014",
|
"content": "**港股(恒指-0.63%):** 中芯国际H(00981) 89.40+5.42%浮盈+17%持有 | 腾讯控股(00700) 429.80+2.28%浮亏-3%距止损3.4% | 丘钛科技(014",
|
||||||
"report_id": "cron_d42f2ce3b479_2026-06-30_20-25-27"
|
"report_id": "cron_d42f2ce3b479_2026-06-30_20-25-27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "| **中芯国际双持(688981+00981)** | A+H 双持合计约 9.64% 仓位,两市场同步净多头,若港股中芯继续走弱(注意 00981 止损 ¥77.55 高于成本 ¥75.94 的异",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -616,6 +616,21 @@
|
|||||||
"content": "- **比亚迪(01211)** — 现价72.65,止损63.99(距12%)。港股汽车板块整体承压,持续下跌趋势未改。建议:重新审视持股逻辑——如果中线看多理由充分,可考虑将止损下移至60附近;如",
|
"content": "- **比亚迪(01211)** — 现价72.65,止损63.99(距12%)。港股汽车板块整体承压,持续下跌趋势未改。建议:重新审视持股逻辑——如果中线看多理由充分,可考虑将止损下移至60附近;如",
|
||||||
"report_id": "cron_e02b8bde74f8_2026-06-28_22-11-21"
|
"report_id": "cron_e02b8bde74f8_2026-06-28_22-11-21"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "3. **0714止盈策略严重过期** — 当前 19 个持仓中有约 13 个(68%)的成本价高于止盈价,说明策略数据已随股价下跌严重偏离,大量止损/止盈位明显陈旧(如万科 02202 成本 ¥4.",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "3. **策略数据翻新** — 针对成本远高于止盈的 13 个持仓(如 万科02202、比亚迪01211、华恒生物688639、中科电气300035、黄金ETF518880 等),建议逐个审核是否需要",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "| **比亚迪(01211)止损临近** | 此前 advice 反复提示止损 ¥81.72、现价距仅 3%。港股比亚迪已是 4.62% 仓位且浮亏 -20.1%,若下周港股继续弱势,止损单需提前挂好",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"time": "2026-06-01T10:25:54.503460",
|
"time": "2026-06-01T10:25:54.503460",
|
||||||
"content": "比亚迪股份(01211) 仓位4.56% +2.27%→ 持有,连涨",
|
"content": "比亚迪股份(01211) 仓位4.56% +2.27%→ 持有,连涨",
|
||||||
|
|||||||
@@ -456,6 +456,16 @@
|
|||||||
"content": "③ 丘钛科技(01478) 距止损6.48仅+7.3%!仓位7.97%",
|
"content": "③ 丘钛科技(01478) 距止损6.48仅+7.3%!仓位7.97%",
|
||||||
"report_id": "cron_d42f2ce3b479_2026-07-03_20-03-44"
|
"report_id": "cron_d42f2ce3b479_2026-07-03_20-03-44"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "2. **收紧输出质量** — 使用\"关注/观望/留意\"时须紧随具体价格、止损位和触发条件。满 3 次模糊表述自动标记为低质量输出。对 01478 和 688795 做一次明确的持仓策略复核:继续持有",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "| **丘钛科技(01478)** | 7.97% 第三大仓位,成本 ¥13.47 远超止盈 ¥7.59(浮亏约 -43%),150 次提及却无追踪结论。无论继续持有还是止损,需 Dad 明确决策 |",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"time": "2026-06-01T10:25:54.503460",
|
"time": "2026-06-01T10:25:54.503460",
|
||||||
"content": "丘钛科技(01478) 仓位8.58% +4.10%→ 持有,走势健康",
|
"content": "丘钛科技(01478) 仓位8.58% +4.10%→ 持有,走势健康",
|
||||||
|
|||||||
@@ -621,6 +621,16 @@
|
|||||||
"content": "- **万科(02202)** — 现价2.20,止损2.00(距9%)。周五港股继续弱,若跌破2.00港币将触发止损。建议:提前制定应对方案,而不是等到触发了再慌。",
|
"content": "- **万科(02202)** — 现价2.20,止损2.00(距9%)。周五港股继续弱,若跌破2.00港币将触发止损。建议:提前制定应对方案,而不是等到触发了再慌。",
|
||||||
"report_id": "cron_e02b8bde74f8_2026-06-28_22-11-21"
|
"report_id": "cron_e02b8bde74f8_2026-06-28_22-11-21"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "3. **0714止盈策略严重过期** — 当前 19 个持仓中有约 13 个(68%)的成本价高于止盈价,说明策略数据已随股价下跌严重偏离,大量止损/止盈位明显陈旧(如万科 02202 成本 ¥4.",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "3. **策略数据翻新** — 针对成本远高于止盈的 13 个持仓(如 万科02202、比亚迪01211、华恒生物688639、中科电气300035、黄金ETF518880 等),建议逐个审核是否需要",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"time": "2026-06-04T09:55:40.300986",
|
"time": "2026-06-04T09:55:40.300986",
|
||||||
"content": "- 万科(02202): 补仓区间2.5~2.6,昨收2.70,距+3.8%,未进入。",
|
"content": "- 万科(02202): 补仓区间2.5~2.6,昨收2.70,距+3.8%,未进入。",
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
"time": "2026-06-28T08:55:52.587605",
|
"time": "2026-06-28T08:55:52.587605",
|
||||||
"content": "6. 🟢 **中国人寿** (02628) — 距买入区-2%,保险板块虽弱但估值低",
|
"content": "6. 🟢 **中国人寿** (02628) — 距买入区-2%,保险板块虽弱但估值低",
|
||||||
"report_id": "cron_watchlist_health_weekly_2026-06-27_20-04-22"
|
"report_id": "cron_watchlist_health_weekly_2026-06-27_20-04-22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-05T08:55:15.087154",
|
||||||
|
"content": "6. 中国人寿(02628) — 接近止损位28.26,保险板块承压",
|
||||||
|
"report_id": "cron_watchlist_health_weekly_2026-07-04_20-13-24"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -725,6 +725,11 @@
|
|||||||
"time": "2026-06-14T08:55:34.460610",
|
"time": "2026-06-14T08:55:34.460610",
|
||||||
"content": "- 中国人寿(02628) & 小鹏汽车(09868) — 盈亏比过低,不建议买入",
|
"content": "- 中国人寿(02628) & 小鹏汽车(09868) — 盈亏比过低,不建议买入",
|
||||||
"report_id": "cron_watchlist_health_weekly_2026-06-13_20-07-41"
|
"report_id": "cron_watchlist_health_weekly_2026-06-13_20-07-41"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-05T08:55:15.087154",
|
||||||
|
"content": "4. 小鹏汽车(09868) — 跌破止损位,汽车行业竞争格局",
|
||||||
|
"report_id": "cron_watchlist_health_weekly_2026-07-04_20-13-24"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -861,6 +861,11 @@
|
|||||||
"content": "{\"type\":\"周复盘\",\"time\":\"周日\",\"summary\":\"A股先跌后弹分化格局,全市场普跌后暴力反弹\",\"key_holdings\":[{\"code\":\"600110\",\"name\":",
|
"content": "{\"type\":\"周复盘\",\"time\":\"周日\",\"summary\":\"A股先跌后弹分化格局,全市场普跌后暴力反弹\",\"key_holdings\":[{\"code\":\"600110\",\"name\":",
|
||||||
"report_id": "cron_e02b8bde74f8_2026-06-14_22-04-57"
|
"report_id": "cron_e02b8bde74f8_2026-06-14_22-04-57"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "2. **海博思创(688411)追踪较好** — 该股在持仓中占比 6.31%,成本 ¥266.95 处于买入区内(¥231.32~¥269.87),参与价合理,后续追踪记录清晰,止盈 ¥277.5",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"time": "2026-06-03T09:56:06.723399",
|
"time": "2026-06-03T09:56:06.723399",
|
||||||
"content": "| 688411 | 海博思创 | 295.00 | 299.17 | -1.39% | 追踪止盈290 | 正常 |",
|
"content": "| 688411 | 海博思创 | 295.00 | 299.17 | -1.39% | 追踪止盈290 | 正常 |",
|
||||||
|
|||||||
@@ -91,6 +91,11 @@
|
|||||||
"content": "- 华恒生物(688639) ¥21.2,买入区20.78~21.4 ✅ 在区间内,RR:2.28",
|
"content": "- 华恒生物(688639) ¥21.2,买入区20.78~21.4 ✅ 在区间内,RR:2.28",
|
||||||
"report_id": "cron_e02b8bde74f8_2026-06-14_22-04-57"
|
"report_id": "cron_e02b8bde74f8_2026-06-14_22-04-57"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "3. **策略数据翻新** — 针对成本远高于止盈的 13 个持仓(如 万科02202、比亚迪01211、华恒生物688639、中科电气300035、黄金ETF518880 等),建议逐个审核是否需要",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"time": "2026-06-01T10:25:54.503460",
|
"time": "2026-06-01T10:25:54.503460",
|
||||||
"content": "华恒生物(688639) 仓位0.47% -1.71%→ 持有,低位整理",
|
"content": "华恒生物(688639) 仓位0.47% -1.71%→ 持有,低位整理",
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
"content": "• **摩尔线程(688795) 616.38** +2.73% | 买入区580-600,偏高→等回调至600以下",
|
"content": "• **摩尔线程(688795) 616.38** +2.73% | 买入区580-600,偏高→等回调至600以下",
|
||||||
"report_id": "cron_d3797d924ddc_2026-06-02_16-33-11"
|
"report_id": "cron_d3797d924ddc_2026-06-02_16-33-11"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "2. **收紧输出质量** — 使用\"关注/观望/留意\"时须紧随具体价格、止损位和触发条件。满 3 次模糊表述自动标记为低质量输出。对 01478 和 688795 做一次明确的持仓策略复核:继续持有",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"time": "2026-06-14T08:55:34.460610",
|
"time": "2026-06-14T08:55:34.460610",
|
||||||
"content": "**🟢 摩尔线程-U(688795) ¥610.55 | 买入区598.34~622.76 ✅ | RR:3.44**",
|
"content": "**🟢 摩尔线程-U(688795) ¥610.55 | 买入区598.34~622.76 ✅ | RR:3.44**",
|
||||||
|
|||||||
@@ -151,6 +151,11 @@
|
|||||||
"content": "② 中芯国际A(688981) 已跌破止损位!仓位5.44%",
|
"content": "② 中芯国际A(688981) 已跌破止损位!仓位5.44%",
|
||||||
"report_id": "cron_d42f2ce3b479_2026-07-03_20-03-44"
|
"report_id": "cron_d42f2ce3b479_2026-07-03_20-03-44"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"time": "2026-07-06T08:55:01.761812",
|
||||||
|
"content": "| **中芯国际双持(688981+00981)** | A+H 双持合计约 9.64% 仓位,两市场同步净多头,若港股中芯继续走弱(注意 00981 止损 ¥77.55 高于成本 ¥75.94 的异",
|
||||||
|
"report_id": "cron_e02b8bde74f8_2026-07-05_22-05-42"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"time": "2026-06-11T08:55:23.441938",
|
"time": "2026-06-11T08:55:23.441938",
|
||||||
"content": "• 中芯国际(688981) 竞价125.00(-1.81%),策略买入区116~136内",
|
"content": "• 中芯国际(688981) 竞价125.00(-1.81%),策略买入区116~136内",
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 策略复盘闭环系统设计
|
||||||
|
|
||||||
|
> 版本: v1 | 最后更新: 2026-06-24
|
||||||
|
> 核心理念:每条策略建议都必须有回头检查,用实际结果驱动策略逻辑进化。
|
||||||
|
|
||||||
|
## 一、现状
|
||||||
|
|
||||||
|
| 环节 | 状态 |
|
||||||
|
|------|------|
|
||||||
|
| 策略生成 | ✅ `strategy_lifecycle.py` 按规则生成买入区/止损/止盈 |
|
||||||
|
| 策略评估 | ✅ 日评估六维分析每条策略的当前状况 |
|
||||||
|
| 策略重评 | ✅ 过期/偏离时自动触发重评 |
|
||||||
|
| **成功率追踪** | ❌ 有 `accuracy_stats` 空表,从未写入 |
|
||||||
|
| **复盘归因** | ❌ 没有"回头看"机制 |
|
||||||
|
| **策略逻辑修正** | ❌ 评估结果从不反馈到生成规则 |
|
||||||
|
| **验证测试** | ❌ 改完规则没有验证环节 |
|
||||||
|
|
||||||
|
## 二、闭环设计
|
||||||
|
|
||||||
|
```
|
||||||
|
生成策略 → 执行/等待 → 回头看(复盘) → 归因分析 → 修正策略逻辑 → 验证 → 部署
|
||||||
|
↑ |
|
||||||
|
└──── 迭代循环 ───────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环节1:回头看(复盘)
|
||||||
|
|
||||||
|
每条策略/建议在生成后 T+5、T+20、T+60 三个时间点回头检查:
|
||||||
|
|
||||||
|
| 检查点 | 时机 | 判断标准 |
|
||||||
|
|--------|------|---------|
|
||||||
|
| T+5 | 5个交易日后 | 止损触发?止盈触发?价格走向是否正确? |
|
||||||
|
| T+20 | 一个月后 | 中期趋势验证。买入区是否有效? |
|
||||||
|
| T+60 | 三个月后 | 长期逻辑验证。大方向判断是否正确? |
|
||||||
|
|
||||||
|
判断分类:
|
||||||
|
- ✅ **正确**:价格朝预期方向走了 > 止损/止盈间距的 50%
|
||||||
|
- ⚠️ **部分正确**:方向对了但幅度不够,或方向对但时机差
|
||||||
|
- ❌ **错误**:方向错了,或止损被打后价格反转了
|
||||||
|
- ⏳ **待定**:还在运行中,未到判断时点
|
||||||
|
|
||||||
|
### 环节2:归因分析
|
||||||
|
|
||||||
|
对 ❌ 错误 和 ⚠️ 部分正确 的做根因分类:
|
||||||
|
|
||||||
|
| 失败模式 | 判断条件 | 修复方向 |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| 止损过紧 | 价格跌破止损 < 3天后回到买入区 | 放宽止损到强支撑 × 0.95 |
|
||||||
|
| 入场过早 | 买入后继续跌 > 入场点 10% 才反弹 | 买入区下移,等缩量确认 |
|
||||||
|
| 止盈过近 | 价格突破止盈 < 5天后继续涨 > 15% | 止盈放到更高阻力位 |
|
||||||
|
| 方向看错 | 价格持续朝反方向走 | 检查多周期趋势判断逻辑 |
|
||||||
|
| 情景错配 | 策略假设的情景与实际不符 | 加入情景过滤条件 |
|
||||||
|
| 信号误判 | timing_signal 信号错误 | 修正信号合成逻辑 |
|
||||||
|
| 行业拖累 | 个股选对了但行业暴跌 | 加入行业动量过滤 |
|
||||||
|
|
||||||
|
### 环节3:策略逻辑修正
|
||||||
|
|
||||||
|
归因结果反馈到策略生成规则的几个层面:
|
||||||
|
|
||||||
|
1. **prompt_manager 规则更新** — 止损/止盈/买入区的生成规则
|
||||||
|
2. **timing_signal 合成权重调整** — 各因子的权重
|
||||||
|
3. **股票分类规则调整** — 什么情况归为弱势/深套/短炒
|
||||||
|
4. **情景判定阈值调整** — detect_scenario 的参数
|
||||||
|
|
||||||
|
### 环节4:回测验证
|
||||||
|
|
||||||
|
修正后的规则用历史数据跑模拟,对比新旧规则的成功率:
|
||||||
|
|
||||||
|
```
|
||||||
|
模拟方式:取过去60天的数据
|
||||||
|
1. 用旧规则生成每条策略 → 计算成功率
|
||||||
|
2. 用新规则生成同样股票的策略 → 计算成功率
|
||||||
|
3. 对比:新规则是否 > 旧规则 +5%?
|
||||||
|
4. 如果是 → 部署新规则
|
||||||
|
5. 如果不是 → 继续调整
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、参考来源
|
||||||
|
|
||||||
|
知识库中有多篇量化分析文章可以参考:
|
||||||
|
- 止损/止盈的统计学最优位置
|
||||||
|
- 多因子信号合成的权重分配方法
|
||||||
|
- 不同市场环境下的策略参数调优
|
||||||
|
- 回测验证的方法论和陷阱(过拟合/幸存者偏差)
|
||||||
|
|
||||||
|
## 四、实施路线
|
||||||
|
|
||||||
|
### Phase 1(本session)
|
||||||
|
- 策略复盘脚本:遍历 active 策略,检查实际结果,写入 accuracy_stats
|
||||||
|
- 归因分析:对失败策略分类失败模式
|
||||||
|
- 初步报告:当前策略整体成功率 + 常见失败模式
|
||||||
|
|
||||||
|
### Phase 2(后续)
|
||||||
|
- 策略逻辑修正:根据归因调整 prompt_manager 规则
|
||||||
|
- 回测验证:用历史数据验证新规则
|
||||||
|
- 知识库文章萃取:从量化分析文章中提取可用因子
|
||||||
|
- 持续迭代:每周跑一次复盘,持续优化
|
||||||
+47
-8
@@ -20,15 +20,22 @@ DB_PATH = '/home/hmo/web-dashboard/data/mofin.db'
|
|||||||
|
|
||||||
|
|
||||||
def _get_db():
|
def _get_db():
|
||||||
db = sqlite3.connect(DB_PATH)
|
"""获取数据库连接(WAL 模式,15秒超时防并发锁)"""
|
||||||
|
db = sqlite3.connect(DB_PATH, timeout=15)
|
||||||
db.row_factory = sqlite3.Row
|
db.row_factory = sqlite3.Row
|
||||||
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
db.execute("PRAGMA busy_timeout=15000")
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
# ── portfolio ─────────────────────────────────────────────────────
|
# ── portfolio ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_portfolio():
|
def read_portfolio():
|
||||||
"""返回 portfolio.json 等价 dict。纯 DB。"""
|
"""返回 portfolio.json 等价 dict。纯 DB。
|
||||||
|
|
||||||
|
总市值从 holdings 实时计算(shares × price,港股 × 汇率),
|
||||||
|
不信任 portfolio_summary 的存储值,因为可能未及时更新。
|
||||||
|
"""
|
||||||
db = _get_db()
|
db = _get_db()
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT code, name, shares, cost, price, market_value, "
|
"SELECT code, name, shares, cost, price, market_value, "
|
||||||
@@ -46,14 +53,46 @@ def read_portfolio():
|
|||||||
|
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# ── 总市值从 holdings 实时计算 ────────────────────
|
||||||
|
cash = float(summary.get('cash', 0) or 0)
|
||||||
|
frozen = float(summary.get('frozen_cash', 0) or 0)
|
||||||
|
|
||||||
|
# 获取港股汇率
|
||||||
|
import subprocess, os as _os
|
||||||
|
rate = 0.865 # 默认值
|
||||||
|
try:
|
||||||
|
hk_rate_py = _os.path.join(_os.path.dirname(_os.path.dirname(__file__)), 'hk_rate.py')
|
||||||
|
if _os.path.exists(hk_rate_py):
|
||||||
|
r = subprocess.run(
|
||||||
|
[_os.path.join(_os.path.dirname(_os.path.dirname(__file__)), 'venv', 'bin', 'python3'),
|
||||||
|
hk_rate_py, '--rate'],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if r.returncode == 0 and r.stdout.strip():
|
||||||
|
rate = float(r.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
total_mv = 0.0
|
||||||
|
for h in holdings:
|
||||||
|
p = float(h.get('price', 0) or 0)
|
||||||
|
s = float(h.get('shares', 0) or 0)
|
||||||
|
if h.get('currency') == 'HKD' or (len(str(h.get('code',''))) == 5 and str(h.get('code',''))[0] in ('0','1')):
|
||||||
|
total_mv += s * p * rate
|
||||||
|
else:
|
||||||
|
total_mv += s * p
|
||||||
|
total_mv = round(total_mv, 2)
|
||||||
|
total_assets = round(total_mv + cash + frozen, 2)
|
||||||
|
position_pct = round(total_mv / total_assets * 100, 2) if total_assets > 0 else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"holdings": holdings,
|
"holdings": holdings,
|
||||||
"total_assets": summary.get("total_assets", 0),
|
"total_assets": total_assets,
|
||||||
"total_mv": summary.get("total_mv", 0),
|
"total_mv": total_mv,
|
||||||
"stock_value": summary.get("stock_value", summary.get("total_mv", 0)),
|
"stock_value": total_mv,
|
||||||
"cash": summary.get("cash", 0),
|
"cash": cash,
|
||||||
"frozen_cash": summary.get("frozen_cash", 0),
|
"frozen_cash": frozen,
|
||||||
"position_pct": summary.get("position_pct", 0),
|
"position_pct": position_pct,
|
||||||
"currency": summary.get("currency", "CNY"),
|
"currency": summary.get("currency", "CNY"),
|
||||||
"updated_at": summary.get("updated_at", ""),
|
"updated_at": summary.get("updated_at", ""),
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-5
@@ -15,9 +15,11 @@
|
|||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
import functools
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Callable
|
||||||
|
|
||||||
DATA_DIR = Path(__file__).parent / "data"
|
DATA_DIR = Path(__file__).parent / "data"
|
||||||
DB_PATH = DATA_DIR / "mofin.db"
|
DB_PATH = DATA_DIR / "mofin.db"
|
||||||
@@ -27,15 +29,87 @@ DB_PATH = DATA_DIR / "mofin.db"
|
|||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def get_conn() -> sqlite3.Connection:
|
def get_conn() -> sqlite3.Connection:
|
||||||
"""获取数据库连接(WAL 模式,外键约束,Row 工厂,5秒超时防并发锁)"""
|
"""获取数据库连接(WAL 模式,外键约束,Row 工厂,30秒超时防并发锁)"""
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(str(DB_PATH), timeout=5)
|
conn = sqlite3.connect(str(DB_PATH), timeout=30)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def execute_with_retry(conn: sqlite3.Connection, sql: str, params: tuple = (),
|
||||||
|
max_retries: int = 3, base_delay: float = 1.0) -> sqlite3.Cursor:
|
||||||
|
"""执行SQL并自动重试(捕获 database is locked)"""
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return conn.execute(sql, params)
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
if "database is locked" not in str(e) and "cannot commit" not in str(e):
|
||||||
|
raise # 非锁错误直接抛
|
||||||
|
last_err = e
|
||||||
|
if attempt < max_retries:
|
||||||
|
delay = base_delay * (2 ** attempt) # 指数退避: 1s, 2s, 4s
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
raise sqlite3.OperationalError(
|
||||||
|
f"DB锁重试{max_retries}次仍失败: {e}"
|
||||||
|
)
|
||||||
|
# unreachable -- both paths in loop either return or raise
|
||||||
|
if last_err:
|
||||||
|
raise last_err # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
def commit_with_retry(conn: sqlite3.Connection, max_retries: int = 3,
|
||||||
|
base_delay: float = 1.0) -> None:
|
||||||
|
"""提交事务并自动重试"""
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
if "database is locked" not in str(e) and "cannot commit" not in str(e):
|
||||||
|
raise
|
||||||
|
last_err = e
|
||||||
|
if attempt < max_retries:
|
||||||
|
delay = base_delay * (2 ** attempt)
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
raise sqlite3.OperationalError(
|
||||||
|
f"DB提交重试{max_retries}次仍失败: {e}"
|
||||||
|
)
|
||||||
|
raise last_err
|
||||||
|
|
||||||
|
|
||||||
|
def retry_db_write(func: Callable) -> Callable:
|
||||||
|
"""装饰器:为 DB 写函数自动添加重试"""
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
max_retries = 3
|
||||||
|
base_delay = 1.0
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
if "database is locked" not in str(e) and "cannot commit" not in str(e):
|
||||||
|
raise
|
||||||
|
last_err = e
|
||||||
|
if attempt < max_retries:
|
||||||
|
delay = base_delay * (2 ** attempt)
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
raise sqlite3.OperationalError(
|
||||||
|
f"DB写重试{max_retries}次仍失败({func.__name__}): {e}"
|
||||||
|
)
|
||||||
|
raise last_err
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
# 建表(幂等)
|
# 建表(幂等)
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
@@ -1058,6 +1132,7 @@ def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool
|
|||||||
def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]:
|
def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]:
|
||||||
"""批量写入持仓(替代 portfolio.json holdings[])"""
|
"""批量写入持仓(替代 portfolio.json holdings[])"""
|
||||||
try:
|
try:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
for h in holdings:
|
for h in holdings:
|
||||||
currency = str(h.get('currency', 'CNY')).upper()
|
currency = str(h.get('currency', 'CNY')).upper()
|
||||||
if currency not in ('CNY', 'HKD'):
|
if currency not in ('CNY', 'HKD'):
|
||||||
@@ -1082,11 +1157,12 @@ def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]:
|
|||||||
except sqlite3.IntegrityError as e:
|
except sqlite3.IntegrityError as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return False, f"币种约束: {e}"
|
return False, f"币种约束: {e}"
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return False, f"DB锁冲突(重试耗尽): {e}"
|
||||||
def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]:
|
def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]:
|
||||||
"""写入持仓汇总(替代 portfolio.json 顶层)"""
|
"""写入持仓汇总(替代 portfolio.json 顶层)"""
|
||||||
try:
|
try:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value,
|
INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value,
|
||||||
cash, frozen_cash, position_pct, total_pnl, currency, updated_at)
|
cash, frozen_cash, position_pct, total_pnl, currency, updated_at)
|
||||||
@@ -1106,6 +1182,8 @@ def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]:
|
|||||||
return True, "汇总已写入"
|
return True, "汇总已写入"
|
||||||
except sqlite3.IntegrityError as e:
|
except sqlite3.IntegrityError as e:
|
||||||
return False, f"约束: {e}"
|
return False, f"约束: {e}"
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return False, f"DB锁冲突: {e}"
|
||||||
|
|
||||||
|
|
||||||
def write_watchlist_stock(conn, stock: dict) -> tuple[bool, str]:
|
def write_watchlist_stock(conn, stock: dict) -> tuple[bool, str]:
|
||||||
|
|||||||
+4
-4
@@ -17,6 +17,8 @@ import urllib.error
|
|||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from mofin_db import get_conn
|
||||||
|
|
||||||
DATA_DIR = "/home/hmo/web-dashboard/data"
|
DATA_DIR = "/home/hmo/web-dashboard/data"
|
||||||
HISTORY_PATH = os.path.join(DATA_DIR, "price_history.json")
|
HISTORY_PATH = os.path.join(DATA_DIR, "price_history.json")
|
||||||
# multi_tf_cache.json 已迁移到 DB (mtf_cache 表)
|
# multi_tf_cache.json 已迁移到 DB (mtf_cache 表)
|
||||||
@@ -91,8 +93,7 @@ def _load_mtf_cache():
|
|||||||
if _MTF_CACHE_DATA is not None:
|
if _MTF_CACHE_DATA is not None:
|
||||||
return _MTF_CACHE_DATA
|
return _MTF_CACHE_DATA
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
db = get_conn()
|
||||||
db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db')
|
|
||||||
rows = db.execute("SELECT code, cache_json FROM mtf_cache").fetchall()
|
rows = db.execute("SELECT code, cache_json FROM mtf_cache").fetchall()
|
||||||
_MTF_CACHE_DATA = {}
|
_MTF_CACHE_DATA = {}
|
||||||
for code, json_str in rows:
|
for code, json_str in rows:
|
||||||
@@ -112,8 +113,7 @@ def _save_mtf_cache():
|
|||||||
if _MTF_CACHE_DATA is None:
|
if _MTF_CACHE_DATA is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
db = get_conn()
|
||||||
db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db')
|
|
||||||
for code, data in _MTF_CACHE_DATA.items():
|
for code, data in _MTF_CACHE_DATA.items():
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))",
|
"INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))",
|
||||||
|
|||||||
+157
-375
@@ -8,17 +8,25 @@ import urllib.request
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# ── MoFin unified model ──────────────────────────────────────────────
|
DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json"
|
||||||
from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct
|
PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json"
|
||||||
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
|
WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json"
|
||||||
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"
|
||||||
STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json")
|
STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json")
|
||||||
EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json"
|
EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json"
|
||||||
|
|
||||||
|
# DB 模块(同步实时价到 mofin.db)
|
||||||
|
sys.path.insert(0, "/home/hmo/MoFin")
|
||||||
|
try:
|
||||||
|
from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_live_prices
|
||||||
|
from mo_models import calc_total_mv, calc_total_assets
|
||||||
|
HAS_DB = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_DB = False
|
||||||
|
|
||||||
# 策略重评依赖(技术面驱动,非机械百分比)
|
# 策略重评依赖(技术面驱动,非机械百分比)
|
||||||
sys.path.insert(0, "/home/hmo/web-dashboard")
|
sys.path.insert(0, "/home/hmo/web-dashboard")
|
||||||
try:
|
try:
|
||||||
@@ -27,51 +35,32 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_REASSESS = False
|
HAS_REASSESS = False
|
||||||
|
|
||||||
try:
|
|
||||||
HK_RATE = get_hk_rate()
|
|
||||||
except Exception:
|
|
||||||
HK_RATE = 0.87 # ultimate fallback
|
|
||||||
|
|
||||||
# 分支系统与情景检测
|
|
||||||
try:
|
|
||||||
sys.path.insert(0, '/home/hmo/MoFin')
|
|
||||||
from strategy_tree import detect_scenario, evaluate_branches
|
|
||||||
HAS_TREE = True
|
|
||||||
except Exception:
|
|
||||||
HAS_TREE = False
|
|
||||||
def detect_scenario(): return {}
|
|
||||||
def evaluate_branches(*a, **kw): return []
|
|
||||||
|
|
||||||
# 情景缓存(每次run_once刷新)
|
|
||||||
_SCENARIO_CACHE = {}
|
|
||||||
_BRANCH_CACHE = {} # code -> branches list
|
|
||||||
|
|
||||||
UA = "Mozilla/5.0"
|
UA = "Mozilla/5.0"
|
||||||
|
|
||||||
# ── 批量拉取价格 ──────────────────────────────────────────────────────────
|
# ── 批量拉取价格 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def fetch_all_prices(codes):
|
def fetch_all_prices(codes):
|
||||||
"""腾讯批量行情API:仅用于A股(沪市/深市)
|
"""腾讯批量行情API:一次请求拉取所有股票(A股+港股)
|
||||||
A股:sh600110 / sz000001
|
A股:sh600110 / sz000001
|
||||||
港股已迁移至 fetch_hk_eastmoney()(东方财富实时行情)
|
港股:hk00700
|
||||||
返回 {code: (price, change, change_pct)}
|
返回 {code: (price, change, change_pct)}
|
||||||
"""
|
"""
|
||||||
if not codes:
|
if not codes:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# 只处理A股(6位代码),港股走东方财富
|
# 构建批量查询串
|
||||||
a_codes = [c for c in codes if len(str(c).strip()) == 6]
|
|
||||||
if not a_codes:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
symbols = []
|
symbols = []
|
||||||
code_map = {}
|
code_map = {} # symbol -> original_code
|
||||||
for code in a_codes:
|
for code in codes:
|
||||||
code_s = str(code).strip()
|
code_s = str(code).strip()
|
||||||
if code_s.startswith(('5', '6', '9')):
|
if len(code_s) == 6:
|
||||||
sym = f"sh{code_s}"
|
# A股:沪市以5/6/9开头,深市以0/3开头
|
||||||
|
if code_s.startswith(('5', '6', '9')):
|
||||||
|
sym = f"sh{code_s}"
|
||||||
|
else:
|
||||||
|
sym = f"sz{code_s}"
|
||||||
else:
|
else:
|
||||||
sym = f"sz{code_s}"
|
sym = f"hk{code_s}"
|
||||||
symbols.append(sym)
|
symbols.append(sym)
|
||||||
code_map[sym] = code_s
|
code_map[sym] = code_s
|
||||||
|
|
||||||
@@ -81,7 +70,7 @@ def fetch_all_prices(codes):
|
|||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
text = r.read().decode("gbk")
|
text = r.read().decode("gbk")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ 腾讯A股拉取失败: {e}", file=sys.stderr)
|
print(f"⚠️ 批量拉取失败: {e}", file=sys.stderr)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
@@ -90,6 +79,7 @@ def fetch_all_prices(codes):
|
|||||||
if not line or "=" not in line:
|
if not line or "=" not in line:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
# 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..."
|
||||||
raw_value = line.split("=", 1)[1].strip().strip('"').strip(";")
|
raw_value = line.split("=", 1)[1].strip().strip('"').strip(";")
|
||||||
fields = raw_value.split("~")
|
fields = raw_value.split("~")
|
||||||
if len(fields) < 6:
|
if len(fields) < 6:
|
||||||
@@ -109,113 +99,20 @@ def fetch_all_prices(codes):
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
# ── 港股实时行情(东方财富,无15分钟延迟)──────────────────────────────
|
|
||||||
|
|
||||||
def fetch_hk_eastmoney(codes):
|
|
||||||
"""东方财富港股实时行情 API — 免费、实时、无15分钟延迟。
|
|
||||||
|
|
||||||
API: push2.eastmoney.com
|
|
||||||
市场代码: 116 (港交所)
|
|
||||||
格式: 116.00700
|
|
||||||
|
|
||||||
返回 {code: (price, change, change_pct)}
|
|
||||||
|
|
||||||
Fallback: 失败时回退到腾讯 qt.gtimg.cn(15分钟延迟)
|
|
||||||
"""
|
|
||||||
if not codes:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5]
|
|
||||||
if not hk_codes:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
|
|
||||||
# 主通道:东方财富实时行情。单个请求5s超时,失败立刻切腾讯。
|
|
||||||
# 实测限流阈值约30秒,间隔2秒可稳定运行(15只港股30秒内完成)
|
|
||||||
for code in hk_codes:
|
|
||||||
try:
|
|
||||||
url = (f"https://push2.eastmoney.com/api/qt/stock/get"
|
|
||||||
f"?secid=116.{code}"
|
|
||||||
f"&fields=f43,f170,f60,f57,f58"
|
|
||||||
f"&fltt=2")
|
|
||||||
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"))
|
|
||||||
|
|
||||||
if resp.get("rc") != 0:
|
|
||||||
break # 东财不可用,切腾讯
|
|
||||||
item = resp.get("data", {})
|
|
||||||
if not item:
|
|
||||||
break
|
|
||||||
price = float(item.get("f43", 0)) if item.get("f43") else 0
|
|
||||||
prev_close = float(item.get("f60", 0)) if item.get("f60") else 0
|
|
||||||
change = round(price - prev_close, 2) if prev_close > 0 else 0
|
|
||||||
change_pct = str(item.get("f170", "0"))
|
|
||||||
if price > 0:
|
|
||||||
results[code] = (price, change, change_pct)
|
|
||||||
time.sleep(2) # 防止触发东财限流(实测阈值~30s)
|
|
||||||
except Exception:
|
|
||||||
break # 东财不可用,立刻切腾讯
|
|
||||||
|
|
||||||
# Fallback: 腾讯 qt.gtimg.cn(15分钟延迟)
|
|
||||||
missing = [c for c in hk_codes if c not in results]
|
|
||||||
if missing:
|
|
||||||
try:
|
|
||||||
fallback = _fetch_hk_tencent_fallback(missing)
|
|
||||||
results.update(fallback)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_hk_tencent_fallback(codes):
|
|
||||||
"""腾讯港股行情(15分钟延迟,仅作 fallback)"""
|
|
||||||
symbols = [f"hk{c}" for c in codes]
|
|
||||||
url = f"http://qt.gtimg.cn/q={','.join(symbols)}"
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
|
||||||
text = r.read().decode("gbk")
|
|
||||||
|
|
||||||
code_map = {f"hk{c}": c for c in codes}
|
|
||||||
results = {}
|
|
||||||
for line in text.strip().split("\n"):
|
|
||||||
if "=" not in line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
raw = line.split("=", 1)[1].strip().strip('"').strip(";")
|
|
||||||
fields = raw.split("~")
|
|
||||||
if len(fields) < 6:
|
|
||||||
continue
|
|
||||||
sym = line.split("=", 1)[0].strip().lstrip("v_")
|
|
||||||
orig = code_map.get(sym)
|
|
||||||
if not orig:
|
|
||||||
continue
|
|
||||||
price = float(fields[3]) if fields[3] else 0
|
|
||||||
prev_close = float(fields[4]) if fields[4] else 0
|
|
||||||
change = price - prev_close if prev_close > 0 else 0
|
|
||||||
change_pct = fields[32] if len(fields) > 32 and fields[32] else "0"
|
|
||||||
results[orig] = (price, change, change_pct)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
continue
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_data_prices():
|
def refresh_data_prices():
|
||||||
"""一次性刷新portfolio.json和watchlist.json的所有实时价"""
|
"""一次性刷新portfolio.json和watchlist.json的所有实时价"""
|
||||||
all_codes = set()
|
all_codes = set()
|
||||||
|
|
||||||
# 收集所有需要拉取的代码
|
# 收集所有需要拉取的代码
|
||||||
try:
|
try:
|
||||||
pf = read_portfolio()
|
pf = json.load(open(PORTFOLIO_PATH))
|
||||||
for s in pf.get('holdings', []):
|
for s in pf.get('holdings', []):
|
||||||
all_codes.add(s['code'])
|
all_codes.add(s['code'])
|
||||||
except:
|
except:
|
||||||
pf = {"holdings": []}
|
pf = {"holdings": []}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wl = read_watchlist()
|
wl = json.load(open(WATCHLIST_PATH))
|
||||||
for s in wl.get('stocks', []):
|
for s in wl.get('stocks', []):
|
||||||
all_codes.add(s['code'])
|
all_codes.add(s['code'])
|
||||||
except:
|
except:
|
||||||
@@ -224,74 +121,24 @@ def refresh_data_prices():
|
|||||||
if not all_codes:
|
if not all_codes:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 分批拉取:A股走腾讯(实时) + 港股走东方财富(实时)
|
# 一次性批量拉取
|
||||||
all_list = list(all_codes)
|
prices = fetch_all_prices(list(all_codes))
|
||||||
prices = fetch_all_prices(all_list) # A股(腾讯,实时)
|
|
||||||
hk_prices = fetch_hk_eastmoney(all_list) # 港股(东方财富,实时)
|
|
||||||
prices.update(hk_prices)
|
|
||||||
updated = 0
|
updated = 0
|
||||||
|
|
||||||
# 保存全量实时价快照到 DB(替代 live_prices.json)
|
|
||||||
try:
|
|
||||||
live = {}
|
|
||||||
for code in all_codes:
|
|
||||||
if code in prices:
|
|
||||||
p, c, chg = prices[code]
|
|
||||||
live[code] = {"price": p, "change_pct": chg}
|
|
||||||
conn = get_conn()
|
|
||||||
write_live_prices(conn, live)
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 更新portfolio(只在价格变化时写入,避免触发文件变更通知)
|
# 更新portfolio(只在价格变化时写入,避免触发文件变更通知)
|
||||||
changed = False
|
changed = False
|
||||||
for s in pf.get('holdings', []):
|
for s in pf.get('holdings', []):
|
||||||
if s['code'] in prices:
|
if s['code'] in prices:
|
||||||
price, _, change_pct = prices[s['code']]
|
price, _, change_pct = prices[s['code']]
|
||||||
if price > 0:
|
if price > 0:
|
||||||
# 港股存 HKD 原值(跟股软一致),总资产汇总时由 calc_total_assets 统一转 CNY
|
old = s.get('price', 0)
|
||||||
old = s.get('price') or 0
|
|
||||||
if abs(old - price) > 0.001:
|
if abs(old - price) > 0.001:
|
||||||
s['price'] = round(price, 2)
|
s['price'] = round(price, 2)
|
||||||
s['change_pct'] = float(change_pct) if change_pct else 0
|
s['change_pct'] = float(change_pct) if change_pct else 0
|
||||||
s['currency'] = 'HKD' if is_hk_stock(s['code']) else 'CNY'
|
|
||||||
updated += 1
|
updated += 1
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M')
|
json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2)
|
||||||
pf['total_mv'] = calc_total_mv(pf.get('holdings', []))
|
|
||||||
pf['total_assets'] = calc_total_assets(pf)
|
|
||||||
pf['position_pct'] = calc_position_pct(pf)
|
|
||||||
# DB 写入(替代 json.dump,强制币种约束)
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
conn = get_conn()
|
|
||||||
conn.execute("PRAGMA busy_timeout=5000") # 等待5秒而非立即放弃
|
|
||||||
write_holdings_batch(conn, pf['holdings'])
|
|
||||||
write_portfolio_summary(conn, pf)
|
|
||||||
conn.close()
|
|
||||||
if attempt > 0:
|
|
||||||
print(f" [DB写入 OK after {attempt+1} retries]", flush=True)
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
if attempt < 2:
|
|
||||||
import time; time.sleep((attempt+1)*1)
|
|
||||||
else:
|
|
||||||
print(f" [DB写入失败 3次重试后放弃] {e}", flush=True)
|
|
||||||
elif pf.get('updated_at'):
|
|
||||||
try:
|
|
||||||
last_ts = datetime.strptime(pf['updated_at'], '%Y-%m-%d %H:%M')
|
|
||||||
if (datetime.now() - last_ts).total_seconds() > 600:
|
|
||||||
pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
||||||
try:
|
|
||||||
conn = get_conn()
|
|
||||||
write_portfolio_summary(conn, pf)
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 更新watchlist(只在价格变化时写入)
|
# 更新watchlist(只在价格变化时写入)
|
||||||
changed = False
|
changed = False
|
||||||
@@ -299,8 +146,7 @@ def refresh_data_prices():
|
|||||||
if s['code'] in prices:
|
if s['code'] in prices:
|
||||||
price, _, change_pct = prices[s['code']]
|
price, _, change_pct = prices[s['code']]
|
||||||
if price > 0:
|
if price > 0:
|
||||||
# 港股存 HKD 原值(跟股软一致)
|
old = s.get('price', 0)
|
||||||
old = s.get('price') or 0
|
|
||||||
if abs(old - price) > 0.001:
|
if abs(old - price) > 0.001:
|
||||||
s['price'] = round(price, 2)
|
s['price'] = round(price, 2)
|
||||||
s['change_pct'] = float(change_pct) if change_pct else 0
|
s['change_pct'] = float(change_pct) if change_pct else 0
|
||||||
@@ -308,85 +154,81 @@ def refresh_data_prices():
|
|||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
wl['updated_at'] = datetime.now().isoformat()
|
wl['updated_at'] = datetime.now().isoformat()
|
||||||
# DB 写入(替代 json.dump)
|
json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2)
|
||||||
try:
|
|
||||||
conn = get_conn()
|
|
||||||
for s in wl.get('stocks', []):
|
|
||||||
s['currency'] = 'CNY' # 自选股价格统一CNY
|
|
||||||
write_watchlist_stock(conn, s)
|
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
|
||||||
print(f" [DB watchlist写入失败] {e}", flush=True)
|
|
||||||
|
|
||||||
# --- 汇总值重算(使用 mo_models 唯一公式)---
|
# === 同步实时价到 mofin.db(带重试防锁) ===
|
||||||
try:
|
if HAS_DB and prices:
|
||||||
live_market_value = calc_total_mv(pf.get('holdings', []))
|
for db_attempt in range(3):
|
||||||
old_mv = pf.get('total_mv', 0)
|
try:
|
||||||
|
conn = get_conn()
|
||||||
|
# 直接从 portfolio.json 构建更新数据(保留已有的 market_value/currency)
|
||||||
|
db_holdings = []
|
||||||
|
for s in pf.get('holdings', []):
|
||||||
|
code = str(s.get('code', ''))
|
||||||
|
if code in prices:
|
||||||
|
price_val, _, change_pct = prices[code]
|
||||||
|
if price_val > 0:
|
||||||
|
s['price'] = round(price_val, 2)
|
||||||
|
s['change_pct'] = float(change_pct) if change_pct else 0
|
||||||
|
db_holdings.append(s)
|
||||||
|
|
||||||
if abs(old_mv - live_market_value) > 0.01:
|
# 写入DB持仓价格(write_holdings_batch 用 ON CONFLICT UPDATE 只改价格字段)
|
||||||
pf['total_mv'] = round(live_market_value, 2)
|
ok, msg = write_holdings_batch(conn, db_holdings)
|
||||||
|
if not ok:
|
||||||
|
conn.close()
|
||||||
|
if db_attempt < 2:
|
||||||
|
wait = (db_attempt + 1) * 2
|
||||||
|
print(f"⏳ DB写持仓失败: {msg} → {wait}s后重试", file=sys.stderr)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
print(f"❌ DB写持仓失败(3次重试耗尽): {msg}", file=sys.stderr)
|
||||||
|
break
|
||||||
|
|
||||||
pf['total_assets'] = calc_total_assets(pf)
|
# 重新计算市值(不变现金——DB的cash是权威)
|
||||||
if pf['total_assets'] > 0:
|
mv = calc_total_mv(db_holdings)
|
||||||
pf['position_pct'] = calc_position_pct(pf)
|
# 读取DB当前的现金和冻结,不覆盖
|
||||||
pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M')
|
existing = conn.execute(
|
||||||
# DB 写入
|
'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1'
|
||||||
try:
|
).fetchone()
|
||||||
conn = get_conn()
|
db_cash = existing['cash'] if existing else 0.0
|
||||||
write_portfolio_summary(conn, pf)
|
db_frozen = existing['frozen_cash'] if existing else 0.0
|
||||||
conn.close()
|
assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen})
|
||||||
except Exception as e:
|
position_pct = round(mv / assets * 100, 2) if assets > 0 else 0
|
||||||
print(f" [DB汇总写入失败] {e}", flush=True)
|
write_portfolio_summary(conn, {
|
||||||
except Exception as e:
|
'total_mv': mv,
|
||||||
print(f" [汇总重算失败] {e}", flush=True)
|
'total_assets': assets,
|
||||||
# --- 结束汇总重算 ---
|
'stock_value': mv,
|
||||||
|
'cash': db_cash,
|
||||||
|
'frozen_cash': db_frozen,
|
||||||
|
'position_pct': position_pct,
|
||||||
|
'currency': 'CNY',
|
||||||
|
})
|
||||||
|
# 写实时价格表(供 read_live_prices 消费)
|
||||||
|
live = {h['code']: {'price': h.get('price',0), 'change_pct': h.get('change_pct',0)}
|
||||||
|
for h in db_holdings if h.get('code')}
|
||||||
|
write_live_prices(conn, live)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if db_attempt > 0:
|
||||||
|
print(f"DB同步成功(第{db_attempt+1}次重试)")
|
||||||
|
break # success
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
conn.close()
|
||||||
|
if db_attempt < 2:
|
||||||
|
wait = (db_attempt + 1) * 2
|
||||||
|
print(f"⏳ DB锁等待(第{db_attempt+1}次): {e} → {wait}s后重试", file=sys.stderr)
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
print(f"❌ DB同步失败(3次重试耗尽): {e}", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
print(f"⚠️ DB同步异常: {e}", file=sys.stderr)
|
||||||
|
break
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
# ── 分支系统辅助函数 ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _branch_alert_suffix(code, price, shares=0, cost=0):
|
|
||||||
"""返回分支信息后缀:「 | 情景→动作」"""
|
|
||||||
if not HAS_TREE or not _SCENARIO_CACHE.get('id'):
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
sc_id = _SCENARIO_CACHE['id']
|
|
||||||
results = evaluate_branches(code, sc_id, price, shares, cost)
|
|
||||||
for r in results:
|
|
||||||
if r.get('applicable'):
|
|
||||||
_record_branch_trigger(code, r.get('branch_id',''), price)
|
|
||||||
branch_action = r.get('action_type', r.get('action', 'hold'))
|
|
||||||
return f" | {sc_id}→{branch_action}"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _record_branch_trigger(code, branch_id, price):
|
|
||||||
"""记录分支触发事件(自成长:trigger_count+1)"""
|
|
||||||
try:
|
|
||||||
raw = read_decisions()
|
|
||||||
for d in raw.get('decisions', []):
|
|
||||||
if d.get('code') == code and d.get('strategy_tree',{}).get('branches'):
|
|
||||||
for b in d['strategy_tree']['branches']:
|
|
||||||
if b['id'] == branch_id:
|
|
||||||
b.setdefault('trigger_count', 0)
|
|
||||||
b['trigger_count'] += 1
|
|
||||||
b['last_trigger_price'] = round(price, 2)
|
|
||||||
b['last_triggered'] = datetime.now().isoformat()
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
conn = get_conn()
|
|
||||||
for d in raw.get('decisions', []):
|
|
||||||
write_holding_strategy(conn, d['code'], d.get('name', ''), d)
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ── 区间偏离检测 ──────────────────────────────────────────────────────────
|
# ── 区间偏离检测 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_state():
|
def load_state():
|
||||||
@@ -429,7 +271,7 @@ def save_events(events):
|
|||||||
|
|
||||||
|
|
||||||
def record_event(code, name, event_type, price, trigger_value, event_label=""):
|
def record_event(code, name, event_type, price, trigger_value, event_label=""):
|
||||||
"""记录一次价格触发事件到 price_events.json + SQLite"""
|
"""记录一次价格触发事件到 price_events.json"""
|
||||||
events = load_events()
|
events = load_events()
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
events["events"].append({
|
events["events"].append({
|
||||||
@@ -446,75 +288,57 @@ def record_event(code, name, event_type, price, trigger_value, event_label=""):
|
|||||||
events["events"] = events["events"][-10000:]
|
events["events"] = events["events"][-10000:]
|
||||||
save_events(events)
|
save_events(events)
|
||||||
|
|
||||||
# ── SQLite 双写 ──
|
|
||||||
try:
|
|
||||||
from mofin_db import get_conn, init_all_tables, write_price_event
|
|
||||||
conn = get_conn()
|
|
||||||
init_all_tables(conn)
|
|
||||||
write_price_event(conn, code, name, event_type, price, trigger_value, event_label)
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass # SQLite 写入失败不影响主流程
|
|
||||||
|
|
||||||
|
def get_trigger_zones(trigger):
|
||||||
def get_trigger_zones(d):
|
"""返回该trigger所有可监控的区间列表,跳过已执行的batch"""
|
||||||
"""返回该decision所有可监控的区间列表,从顶层字段读取"""
|
|
||||||
zones = []
|
zones = []
|
||||||
is_holding = d.get('shares', 0) > 0
|
for key, label in [
|
||||||
# 买入区间(自选和持仓都监控)
|
("entry_zone", "加仓区间"),
|
||||||
el = d.get("entry_low", 0)
|
("batch1_price", "试仓区间"),
|
||||||
eh = d.get("entry_high", 0)
|
("batch2_price", "加仓区间"),
|
||||||
if el and eh and float(el) > 0 and float(eh) > 0:
|
("take_profit_zone", "止盈区间"),
|
||||||
|
("watch_low", "关注区间"),
|
||||||
|
("watch_high", "减仓区间"),
|
||||||
|
("watch_break", "止损区间")
|
||||||
|
]:
|
||||||
|
status_key = key.replace("_price", "_status")
|
||||||
|
if status_key in trigger and trigger[status_key] == "executed":
|
||||||
|
continue
|
||||||
|
val = trigger.get(key, "")
|
||||||
|
if val and "~" in val:
|
||||||
|
try:
|
||||||
|
parts = val.split("~")
|
||||||
|
lo, hi = float(parts[0]), float(parts[1])
|
||||||
|
zones.append((key, label, lo, hi))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
sl = trigger.get("stop_loss", "")
|
||||||
|
if sl:
|
||||||
try:
|
try:
|
||||||
zones.append(("entry_zone", "买入区间", float(el), float(eh)))
|
sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl)
|
||||||
|
zones.append(("stop_loss", "止损", 0, sl_price))
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
# 止损+止盈(只有持仓才监控,自选无意义)
|
|
||||||
if is_holding:
|
|
||||||
sl = d.get("stop_loss", 0)
|
|
||||||
if sl and float(sl) > 0:
|
|
||||||
try:
|
|
||||||
zones.append(("stop_loss", "止损", 0, float(sl)))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
tp = d.get("take_profit", 0)
|
|
||||||
if tp and float(tp) > 0:
|
|
||||||
try:
|
|
||||||
zones.append(("take_profit_zone", "止盈区间", 0, float(tp)))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return zones
|
return zones
|
||||||
|
|
||||||
|
|
||||||
def run_once(round_label=""):
|
def run_once(round_label=""):
|
||||||
"""执行一轮完整的监控流程"""
|
"""执行一轮完整的监控流程"""
|
||||||
global _SCENARIO_CACHE, _BRANCH_CACHE
|
|
||||||
label = f" [{round_label}]" if round_label else ""
|
label = f" [{round_label}]" if round_label else ""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
# 刷新情景与分支缓存(每轮更新)
|
|
||||||
_SCENARIO_CACHE = detect_scenario() if HAS_TREE else {}
|
|
||||||
_BRANCH_CACHE = {}
|
|
||||||
try:
|
|
||||||
raw = read_decisions()
|
|
||||||
for d in raw.get('decisions', []):
|
|
||||||
tree = d.get('strategy_tree', {})
|
|
||||||
if tree and tree.get('branches'):
|
|
||||||
_BRANCH_CACHE[d['code']] = tree['branches']
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# === 第一步:一次性刷新所有价格 ===
|
# === 第一步:一次性刷新所有价格 ===
|
||||||
refreshed = refresh_data_prices()
|
refreshed = refresh_data_prices()
|
||||||
|
|
||||||
# === 第二步:检查触发条件 ===
|
# === 第二步:检查触发条件 ===
|
||||||
try:
|
try:
|
||||||
dec = read_decisions()
|
with open(DECISIONS_PATH) as f:
|
||||||
|
dec = json.load(f)
|
||||||
except:
|
except:
|
||||||
print(f"❌{label} 无法读取决策数据", file=sys.stderr)
|
print(f"❌{label} 无法读取decisions.json", file=sys.stderr)
|
||||||
return
|
return
|
||||||
|
|
||||||
active = [d for d in dec.get("decisions", []) if d.get("status") in ("active", "updated")]
|
active = [d for d in dec.get("decisions", []) if d.get("status") == "active"]
|
||||||
state = load_state()
|
state = load_state()
|
||||||
outputs = []
|
outputs = []
|
||||||
state_updated = False
|
state_updated = False
|
||||||
@@ -522,28 +346,27 @@ def run_once(round_label=""):
|
|||||||
# 收集所有需要检查的代码
|
# 收集所有需要检查的代码
|
||||||
check_codes = set()
|
check_codes = set()
|
||||||
for d in active:
|
for d in active:
|
||||||
if get_trigger_zones(d):
|
trig = d.get("trigger", {})
|
||||||
|
if trig:
|
||||||
check_codes.add(d["code"])
|
check_codes.add(d["code"])
|
||||||
|
|
||||||
# 批量拉取这些股票的价格(A股走腾讯 + 港股走东方财富)
|
# 批量拉取这些股票的价格
|
||||||
all_codes = list(check_codes)
|
prices = fetch_all_prices(list(check_codes))
|
||||||
prices = fetch_all_prices(all_codes)
|
|
||||||
hk_codes = [c for c in all_codes if is_hk_stock(str(c))]
|
|
||||||
if hk_codes:
|
|
||||||
hk_prices = fetch_hk_eastmoney(hk_codes)
|
|
||||||
prices.update(hk_prices)
|
|
||||||
|
|
||||||
for d in active:
|
for d in active:
|
||||||
code = d["code"]
|
code = d["code"]
|
||||||
|
trig = d.get("trigger", {})
|
||||||
|
if not trig:
|
||||||
|
continue
|
||||||
|
|
||||||
zones = get_trigger_zones(d)
|
zones = get_trigger_zones(trig)
|
||||||
if not zones:
|
if not zones:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
price_info = prices.get(code)
|
price_info = prices.get(code)
|
||||||
if not price_info:
|
if not price_info:
|
||||||
continue
|
continue
|
||||||
price, _, _ = price_info
|
price, _ = price_info
|
||||||
if price == 0:
|
if price == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -557,22 +380,20 @@ def run_once(round_label=""):
|
|||||||
|
|
||||||
if in_zone and prev_in_zone != True:
|
if in_zone and prev_in_zone != True:
|
||||||
if key == "stop_loss":
|
if key == "stop_loss":
|
||||||
branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0))
|
outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!")
|
||||||
outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!{branch_sfx}")
|
|
||||||
record_event(code, name, "stop_loss", price, str(hi))
|
record_event(code, name, "stop_loss", price, str(hi))
|
||||||
else:
|
else:
|
||||||
extra = ""
|
extra = ""
|
||||||
if "_price" in key:
|
if "_price" in key:
|
||||||
batch_shares = d.get(key.replace("_price", "_shares"), "")
|
batch_shares = trig.get(key.replace("_price", "_shares"), "")
|
||||||
action = d.get(key.replace("_price", "_action"), "")
|
action = trig.get(key.replace("_price", "_action"), "")
|
||||||
if batch_shares:
|
if batch_shares:
|
||||||
extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股"
|
extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股"
|
||||||
elif key in ("take_profit_zone",):
|
elif key in ("take_profit_zone",):
|
||||||
act = d.get("take_profit_action", "")
|
act = trig.get("take_profit_action", "")
|
||||||
if act:
|
if act:
|
||||||
extra = f"({act})"
|
extra = f"({act})"
|
||||||
branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0))
|
outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}")
|
||||||
outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}{branch_sfx}")
|
|
||||||
record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label)
|
record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label)
|
||||||
state[code][key] = True
|
state[code][key] = True
|
||||||
state_updated = True
|
state_updated = True
|
||||||
@@ -591,7 +412,7 @@ def run_once(round_label=""):
|
|||||||
price_info = prices.get(code)
|
price_info = prices.get(code)
|
||||||
if not price_info:
|
if not price_info:
|
||||||
continue
|
continue
|
||||||
price, _, _ = price_info
|
price, _ = price_info
|
||||||
if price == 0:
|
if price == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -659,64 +480,25 @@ def run_once(round_label=""):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
outputs.append(f" ⚠️ 全量重评失败: {e}")
|
outputs.append(f" ⚠️ 全量重评失败: {e}")
|
||||||
|
|
||||||
# === 3.5 资金流异常检测(2026-06-27 新增)===
|
# === 第四步:输出 ===
|
||||||
try:
|
|
||||||
cf = read_capital_flow_cache(get_conn())
|
|
||||||
# 检查所有 active decision 中的资金流异常
|
|
||||||
for d in active:
|
|
||||||
code = d["code"]
|
|
||||||
stock_cf = cf.get("stocks", {}).get(code, {})
|
|
||||||
analysis = stock_cf.get("analysis", {})
|
|
||||||
alerts = analysis.get("alerts", [])
|
|
||||||
if alerts:
|
|
||||||
name = d.get("name", code)
|
|
||||||
for a in alerts:
|
|
||||||
outputs.append(f" 💰 {name}({code}) {a}")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# === 第四步:情景变化检测 + 输出 → 直接推XMPP ===
|
|
||||||
now_str = datetime.now().strftime("%H:%M:%S")
|
now_str = datetime.now().strftime("%H:%M:%S")
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
|
|
||||||
# 情景变化检测(跨轮对比)
|
|
||||||
if HAS_TREE and _SCENARIO_CACHE.get('id'):
|
|
||||||
prev_scenario = state.get('_system', {}).get('last_scenario', '')
|
|
||||||
curr_scenario = _SCENARIO_CACHE['id']
|
|
||||||
if prev_scenario and curr_scenario != prev_scenario:
|
|
||||||
combo = _SCENARIO_CACHE.get('combo_action', '')
|
|
||||||
outputs.insert(0, f"🌀 情景切换: {prev_scenario}→{curr_scenario} | {combo}")
|
|
||||||
if outputs:
|
|
||||||
state.setdefault('_system', {})['last_scenario'] = curr_scenario
|
|
||||||
state_updated = True
|
|
||||||
elif not prev_scenario:
|
|
||||||
state.setdefault('_system', {})['last_scenario'] = curr_scenario
|
|
||||||
state_updated = True
|
|
||||||
|
|
||||||
if outputs:
|
if outputs:
|
||||||
# 简短一行一个触发
|
print(f"\n🔔 {now_str}{label}")
|
||||||
for o in outputs:
|
for o in outputs:
|
||||||
print(o)
|
print(o)
|
||||||
# 推送XMPP(只推关键事件:止损跌破+情景切换+资金流异动,不推买入区进出/重评等操作细节)
|
print(f"\n<structured_data>{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}</structured_data>")
|
||||||
critical = [o for o in outputs if o.startswith(("⚠️", "🌀", "💰"))]
|
else:
|
||||||
if critical:
|
# 无触发时 SILENT(中继不推送)
|
||||||
try:
|
print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s")
|
||||||
body = "\n".join([f"{now_str}"] + critical)
|
|
||||||
payload = json.dumps({
|
|
||||||
"to": "hmo@yoin.fun", "body": body, "type": "chat",
|
|
||||||
}).encode("utf-8")
|
|
||||||
req = urllib.request.Request(
|
|
||||||
"http://127.0.0.1:5805/", data=payload,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
urllib.request.urlopen(req, timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# else: SILENT — 无触发,无输出,不推
|
|
||||||
|
|
||||||
if state_updated:
|
if state_updated:
|
||||||
save_state(state)
|
save_state(state)
|
||||||
|
|
||||||
|
# 输出耗时
|
||||||
|
print(f"⏱{label} {elapsed:.1f}s", flush=True)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""每cron触发跑一轮"""
|
"""每cron触发跑一轮"""
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
# Script connects to THIS db
|
||||||
|
proj_db = '/home/hmo/projects/MoFin/data/mofin.db'
|
||||||
|
# Real data lives in THIS db
|
||||||
|
real_db = '/home/hmo/web-dashboard/data/mofin.db'
|
||||||
|
|
||||||
|
for label, path in [("project", proj_db), ("real", real_db)]:
|
||||||
|
db = sqlite3.connect(path)
|
||||||
|
tables = [r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||||
|
print(f"{label} db ({path}): {len(tables)} tables")
|
||||||
|
for t in tables:
|
||||||
|
if t == 'todos':
|
||||||
|
sql = db.execute(f"SELECT sql FROM sqlite_master WHERE name='{t}'").fetchone()
|
||||||
|
print(f" {t}: {sql[0][:100] if sql else 'no sql'}")
|
||||||
|
elif t in ('holdings', 'holding_strategies', 'watchlist_stocks', 'portfolio_summary'):
|
||||||
|
cnt = db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
|
||||||
|
print(f" {t}: {cnt} rows")
|
||||||
|
else:
|
||||||
|
cnt = db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
|
||||||
|
print(f" {t}: {cnt} rows")
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
for label, path in [("project", '/home/hmo/projects/MoFin/data/mofin.db'), ("real", '/home/hmo/web-dashboard/data/mofin.db')]:
|
||||||
|
db = sqlite3.connect(path)
|
||||||
|
sql = db.execute("SELECT sql FROM sqlite_master WHERE name='todos'").fetchone()
|
||||||
|
print(f"=== {label}: {path} ===")
|
||||||
|
print(sql[0] if sql else "NOT FOUND")
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Fix DB_PATH in self_todo_executor.py"""
|
||||||
|
path = '/home/hmo/.hermes/profiles/position-analyst/scripts/self_todo_executor.py'
|
||||||
|
content = open(path).read()
|
||||||
|
content = content.replace('projects/MoFin/data', 'web-dashboard/data')
|
||||||
|
open(path, 'w').write(content)
|
||||||
|
print("DB_PATH fixed to web-dashboard/data/mofin.db")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Fix: unify todos table schema across project and real DB"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
project_db = '/home/hmo/projects/MoFin/data/mofin.db'
|
||||||
|
real_db = '/home/hmo/web-dashboard/data/mofin.db'
|
||||||
|
|
||||||
|
# Zhiwei's canonical schema (from project db)
|
||||||
|
target_schema = """
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
priority TEXT DEFAULT 'medium',
|
||||||
|
source TEXT DEFAULT 'manual',
|
||||||
|
fix_action TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
"""
|
||||||
|
|
||||||
|
def ensure_todos(db_path, label):
|
||||||
|
db = sqlite3.connect(db_path)
|
||||||
|
existing = db.execute("SELECT name FROM sqlite_master WHERE name='todos'").fetchone()
|
||||||
|
if not existing:
|
||||||
|
db.execute(f"CREATE TABLE todos ({target_schema})")
|
||||||
|
print(f"{label}: created todos table")
|
||||||
|
else:
|
||||||
|
# Ensure all columns exist
|
||||||
|
existing_cols = {r[1] for r in db.execute("PRAGMA table_info(todos)")}
|
||||||
|
needed = {'title', 'description', 'status', 'priority', 'source', 'fix_action',
|
||||||
|
'retry_count', 'note', 'created_at', 'updated_at'}
|
||||||
|
missing = needed - existing_cols
|
||||||
|
for col in missing:
|
||||||
|
if col in ('retry_count',):
|
||||||
|
db.execute(f"ALTER TABLE todos ADD COLUMN {col} INTEGER DEFAULT 0")
|
||||||
|
elif col in ('created_at', 'updated_at'):
|
||||||
|
db.execute(f"ALTER TABLE todos ADD COLUMN {col} TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
|
||||||
|
else:
|
||||||
|
db.execute(f"ALTER TABLE todos ADD COLUMN {col} TEXT")
|
||||||
|
print(f"{label}: checked, {len(missing)} missing columns added" if missing else f"{label}: schema OK")
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
ensure_todos(project_db, "project db")
|
||||||
|
ensure_todos(real_db, "real db")
|
||||||
|
print("\nDone. Both DBs now have matching todos schema.")
|
||||||
@@ -20,6 +20,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, time
|
from datetime import datetime, time
|
||||||
from mo_data import read_portfolio, read_decisions
|
from mo_data import read_portfolio, read_decisions
|
||||||
|
from mofin_db import get_conn
|
||||||
|
|
||||||
# ── MoFin unified model ──────────────────────────────────────────────
|
# ── MoFin unified model ──────────────────────────────────────────────
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
@@ -63,9 +64,7 @@ def fetch_trend_data(code):
|
|||||||
# 价格从 DB 读取,不再自拉腾讯 API
|
# 价格从 DB 读取,不再自拉腾讯 API
|
||||||
current = 0
|
current = 0
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
db = get_conn()
|
||||||
db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db')
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone()
|
row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code,)).fetchone()
|
row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code,)).fetchone()
|
||||||
@@ -155,8 +154,7 @@ def load_macro_line():
|
|||||||
parts = []
|
parts = []
|
||||||
try:
|
try:
|
||||||
# 优先 DB
|
# 优先 DB
|
||||||
import sqlite3
|
db = get_conn()
|
||||||
db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
"SELECT structure FROM macro_context_log "
|
"SELECT structure FROM macro_context_log "
|
||||||
"WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1"
|
"WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Verify self_todo_executor works with real DB"""
|
||||||
|
import subprocess
|
||||||
|
script = '/home/hmo/.hermes/profiles/position-analyst/scripts/self_todo_executor.py'
|
||||||
|
|
||||||
|
# Test 1: DB_PATH
|
||||||
|
content = open(script).read()
|
||||||
|
if 'web-dashboard/data/mofin.db' in content:
|
||||||
|
print("DB_PATH: OK")
|
||||||
|
else:
|
||||||
|
print("DB_PATH: WRONG")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Test 2: script can import and run
|
||||||
|
try:
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location("executor", script)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
print("Import: OK")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Import: FAIL -> {e}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Test 3: get_pending works
|
||||||
|
try:
|
||||||
|
rows = mod.get_pending()
|
||||||
|
print(f"get_pending: OK ({len(rows)} pending)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"get_pending: FAIL -> {e}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print("\nAll checks passed.")
|
||||||
@@ -698,9 +698,8 @@ def compute_sector_adjustment(code, market_ctx, stock_sector_map):
|
|||||||
def load_macro_context():
|
def load_macro_context():
|
||||||
"""读取宏观上下文,返回 (bias, desc),优先 DB,回退 JSON"""
|
"""读取宏观上下文,返回 (bias, desc),优先 DB,回退 JSON"""
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
from mofin_db import get_conn
|
||||||
from pathlib import Path
|
conn = get_conn()
|
||||||
conn = sqlite3.connect(str(Path(__file__).parent.parent / "data" / "mofin.db"))
|
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT indices, structure FROM macro_context_log "
|
"SELECT indices, structure FROM macro_context_log "
|
||||||
"WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1"
|
"WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1"
|
||||||
@@ -740,9 +739,8 @@ def batch_fetch_prices(codes):
|
|||||||
|
|
||||||
# 主通道:从 DB 读取(price_monitor 唯一价格入口)
|
# 主通道:从 DB 读取(price_monitor 唯一价格入口)
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
from mofin_db import get_conn
|
||||||
db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db')
|
db = get_conn()
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
for raw_code in codes:
|
for raw_code in codes:
|
||||||
raw_code = str(raw_code).split('_')[0]
|
raw_code = str(raw_code).split('_')[0]
|
||||||
if not raw_code: continue
|
if not raw_code: continue
|
||||||
@@ -842,9 +840,8 @@ def get_price_tencent(code):
|
|||||||
|
|
||||||
# 主通道: DB
|
# 主通道: DB
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
from mofin_db import get_conn
|
||||||
db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db')
|
db = get_conn()
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (raw_code,)).fetchone()
|
row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (raw_code,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (raw_code,)).fetchone()
|
row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (raw_code,)).fetchone()
|
||||||
|
|||||||
Reference in New Issue
Block a user