summaryrefslogtreecommitdiff
path: root/docs/design.md
blob: dbede4f0e60db9650da46b4cfa6c3a9bbe91e8d8 (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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# fund-lab 设计说明

状态:Draft,供 Hermes 审阅。本文只定义方案,不代表应用已经实现、部署或完成远端托管。

## 1. 目标与硬约束

`fund-lab` 是部署在 `fund.somhairle.bid` 的私人单用户模拟基金平台。第一阶段必须用真实基金数据完成从采集、入库、估值到页面展示的闭环;股票和债券是后续扩展,不得为了演示而伪造真实行情。

以下约束是设计和最终验收条件:

- 源码必须位于 `git.somhairle.bid` 上独立的 `fund-lab` 仓库中。
- 数据库、行情缓存、凭证、环境文件和构建产物均不得进入 Git 历史。
- 必须先确认自托管 Git 服务的写入机制、真实仓库写入路径和权限,再创建远端仓库;不得把 cgit 浏览地址当成创建仓库 API,不得猜远端地址。
- 实施完成后必须提交并推送,然后回读远端准确分支名和提交哈希,并读取对应的 `git.somhairle.bid` 浏览页面,确认远端与本地一致。
- 本地开发不因写入路径尚未确认而停止,但“远端托管验收”在写入机制确认前不能通过。
- 本轮不修改 `strategy-lab`、Ingress、DNS、共享服务或其他项目。
- 不启用真实下单;所有交易都是可审计的模拟交易。

## 2. 已完成的只读发现

### 2.1 Git 服务

公开入口 `https://git.somhairle.bid/` 的 HTML 标识为 `cgit v1.2.3`,页脚显示 Git `2.39.1`,页面标题为 `Somhairle's Git`。因此当前已确认的是 cgit 浏览层,不是 Gitea、GitLab 或公开仓库创建 API。

根页面当前列出以下顶层仓库:

| 观察到的仓库 | 观察到的分支/信息 |
| --- | --- |
| `blog.git` | `main`、`gh-pages` 及依赖更新分支 |
| `living-village.git` | 顶层仓库 |
| `sandbox.git` | 顶层仓库 |
| `strategy-lab.git` | `main` |

目前可以只读访问已有仓库的 cgit 页面,并且已有仓库的 Git smart HTTP 读取路径可以被 `git ls-remote` 读取。已有命名证据是顶层、全小写、短横线分隔、以 `.git` 结尾;这只是观察到的规范,不能代替服务端写入配置。

以下事实仍未确认,不能在实施阶段猜测:

- 新仓库的创建入口和权限边界。
- HTTP push、SSH push、git-shell 或其他写入机制是否启用。
- bare repository 的实际文件系统路径。
- 创建后 cgit 自动发现仓库的配置和刷新方式。
- 本项目应使用的准确 remote URL、浏览 URL 和默认分支。

因此,实施阶段的第一项部署前置检查必须从服务主机配置或明确的运维入口确认上述信息。公开 cgit 页面只能用于浏览和最终回读,不能用于推断创建 API。当前写入路径未知是部署门槛,不阻塞本地代码和文档工作。

### 2.2 其他上下文

`/home/somhairle/projects/strategy-lab/README.md` 的本地读取曾受外部目录权限限制;本方案不猜测该 README 的内容。已确认的技术栈和产品要求以本项目需求为准。

## 3. 技术架构

```text
Browser
  -> existing ingress/auth boundary
  -> ASP.NET Core + Giraffe API
       -> F# domain and valuation functions
       -> PostgreSQL
       -> F# Worker
            -> thin Python AKShare collector
            -> source payload validation and provenance
  <- Fable + Feliz + Elmish + ECharts
```

### 3.1 应用组件

| 组件 | 职责 | 边界 |
| --- | --- | --- |
| Fable Web | 中文单用户界面、筛选、图表、状态管理 | 不直接访问数据库或数据源 |
| Giraffe API | 认证后的查询、模拟交易命令、数据质量状态 | 不执行真实交易 |
| F# Domain | 交易账本、估值、收益、对账、时间规则 | 尽量纯函数,使用 `decimal` |
| F# Worker | 定时采集、重试、补采、版本修订、运行记录 | 不绕过领域校验写入业务结果 |
| Python AKShare adapter | 将 AKShare 调用限制为薄适配层并输出版本化结构化数据 | 不保存凭证,不定义业务估值 |
| PostgreSQL | 业务账本、行情观测、估值快照、来源审计 | 运行时数据位于仓库外部卷 |
| ECharts | 净值、收益、现金和资产配置图表 | 只消费 API 返回的已校验数据 |

推荐的源码边界如下,实际目录可在实现时微调,但不能把运行时数据放入这些目录:

```text
src/FundLab.Domain
src/FundLab.Api
src/FundLab.Worker
src/FundLab.Web
src/FundLab.Collector.Akshare
tests/FundLab.Domain.Tests
db/migrations
ops
docs
```

## 4. 领域模型与数据边界

### 4.1 核心实体

| 实体 | 关键字段 | 说明 |
| --- | --- | --- |
| `portfolio` | `id`, `name`, `currency`, `status` | 单用户可有多个模拟组合 |
| `fund` | `id`, `name`, `currency`, `initial_unit_nav`, `unit_scale`, `status`, `created_at` | 用户自建的模拟基金;与底层可申购基金 instrument 分离 |
| `instrument` | `id`, `kind`, `symbol`, `name`, `currency`, `source` | 第一阶段 `kind=fund`;股票和债券保留扩展位 |
| `portfolio_transaction` | `id`, `portfolio_id`, `idempotency_key`, `trade_date`, `kind`, `instrument_id`, `units`, `cash_amount`, `price`, `is_synthetic` | 交易账本的不可变事实 |
| `fund_unit_ledger` | `id`, `fund_id`, `event_type`, `effective_at`, `unit_nav`, `units_delta`, `cash_amount`, `rounding_residual`, `source_order_id` | 用户自建基金单位发行/赎回和更正流水 |
| `fund_cash_flow` | `id`, `fund_id`, `flow_kind`, `requested_at`, `effective_at`, `available_at`, `gross_cash`, `credited_cash`, `converted_units`, `unit_nav`, `rounding_residual` | 外部现金流的时间和份额化记录 |
| `order` | `id`, `fund_id`, `instrument_id`, `side`, `status`, `request_hash`, `idempotency_key`, `submitted_at`, `confirmed_at` | 申购/赎回等订单状态机;未确认订单不产生已成交持仓 |
| `cash_reservation` | `fund_id`, `order_id`, `available_cash`, `frozen_cash`, `redemption_receivable`, `redemption_payable`, `settled_at` | 可用/冻结现金、底层赎回应收款、对外赎回应付款和到账状态 |
| `fund_target_weight` | `fund_id`, `instrument_id`, `target_weight`, `effective_from`, `effective_to` | 用户明确选择后的目标权重,不自动替用户选品 |
| `investment_plan` | `id`, `fund_id`, `kind`, `frequency`, `amount`, `next_run_at`, `status` | 定投和再平衡计划;执行前生成可审阅意图 |
| `fund_nav_observation` | `instrument_id`, `nav_date`, `published_at`, `nav`, `accumulated_nav`, `source`, `source_revision`, `payload_hash` | 基金净值及其来源版本 |
| `market_price_observation` | `instrument_id`, `trade_date`, `price`, `price_kind`, `source`, `payload_hash` | `actual` 与 `adjusted` 必须分开 |
| `collector_run` | `id`, `source`, `started_at`, `finished_at`, `status`, `request_fingerprint` | 采集运行、重试和失败原因 |
| `source_payload` | `run_id`, `source`, `schema_version`, `payload_hash`, `stored_at` | 原始响应或规范化快照的审计索引 |
| `valuation_snapshot` | `fund_id`, `as_of`, `cutoff`, `status`, `cash`, `market_value`, `net_assets`, `unit_nav`, `outstanding_units`, `source_revision_set` | 可重算的基金资产估值;单位净值与资产曲线分开 |
| `data_quality_event` | `entity`, `entity_id`, `severity`, `code`, `message`, `observed_at` | 缺失、过期、冲突、修订和未知状态 |

金额、份额、价格、净值和比率在领域层使用 F# `decimal`;PostgreSQL 使用足够精度的 `numeric`。不使用 `float` 作为账本或估值的持久化类型。

### 4.2 用户自建基金与单位份额

用户创建的是一个空的、人民币计价的模拟基金,不是系统替用户选择的投资组合。创建时只保存名称、初始资金和用户明确选择的目标权重;如果没有选择,基金保持空持仓,初始资金保持可用现金。

- 每个用户基金独立维护 `outstanding_units`、`net_assets` 和 `unit_nav`。总资产/净资产曲线不是单位净值曲线,两者必须使用不同的字段、API 名称和图表标签。
- 首次单位净值由创建参数明确给出,默认建议为 `1.00000000 CNY`,但不能隐式替用户定价;首次外部资金流按其 `effective_at` 和该净值发行单位。
- 申购/外部入金:`candidate_units = gross_cash / effective_unit_nav`,按单位精度截断得到 `converted_units`;实际计入基金的 `credited_cash = converted_units * effective_unit_nav`,其余为显式 `rounding_residual`,不得静默丢弃。
- 赎回/外部出金:按确认时单位净值计算金额,单位先减少;如果是底层资产赎回,金额进入资产侧 `redemption_receivable`,到账时才转为可用现金;如果是对外赎回,确认后创建负债侧 `redemption_payable`,到账后才结清。现金金额按人民币分位结算,分位以下残差进入可追踪的 residual carry,不改变单位余额。
- 份额精度、净值精度和人民币现金精度固定在 schema/config 中;所有量化操作使用同一舍入函数并记录舍入方向、原始值和残差。
- `requested_at`、`effective_at`、`confirmed_at`、`available_at` 分开保存。历史回填不能把采集时间伪装成当时已知时间。
- 单位归零时状态为 `zero_units`,不把单位净值强行显示为零;如果仍有资产或应收款,继续展示净资产和在途状态。下一次发行按记录的首次/当前有效净值重新建立单位,不能用零除。

单位净值定义为:

```text
unit_nav(t) = net_assets(t) / outstanding_units(t), when outstanding_units(t) > 0
```

`net_assets` 资产曲线、`outstanding_units` 单位曲线和 `unit_nav` 单位净值曲线分别计算和展示。没有单位时,API 返回 `unit_nav_status=zero_units`,而不是伪造 `unit_nav=0`。

### 4.3 订单状态机与在途账务

首版只接受有已验证交易规则的人民币普通开放式基金。QDII、货币基金、短债等需要单独的 `trading_rule`、估值日历和结算规则,未完成规则探针前拒绝下单,不能套用通用 T+1。

订单状态必须按以下状态机推进:

```text
submitted
  -> cash_frozen       (申购/买入)
  -> units_frozen      (赎回/卖出)
  -> pending_nav
  -> confirmed
  -> settled

submitted -> failed
cash_frozen/units_frozen/pending_nav -> cancelled
cash_frozen/units_frozen/pending_nav -> failed
```

实际只允许适用的路径;例如赎回从 `submitted` 进入 `units_frozen`,不能同时冻结现金。状态转换必须写事件和原因:

- 提交时校验基金、金额/份额、用户授权、交易日历和幂等键。
- 冻结时把资金或份额从 available 转为 frozen;冻结失败不得创建订单。
- 待净值期间不写入已成交持仓、已确认单位或已确认收益。
- 确认时原子地扣除冻结量、增加成交持仓/单位,并创建费用、分红或应收账款事件。
- 撤销/失败时释放冻结量;赎回应收款只在确认后创建,到账后才变为可用现金。
- 费用可以现金扣除或进入 `fee_payable`;分红可以进入可用现金,或由明确的再投资订单转成单位,不能隐式混用。

资产负债恒等式必须包含在途项:

```text
assets = available_cash
       + frozen_cash
       + settled_holdings_value
       + redemption_receivable
       + in_transit_assets

liabilities = redemption_payable
             + subscription_refund_payable
             + fee_payable
             + other_in_transit_liabilities

net_assets = assets - liabilities
```

`redemption_receivable` 是底层资产到账前的资产侧应收款,`redemption_payable` 是对外赎回确认后的负债侧待付款;两者必须单列,不能用一笔“总现金”掩盖。未确认订单不进入 `settled_holdings_value`,只通过冻结项和在途项影响状态/恒等式。

每个可申购 instrument 都必须关联已验证的 `trading_rule`:订单截止时间、可用净值日期、确认延迟、现金到账延迟、节假日历和费用规则。第一阶段只允许规则完整且来源可追溯的人民币普通开放式基金。

### 4.4 账本不变量

- `idempotency_key` 在组合范围内唯一;重试同一交易只能返回原交易,不能重复记账。
- 入金和出金改变现金或外部现金流,不直接产生盈利。
- 买入和卖出同时更新现金与份额;交易后现金、份额和交易明细必须可对账。
- 分红、费用、调整必须有明确交易类型和来源,不允许通过修改历史余额隐藏差异。
- 交易事实不可变;更正使用冲正或版本化修订,不覆盖审计历史。
- 合成测试交易必须带 `is_synthetic=true`,API、页面和报表均显示其状态,并与真实数据查询隔离。

### 4.5 估值和收益

在估值时点 `t`,组合价值为:

```text
net_assets(t) = available_cash(t) + frozen_cash(t)
             + redemption_receivable(t)
             + settled_holdings_value(t)
             + in_transit_assets(t)
             - redemption_payable(t)
             - in_transit_liabilities(t)

unit_nav(t) = net_assets(t) / outstanding_units(t), when outstanding_units(t) > 0
```

期间收益按外部现金流调整:

```text
profit(period) = ending_value + withdrawals - deposits - beginning_value
```

展示层可以同时显示绝对收益、收益率和现金流,但不能把入金折线标记为盈利。

`effective_price` 的选择必须记录来源和规则:

- 基金使用不晚于估值截止时间可获得的净值;用户自建基金的单位净值另按本节公式计算。
- 股票和债券后续接入时,实际价格与复权价格分列;估值默认使用实际可交易口径,回测或收益分析才显式选择复权口径。
- `published_at` 没有来源时保持 nullable;`first_seen_at` 单独记录首次被系统看到的时间。严格 point-in-time 模拟只能使用 `published_at` 明确且满足 `published_at <= cutoff`、`observation_date <= as_of` 的数据。
- 历史回填记录 `knowledge_status=backfilled` 时,只能用于“历史展示”并显示数据限制;不得在严格模拟中冒称该数据在历史时点已知。
- 当净值未知、来源冲突或已过期时,估值状态为 `unknown` 或 `incomplete`,不得伪造“今日已更新”。
- 节假日和周末使用数据源真实交易日;不把最近一个交易日冒充当天,页面需显示实际数据日期。

### 4.6 阶段 2a 纯函数账本切片

当前本地实现先覆盖不依赖数据库的账本核心,作为后续持久化和 API 的单一领域规则来源:

- `LedgerState` 以 `Map<Guid, LedgerFund>` 隔离多支基金,并以 `(fund_id, idempotency_key)` 保存请求指纹。
- `Ledger.initializeFund`、`Ledger.confirmExternalDeposit`、`Ledger.confirmExternalRedemption` 和 `Ledger.payExternalRedemption` 覆盖基金初始化、外部入金、对外赎回确认和付款。
- `Ledger.freezeUnderlyingPurchase`、`Ledger.confirmUnderlyingPurchase`、`Ledger.cancelUnderlyingPurchase` 覆盖底层申购的冻结、确认和取消。
- `Ledger.freezeUnderlyingRedemption`、`Ledger.confirmUnderlyingRedemption`、`Ledger.receiveUnderlyingRedemption` 覆盖底层赎回的份额冻结、应收款确认和到账。
- `Ledger.allocateResidual` 要求零份额状态下的舍入残差先明确归属,之后才允许重新发行单位。
- `LedgerFund.balanceSheet` 显式区分 `available_cash`、`frozen_cash`、`redemption_receivable`、`redemption_payable` 和其他在途项;底层赎回应收款不会自动变成负债。
- `Performance.timeWeightedReturn` 按调用方提供的观察点顺序计算现金流调整后的链式收益;输入时间必须严格递增。

该切片仍是内存中的纯函数模型,不代表数据库、API、真实行情采集或部署已经完成。订单当前从适用的 `cash_frozen`/`units_frozen` 路径开始,`submitted`、`pending_nav`、费用、分红、冲正和数据库事务将在后续阶段补齐。

## 5. 真实数据和 FOF 优先策略

### 5.1 采集流程

1. Worker 创建 `collector_run`,记录 source、请求范围、代码版本和开始时间。
2. Python AKShare adapter 只负责调用数据源、规范化字段、输出 schema-versioned JSON;不负责组合估值。
3. F# Worker 校验日期、数值精度、标识符、重复记录和来源版本。
4. 通过事务写入观测、来源哈希、采集运行和质量事件。
5. 页面只读取已通过领域校验的观测和估值快照。
6. 失败任务保留失败原因和重试次数;成功补采不能删除旧版本。

运行时缓存和原始数据可以放在数据库或部署配置指定的外部数据卷中,但不在 Git 工作树中。缓存失效、重建和清理必须不会破坏来源审计或交易账本。

### 5.2 FOF 第一里程碑

第一里程碑支持真实基金作为组合持仓,完成:

- 基金主数据和代码映射。
- 历史净值、累计净值和可用日期检查。
- 组合买入、卖出、入金、出金和基金净值估值。
- 净值曲线、现金、持仓、收益和数据质量状态。
- 第一阶段只需要基金作为持仓完成估值;look-through 不是本次核心,延后到数据来源稳定且规则单独验收后。

“FOF”页面可以展示基金层级的组合配置和净值贡献,但不能把基金名称、资产类别或最近一次结果推断成未经来源支持的底层持仓。

股票和债券按独立里程碑实施:

1. 数据探针:确认标识符、交易日、实际/复权价格、公司行动、缺失和修订。
2. 交易规则:建立股票交易、人民币债券交易、费用、结算和停牌规则;每类 instrument 必须绑定规则版本。
3. 领域与数据库:加入价格类型、在途资产、公司行动/利息和规则版本的账本测试。
4. 估值与回放:固定来源快照重算,分别验收实际估值和复权收益分析,验证无前视。
5. UI/API:搜索、选择、持仓金额/份额输入、数据状态和不可用原因,不能把缺失行情当零值。
6. 上线门槛:探针、账本、估值、浏览器和恢复测试全部通过后,才允许该品种进入可选列表。

若 AKShare 对某类股票或债券没有稳定、可追溯、日期正确的数据,产品应将其显示为“未接入/数据不可用”,而不是生成合成行情混入真实曲线。

## 6. API 与前端约定

建议的最小 API:

| Endpoint | 用途 |
| --- | --- |
| `GET /api/portfolio/summary?asOf=...` | 现金、持仓、总值、收益和数据状态 |
| `GET /api/portfolio/series?from=...&to=...` | 净值与收益时间序列 |
| `GET /api/portfolio/positions?asOf=...` | 持仓和使用的有效价格 |
| `POST /api/transactions` | 写入模拟交易,要求幂等键 |
| `GET /api/data-quality` | 缺失、过期、冲突和最近采集运行 |
| `POST /api/collector-runs/{id}/retry` | 认证后的失败任务重试 |
| `GET /health` | 存活检查;不泄露凭证或连接串 |
| `POST /api/funds` | 创建空的用户自建基金:名称、人民币初始资金、首次单位净值 |
| `GET /api/funds` | 列出用户自己的多支基金及单位/净资产状态 |
| `GET /api/instruments/search?q=...&kind=fund` | 按代码/名称搜索已验证可选基金 |
| `PUT /api/funds/{id}/target-weights` | 保存用户明确选择的目标权重 |
| `POST /api/funds/{id}/orders` | 以基金代码和持仓金额/份额创建订单意图,不直接写成交持仓 |
| `POST /api/funds/{id}/plans` | 创建定投或再平衡计划;执行前生成可审阅订单 |

外部 JSON 对金额、价格、份额和净值采用字符串形式,例如 `"100.00"`,避免 JavaScript number 和跨语言序列化损失;服务端领域层仍使用 `decimal`。日期使用 ISO 8601,所有响应带明确数据日期和状态。

前端使用中文、简洁字体和清晰的状态标签。创建基金界面支持多支基金、名称、初始资金、首次单位净值、基金代码搜索/选择、持仓金额输入和目标权重编辑。没有用户选择时账户/基金初始为空,不替用户选基金、不替用户分配权重、不自动产生持仓。

每个图表必须同时展示数据日期、数据来源状态、是否含合成交易和曲线口径;资产曲线必须标为“净资产/总资产”,不能标为“单位净值”。加载中、空数据、部分数据和失败状态分别渲染,不能用零值伪装缺失。

## 7. 认证、部署和安全

- 只允许一个已认证用户访问业务接口;认证由现有 ingress 和应用授权边界共同完成。
- 应用只信任部署边界明确传入的认证身份,不直接信任任意公网请求头。
- 不实现真实券商连接或下单 API。
- 密钥、数据库连接串和认证配置通过部署环境的 secret 机制注入,不写入仓库、日志、HTML 或错误响应。
- PostgreSQL 数据卷、行情缓存卷、上传/导出目录和构建目录都必须位于仓库外部;具体路径由部署环境确认,不在方案中猜测共享主机路径。
- Ingress、DNS 和共享服务配置不是本轮工作内容;部署只提交本项目所需的独立服务配置。
- 所有失败和重试日志使用 run id、payload hash 和错误码,不记录访问令牌或完整敏感响应。

## 8. Git 托管设计

### 8.1 仓库边界

目标是独立的 `fund-lab` 仓库。由于当前只确认了 cgit 浏览层,准确的 clone/push URL 必须在实施前由服务配置或明确运维入口确认。文档和脚本只能使用运行时注入的 `CONFIRMED_REMOTE_URL`,不能硬编码猜测地址。

仓库应包含源码、迁移、测试、部署模板、文档和不含秘密的 `.env.example`。以下内容禁止入库:

```text
.env
.env.*
!.env.example
credentials/
secrets/
*.pem
*.key
data/
cache/
var/
*.db
*.sqlite
bin/
obj/
dist/
build/
node_modules/
```

`.gitignore` 只是第一道防线;每次提交前还必须检查 staged 文件、已跟踪文件和构建输出,避免把已经被 Git 跟踪的秘密仅靠 ignore 掩盖。

### 8.2 托管验收

实施完成后必须保存以下证据:

1. 本地 `git rev-parse --abbrev-ref HEAD` 的准确分支名和 `git rev-parse HEAD` 的准确提交哈希。
2. 用已经确认的 remote URL 执行 `git ls-remote "$CONFIRMED_REMOTE_URL" refs/heads/$CONFIRMED_BRANCH` 的结果。
3. `git.somhairle.bid` 对应仓库浏览页面的 HTTP 读取结果,页面显示同一分支和提交哈希。
4. 本地 `git status --short` 为空,且 `git ls-files` 不含数据库、缓存、凭证、环境文件或构建产物。

只有本地哈希、远端分支哈希和浏览页面哈希三者一致,才能声称源码已托管。只创建本地仓库、只看到 cgit 列表或只看到一个可能的 URL 都不算完成。

## 9. 认证、幂等和最终部署边界

### 9.1 实际认证方案

阶段 1 和首版部署使用无 Cookie 的 Bearer token:应用从环境 secret `FUND_LAB_AUTH_TOKEN` 读取一个随机高熵 token,业务请求必须发送 `Authorization: Bearer <token>`。缺失、格式错误或常量时间比较不相等时返回 `401`;健康检查可匿名访问且只返回非敏感状态。token 不写入前端构建物、日志、URL、仓库或错误响应。

首版不建立 session cookie,因此不存在 cookie 自动发送带来的 CSRF 面;如果未来改为 cookie,会同时启用 `SameSite=Lax/Strict`、Origin/Referer 校验和服务端 anti-forgery token,未完成前不得切换。

如部署在认证 ingress 后,应用只在配置的 `FUND_LAB_TRUSTED_PROXY_CIDRS` 范围内接受转发身份;默认空范围,不信任任意 `X-Forwarded-*` 或 `X-User`。可信代理网络、TLS 终止点和转发头由部署验收记录确认,不能用 `0.0.0.0/0`。

### 9.2 数据库事务和幂等冲突

订单/交易写入在单个数据库事务内完成:校验请求、锁定 fund/balance 行、检查可用或冻结余额、写入订单和 reservation、提交状态事件,然后提交事务。不能先返回成功再异步补写账本。

`idempotency_key` 与 fund/user 范围内的 canonical request hash 唯一约束绑定:同 key 且 hash 相同返回原结果;同 key 但请求内容不同返回 `409 IDEMPOTENCY_CONFLICT`,不修改原订单,不创建第二条记录。任何唯一键冲突、余额不足或状态转换冲突都回滚该事务。

### 9.3 最终路由授权

部署准备阶段不修改共享 ingress 配置;阶段 1 也不声称已经拥有公网路由。获得明确授权并确认精确配置后,才增加 `fund.somhairle.bid` 到本项目服务的独立路由,随后验收 DNS/TLS、代理信任范围、认证拒绝、`/health`、业务接口和回滚。设计中的“暂不修改共享配置”表示当前阶段边界,不是永远只交模板。

## 10. 主要风险与未决项

| 项目 | 当前结论 | 处理方式 |
| --- | --- | --- |
| Git 写入机制 | 未由 cgit 公共页面确认 | 本地开发可继续;部署前读取服务配置并完成独立仓库创建/推送验收 |
| 债券数据覆盖 | 尚未完成探针 | 第二阶段先验证字段、日期和修订行为,再决定支持范围 |
| 基金净值发布时间 | 可能晚于交易日 | 存储 `published_at`,估值按 cutoff 拒绝前视数据 |
| 真实与合成数据混合 | 容易造成收益误读 | `is_synthetic` 强制标记,查询默认隔离 |
| 复权与实际估值 | 口径不同 | `price_kind` 分列,API 必须显式声明口径 |
| 本地缺少 Node/npm/Playwright/psql | 当前环境能力不足 | 计划中列为实施环境前置或使用容器补齐,不改变领域设计 |