summaryrefslogtreecommitdiff
path: root/docs/stock-milestone-survey.md
blob: ce96c5e80f5b59e6f161afadc4842eec27b9aa53 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# 直接股票持仓里程碑调研

状态:**只调研,不实施**。本文给出实现「直接股票持仓里程碑」所需的既有限件、schema 现状与缺口、UI 范围,以及 AKShare 股票日行情最小探针设计。所有代码位置均为当前 `main`(`877e031`)实际位置,引用格式 `file:line`。

验收口径来自 `docs/design.md`(§4.5/§5.2/§10.1)、`docs/implementation-plan.md`(§10.1)与 `README.md`;环境与 Git 口径见 `docs/leader-environment-unblock.md`。

## 0. 结论摘要

- **已有可复用垂直切片**:股票买入/卖出/持仓/现金账本/行情探针/估值/UI 均已实现并有回归(`stock_trades`、`stock_positions`、`stock_sells`、`stock_cashflow_events`、`instrument_snapshots`;`POST /api/funds/{id}/stock-trades` 等)。
- **里程碑真正缺口(设计已要求、当前未落地)**:
  1. 实际价与复权价分列(设计 `market_price_observation.price_kind`,`docs/design.md:107`);现有 `instrument_snapshots` 只有单一 `price`,无 `adjust`/`price_kind`。
  2. 公司行动(除权除息/送股/配股)与停牌持久化;`stock_cashflow_events` 仅 `buy/sell/dividend`,无 split/rights。
  3. 股票交易日历与每 instrument 的 `trading_rule` 版本(设计 `docs/design.md:182`、`docs/implementation-plan.md:227`)。
  4. point-in-time 估值重算与无前视验收(当前估值按 `asOfDate` 取快照 + 实时探针兜底,未做严格 `published_at <= cutoff`)。
  5. 代码搜索/选择(当前 UI 为自由输入六位码)。
- **最小探针结论**:既有 collector 已能拉股票日线(`akshare_collector.py:461`,三源 fallback),里程碑探针只需**验证**交易日真实性、实际/复权列差异、公司行动跳变、缺失/修订与当日可用时点;**不需要新增 operation**。

## 1. 既有限件(硬约束,不可违背)

摘自 `docs/design.md` §1/§4.4/§4.5 与 `README.md`:

- 只用真实数据,缺失显示「未接入/数据不可用」,**不得伪造行情、不得把缺失当 0**(`docs/design.md:273`、`README.md:40`)。
- 合成数据必须 `is_synthetic=true` 并与真实查询隔离(`docs/design.md:191`)。
- 金额/价格/份额/净值用 F# `decimal` 与 PostgreSQL `numeric`;对外 JSON 用字符串(`docs/design.md:113`、`docs/design.md:295`)。
- 写命令要求 `Idempotency-Key`;同 key 同哈希返回原结果,不同哈希 `409 IDEMPOTENCY_CONFLICT`;写入在单事务内(`docs/design.md:186`、`docs/design.md:366`)。
- point-in-time:`published_at`/`first_seen_at` 分离,历史回填标 `backfilled` 且不得前视(`docs/design.md:220`)。
- 不接真实券商/下单(`docs/design.md:17`、`docs/design.md:305`)。
- 运行时数据库、缓存、凭证、构建产物不入 Git(`docs/design.md:317`)。
- 前端中文,每个图表/持仓必须展示数据日期、来源状态、是否含合成(`docs/design.md:299`)。

## 2. 现有实现(可直接复用)

### 2.1 Schema(`src/FundLab.Api/Persistence.fs`)

| 表 | 位置 | 关键列 | 备注 |
| --- | --- | --- | --- |
| `instruments` | `Persistence.fs:853` | `code`(`^[0-9]{6}$`)、`name`、`fund_type`、`source`、`source_revision`、`first_seen_at`/`last_seen_at` | 基金/股票共用,无显式 `kind` |
| `stock_trades` | `Persistence.fs:1131` | `fund_id`、`instrument_code`、`stock_name`、`quantity`、`price`、`cost_cash`、`is_synthetic`、`executed_at` | 买入不可变事实 |
| `stock_trade_idempotencies` | `Persistence.fs:1143` | `idempotency_key`、`request_hash`、`trade_id`、`fund_id` | 幂等 |
| `stock_positions` | `Persistence.fs:1151` | `(fund_id, instrument_code)` 主键、`quantity`、`cost_cash`、`last_traded_at` | 持仓聚合(成本口径,非市值) |
| `stock_sells` | `Persistence.fs:1161` | `quantity`、`price`、`fee_amount`、`proceeds`、`is_synthetic`、`executed_at` | 卖出 |
| `stock_cashflow_events` | `Persistence.fs:1174` | `event_type IN ('buy','sell','dividend')`、`event_date`、`quantity`、`amount` | 无 split/rights |
| `instrument_snapshots` | `Persistence.fs:1296` | `(instrument_code, asset_class, snapshot_date)`、`price`、`source*` | `asset_class IN ('stock','bond')`,**无 adjust/price_kind** |

设计对照缺口:`docs/design.md:107` 的 `market_price_observation(trade_date, price, price_kind, source, payload_hash)` 与 `docs/design.md:110` 的 `valuation_snapshot` **均未落地**;当前以 `instrument_snapshots` 与「按需计算」替代。

### 2.2 写入路径与 `stock_buy` 账本

- `FundRepository.CreateStockTrade`:`Persistence.fs:5951`。
  - 校验六位码/正数量/正价格:`Persistence.fs:5957`。
  - advisory lock + 幂等重放/冲突:`Persistence.fs:5970`–`5992`。
  - 可选现金借记(3d-34 起默认 `true`):`Persistence.fs:6002`–`6014`;余额不足回滚并返回 `StockTradeInsufficientFunds`:`Persistence.fs:6016`。
  - 写 `stock_trades` + 幂等记录:`Persistence.fs:6038`。
  - **写 `stock_buy` 账本事件**:`Persistence.fs:6041`–`6051`(`insertCashLedgerEvent … CashLedger.StockBuy … "stock_buy"`)。
  - upsert `stock_positions`:`Persistence.fs:6053`–`6080`。
- `BackfillCashLedger` 的 `stock_buy` 补事件批(含 pre-3d-34 缺借记一次性修正,3d-39/`877e031`):`Persistence.fs:7240` 起。
- HTTP 处理器 `createStockTrade`:`App.fs:2480`。
  - 实时行情取价:`App.fs:2499`;停牌 → `400 STOCK_SUSPENDED`:`App.fs:2504`;无价 → 数据错误:`App.fs:2511`。
  - 幂等冲突 `409`:`App.fs:2535`;余额不足 `400 INSUFFICIENT_FUNDS`:`App.fs:2539`。
- 路由:`POST /api/funds/%s/stock-trades`(`App.fs:3670`)、`stock-sells`(`3671`)、`GET stock-positions`(`3672`)、`stock-cashflows`(`3673`/`3674`)。

### 2.3 行情探针与估值

- 路由:`GET /api/market/stock-quote`(`App.fs:3630`)、`GET /api/market/stock-daily`(`App.fs:3631`)、`GET /api/funds/%s/valuation`(`App.fs:3682`)。
- 探针接口 `MarketProbes`(`App.fs:604`–`611`:`StockQuotes`/`StockDaily`);`MarketDataService.FetchStockDaily`:`MarketDataService.fs:215`(调用 `--operation stock-daily`)。
- collector:`stock_market`(沪/深/北前缀映射)`akshare_collector.py:446`;`stock_daily`(三源 fallback)`akshare_collector.py:461`;`stock_quote`(`stock_bid_ask_em` → 快照)`akshare_collector.py:514`。
- 估值处理器 `getFundValuation`:`App.fs:3317`;价格解析 `resolveValuationPrice`(快照优先,实时兜底):`App.fs:3306`;缺失标 `unavailable` 不补零:`App.fs:3406`。
- 行情刷新把最新日线收盘写入 `instrument_snapshots`:`App.fs:3447`–`3496`。
- 回归:`tests/FundLab.Api.Tests/StockDailyProbeTests.fs`(升序去重、代码不符拒绝、503/400 边界)、`StockQuoteProbeTests.fs`(名称缺失不伪造、停牌标记、幂等缓存)、`StockTradeTests.fs`/`StockSellTests.fs`/`StockCashflowTests.fs`。

### 2.4 UI(`src/FundLab.Web/App.fs`)

- 股票行情探针面板(代码输入/查行情/查近 5 日日线):`App.fs:6166` 起。
- 买入 + 股票持仓表 + 卖出:`App.fs:6240`–`6325`(表头「代码/名称/数量/成本/卖出数量/操作」)。
- 组合估值卡(现金/持仓市值/组合合计/已定价·缺失,含 `priceSource`):`App.fs:6326` 起。
- API 客户端 `./src/api.js` 导入:`App.fs:1143`–`1156`。
- QA 场景:`qa/driver/browser-test.js:795`(`stocksScenario`)、`:858`(`stockDailyScenario`)、股票买卖与持仓(`:314`–`322`);桩采集器 `qa/stub/fund-lab-python:48`(`stock-quote`)/`:59`(`stock-daily`)。

## 3. Schema 现状与缺口(里程碑必须补)

| 设计实体/字段 | 现状 | 缺口 |
| --- | --- | --- |
| `market_price_observation.price_kind`(actual/adjusted 分列) | 无;`instrument_snapshots.price` 单一值 | 需新增 `price_kind`/`adjust` 列或新表,估值默认 actual,复权仅用于收益分析 |
| 公司行动(送转/配股/除权除息) | `stock_cashflow_events.event_type` 仅 `buy/sell/dividend` | 缺 split/rights/除权日;复权因子无处存储 |
| 停牌状态 | 仅 `stock_quote` 响应内瞬态 `suspended`(`akshare_collector.py:537`) | 无持久化,估值/UI 无法回溯停牌 |
| 交易日历 | 无 | 需按数据源真实交易日,禁止把最近交易日冒充当天(`docs/design.md:223`) |
| 每 instrument `trading_rule` 版本 | 无 | 需绑定 T+1、最小 100 股、费用、结算、停牌规则(`docs/implementation-plan.md:227`) |
| `valuation_snapshot`(含 source_revision_set) | 按需计算,无快照表 | 需可重算快照以支持 point-in-time 与无前视 |
| 成本 vs 市值 | `stock_positions.cost_cash`(成本) | 市值来自 `instrument_snapshots`/实时价,二者未在同一快照冻结 |

## 4. UI 范围

已有(可复用):行情探针、买入(整数股)、持仓表、卖出、估值卡、`priceSource` 来源标注、缺失/不可用中文提示。

里程碑需新增/调整:

1. 股票代码**搜索/选择**(复用 `GET /api/instruments/search`,当前 UI 是自由输入六位码)。
2. 持仓**金额或份额**输入(当前仅整数股数),并显示最小交易单位/费用。
3. 实际价/复权价**口径标注**(`price_kind`)与「净资产/市值」曲线标签区分(`docs/design.md:299`)。
4. 停牌/非交易日/缺失的**明确状态与原因**(不补零)。
5. 公司行动(分红/送转)展示与再投资选择。

## 5. AKShare 股票日行情最小探针(只调研)

**目标**:回答 `docs/design.md:266` 与 `docs/implementation-plan.md:226` 的六个问题,不新增 collector operation、不改代码。

复用既有 `stock_daily(code, days, adjust)`(`akshare_collector.py:461`),其 provider fallback 顺序:

1. `ak.stock_zh_a_hist(symbol=code, period="daily", adjust=adjust)` — 东方财富,列 `日期/收盘/成交量/成交额`(`akshare_collector.py:481`)。
2. `ak.stock_zh_a_daily(symbol="sh600519", adjust=adjust)` — 新浪(`:482`)。
3. `ak.stock_zh_a_hist_tx(symbol="sh600519")` — 腾讯(`:483`)。
4. `adjust ∈ {"", "qfq", "hfq"}`,交易所前缀由 `stock_market` 决定(`:446`、`:477`)。

建议新增 `scripts/probe_akshare_stock_daily.py`(仅脚本,不接入 API),采样 3–5 只(沪 `600519`、深 `000001`、北 `8xxxxx`),对每只分别以 `adjust=""`/`qfq`/`hfq` 拉取并输出:

- `date`、`close`、`volume`、`amount`、`adjust`;
- **交易日真实性**:返回日期是否为真实交易日;周末/节假日是否缺行。
- **停牌**:停牌日是否缺行或 `volume==0`。
- **实际 vs 复权**:`adjust=""` 与 `qfq`/`hfq` 首尾值差异、复权基准漂移。
- **公司行动**:除权除息日附近价格跳变是否与 `adjust` 对应。
- **缺失与修订**:同日重复拉取是否修订历史行;`source_revision` 是否变化。
- **当日可用时点**:T 日收盘价何时出现(类比基金净值延迟;3d-38 已证明净值端点会滞后)。

**判定门槛**:若某类股票在 AKShare 无稳定、可追溯、日期正确的数据,UI/API 必须显示「未接入/数据不可用」,不得合成行情混入真实曲线(`docs/design.md:273`)。

## 6. 最小实施切分(建议,非本单范围)

| 步骤 | 内容 | 依赖 |
| --- | --- | --- |
| S1 | schema:`instrument_snapshots` 增 `price_kind`/`adjust`(或建 `market_price_observation`);停牌与交易日历表 | 迁移 + 回滚测试 |
| S2 | `scripts/probe_akshare_stock_daily.py` + 探针结论 | 真实解释器 |
| S3 | 交易规则:T+1、100 股整手、费用、结算、停牌拒单 | 规则版本表 |
| S4 | 公司行动/分红入账(现金/再投资) | `stock_cashflow_events` 扩展 |
| S5 | UI:搜索/选择、金额/份额输入、口径与状态标注 | S1/S3 |
| S6 | 估值 point-in-time 重算 + 无前视 + 浏览器验收 | S1–S5 |

## 7. 引用索引(file:line)

- `src/FundLab.Api/Persistence.fs:853` `instruments`;`:1131` `stock_trades`;`:1143` `stock_trade_idempotencies`;`:1151` `stock_positions`;`:1161` `stock_sells`;`:1174` `stock_cashflow_events`;`:1296` `instrument_snapshots`。
- `src/FundLab.Api/Persistence.fs:5951` `CreateStockTrade`;`:6002` 借记;`:6041` `stock_buy` 账本事件;`:6053` `stock_positions` upsert;`:7240` `BackfillCashLedger` stock_buy 批。
- `src/FundLab.Api/App.fs:2480` `createStockTrade`;`:3306` `resolveValuationPrice`;`:3317` `getFundValuation`;`:3670`–`3682` 路由;`:3630`/`:3631` market 探针路由;`:604` `MarketProbes`。
- `src/FundLab.Api/MarketDataService.fs:215` `FetchStockDaily`。
- `src/FundLab.Api/akshare_collector.py:446` `stock_market`;`:461` `stock_daily`;`:514` `stock_quote`。
- `src/FundLab.Web/App.fs:6166` 行情探针面板;`:6240` 买入/持仓/卖出;`:6326` 估值卡;`:1143` api.js 导入。
- `qa/driver/browser-test.js:795`/`:858` 股票 QA 场景;`qa/stub/fund-lab-python:48`/`:59` 桩采集器。
- `docs/design.md:107` `market_price_observation`;`:110` `valuation_snapshot`;`:223` 交易日;`:266` 探针清单;`:273` 不可用不合成。
- `docs/implementation-plan.md:226`–`231` 股票里程碑步骤。