From 7d49470aeb170b1972d912ccf73a06563eff155a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E5=BE=AE?= Date: Mon, 29 Jun 2026 22:35:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=80=BB=E8=B5=84=E4=BA=A7=E5=85=AC=E5=BC=8F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D+=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bugfix: price_monitor写total_assets时漏算frozen_cash 公式修正: total_assets = market_value + cash + frozen_cash 影响: price_monitor两处公式 + stale_push_wlin fallback路径 docs: portfolio-data-model.md 新增 数据模型字段说明 现金流更新规则 常见错误清单 --- data/portfolio.json | 20 ++++-- docs/portfolio-data-model.md | 115 +++++++++++++++++++++++++++++++++++ price_monitor.py | 13 +++- 3 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 docs/portfolio-data-model.md diff --git a/data/portfolio.json b/data/portfolio.json index 6d0f6ed..04b6d70 100644 --- a/data/portfolio.json +++ b/data/portfolio.json @@ -651,15 +651,15 @@ ], "cash": 92678.85, "total_market_value": 835552.6, - "total_assets": 928231.45, + "total_assets": 967712.85, "total_pl": 0, "position_pct": 90.02, - "updated_at": "2026-06-29 22:23:47", + "updated_at": "2026-06-29 22:28", "source": "/home/hmo/stocks/holding.xls", "frozen_cash": 39481.4, "available_cash": 92678.85, "frozen": 39481.4, - "total_cash": 132145.6, + "total_cash": 132160.25, "cash_updated_at": "2026-06-29 12:34", "recent_trades": [ { @@ -675,10 +675,10 @@ "total_mv": 835552.6, "note": "cash fixed from screenshot 6/29, prices=CNY", "currency": "CNY", - "last_verified_at": "2026-06-29 22:20", + "last_verified_at": "2026-06-29 22:28", "_total_mv": 835552.6, - "_total_cash": 113240.25, - "_total_assets": 948792.85, + "_total_cash": 132160.25, + "_total_assets": 967712.85, "cash_history": [ { "time": "2026-06-29 22:23:47", @@ -686,6 +686,14 @@ "frozen": 39481.4, "source": "manual:post-法拉电子-sell-100shares@189.2", "formula": "初始可用73758.85+法拉电子18920" + }, + { + "time": "2026-06-29 22:28", + "cash": 92678.85, + "frozen": 39481.4, + "total_mv": 835552.6, + "total_assets": 967712.85, + "source": "price_monitor_auto" } ] } \ No newline at end of file diff --git a/docs/portfolio-data-model.md b/docs/portfolio-data-model.md new file mode 100644 index 0000000..e985e05 --- /dev/null +++ b/docs/portfolio-data-model.md @@ -0,0 +1,115 @@ +# portfolio.json 数据模型 + +## 一句话 + +**总资产 = 持仓市值 + 可用现金 + 冻结资金**,三个数字必须全对,缺少任何一项数字就不对。 + +## 字段说明 + +```jsonc +{ + // 持仓列表(price_monitor 每2分钟更新价格) + "holdings": [ + { + "code": "01888", // 股票代码 + "name": "建滔积层板", // 股票名称 + "shares": 500, // 持股数 + "price": 83.59, // 最新价(CNY!所有股票统一人民币计价) + "cost": 88.23, // 成本价(CNY) + "market_value": 41795.0 // 市值 = shares × price + } + // ... + ], + + // 现金(从 Dad 截图来源更新,price_monitor 不碰!) + "cash": 92678.85, // 可用资金(人民币) + "frozen_cash": 39481.40, // 冻结资金(T+2未交收/挂单占用) + + // 汇总(price_monitor 每2分钟自动重算) + "total_mv": 835552.6, // 持仓市值 = Σ(shares × price) + "total_assets": 967712.85, // 总资产 = total_mv + cash + frozen_cash + "position_pct": 86.3, // 仓位% = total_mv / total_assets × 100 + + // 元数据 + "currency": "CNY", // 本文件所有价格均为人民币 + "updated_at": "2026-06-29 22:33:00", // 最近一次更新 + "source": "holding.xls / manual", // 持仓来源 + "data_model_version": "2" // 数据模型版本(防止新旧数据混淆) +} +``` + +## 现金流 + +### 现金如何被更新? + +| 渠道 | 操作 | 更新字段 | 频次 | +|------|------|---------|------| +| holding.xls 导入 | `import_holding_xls.py` | cash, frozen_cash | Dad更新后 | +| 成交截图 | 手动解析 + 对比旧数据计算变更 | cash(+/-), frozen_cash | Dad发送时 | +| price_monitor | 只更新价格,不动现金 | 不更新 | 每2分钟 | +| 手动修正 | Dad告知准确数字 | cash, frozen_cash | Dad告知时 | + +### 现金变更追踪 + +portfolio.json 的 `cash_history` 数组记录了每次现金变更: +```jsonc +"cash_history": [ + { + "time": "2026-06-29 22:23:47", + "cash": 92678.85, // 更新后的可用资金 + "frozen": 39481.40, // 更新后的冻结资金 + "source": "manual:post-法拉电子-sell", // 变更来源 + "formula": "初始73758.85 + 法拉电子18920" // 计算过程 + } +] +``` + +### 规则 + +1. **price_monitor 绝不能修改 cash / frozen_cash** — 它只更新 price 字段和 total_mv / total_assets +2. 现金变更只能来自 Dad 截图、holding.xls 导入、或 Dad 手动告知 +3. 每次现金变更必须记录到 cash_history,注明来源和计算过程 +4. 所有报告脚本读总资产时,从 `portfolio.json.total_assets` 取,不要自己算 + +## 币种 + +- portfolio.json 全部存 CNY(港股价格 × HK_RATE 转人民币) +- decisions.json 港股价格存 HKD 原值(带 `currency: "HKD"` 标记) +- price_monitor 比较 decisions.json 中的价格和止损时:同币种(都是 HKD),直接比较 +- 报告输出港股价格时显示 HKD 并标注「(HKD)」 + +## 常见错误 + +### ❌ total_assets 漏了冻结资金 + +```python +# WRONG — 只加了可用现金,冻结资金漏了 +total_assets = market_value + cash + +# RIGHT — 可用 + 冻结 +total_assets = market_value + cash + frozen_cash +``` + +### ❌ 港股硬编码 ×0.866 + +```python +# WRONG — 价格本身已经是 CNY(price_monitor在写入时就转了) +mv = shares * price * 0.866 + +# RIGHT — price 已经是 CNY +mv = shares * price +``` + +### ❌ LLM 报告自己算总资产 + +``` +WRONG: 报告里写 "总资产 = 持仓市值 + 现金" +RIGHT: 报告里写 "总资产 = portfolio.json.total_assets" +``` + +## 版本记录 + +| 版本 | 日期 | 变更 | +|------|------|------| +| 1 | 2026-06-29以前 | 无规范,cash字段含义模糊 | +| 2 | 2026-06-29 | 明确 cash=可用, frozen_cash=冻结, total_assets=市值+可用+冻结 | diff --git a/price_monitor.py b/price_monitor.py index 779e888..14241aa 100644 --- a/price_monitor.py +++ b/price_monitor.py @@ -167,7 +167,12 @@ def refresh_data_prices(): pf['total_mv'] = round(sum( h.get('shares',0) * h.get('price',0) for h in pf.get('holdings',[]) ), 2) - pf['total_assets'] = round(pf['total_mv'] + pf.get('cash',0), 2) + # total_assets = 持仓市值 + 可用现金 + 冻结资金(缺一不可!2026-06-29 bugfix) + # cash = 可用资金(从截图/导入/成交记录来的,price_monitor不动它) + # frozen_cash = 冻结资金(T+2未交收/挂单占用) + available = float(pf.get('cash', 0) or 0) + frozen = float(pf.get('frozen_cash', 0) or 0) + pf['total_assets'] = round(pf['total_mv'] + available + frozen, 2) json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) elif pf.get('updated_at'): # 即使价格无变化,每10分钟刷新一次updated_at,防健康检查误报 @@ -214,8 +219,10 @@ def refresh_data_prices(): if abs(old_mv - live_market_value) > 0.01: pf['total_mv'] = round(live_market_value, 2) - total_cash = pf.get('cash', 0) # 保留上次的现金值,不重算 - pf['total_assets'] = round(live_market_value + total_cash, 2) + # total_assets = 持仓市值 + 可用现金 + 冻结资金(重复!同步上一处公式) + available = float(pf.get('cash', 0) or 0) + frozen = float(pf.get('frozen_cash', 0) or 0) + pf['total_assets'] = round(live_market_value + available + frozen, 2) if pf['total_assets'] > 0: pf['position_pct'] = round(live_market_value / pf['total_assets'] * 100, 2) pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M')