diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-20 22:59:00 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-20 22:59:00 +0800 |
| commit | c60905e9e7f992a7f8c79c3812e92a44b2d606f5 (patch) | |
| tree | a895bc6f0ebe669407ffe4d1a11439e03a6fe0d4 | |
| download | fund-lab-c60905e9e7f992a7f8c79c3812e92a44b2d606f5.tar.gz | |
feat(core): 建立 fund-lab 可运行基线
[变更性质]
- 本提交冻结当前可构建、可测试的应用基线,不包含 PostgreSQL 持久化。
[新增功能]
- 建立 F# Domain、API、Worker、Web 及测试项目。
- 增加 Bearer 认证、健康检查、账本领域模型和中文空状态页面。
[实现方案]
- 使用环境变量模板注入认证配置,并排除数据、凭证和构建产物。
- 保留 19 个 Domain 测试和 5 个 API 测试作为后续变更基准。
[影响范围]
- 为后续 3a PostgreSQL FOF 创建/读取切片提供可回滚基线。
- 当前仍不接入真实基金数据、真实交易或数据库。
34 files changed, 4362 insertions, 0 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..196be11 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fable": { + "version": "4.24.0", + "commands": [ + "fable" + ], + "rollForward": false + } + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f7fd666 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +FUND_LAB_AUTH_TOKEN=replace-with-a-local-secret +FUND_LAB_TRUSTED_PROXY_CIDRS= +ASPNETCORE_URLS=http://127.0.0.1:5080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3123ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.env +.env.* +!.env.example + +credentials/ +secrets/ +*.pem +*.key + +data/ +cache/ +var/ +*.db +*.sqlite + +bin/ +obj/ +dist/ +build/ +node_modules/ +.fable/ +.tools/ diff --git a/FundLab.sln b/FundLab.sln new file mode 100644 index 0000000..a9fdb01 --- /dev/null +++ b/FundLab.sln @@ -0,0 +1,51 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Domain", "src\FundLab.Domain\FundLab.Domain.fsproj", "{11111111-1111-4111-8111-111111111111}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Api", "src\FundLab.Api\FundLab.Api.fsproj", "{22222222-2222-4222-8222-222222222222}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Worker", "src\FundLab.Worker\FundLab.Worker.fsproj", "{33333333-3333-4333-8333-333333333333}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Web", "src\FundLab.Web\FundLab.Web.fsproj", "{44444444-4444-4444-8444-444444444444}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Domain.Tests", "tests\FundLab.Domain.Tests\FundLab.Domain.Tests.fsproj", "{55555555-5555-4555-8555-555555555555}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Api.Tests", "tests\FundLab.Api.Tests\FundLab.Api.Tests.fsproj", "{66666666-6666-4666-8666-666666666666}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {11111111-1111-4111-8111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11111111-1111-4111-8111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11111111-1111-4111-8111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11111111-1111-4111-8111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU + {22222222-2222-4222-8222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22222222-2222-4222-8222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22222222-2222-4222-8222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22222222-2222-4222-8222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU + {33333333-3333-4333-8333-333333333333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33333333-3333-4333-8333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33333333-3333-4333-8333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33333333-3333-4333-8333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU + {44444444-4444-4444-8444-444444444444}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44444444-4444-4444-8444-444444444444}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44444444-4444-4444-8444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44444444-4444-4444-8444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU + {55555555-5555-4555-8555-555555555555}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55555555-5555-4555-8555-555555555555}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55555555-5555-4555-8555-555555555555}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55555555-5555-4555-8555-555555555555}.Release|Any CPU.Build.0 = Debug|Any CPU + {66666666-6666-4666-8666-666666666666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {66666666-6666-4666-8666-666666666666}.Debug|Any CPU.Build.0 = Debug|Any CPU + {66666666-6666-4666-8666-666666666666}.Release|Any CPU.ActiveCfg = Release|Any CPU + {66666666-6666-4666-8666-666666666666}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d1bfbb --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# fund-lab + +私人单用户模拟基金平台。当前仓库处于阶段 1 骨架和阶段 2a 纯 F# 账本切片阶段;尚未接入数据库、真实行情或真实下单。 + +## 本地验证 + +在仓库根目录运行: + +```sh +dotnet test +dotnet build FundLab.sln +``` + +只运行账本领域测试: + +```sh +dotnet test tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj +``` + +构建前端: + +```sh +cd src/FundLab.Web +npm install +npm run build +``` + +API 使用环境变量 `FUND_LAB_AUTH_TOKEN` 配置 Bearer token;仓库只提供 `.env.example`,不保存真实凭证。`/health` 可匿名访问,业务 API 需要 `Authorization: Bearer <token>`。 + +运行时数据库、行情缓存、凭证和构建产物不应进入 Git。远端 Git 写入机制和准确 remote URL 尚未确认,因此当前只进行本地开发和验证。 diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..dbede4f --- /dev/null +++ b/docs/design.md @@ -0,0 +1,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 | 当前环境能力不足 | 计划中列为实施环境前置或使用容器补齐,不改变领域设计 | diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..9bd3c4f --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,329 @@ +# fund-lab 实施计划 + +状态:已获 Hermes 方案批准,进入阶段 1 骨架实施;后续业务阶段仍需按本计划逐项验收。 + +## 1. 执行原则 + +- 先完成可验证的最小垂直切片,再扩展股票和债券。 +- 任何数据源不确定性都进入数据质量状态,不用假数据掩盖。 +- 交易、估值、来源修订和采集运行都可追溯。 +- Git 托管是最终硬条件;远端写入路径未确认时不阻塞本地开发,但不能通过最终部署验收。 +- 不修改其他项目、共享服务、Ingress、DNS 或 `strategy-lab`。 +- 不调用或记录任何凭证;所有需要认证的步骤使用实施环境已经提供的 secret 机制。 + +### 阶段 1 认证约定 + +- 使用无 Cookie 的 `Authorization: Bearer` token,token 来自 `FUND_LAB_AUTH_TOKEN`,缺失或不匹配的业务请求返回 `401`。 +- `/health` 匿名可读,但不暴露数据库连接串、token 或代理身份。 +- 首版不使用 cookie,因此不引入 CSRF;若以后使用 cookie,必须先加入 SameSite、Origin/Referer 和 anti-forgery 校验。 +- 只信任 `FUND_LAB_TRUSTED_PROXY_CIDRS` 中明确配置的代理网络;默认不信任转发身份,不接受任意 `X-Forwarded-*`。 +- 订单/交易写入必须在同一 DB 事务内完成;同一幂等键同一请求哈希返回原结果,不同请求哈希返回 `409 IDEMPOTENCY_CONFLICT`。 + +## 2. 阶段 0:仓库与工具前置 + +### 目标 + +让本地工作树具备独立 `fund-lab` 源码仓库的边界,同时完成远端写入机制的事实确认。 + +### 工作项 + +1. 在服务配置或明确运维入口中只读确认 Git 服务的写入协议、仓库创建步骤、bare repository 实际写入路径、权限和 cgit 刷新方式。 +2. 根据已有顶层命名证据准备独立仓库名 `fund-lab`;不得复用 `strategy-lab` 或其他仓库。 +3. 在本地初始化 `main`(若服务端确认的默认分支不同,以服务端证据为准)。 +4. 创建 `.gitignore`,加入数据库、行情缓存、凭证、环境文件和构建产物规则;保留 `.env.example`,不得保留真实值。 +5. 只在远端写入机制确认后配置 `origin`。远端 URL 由确认结果注入,不从 cgit 页面猜测。 + +### 证明命令 + +```sh +git status --short --branch +git check-ignore -v .env data/example cache/example bin/example +git ls-files +``` + +### 单一验收条件 + +本地仓库是独立 `fund-lab` 工作树,初始 staged 内容只包含源码/文档/模板;服务端写入路径和准确 remote URL 已有非秘密证据。若写入路径仍未知,阶段 0 的远端子项标记为 `BLOCKED`,但后续本地阶段可继续。 + +## 3. 阶段 1:可运行骨架(本轮立即实施) + +### 目标 + +建立 F# 全栈骨架、健康检查、实际认证边界、中文空状态页和最小测试,不接入真实交易或数据库业务。 + +### 工作项 + +- 创建 `FundLab.Domain`、`FundLab.Api`、`FundLab.Worker`、`FundLab.Web` 和测试项目。 +- 建立 Giraffe API、Fable/Feliz/Elmish 页面和统一错误响应。 +- 建立 `/health`,只报告应用和依赖状态,不回显连接串。 +- 建立单用户授权中间件,并在不满足认证时返回明确的未授权状态。 +- 建立配置读取约定:环境变量/secret 注入,仓库只有 `.env.example`。 +- 建立 F# Domain 的最小类型边界,显式区分净资产、单位净值、空基金和订单状态,暂不实现持久化。 +- 建立前端构建入口和中文空状态页,显示“尚未创建基金/尚未选择投资”,不预选基金、不自动配置资金。 +- 添加 API 认证拒绝测试、health 测试、Domain 单元测试和前端构建脚本。 + +### 验收 + +```sh +dotnet test +curl -fsS "$APP_BASE_URL/health" +``` + +浏览器能看到中文空状态页面;未认证请求不能读取组合数据;没有真实券商或下单依赖。 + +本轮阶段 1 文件预计包括:`src/FundLab.Domain`、`src/FundLab.Api`、`src/FundLab.Worker`、`src/FundLab.Web`、`tests/FundLab.Domain.Tests`、`tests/FundLab.Api.Tests`、`.gitignore` 和不含秘密的 `.env.example`。运行时数据库、缓存、凭证和构建输出不在工作树提交范围内。 + +## 4. 阶段 2:交易账本与合成数据隔离 + +### 目标 + +先用明确标记的合成数据验证记账不变量,避免在真实数据未通前构建错误估值。 + +### 工作项 + +- 实现入金、出金、买入、卖出、分红、费用和冲正的领域类型。 +- 为每个写命令要求 `idempotency_key`。 +- 实现现金、份额和交易明细的纯函数对账。 +- 所有合成交易写入 `is_synthetic=true`,默认查询与真实交易隔离。 +- 为重复请求、负数/精度、跨组合交易和冲正添加测试。 + +### 验收 + +```sh +dotnet test tests/FundLab.Domain.Tests +``` + +同一幂等键重复提交只产生一条交易;入金不会增加收益;买卖后现金和份额守恒;合成数据在页面有显式标识且不会混入真实收益图。 + +### 4.1 阶段 2a 当前进度 + +状态:纯 F# 内存账本切片已实现并通过当前 Domain 测试;阶段 2 整体尚未完成。 + +已完成: + +- `LedgerState`、多基金隔离、现金/持仓/冻结项/应收应付款和净资产恒等式。 +- 外部入金、外部赎回确认、外部赎回付款及同幂等键重放/冲突。 +- 底层申购冻结/确认/取消,以及底层赎回冻结/确认/到账。 +- 固定现金 2 位、单位/净值 8 位、舍入残差和残差归属状态。 +- synthetic provenance、零份额状态、typed domain errors 和基础 TWR 计算。 +- 当前验证命令: + + ```sh + dotnet test tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj + ``` + + 当前切片测试数为 19 个;全量解决方案测试和 .NET 构建已通过;前端 npm 构建和运行时检查仍需执行。 + +待完成: + +- `submitted -> frozen -> pending_nav -> confirmed -> settled` 的完整订单入口和非法转换矩阵。 +- 分红现金/再投资、费用、冲正/更正和更完整的现金流时间点模型。 +- 领域不变量/property tests,以及未来 PostgreSQL 事务锁和幂等约束测试。 +- API/数据库持久化、真实基金数据、默认合成数据隔离查询和页面 provenance 展示。 + +## 5. 阶段 3:PostgreSQL 和迁移 + +### 目标 + +将账本、来源审计和运行状态持久化,同时保持运行数据在仓库外。 + +### 工作项 + +- 创建 `portfolio`、`instrument`、`portfolio_transaction`、`collector_run`、`source_payload`、`data_quality_event` 和估值相关表。 +- 增加 `fund`、`fund_unit_ledger`、`fund_cash_flow`、`order`、`cash_reservation`、`fund_target_weight` 和 `investment_plan`;单位净值、净资产和总资产使用不同字段。 +- 所有金额、份额、价格和净值使用 PostgreSQL `numeric`。 +- 添加唯一约束、外键、时间索引、幂等键/请求哈希索引和来源 payload hash 索引;published_at nullable,first_seen_at 单列。 +- 迁移文件入库;数据库实例、数据目录和备份目录放到部署外部卷。 +- 测试空库迁移、重复迁移、失败回滚和从备份恢复。 + +### 验收 + +```sh +dotnet test +docker compose config +``` + +空数据库可重复迁移;迁移失败不会留下半套业务结构;`git ls-files` 不包含数据库文件、缓存或导出的数据。 + +## 6. 阶段 4:AKShare 真实基金数据探针 + +### 目标 + +在扩大产品范围前确认真实基金数据的字段、日期、缺失、修订和限流行为。 + +### 工作项 + +- 选择少量代表性基金代码,覆盖有净值、周末/节假日、缺失区间和历史修订场景。 +- Python adapter 输出 schema-versioned JSON,不直接写业务估值表。 +- Worker 记录请求范围、响应 hash、来源版本、耗时、失败原因和重试次数。 +- 对 `nav_date`、`published_at`、净值精度、重复记录和异常跳变做校验。 +- 记录哪些基金字段能支持 FOF 展示,哪些只能作为未知状态。 + +### 验收 + +一次成功采集可从原始来源索引重建规范化净值;一次失败采集保留失败记录并可重试;缺失日期在 API 和页面显示为缺失/未知,不被填成当天。 + +## 7. 阶段 5:真实基金估值和 FOF 垂直切片 + +### 目标 + +用真实基金数据完成端到端闭环。 + +### 工作项 + +- 实现基于 cutoff 的有效净值选择。 +- 实现现金加基金份额估值、用户自建基金单位发行/赎回、期间收益、收益率和数据日期展示。 +- 实现 submitted/frozen/pending_nav/confirmed/cancelled/failed/settled 状态机;未确认订单不得出现在已成交持仓。 +- 实现 available_cash、frozen_cash、redemption_receivable、in_transit_assets/liabilities 的恒等式和确认/到账分离。 +- 实现估值快照,保存使用的来源 revision 集合。 +- 对净值冲突、过期、未发布和交易日非营业日返回明确状态。 +- look-through 延后,不作为本阶段核心;没有来源时禁止推断。 +- API 返回金额、价格和净值字符串,前端按声明口径显示。 + +### 验收 + +用固定交易账本和固定来源快照重算,结果可重复;把入金日期向前移动不能改变其“盈利”分类;请求未来日期不会读取未来发布的净值;页面显示基金净值日期、来源状态和是否含合成交易。 + +首版品种白名单只接受已经验证规则的人民币普通开放式基金;QDII、货币基金等没有各自规则前必须在搜索和订单 API 中拒绝。 + +## 8. 阶段 6:用户基金、基金搜索和前端仪表盘 + +### 目标 + +让用户可以创建多支空基金并明确选择基金代码、持仓金额和目标权重,同时不替用户做投资决定。 + +### 工作项 + +- `POST /api/funds` 接受名称、人民币初始资金和首次单位净值;无选择时只保留现金,不生成持仓。 +- `GET /api/funds` 返回每支基金的净资产、单位数、单位净值状态和空/零份额状态。 +- `GET /api/instruments/search` 支持代码/名称搜索,只返回规则验证通过的基金。 +- `PUT /api/funds/{id}/target-weights` 保存用户选择的基金与权重,并校验总权重。 +- `POST /api/funds/{id}/orders` 接受基金代码及持仓金额或份额,生成订单意图,不能直接写成交持仓。 +- 空状态、搜索结果、持仓金额输入、目标权重和待净值状态全部有中文页面。 + +### 验收 + +创建两支基金后互不混淆;不选择任何基金时持仓为空;搜索必须先选择代码;输入持仓金额只创建待处理意图/订单;未确认订单不增加已成交持仓;资产曲线和单位净值曲线标签不同。 + +## 9. 阶段 7:定投与目标权重再平衡 + +### 9.1 定投 + +建立 `investment_plan(kind=scheduled_contribution)`,包含用户选择的基金、金额、频率、执行日、起止时间、失败重试和暂停状态。到期只生成待确认订单;没有可用净值、交易日或资金时显示跳过原因,不自动替换基金。每次执行使用唯一的 plan/run key,重复 Worker 不得重复冻结资金。 + +验收:模拟月度/周度计划、节假日、缺失净值、余额不足、暂停和重试;每次运行可追踪,未确认不变成持仓。 + +### 9.2 目标权重再平衡 + +建立用户明确选择的目标权重、容差和执行窗口。系统计算当前权重偏离并展示 proposed orders;只有用户确认后才进入订单状态机。禁止自动选新基金、自动改变目标权重或忽略冻结/在途资金。 + +验收:权重和校验、现金不足、待净值订单、舍入残差、零份额和确认后权重回算;同一再平衡 run key 幂等;取消不会留下冻结资产。 + +## 10. 阶段 8:股票和债券完整里程碑 + +### 10.1 股票 + +1. 探针:代码映射、交易日、实际/复权价格、公司行动、停牌和缺失修订。 +2. 规则:交易截止、结算、费用、最小交易单位、在途资产/负债。 +3. 账本:价格类型、公司行动、分红现金/再投资和订单状态测试。 +4. 估值:point-in-time 重算、复权分析分离、无前视验收。 +5. UI/API:搜索、选择、持仓金额/份额输入、不可用状态和审计信息。 +6. 上线:数据、规则、领域、API、浏览器、恢复和部署验收全部通过。 + +### 10.2 债券 + +1. 探针:人民币债券代码、收盘价/估值价、应计利息、到期日、付息日和数据发布时间。 +2. 规则:交易日历、报价/成交口径、最小单位、费用、结算和利息现金流。 +3. 账本:净价/全价、应计利息、付息、到期和再投资事件可审计。 +4. 估值:实际持仓价值与收益分析分离,历史回填标记限制,严格模拟只用 point-in-time 来源。 +5. UI/API:搜索、选择、金额输入、利息/估值日期和数据质量状态。 +6. 上线:同股票里程碑,且必须完成债券专属规则审阅;不能复用普通基金或通用 T+1。 + +look-through 在股票/债券和基金数据稳定后另立里程碑,不阻塞首版基金单位和订单闭环。 + +## 11. 阶段 11:前端仪表盘 + +### 目标 + +提供足以审计而不是只展示漂亮数字的中文界面。 + +### 工作项 + +- 总资产、现金、基金持仓、收益和数据更新时间卡片。 +- 净值/资产曲线、收益曲线、现金流和资产配置图表。 +- 交易录入和幂等错误提示。 +- 缺失、过期、冲突、部分数据和采集中状态。 +- 移动端布局和键盘/屏幕阅读器可用的状态标签。 + +### 验收 + +浏览器测试覆盖:未认证、空组合、一次入金、一次基金买入、缺失净值和采集失败;所有页面状态与 API 状态一致,没有把 `0` 当作缺失数据。 + +## 12. 阶段 12:部署准备与安全复核 + +### 工作项 + +- 构建 API、Worker、Web 和 Python adapter 的可重复构建流程。 +- PostgreSQL 使用仓库外部数据卷;行情缓存和原始响应使用仓库外部卷或数据库。 +- 当前阶段不改共享 ingress 配置;获得明确授权并确认精确变更后,增加 `fund.somhairle.bid` 到本项目服务的独立路由,并验收 DNS/TLS/认证/health/回滚。 +- 配置 secret 注入、最小权限、备份和恢复演练。 +- 扫描 staged 文件和 Git 历史中的凭证模式,检查构建产物没有被跟踪。 +- 通过健康检查、迁移检查和浏览器冒烟测试。 + +### 验收 + +删除并重建构建目录不会影响数据库;重启 Worker 不会重复记账;恢复数据库后来源索引和估值快照可用;日志不含凭证;公网只能到达认证后的业务接口。 + +## 13. 阶段 13:Git 提交、推送和最终远端回读 + +这是“源码已托管”的唯一判定流程,不能用本地仓库替代。 + +### 工作项 + +1. 提交前检查 staged 文件、忽略规则、Git 历史和构建输出。 +2. 使用服务配置确认的准确 remote URL 和准确分支创建/更新独立远端 `fund-lab` 仓库。 +3. 完成实施提交并推送;不推送数据库、行情缓存、凭证、环境文件或构建产物。 +4. 记录本地分支和 `HEAD` 哈希。 +5. 使用远端 Git 协议读取同一分支,比较远端哈希和本地哈希。 +6. 使用确认过的 cgit 浏览页读取该提交,比较页面中的分支、提交哈希和仓库名。 + +### 验收命令模板 + +以下变量必须来自已确认的服务信息,不能自行填写猜测值: + +```sh +CONFIRMED_REMOTE_URL='operator-confirmed-value' +CONFIRMED_BRANCH='operator-confirmed-value' +CONFIRMED_CGIT_REPO_URL='operator-confirmed-value' + +LOCAL_SHA=$(git rev-parse HEAD) +REMOTE_SHA=$(git ls-remote "$CONFIRMED_REMOTE_URL" "refs/heads/$CONFIRMED_BRANCH" | cut -f1) +test -n "$REMOTE_SHA" +test "$LOCAL_SHA" = "$REMOTE_SHA" +git status --short +curl -fsS "$CONFIRMED_CGIT_REPO_URL/commit/?id=$LOCAL_SHA" +``` + +命令输出需保存为审阅证据;最后一个页面响应必须人工或脚本确认包含准确仓库、分支和 `$LOCAL_SHA`。若远端协议、路径、分支或页面不能被准确确认,结果为 `BLOCKED`,不能声称托管成功。 + +## 14. 交付顺序和提交边界 + +建议每个阶段形成可回滚提交,提交信息说明行为变化,不把数据快照混入提交: + +| 顺序 | 提交内容 | 证明 | +| --- | --- | --- | +| 1 | 骨架、忽略规则、配置模板、文档 | 测试可启动,敏感文件未跟踪 | +| 2 | 领域账本和测试 | 幂等与现金/份额不变量通过 | +| 3 | 数据库迁移和审计表 | 空库迁移与回滚通过 | +| 4 | AKShare 探针和 Worker | 成功/失败/重试/修订可追踪 | +| 5 | 基金估值和 FOF 垂直切片 | 固定快照重算一致,禁止前视 | +| 6 | UI、部署模板和安全检查 | 浏览器、健康检查和恢复演练通过 | +| 7 | 最终 Git 推送与远端回读证据 | 三方哈希一致,cgit 页面可读 | + +## 15. 需 Hermes 审阅的取舍 + +1. Python AKShare 采用由 F# Worker 调用的薄 CLI,而不是独立长期服务:部署更小、领域逻辑集中,但需要明确 Python 运行时和进程失败重试边界。 +2. API 对金额等精确数值返回 JSON 字符串,而不是 JSON number:避免前端精度损失,但前端需要统一解析和格式化。 +3. FOF look-through 只接受有日期、有来源的持仓数据:牺牲覆盖率,换取不编造底层资产。 +4. cgit 写入路径未确认不阻塞本地开发,但作为部署前置和最终验收硬门槛:可以先完成代码和测试,不能跳过远端创建、推送和回读。 +5. 初始默认分支倾向使用已观察到的 `main`,但最终以服务端确认结果为准,不在方案中猜测 remote URL 或仓库创建 API。 diff --git a/docs/leader-environment-unblock.md b/docs/leader-environment-unblock.md new file mode 100644 index 0000000..d3ea84d --- /dev/null +++ b/docs/leader-environment-unblock.md @@ -0,0 +1,19 @@ +# Hermes environment verification + +Verified independently on host, for next worker/supervisor step. Do not interrupt current ledger fix to repeat discovery. + +## Frontend + +Existing node executable: /home/somhairle/projects/fund-lab/.tools/node-v22.23.2/bin/node +Actual output: v22.23.2 +With PATH=/home/somhairle/projects/fund-lab/.tools/node-v22.23.2/bin:$PATH, npm --version returns 10.9.8. +Use this PATH for frontend builds. Node/npm absence in system PATH is not missing installation; do not reinstall or report frontend blocked for this reason. + +## Git service discovery + +Host /home/somhairle/gitweb/docker-compose.yml maps /home/somhairle/git to /git in gitweb container. cgitrc uses scan-path=/git. Existing bare-style repository directories: blog.git, living-village.git, sandbox.git, strategy-lab.git under /home/somhairle/git. Thus actual local serving root has been found. Do not continue guessing web creation APIs. +Next authorized operational step: verify running mount/config, ownership/write permissions and an existing repo's bare status. Only after secret exclusions review, initialize independent fund-lab.git within confirmed root, establish exact local remote, push and compare commit hash with public cgit/remote readback. This needs precise external-directory access; if OpenCode access denied, stop and have Hermes perform authorized operational repository setup, not bypass permissions. Never modify existing repos or shared service configuration to add the new repository. Bare repository setup is now independently complete: /home/somhairle/git/fund-lab.git, verified bare=true and HEAD=refs/heads/main. Running gitweb mount was read back and maps /home/somhairle/git to /git read-write. The working tree still has no initial commit or remote; no source push or public code-tree verification has occurred. A preliminary scan of 33 nonignored candidate files found no private-key/token/password-pattern matches; repeat the scan on the exact final commit before publishing. Use this existing bare target, do not recreate it. + +## Work order + +Finish concrete ledger review regressions, freeze a tested commit, then move to database plus real fund data and visible end-to-end UI. Do not expand ledger abstractions beyond agreed requirements or repeatedly reload all design history. Keep non-overlapping operational discovery separate from the single application writer. diff --git a/docs/overnight-progress.md b/docs/overnight-progress.md new file mode 100644 index 0000000..1782d6d --- /dev/null +++ b/docs/overnight-progress.md @@ -0,0 +1,39 @@ +# Overnight implementation evidence + +## 2026-09-20 22:27 CST supervisor check + +- The sole application writer is OpenCode session `ses_f41457d90ffeBoFot4SWxUSRm5`, actual assistant metadata `openai/gpt-5.6-luna`, variant `max`. Directory-scoped status remains busy; message count advanced from 245 at the preceding tick to 289 and then 294. No pending question was found. No duplicate writer or resume was started. +- Worker tools show ledger/test corrections and subsequent documentation cleanup. Earlier compile failures and one residual-ownership test failure were followed by a passing worker test run. These historical failures are retained in OpenCode messages. +- Hermes independently executed `dotnet build FundLab.sln --no-restore`: exit 0, zero warnings and errors. Then `dotnet test FundLab.sln --no-build --no-restore`: exit 0, domain 19/19 and API 5/5. This proves the current suites pass; it does not establish full accounting coverage or release completion. The source is not yet frozen in a commit. +- Independently verified running gitweb mount `/home/somhairle/git` to `/git` and root write permission. Created only `/home/somhairle/git/fund-lab.git`; readback reports bare=true and HEAD=refs/heads/main. No other repository/service was changed. +- Preliminary secret-pattern scan over 33 nonignored candidate files returned no matches. `.gitignore` excludes env secrets, credentials, data/cache/var, private keys, build outputs, node_modules and local tools. Repeat scan over exact final commit before publication. Working tree has no initial commit or remote; source push and public tree/hash checks remain pending. + +## Supervisor follow-up: environment blocker resolved + +- Worker became idle with a BLOCKED verdict claiming missing Node/npm and Git write discovery. Hermes re-read the handoff and independently reran both builds: .NET zero warnings/errors, Domain 19/19 and API 5/5, and Fable/Vite frontend production build passed using the project-local Node PATH. No environment installation is required. +- Issued one scoped follow-up to the existing sole writer for milestone 3a: authenticated PostgreSQL-backed FOF create/read, exact money representation, transaction/idempotency and restart recovery tests. Required a reviewed-secret-exclusion baseline commit and a separately tested implementation commit. Git publication remains a separate independent gate. +- API returned 204, exact user prompt was read back, scoped status became busy. Worker execution evidence is recorded in the dispatch state; no completion is claimed. Next milestone remains actual fund selection, simulated buy and visible holdings/NAV flow. + +## Next dependency and acceptance + +Wait for the active writer's terminal verdict, inspect the ledger fixes and added regression coverage, then freeze the accepted baseline and dispatch a small database-backed FOF vertical slice. Reuse the verified bare repository above; do not repeat service discovery. Prioritize fund creation, real instrument selection, simulated buy, persistence, holdings and NAV UI. Keep calendar/no-lookahead, numeric accounting, transactional idempotency, quote timestamps and authentication gates. + +## 2026-09-20 22:44 CST supervisor check + +- Directory-scoped status is busy. Main session messages increased from 316 to 325; no pending questions. Actual assistant metadata remains `openai/gpt-5.6-luna`, variant `max`. No resume or duplicate implementation session was dispatched. +- A second busy session `ses_f40bc99f9ffe1mA4jUBLdfVofV` is the main writer's architecture-review child. Its explicit instruction is read-only; observed tools are reads/globs, not application writes. Its session metadata confirms the parent relationship. Some glob calls failed; the child has not yet returned a terminal verdict. +- Newly completed worker commands show .NET build exit 0 with zero warnings/errors, Domain 19/19 and API 5/5 tests, and Fable/Vite build exit 0. These are worker execution evidence, not a fresh independent acceptance run. +- Independent filesystem inspection still finds only the pre-existing API/test source files; no PostgreSQL slice is present yet. The bare target still has no `refs/heads/main`, so Git publication remains pending. Do not claim DB persistence or remote hosting from baseline builds. +- Current dependency is the active read-only architecture review and main writer's baseline commit/3a implementation. Preserve the active writer; next tick inspect review completion, actual new source/tests and baseline commit before dispatching further work. Public UI, real quotes and restart/idempotency gates remain open. + +## 2026-09-20 22:52 CST supervisor check + +- Main writer remains busy at 325 messages, waiting for its read-only architecture child. Child has 28 messages and successful read tools through 22:52 CST, so the dependency is advancing; no pending question, duplicate writer or resume was introduced. Main assistant metadata remains openai/gpt-5.6-luna with max variant. +- Independent filesystem read still shows the original four API F# files; no PostgreSQL implementation is present. Current wait is specifically the active architecture review, not a running database test. Inspect child verdict and main writer's first implementation artifact next tick. +- Independent public probes of fund.somhairle.bid /health and /api/funds both failed TLS handshake with SSLEOFError. This records unavailable public acceptance from this host, without diagnosing DNS/tunnel cause or changing ingress. Deployment remains open. +- https://git.somhairle.bid/fund-lab.git/ returned HTTP 200 and a cgit page naming fund-lab.git. This proves repository browser discovery only; commit/tree/hash publication is still unverified. +- No fresh builds were run against the unchanged API slice, and no completion notice was created. No cron configuration was changed. + +## Open completion gates + +Real AKShare retrieval and dates; PostgreSQL integration/restart/idempotency; complete browser business flows and charts; authenticated public deployment; exact committed/remote/public Git hash; periodic investment/rebalancing and later direct stocks/bonds scope; quiet terminal notifier lifecycle. No completion notice exists and no release is claimed. Morning remains a progress checkpoint. This tick did not create or update cron jobs. diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs new file mode 100644 index 0000000..44628ab --- /dev/null +++ b/src/FundLab.Api/App.fs @@ -0,0 +1,53 @@ +namespace FundLab.Api + +open System +open Giraffe +open Microsoft.AspNetCore.Http + +type EmptyPortfolioResponse = + { + status: string + message: string + } + +module App = + let private unauthorized : HttpHandler = + setStatusCode 401 + >=> setHttpHeader "WWW-Authenticate" "Bearer" + >=> text "Unauthorized" + + let private requireBearer (next: HttpFunc) (ctx: HttpContext) = + let expectedToken = + Environment.GetEnvironmentVariable("FUND_LAB_AUTH_TOKEN") + |> Option.ofObj + |> Option.defaultValue "" + + let authorizationHeader = ctx.Request.Headers.Authorization.ToString() + + match Authentication.authorize expectedToken authorizationHeader with + | AuthDecision.Authorized -> next ctx + | AuthDecision.Unauthorized -> unauthorized next ctx + + let private health : HttpHandler = + json ({ + service = "fund-lab-api" + status = "ok" + } : HealthResponse) + + let private emptyPortfolio : HttpHandler = + json { + status = "empty" + message = "尚未创建基金/尚未选择投资" + } + + let createApplication () : HttpHandler = + choose [ + GET >=> route "/health" >=> health + subRoute "/api" ( + requireBearer + >=> choose [ + GET >=> route "/portfolio/summary" >=> emptyPortfolio + ] + ) + setStatusCode 404 >=> text "Not Found" + ] diff --git a/src/FundLab.Api/Authentication.fs b/src/FundLab.Api/Authentication.fs new file mode 100644 index 0000000..e98d71e --- /dev/null +++ b/src/FundLab.Api/Authentication.fs @@ -0,0 +1,25 @@ +namespace FundLab.Api + +open System +open System.Security.Cryptography +open System.Text + +type AuthDecision = + | Authorized + | Unauthorized + +module Authentication = + let authorize (expectedToken: string) (authorizationHeader: string) : AuthDecision = + if String.IsNullOrWhiteSpace expectedToken + || String.IsNullOrWhiteSpace authorizationHeader + || not (authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) then + AuthDecision.Unauthorized + else + let presentedToken = authorizationHeader.Substring("Bearer ".Length) + let expectedBytes = Encoding.UTF8.GetBytes expectedToken + let presentedBytes = Encoding.UTF8.GetBytes presentedToken + + if CryptographicOperations.FixedTimeEquals(expectedBytes, presentedBytes) then + AuthDecision.Authorized + else + AuthDecision.Unauthorized diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj new file mode 100644 index 0000000..81952df --- /dev/null +++ b/src/FundLab.Api/FundLab.Api.fsproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Giraffe" Version="6.0.0" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\FundLab.Domain\FundLab.Domain.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="Authentication.fs" /> + <Compile Include="Health.fs" /> + <Compile Include="App.fs" /> + <Compile Include="Program.fs" /> + </ItemGroup> +</Project> diff --git a/src/FundLab.Api/Health.fs b/src/FundLab.Api/Health.fs new file mode 100644 index 0000000..719446f --- /dev/null +++ b/src/FundLab.Api/Health.fs @@ -0,0 +1,16 @@ +namespace FundLab.Api + +open System.Text.Json + +type HealthResponse = + { + service: string + status: string + } + +module Health = + let payload (serviceName: string) : string = + JsonSerializer.Serialize<HealthResponse> { + service = serviceName + status = "ok" + } diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs new file mode 100644 index 0000000..d5cc0b2 --- /dev/null +++ b/src/FundLab.Api/Program.fs @@ -0,0 +1,15 @@ +module FundLab.Api.Program + +open Giraffe +open Microsoft.AspNetCore.Builder +open Microsoft.Extensions.DependencyInjection + +[<EntryPoint>] +let main argv = + let builder = WebApplication.CreateBuilder(argv) + builder.Services.AddGiraffe() |> ignore + + let app = builder.Build() + app.UseGiraffe(App.createApplication()) + app.Run() + 0 diff --git a/src/FundLab.Domain/Domain.fs b/src/FundLab.Domain/Domain.fs new file mode 100644 index 0000000..9c42d5a --- /dev/null +++ b/src/FundLab.Domain/Domain.fs @@ -0,0 +1,60 @@ +namespace FundLab.Domain + +open System + +type FundStatus = + | Empty + | Active + | ZeroUnits + +type UnitNavStatus = + | HasValue of decimal + | ZeroUnits + +type Fund = + { + Id: Guid + Name: string + Currency: string + InitialCash: decimal + InitialUnitNav: decimal + NetAssets: decimal + OutstandingUnits: decimal + Status: FundStatus + } + +module Fund = + let create (name: string) (initialCash: decimal) (initialUnitNav: decimal) : Fund = + { + Id = Guid.NewGuid() + Name = name + Currency = "CNY" + InitialCash = initialCash + InitialUnitNav = initialUnitNav + NetAssets = initialCash + OutstandingUnits = 0m + Status = FundStatus.Empty + } + + let unitNavStatus (fund: Fund) : UnitNavStatus = + if fund.OutstandingUnits = 0m then + UnitNavStatus.ZeroUnits + else + UnitNavStatus.HasValue (fund.NetAssets / fund.OutstandingUnits) + +type OrderStatus = + | Submitted + | CashFrozen + | UnitsFrozen + | PendingNav + | Confirmed + | Settled + | Cancelled + | Failed + +module OrderStatus = + let isConfirmed status = + match status with + | OrderStatus.Confirmed + | OrderStatus.Settled -> true + | _ -> false diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj new file mode 100644 index 0000000..9e46c00 --- /dev/null +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -0,0 +1,13 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <Compile Include="Domain.fs" /> + <Compile Include="Ledger.fs" /> + <Compile Include="Performance.fs" /> + </ItemGroup> +</Project> diff --git a/src/FundLab.Domain/Ledger.fs b/src/FundLab.Domain/Ledger.fs new file mode 100644 index 0000000..2937ca6 --- /dev/null +++ b/src/FundLab.Domain/Ledger.fs @@ -0,0 +1,921 @@ +namespace FundLab.Domain + +open System + +type ResidualOwner = + | FundReserve + | ExternalParty + +type ResidualState = + | Clear + | RequiresAllocation of decimal + | Allocated of decimal * ResidualOwner + +type Holding = + { + InstrumentId: Guid + AvailableUnits: decimal + FrozenUnits: decimal + SettledValue: decimal + } + +type BalanceSheet = + { + AvailableCash: decimal + FrozenCash: decimal + SettledHoldingsValue: decimal + RedemptionReceivable: decimal + OtherInTransitAssets: decimal + Assets: decimal + RedemptionPayable: decimal + SubscriptionRefundPayable: decimal + FeePayable: decimal + OtherInTransitLiabilities: decimal + Liabilities: decimal + NetAssets: decimal + } + +type CashFlowKind = + | ExternalDeposit + | ExternalRedemption + | ExternalRedemptionPayment + | UnderlyingPurchase + | UnderlyingRedemption + | UnderlyingRedemptionReceipt + +type CashFlow = + { + TransactionId: Guid + FundId: Guid + Kind: CashFlowKind + EffectiveAt: DateTimeOffset + GrossCash: decimal + CreditedCash: decimal + ConvertedUnits: decimal + UnitNav: decimal option + RoundingResidual: decimal + ResidualOwner: ResidualOwner option + IsSynthetic: bool + } + +type ExternalRedemptionStatus = + | PendingPayment + | Paid + +type ExternalRedemptionRecord = + { + TransactionId: Guid + FundId: Guid + IdempotencyKey: string + Units: decimal + UnitNav: decimal + PayableCash: decimal + Status: ExternalRedemptionStatus + ConfirmedAt: DateTimeOffset + PaidAt: DateTimeOffset option + IsSynthetic: bool + } + +type ResidualAllocationRecord = + { + TransactionId: Guid + FundId: Guid + Amount: decimal + Owner: ResidualOwner + EffectiveAt: DateTimeOffset + IsSynthetic: bool + } + +type LedgerEvent = + { + TransactionId: Guid + FundId: Guid + EventType: string + EffectiveAt: DateTimeOffset + CashAmount: decimal + Units: decimal + RoundingResidual: decimal + ResidualOwner: ResidualOwner option + IsSynthetic: bool + } + +type LedgerOrderKind = + | Purchase + | Redemption + +type LedgerOrder = + { + Id: Guid + FundId: Guid + InstrumentId: Guid + Kind: LedgerOrderKind + IdempotencyKey: string + Status: OrderStatus + RequestedCash: decimal + RequestedUnits: decimal + ConfirmedCash: decimal + ConfirmedUnits: decimal + RoundingResidual: decimal + ResidualOwner: ResidualOwner option + IsSynthetic: bool + SubmittedAt: DateTimeOffset + ConfirmedAt: DateTimeOffset option + } + +type LedgerFund = + { + Id: Guid + Name: string + Currency: string + InitialCash: decimal + InitialUnitNav: decimal + IsSynthetic: bool + AvailableCash: decimal + FrozenCash: decimal + Holdings: Map<Guid, Holding> + OutstandingUnits: decimal + RedemptionPayable: decimal + RedemptionReceivable: decimal + OtherInTransitAssets: decimal + SubscriptionRefundPayable: decimal + FeePayable: decimal + OtherInTransitLiabilities: decimal + Status: FundStatus + Orders: Map<Guid, LedgerOrder> + ExternalRedemptions: Map<Guid, ExternalRedemptionRecord> + CashFlows: CashFlow list + Events: LedgerEvent list + ResidualAllocations: ResidualAllocationRecord list + ResidualState: ResidualState + } + with + member this.SettledHoldingsValue = + this.Holdings + |> Map.toSeq + |> Seq.sumBy (fun (_, holding) -> holding.SettledValue) + + member this.balanceSheet = + let settledHoldingsValue = this.SettledHoldingsValue + let assets = + this.AvailableCash + + this.FrozenCash + + settledHoldingsValue + + this.RedemptionReceivable + + this.OtherInTransitAssets + + let liabilities = + this.RedemptionPayable + + this.SubscriptionRefundPayable + + this.FeePayable + + this.OtherInTransitLiabilities + + { + AvailableCash = this.AvailableCash + FrozenCash = this.FrozenCash + SettledHoldingsValue = settledHoldingsValue + RedemptionReceivable = this.RedemptionReceivable + OtherInTransitAssets = this.OtherInTransitAssets + Assets = assets + RedemptionPayable = this.RedemptionPayable + SubscriptionRefundPayable = this.SubscriptionRefundPayable + FeePayable = this.FeePayable + OtherInTransitLiabilities = this.OtherInTransitLiabilities + Liabilities = liabilities + NetAssets = assets - liabilities + } + + member this.BalanceSheet = this.balanceSheet + member this.NetAssets = this.balanceSheet.NetAssets + member this.UnitNavStatus = + if this.OutstandingUnits = 0m then + UnitNavStatus.ZeroUnits + else + UnitNavStatus.HasValue (this.NetAssets / this.OutstandingUnits) + +type LedgerState = + { + Funds: Map<Guid, LedgerFund> + Idempotencies: Map<Guid * string, string> + } + +type LedgerError = + | FundNotFound of Guid + | FundAlreadyExists of Guid + | OrderNotFound of Guid + | ExternalRedemptionNotFound of Guid + | IdempotencyConflict of string + | InvalidIdentifier of string + | InvalidAmount of string + | InvalidPrecision of string + | InvalidState of string + | TransactionAlreadyExists of Guid + | InsufficientCash of decimal + | InsufficientUnits of decimal + | ResidualOwnershipRequired of decimal + +module Ledger = + type private ResultBuilder() = + member _.Bind(result, binder) = Result.bind binder result + member _.Return(value) = Ok value + member _.ReturnFrom(result) = result + member _.Zero() = Ok() + member _.Combine(first, second) = Result.bind (fun () -> second) first + member _.Delay(generator) = generator() + + let private result = ResultBuilder() + + [<Literal>] + let CashScale = 2 + + [<Literal>] + let UnitScale = 8 + + let empty = + { + Funds = Map.empty + Idempotencies = Map.empty + } + + let private decimalScale value = + let bits = Decimal.GetBits(value) + int ((bits[3] >>> 16) &&& 0x7f) + + let private roundDown scale value = + Decimal.Round(value, scale, MidpointRounding.ToZero) + + let private validateNonNegative label scale value = + if value < 0m then + Error(InvalidAmount label) + elif decimalScale value > scale then + Error(InvalidPrecision label) + else + Ok() + + let private validatePositive label scale value = + validateNonNegative label scale value + |> Result.bind (fun () -> + if value = 0m then + Error(InvalidAmount label) + else + Ok()) + + let private fundStatus outstandingUnits = + if outstandingUnits = 0m then + FundStatus.ZeroUnits + else + FundStatus.Active + + let getFund fundId state = + match state.Funds |> Map.tryFind fundId with + | Some fund -> Ok fund + | None -> Error(FundNotFound fundId) + + let private updateFund fund state = + { state with Funds = state.Funds |> Map.add fund.Id fund } + + let private validateIdentifier label value = + if value = Guid.Empty then + Error(InvalidIdentifier label) + else + Ok() + + let private transactionExists transactionId fund = + fund.Events |> List.exists (fun event -> event.TransactionId = transactionId) + || fund.CashFlows |> List.exists (fun cashFlow -> cashFlow.TransactionId = transactionId) + || fund.ExternalRedemptions |> Map.containsKey transactionId + || fund.ResidualAllocations |> List.exists (fun allocation -> allocation.TransactionId = transactionId) + + let private ensureTransactionIsNew transactionId fund = + if transactionExists transactionId fund then + Error(TransactionAlreadyExists transactionId) + else + Ok() + + let private addResidual residual residualState = + if residual = 0m then + residualState + else + match residualState with + | Clear -> RequiresAllocation residual + | RequiresAllocation existing -> RequiresAllocation(existing + residual) + | Allocated _ -> RequiresAllocation residual + + let private runIdempotent fundId key fingerprint state action = + if fundId = Guid.Empty then + Error(InvalidIdentifier "fund id") + elif String.IsNullOrWhiteSpace key then + Error(InvalidState "idempotency key cannot be empty") + else + match state.Idempotencies |> Map.tryFind (fundId, key) with + | Some previous when previous = fingerprint -> Ok state + | Some _ -> Error(IdempotencyConflict key) + | None -> + action state + |> Result.map (fun next -> + { + next with + Idempotencies = next.Idempotencies |> Map.add (fundId, key) fingerprint + }) + + let initializeFund fundId name initialCash initialUnitNav isSynthetic state = + result { + do! validateIdentifier "fund id" fundId + do! validateNonNegative "initial cash" CashScale initialCash + do! validatePositive "initial unit NAV" UnitScale initialUnitNav + + if String.IsNullOrWhiteSpace name then + return! Error(InvalidState "fund name cannot be empty") + + if state.Funds |> Map.containsKey fundId then + return! Error(FundAlreadyExists fundId) + + let fund = + { + Id = fundId + Name = name + Currency = "CNY" + InitialCash = initialCash + InitialUnitNav = initialUnitNav + IsSynthetic = isSynthetic + AvailableCash = initialCash + FrozenCash = 0m + Holdings = Map.empty + OutstandingUnits = 0m + RedemptionPayable = 0m + RedemptionReceivable = 0m + OtherInTransitAssets = 0m + SubscriptionRefundPayable = 0m + FeePayable = 0m + OtherInTransitLiabilities = 0m + Status = FundStatus.Empty + Orders = Map.empty + ExternalRedemptions = Map.empty + CashFlows = [] + Events = [] + ResidualAllocations = [] + ResidualState = Clear + } + + return updateFund fund state + } + + let private canIssueUnits fund = + match fund.ResidualState with + | RequiresAllocation residual when fund.OutstandingUnits = 0m -> + Error(ResidualOwnershipRequired residual) + | _ -> Ok() + + let private addEvent transactionId eventType at cashAmount units residual owner isSynthetic fund = + { + fund with + Events = + { + TransactionId = transactionId + FundId = fund.Id + EventType = eventType + EffectiveAt = at + CashAmount = cashAmount + Units = units + RoundingResidual = residual + ResidualOwner = owner + IsSynthetic = isSynthetic + } + :: fund.Events + } + + let confirmExternalDeposit fundId transactionId idempotencyKey grossCash unitNav effectiveAt isSynthetic state = + let fingerprint = sprintf "external-deposit|%O|%M|%M|%O|%b" transactionId grossCash unitNav effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "transaction id" transactionId + do! validatePositive "external deposit cash" CashScale grossCash + do! validatePositive "external deposit unit NAV" UnitScale unitNav + + let! fund = getFund fundId current + do! ensureTransactionIsNew transactionId fund + do! canIssueUnits fund + + let convertedUnits = roundDown UnitScale (grossCash / unitNav) + if convertedUnits = 0m then + return! Error(InvalidAmount "external deposit does not create units") + + let creditedCash = roundDown CashScale (convertedUnits * unitNav) + let residual = grossCash - creditedCash + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash + creditedCash + OutstandingUnits = fund.OutstandingUnits + convertedUnits + Status = FundStatus.Active + ResidualState = addResidual residual fund.ResidualState + CashFlows = + { + TransactionId = transactionId + FundId = fundId + Kind = CashFlowKind.ExternalDeposit + EffectiveAt = effectiveAt + GrossCash = grossCash + CreditedCash = creditedCash + ConvertedUnits = convertedUnits + UnitNav = Some unitNav + RoundingResidual = residual + ResidualOwner = None + IsSynthetic = isSynthetic + } + :: fund.CashFlows + } + |> addEvent transactionId "external_deposit_confirmed" effectiveAt creditedCash convertedUnits residual None isSynthetic + + return updateFund updatedFund current + }) + + let private orderAlreadyExists orderId fund = + if fund.Orders |> Map.containsKey orderId then + Error(InvalidState "order already exists") + else + Ok() + + let private emptyHolding instrumentId = + { + InstrumentId = instrumentId + AvailableUnits = 0m + FrozenUnits = 0m + SettledValue = 0m + } + + let private addSettledHolding instrumentId units value holdings = + let holding = holdings |> Map.tryFind instrumentId |> Option.defaultValue (emptyHolding instrumentId) + + holdings + |> Map.add instrumentId { + holding with + AvailableUnits = holding.AvailableUnits + units + SettledValue = holding.SettledValue + value + } + + let private addCashFlow cashFlow fund = + { fund with CashFlows = cashFlow :: fund.CashFlows } + + let private missingOrder orderId = + Error(OrderNotFound orderId) + + let freezeUnderlyingPurchase fundId orderId idempotencyKey instrumentId requestedCash effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-purchase-freeze|%O|%O|%M|%O|%b" orderId instrumentId requestedCash effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + do! validateIdentifier "instrument id" instrumentId + do! validatePositive "underlying purchase cash" CashScale requestedCash + let! fund = getFund fundId current + do! orderAlreadyExists orderId fund + do! ensureTransactionIsNew orderId fund + + if fund.AvailableCash < requestedCash then + return! Error(InsufficientCash requestedCash) + + let order = + { + Id = orderId + FundId = fundId + InstrumentId = instrumentId + Kind = LedgerOrderKind.Purchase + IdempotencyKey = idempotencyKey + Status = OrderStatus.CashFrozen + RequestedCash = requestedCash + RequestedUnits = 0m + ConfirmedCash = 0m + ConfirmedUnits = 0m + RoundingResidual = 0m + ResidualOwner = None + IsSynthetic = isSynthetic + SubmittedAt = effectiveAt + ConfirmedAt = None + } + + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash - requestedCash + FrozenCash = fund.FrozenCash + requestedCash + Orders = fund.Orders |> Map.add orderId order + } + |> addEvent orderId "underlying_purchase_frozen" effectiveAt requestedCash 0m 0m None isSynthetic + + return updateFund updatedFund current + }) + + let confirmUnderlyingPurchase fundId orderId idempotencyKey confirmedUnits unitNav effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-purchase-confirm|%O|%M|%M|%O|%b" orderId confirmedUnits unitNav effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + do! validatePositive "underlying purchase units" UnitScale confirmedUnits + do! validatePositive "underlying purchase unit NAV" UnitScale unitNav + let! fund = getFund fundId current + let! order = fund.Orders |> Map.tryFind orderId |> Option.map Ok |> Option.defaultValue (missingOrder orderId) + + if order.Kind <> LedgerOrderKind.Purchase || order.Status <> OrderStatus.CashFrozen then + return! Error(InvalidState "underlying purchase is not cash frozen") + + let grossCash = confirmedUnits * unitNav + let confirmedCash = roundDown CashScale grossCash + if confirmedCash <= 0m then + return! Error(InvalidAmount "underlying purchase cash rounds to zero") + + if confirmedCash > order.RequestedCash then + return! Error(InvalidState "underlying purchase exceeds frozen cash") + + let residual = grossCash - confirmedCash + let updatedOrder = + { + order with + Status = OrderStatus.Confirmed + ConfirmedCash = confirmedCash + ConfirmedUnits = confirmedUnits + RoundingResidual = residual + ResidualOwner = None + ConfirmedAt = Some effectiveAt + } + + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash + order.RequestedCash - confirmedCash + FrozenCash = fund.FrozenCash - order.RequestedCash + Holdings = fund.Holdings |> addSettledHolding order.InstrumentId confirmedUnits confirmedCash + Orders = fund.Orders |> Map.add orderId updatedOrder + ResidualState = addResidual residual fund.ResidualState + } + |> addCashFlow { + TransactionId = orderId + FundId = fundId + Kind = CashFlowKind.UnderlyingPurchase + EffectiveAt = effectiveAt + GrossCash = grossCash + CreditedCash = confirmedCash + ConvertedUnits = confirmedUnits + UnitNav = Some unitNav + RoundingResidual = residual + ResidualOwner = None + IsSynthetic = isSynthetic + } + |> addEvent orderId "underlying_purchase_confirmed" effectiveAt confirmedCash confirmedUnits residual None isSynthetic + + return updateFund updatedFund current + }) + + let cancelUnderlyingPurchase fundId orderId idempotencyKey effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-purchase-cancel|%O|%O|%b" orderId effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + let! fund = getFund fundId current + let! order = fund.Orders |> Map.tryFind orderId |> Option.map Ok |> Option.defaultValue (missingOrder orderId) + + if order.Kind <> LedgerOrderKind.Purchase || order.Status <> OrderStatus.CashFrozen then + return! Error(InvalidState "underlying purchase is not cancellable") + + let updatedOrder = { order with Status = OrderStatus.Cancelled } + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash + order.RequestedCash + FrozenCash = fund.FrozenCash - order.RequestedCash + Orders = fund.Orders |> Map.add orderId updatedOrder + } + |> addEvent orderId "underlying_purchase_cancelled" effectiveAt order.RequestedCash 0m 0m None isSynthetic + + return updateFund updatedFund current + }) + + let freezeUnderlyingRedemption fundId orderId idempotencyKey instrumentId requestedUnits effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-redemption-freeze|%O|%O|%M|%O|%b" orderId instrumentId requestedUnits effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + do! validateIdentifier "instrument id" instrumentId + do! validatePositive "underlying redemption units" UnitScale requestedUnits + let! fund = getFund fundId current + do! orderAlreadyExists orderId fund + do! ensureTransactionIsNew orderId fund + let! holding = fund.Holdings |> Map.tryFind instrumentId |> Option.map Ok |> Option.defaultValue (Error(InvalidState "holding not found")) + + if holding.AvailableUnits < requestedUnits then + return! Error(InsufficientUnits requestedUnits) + + let updatedHolding = + { + holding with + AvailableUnits = holding.AvailableUnits - requestedUnits + FrozenUnits = holding.FrozenUnits + requestedUnits + } + + let order = + { + Id = orderId + FundId = fundId + InstrumentId = instrumentId + Kind = LedgerOrderKind.Redemption + IdempotencyKey = idempotencyKey + Status = OrderStatus.UnitsFrozen + RequestedCash = 0m + RequestedUnits = requestedUnits + ConfirmedCash = 0m + ConfirmedUnits = 0m + RoundingResidual = 0m + ResidualOwner = None + IsSynthetic = isSynthetic + SubmittedAt = effectiveAt + ConfirmedAt = None + } + + let updatedFund = + { + fund with + Holdings = fund.Holdings |> Map.add instrumentId updatedHolding + Orders = fund.Orders |> Map.add orderId order + } + |> addEvent orderId "underlying_redemption_frozen" effectiveAt 0m requestedUnits 0m None isSynthetic + + return updateFund updatedFund current + }) + + let private releaseFrozenHolding holding units = + let totalUnits = holding.AvailableUnits + holding.FrozenUnits + let releasedValue = + if units = totalUnits then + holding.SettledValue + else + roundDown CashScale (holding.SettledValue * units / totalUnits) + + let remaining = + { + holding with + FrozenUnits = holding.FrozenUnits - units + SettledValue = holding.SettledValue - releasedValue + } + + remaining, releasedValue + + let confirmUnderlyingRedemption fundId orderId idempotencyKey unitNav effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-redemption-confirm|%O|%M|%O|%b" orderId unitNav effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + do! validatePositive "underlying redemption unit NAV" UnitScale unitNav + let! fund = getFund fundId current + let! order = fund.Orders |> Map.tryFind orderId |> Option.map Ok |> Option.defaultValue (missingOrder orderId) + + if order.Kind <> LedgerOrderKind.Redemption || order.Status <> OrderStatus.UnitsFrozen then + return! Error(InvalidState "underlying redemption is not units frozen") + + let! holding = fund.Holdings |> Map.tryFind order.InstrumentId |> Option.map Ok |> Option.defaultValue (Error(InvalidState "holding not found")) + if holding.FrozenUnits < order.RequestedUnits then + return! Error(InsufficientUnits order.RequestedUnits) + + let grossCash = order.RequestedUnits * unitNav + let receivable = roundDown CashScale grossCash + if receivable <= 0m then + return! Error(InvalidAmount "underlying redemption cash rounds to zero") + + let residual = grossCash - receivable + let updatedHolding, _ = releaseFrozenHolding holding order.RequestedUnits + let updatedHoldings = + if updatedHolding.AvailableUnits = 0m && updatedHolding.FrozenUnits = 0m then + fund.Holdings |> Map.remove order.InstrumentId + else + fund.Holdings |> Map.add order.InstrumentId updatedHolding + + let updatedOrder = + { + order with + Status = OrderStatus.Confirmed + ConfirmedCash = receivable + ConfirmedUnits = order.RequestedUnits + RoundingResidual = residual + ResidualOwner = None + ConfirmedAt = Some effectiveAt + } + + let updatedFund = + { + fund with + Holdings = updatedHoldings + RedemptionReceivable = fund.RedemptionReceivable + receivable + Orders = fund.Orders |> Map.add orderId updatedOrder + ResidualState = addResidual residual fund.ResidualState + } + |> addCashFlow { + TransactionId = orderId + FundId = fundId + Kind = CashFlowKind.UnderlyingRedemption + EffectiveAt = effectiveAt + GrossCash = grossCash + CreditedCash = receivable + ConvertedUnits = order.RequestedUnits + UnitNav = Some unitNav + RoundingResidual = residual + ResidualOwner = None + IsSynthetic = isSynthetic + } + |> addEvent orderId "underlying_redemption_confirmed" effectiveAt receivable order.RequestedUnits residual None isSynthetic + + return updateFund updatedFund current + }) + + let receiveUnderlyingRedemption fundId orderId idempotencyKey effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-redemption-receive|%O|%O|%b" orderId effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + let! fund = getFund fundId current + let! order = fund.Orders |> Map.tryFind orderId |> Option.map Ok |> Option.defaultValue (missingOrder orderId) + + if order.Kind <> LedgerOrderKind.Redemption || order.Status <> OrderStatus.Confirmed then + return! Error(InvalidState "underlying redemption is not confirmed") + + if fund.RedemptionReceivable < order.ConfirmedCash then + return! Error(InvalidState "underlying redemption receivable is unavailable") + + let updatedOrder = { order with Status = OrderStatus.Settled } + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash + order.ConfirmedCash + RedemptionReceivable = fund.RedemptionReceivable - order.ConfirmedCash + Orders = fund.Orders |> Map.add orderId updatedOrder + } + |> addCashFlow { + TransactionId = orderId + FundId = fundId + Kind = CashFlowKind.UnderlyingRedemptionReceipt + EffectiveAt = effectiveAt + GrossCash = order.ConfirmedCash + CreditedCash = order.ConfirmedCash + ConvertedUnits = 0m + UnitNav = None + RoundingResidual = 0m + ResidualOwner = None + IsSynthetic = isSynthetic + } + |> addEvent orderId "underlying_redemption_received" effectiveAt order.ConfirmedCash 0m 0m None isSynthetic + + return updateFund updatedFund current + }) + + let allocateResidual fundId transactionId idempotencyKey owner effectiveAt isSynthetic state = + let fingerprint = sprintf "residual-allocation|%O|%A|%O|%b" transactionId owner effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "transaction id" transactionId + let! fund = getFund fundId current + + match fund.ResidualState with + | RequiresAllocation residual -> + if fund.OutstandingUnits <> 0m then + return! Error(InvalidState "residual allocation requires zero outstanding units") + + do! ensureTransactionIsNew transactionId fund + let allocation = + { + TransactionId = transactionId + FundId = fundId + Amount = residual + Owner = owner + EffectiveAt = effectiveAt + IsSynthetic = isSynthetic + } + + let updatedFund = + { + fund with + ResidualState = Allocated(residual, owner) + ResidualAllocations = allocation :: fund.ResidualAllocations + } + |> addEvent transactionId "residual_allocated" effectiveAt 0m 0m residual (Some owner) isSynthetic + + return updateFund updatedFund current + | _ -> + return! Error(InvalidState "no residual requires allocation") + }) + + let confirmExternalRedemption fundId transactionId idempotencyKey units unitNav effectiveAt isSynthetic state = + let fingerprint = sprintf "external-redemption|%O|%M|%M|%O|%b" transactionId units unitNav effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "transaction id" transactionId + do! validatePositive "external redemption units" UnitScale units + do! validatePositive "external redemption unit NAV" UnitScale unitNav + + let! fund = getFund fundId current + do! ensureTransactionIsNew transactionId fund + if fund.OutstandingUnits < units then + return! Error(InsufficientUnits units) + + let grossCash = units * unitNav + let payableCash = roundDown CashScale grossCash + if payableCash = 0m then + return! Error(InvalidAmount "external redemption cash rounds to zero") + + let residual = grossCash - payableCash + let redemption = + { + TransactionId = transactionId + FundId = fundId + IdempotencyKey = idempotencyKey + Units = units + UnitNav = unitNav + PayableCash = payableCash + Status = ExternalRedemptionStatus.PendingPayment + ConfirmedAt = effectiveAt + PaidAt = None + IsSynthetic = isSynthetic + } + + let updatedFund = + { + fund with + OutstandingUnits = fund.OutstandingUnits - units + RedemptionPayable = fund.RedemptionPayable + payableCash + Status = fundStatus (fund.OutstandingUnits - units) + ResidualState = addResidual residual fund.ResidualState + ExternalRedemptions = fund.ExternalRedemptions |> Map.add transactionId redemption + CashFlows = + { + TransactionId = transactionId + FundId = fundId + Kind = CashFlowKind.ExternalRedemption + EffectiveAt = effectiveAt + GrossCash = grossCash + CreditedCash = payableCash + ConvertedUnits = units + UnitNav = Some unitNav + RoundingResidual = residual + ResidualOwner = None + IsSynthetic = isSynthetic + } + :: fund.CashFlows + } + |> addEvent transactionId "external_redemption_confirmed" effectiveAt payableCash units residual None isSynthetic + + return updateFund updatedFund current + }) + + let payExternalRedemption fundId transactionId idempotencyKey effectiveAt isSynthetic state = + let fingerprint = sprintf "external-redemption-payment|%O|%O|%b" transactionId effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "transaction id" transactionId + let! fund = getFund fundId current + let! redemption = + fund.ExternalRedemptions + |> Map.tryFind transactionId + |> Option.map Ok + |> Option.defaultValue (Error(ExternalRedemptionNotFound transactionId)) + + if redemption.Status <> ExternalRedemptionStatus.PendingPayment then + return! Error(InvalidState "external redemption is already paid") + + let payable = redemption.PayableCash + + if fund.AvailableCash < payable then + return! Error(InsufficientCash payable) + + let updatedRedemption = { redemption with Status = ExternalRedemptionStatus.Paid; PaidAt = Some effectiveAt } + + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash - payable + RedemptionPayable = fund.RedemptionPayable - payable + ExternalRedemptions = fund.ExternalRedemptions |> Map.add transactionId updatedRedemption + CashFlows = + { + TransactionId = transactionId + FundId = fundId + Kind = CashFlowKind.ExternalRedemptionPayment + EffectiveAt = effectiveAt + GrossCash = payable + CreditedCash = payable + ConvertedUnits = 0m + UnitNav = None + RoundingResidual = 0m + ResidualOwner = None + IsSynthetic = isSynthetic + } + :: fund.CashFlows + } + |> addEvent transactionId "external_redemption_paid" effectiveAt payable 0m 0m None isSynthetic + + return updateFund updatedFund current + }) diff --git a/src/FundLab.Domain/Performance.fs b/src/FundLab.Domain/Performance.fs new file mode 100644 index 0000000..b1c51db --- /dev/null +++ b/src/FundLab.Domain/Performance.fs @@ -0,0 +1,51 @@ +namespace FundLab.Domain + +open System + +type PerformanceObservation = + { + At: DateTimeOffset + NetAssets: decimal + ExternalCashFlow: decimal + } + +type PerformanceError = + | InvalidObservation of string + +module Performance = + let timeWeightedReturn observations = + let validate observation previousAt = + if observation.NetAssets < 0m then + Error(InvalidObservation "net assets cannot be negative") + elif previousAt |> Option.exists (fun at -> observation.At <= at) then + Error(InvalidObservation "observations must be strictly ordered") + else + Ok() + + match observations with + | [] -> Ok 0m + | first :: rest -> + validate first None + |> Result.bind (fun () -> + if first.NetAssets <= 0m then + Error(InvalidObservation "first net assets must be positive") + else + rest + |> List.fold + (fun result observation -> + result + |> Result.bind (fun (previous, linkedReturn) -> + validate observation (Some previous.At) + |> Result.bind (fun () -> + let endingAssetsBeforeFlow = observation.NetAssets - observation.ExternalCashFlow + + if endingAssetsBeforeFlow < 0m then + Error(InvalidObservation "external cash flow exceeds ending net assets") + elif previous.NetAssets <= 0m then + Error(InvalidObservation "period start net assets must be positive") + else + let periodReturn = endingAssetsBeforeFlow / previous.NetAssets + Ok(observation, linkedReturn * periodReturn))) + ) + (Ok(first, 1m)) + |> Result.map (fun (_, linkedReturn) -> linkedReturn - 1m)) diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs new file mode 100644 index 0000000..630a8e9 --- /dev/null +++ b/src/FundLab.Web/App.fs @@ -0,0 +1,41 @@ +module FundLab.Web + +open Elmish +open Elmish.React +open Feliz + +type Model = + { + hasFund: bool + } + +type Msg = + | CreateFundRequested + +let init () = + { hasFund = false } + +let update message model = + match message with + | CreateFundRequested -> model + +let view model dispatch = + Html.main [ + prop.className "empty-state" + prop.children [ + Html.p [ prop.className "eyebrow"; prop.text "FUND LAB" ] + Html.h1 "尚未创建基金" + Html.p "先创建一支人民币模拟基金,再选择基金代码和持仓金额。" + Html.p (if model.hasFund then "已创建基金" else "尚未选择投资") + Html.button [ + prop.className "primary-action" + prop.text "创建基金" + prop.onClick (fun _ -> dispatch CreateFundRequested) + ] + ] + ] + +let start () = + Program.mkSimple init update view + |> Program.withReactSynchronous "fund-lab-root" + |> Program.run diff --git a/src/FundLab.Web/FundLab.Web.fsproj b/src/FundLab.Web/FundLab.Web.fsproj new file mode 100644 index 0000000..529740f --- /dev/null +++ b/src/FundLab.Web/FundLab.Web.fsproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <OutputType>Library</OutputType> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Elmish" Version="4.1.0" /> + <PackageReference Include="Fable.Core" Version="4.4.0" /> + <PackageReference Include="Fable.Elmish.React" Version="4.0.0" /> + <PackageReference Include="Feliz" Version="2.9.0" /> + </ItemGroup> + <ItemGroup> + <Compile Include="App.fs" /> + </ItemGroup> +</Project> diff --git a/src/FundLab.Web/index.html b/src/FundLab.Web/index.html new file mode 100644 index 0000000..7b27019 --- /dev/null +++ b/src/FundLab.Web/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="zh-CN"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Fund Lab</title> + </head> + <body> + <div id="fund-lab-root"></div> + <script type="module" src="/src/main.js"></script> + </body> +</html> diff --git a/src/FundLab.Web/package-lock.json b/src/FundLab.Web/package-lock.json new file mode 100644 index 0000000..56782fe --- /dev/null +++ b/src/FundLab.Web/package-lock.json @@ -0,0 +1,1581 @@ +{ + "name": "fund-lab-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fund-lab-web", + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.3.4", + "vite": "5.4.14" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/browserslist/node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.433", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.14", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/FundLab.Web/package.json b/src/FundLab.Web/package.json new file mode 100644 index 0000000..98db215 --- /dev/null +++ b/src/FundLab.Web/package.json @@ -0,0 +1,17 @@ +{ + "name": "fund-lab-web", + "private": true, + "type": "module", + "scripts": { + "build": "dotnet fable FundLab.Web.fsproj --outDir dist && vite build", + "dev": "dotnet fable FundLab.Web.fsproj --outDir dist --run vite" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.3.4", + "vite": "5.4.14" + } +} diff --git a/src/FundLab.Web/src/main.js b/src/FundLab.Web/src/main.js new file mode 100644 index 0000000..57c83db --- /dev/null +++ b/src/FundLab.Web/src/main.js @@ -0,0 +1,3 @@ +import { start } from "../dist/App.js"; + +start(); diff --git a/src/FundLab.Web/vite.config.js b/src/FundLab.Web/vite.config.js new file mode 100644 index 0000000..5e01572 --- /dev/null +++ b/src/FundLab.Web/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + outDir: "dist", + emptyOutDir: true + } +}); diff --git a/src/FundLab.Worker/FundLab.Worker.fsproj b/src/FundLab.Worker/FundLab.Worker.fsproj new file mode 100644 index 0000000..9694479 --- /dev/null +++ b/src/FundLab.Worker/FundLab.Worker.fsproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk.Worker"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <OutputType>Exe</OutputType> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\FundLab.Domain\FundLab.Domain.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="Worker.fs" /> + <Compile Include="Program.fs" /> + </ItemGroup> +</Project> diff --git a/src/FundLab.Worker/Program.fs b/src/FundLab.Worker/Program.fs new file mode 100644 index 0000000..058d9b7 --- /dev/null +++ b/src/FundLab.Worker/Program.fs @@ -0,0 +1,12 @@ +module FundLab.Worker.Program + +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting + +[<EntryPoint>] +let main argv = + Host.CreateDefaultBuilder(argv) + .ConfigureServices(fun services -> services.AddHostedService<Worker>() |> ignore) + .Build() + .Run() + 0 diff --git a/src/FundLab.Worker/Worker.fs b/src/FundLab.Worker/Worker.fs new file mode 100644 index 0000000..6a7b7a7 --- /dev/null +++ b/src/FundLab.Worker/Worker.fs @@ -0,0 +1,15 @@ +namespace FundLab.Worker + +open System +open System.Threading +open System.Threading.Tasks +open Microsoft.Extensions.Hosting + +type Worker() = + inherit BackgroundService() + + override _.ExecuteAsync(stoppingToken: CancellationToken) : Task = + task { + while not stoppingToken.IsCancellationRequested do + do! Task.Delay(TimeSpan.FromMinutes 5, stoppingToken) + } diff --git a/tests/FundLab.Api.Tests/ApiTests.fs b/tests/FundLab.Api.Tests/ApiTests.fs new file mode 100644 index 0000000..a46d330 --- /dev/null +++ b/tests/FundLab.Api.Tests/ApiTests.fs @@ -0,0 +1,24 @@ +namespace FundLab.Api.Tests + +module ApiTests = + + open Xunit + open FundLab.Api + + [<Theory>] + [<InlineData("")>] + [<InlineData("Basic secret")>] + [<InlineData("Bearer wrong")>] + let ``missing or wrong bearer token is unauthorized`` header = + Assert.Equal(AuthDecision.Unauthorized, Authentication.authorize "secret" header) + + [<Fact>] + let ``matching bearer token is authorized`` () = + Assert.Equal(AuthDecision.Authorized, Authentication.authorize "secret" "Bearer secret") + + [<Fact>] + let ``health payload is non-sensitive`` () = + let payload = Health.payload "fund-lab-api" + + Assert.Contains("\"status\":\"ok\"", payload) + Assert.DoesNotContain("secret", payload) diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj new file mode 100644 index 0000000..ef9f063 --- /dev/null +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <OutputType>Library</OutputType> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <GenerateProgramFile>false</GenerateProgramFile> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FundLab.Api\FundLab.Api.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="ApiTests.fs" /> + <Compile Include="Program.fs" /> + </ItemGroup> +</Project> diff --git a/tests/FundLab.Api.Tests/Program.fs b/tests/FundLab.Api.Tests/Program.fs new file mode 100644 index 0000000..a139996 --- /dev/null +++ b/tests/FundLab.Api.Tests/Program.fs @@ -0,0 +1,5 @@ +module FundLab.Api.Tests.Program + +[<EntryPoint>] +let main _argv = + 0 diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs new file mode 100644 index 0000000..f2b4138 --- /dev/null +++ b/tests/FundLab.Domain.Tests/DomainTests.fs @@ -0,0 +1,493 @@ +namespace FundLab.Domain.Tests + +module DomainTests = + + open System + open Xunit + open FundLab.Domain + + let private unwrap result = + match result with + | Ok value -> value + | Error error -> failwithf "%A" error + + [<Fact>] + let ``new fund retains initial cash and starts with zero units`` () = + let fund = Fund.create "退休账户" 1000m 1.00000000m + + Assert.Equal("退休账户", fund.Name) + Assert.Equal(1000m, fund.NetAssets) + Assert.Equal(0m, fund.OutstandingUnits) + Assert.Equal(FundStatus.Empty, fund.Status) + Assert.Equal(UnitNavStatus.ZeroUnits, Fund.unitNavStatus fund) + + [<Fact>] + let ``pending order is not a confirmed holding`` () = + Assert.False(OrderStatus.isConfirmed OrderStatus.PendingNav) + Assert.True(OrderStatus.isConfirmed OrderStatus.Settled) + + [<Fact>] + let ``external redemption creates a payable without changing user holdings`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-000000000001") + let depositId = Guid.Parse("00000000-0000-0000-0000-000000000011") + let redemptionId = Guid.Parse("00000000-0000-0000-0000-000000000012") + let at = DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero) + + let state = + Ledger.empty + |> Ledger.initializeFund fundId "退休账户" 0m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true + |> unwrap + |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 25.00000000m 1.00000000m at true + |> unwrap + + let fund = Ledger.getFund fundId state |> unwrap + + Assert.Equal(75.00000000m, fund.OutstandingUnits) + Assert.Equal(100.00m, fund.AvailableCash) + Assert.Equal(25.00m, fund.balanceSheet.RedemptionPayable) + Assert.Equal(75.00m, fund.balanceSheet.NetAssets) + + [<Fact>] + let ``external redemption payment is idempotent and conflicts are rejected`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-000000000002") + let depositId = Guid.Parse("00000000-0000-0000-0000-000000000021") + let redemptionId = Guid.Parse("00000000-0000-0000-0000-000000000022") + let at = DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero) + + let deposited = + Ledger.empty + |> Ledger.initializeFund fundId "支付测试" 0m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true + |> unwrap + + let redeemed = + deposited + |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 25.00000000m 1.00000000m at true + |> unwrap + + let paid = + redeemed + |> Ledger.payExternalRedemption fundId redemptionId "payment-1" at true + |> unwrap + + let replayed = + redeemed + |> Ledger.payExternalRedemption fundId redemptionId "payment-1" at true + |> unwrap + + let conflict = + paid + |> Ledger.payExternalRedemption fundId redemptionId "payment-1" (at.AddMinutes 1.0) true + + let fund = Ledger.getFund fundId paid |> unwrap + + Assert.Equal(paid, replayed) + Assert.Equal(Error(IdempotencyConflict "payment-1"), conflict) + Assert.Equal(75.00m, fund.AvailableCash) + Assert.Equal(0.00m, fund.balanceSheet.RedemptionPayable) + Assert.Equal(75.00m, fund.balanceSheet.NetAssets) + + [<Fact>] + let ``underlying purchase freezes then confirms cash and holding`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-000000000003") + let depositId = Guid.Parse("00000000-0000-0000-0000-000000000031") + let orderId = Guid.Parse("00000000-0000-0000-0000-000000000032") + let instrumentId = Guid.Parse("00000000-0000-0000-0000-000000000033") + let at = DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero) + + let deposited = + Ledger.empty + |> Ledger.initializeFund fundId "底层申购" 0m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true + |> unwrap + + let frozen = + deposited + |> Ledger.freezeUnderlyingPurchase fundId orderId "purchase-1" instrumentId 60.00m at true + |> unwrap + + let frozenFund = Ledger.getFund fundId frozen |> unwrap + Assert.Equal(40.00m, frozenFund.AvailableCash) + Assert.Equal(60.00m, frozenFund.FrozenCash) + Assert.Equal(100.00m, frozenFund.balanceSheet.NetAssets) + + let confirmed = + frozen + |> Ledger.confirmUnderlyingPurchase fundId orderId "purchase-confirm-1" 20.00000000m 3.00000000m at true + |> unwrap + + let fund = Ledger.getFund fundId confirmed |> unwrap + let holding = fund.Holdings |> Map.find instrumentId + let order = fund.Orders |> Map.find orderId + + Assert.Equal(40.00m, fund.AvailableCash) + Assert.Equal(0.00m, fund.FrozenCash) + Assert.Equal(20.00000000m, holding.AvailableUnits) + Assert.Equal(60.00m, holding.SettledValue) + Assert.Equal(OrderStatus.Confirmed, order.Status) + Assert.Equal(100.00m, fund.balanceSheet.NetAssets) + + [<Fact>] + let ``cancelled underlying purchase releases its frozen cash`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-000000000004") + let depositId = Guid.Parse("00000000-0000-0000-0000-000000000041") + let orderId = Guid.Parse("00000000-0000-0000-0000-000000000042") + let instrumentId = Guid.Parse("00000000-0000-0000-0000-000000000043") + let at = DateTimeOffset(2026, 1, 4, 0, 0, 0, TimeSpan.Zero) + + let state = + Ledger.empty + |> Ledger.initializeFund fundId "取消申购" 0m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true + |> unwrap + |> Ledger.freezeUnderlyingPurchase fundId orderId "purchase-1" instrumentId 60.00m at true + |> unwrap + |> Ledger.cancelUnderlyingPurchase fundId orderId "purchase-cancel-1" at true + |> unwrap + + let fund = Ledger.getFund fundId state |> unwrap + let order = fund.Orders |> Map.find orderId + + Assert.Equal(100.00m, fund.AvailableCash) + Assert.Equal(0.00m, fund.FrozenCash) + Assert.Equal(OrderStatus.Cancelled, order.Status) + Assert.Empty(fund.Holdings) + + [<Fact>] + let ``underlying redemption uses a receivable until cash is received`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-000000000005") + let depositId = Guid.Parse("00000000-0000-0000-0000-000000000051") + let purchaseId = Guid.Parse("00000000-0000-0000-0000-000000000052") + let redemptionId = Guid.Parse("00000000-0000-0000-0000-000000000053") + let instrumentId = Guid.Parse("00000000-0000-0000-0000-000000000054") + let at = DateTimeOffset(2026, 1, 5, 0, 0, 0, TimeSpan.Zero) + + let purchased = + Ledger.empty + |> Ledger.initializeFund fundId "底层赎回" 0m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true + |> unwrap + |> Ledger.freezeUnderlyingPurchase fundId purchaseId "purchase-1" instrumentId 60.00m at true + |> unwrap + |> Ledger.confirmUnderlyingPurchase fundId purchaseId "purchase-confirm-1" 20.00000000m 3.00000000m at true + |> unwrap + |> Ledger.freezeUnderlyingRedemption fundId redemptionId "redemption-1" instrumentId 20.00000000m at true + |> unwrap + + let confirmed = + purchased + |> Ledger.confirmUnderlyingRedemption fundId redemptionId "redemption-confirm-1" 3.00000000m at true + |> unwrap + + let beforeReceipt = Ledger.getFund fundId confirmed |> unwrap + Assert.Equal(40.00m, beforeReceipt.AvailableCash) + Assert.Equal(60.00m, beforeReceipt.balanceSheet.RedemptionReceivable) + Assert.Equal(0.00m, beforeReceipt.balanceSheet.OtherInTransitAssets) + Assert.Equal(100.00m, beforeReceipt.balanceSheet.NetAssets) + Assert.Equal(100.00000000m, beforeReceipt.OutstandingUnits) + + let received = + confirmed + |> Ledger.receiveUnderlyingRedemption fundId redemptionId "redemption-receipt-1" at true + |> unwrap + + let fund = Ledger.getFund fundId received |> unwrap + let order = fund.Orders |> Map.find redemptionId + + Assert.Equal(100.00m, fund.AvailableCash) + Assert.Equal(0.00m, fund.balanceSheet.RedemptionReceivable) + Assert.Equal(0.00m, fund.balanceSheet.OtherInTransitAssets) + Assert.Equal(OrderStatus.Settled, order.Status) + Assert.Equal(100.00m, fund.balanceSheet.NetAssets) + + [<Fact>] + let ``operations keep two funds isolated and synthetic provenance explicit`` () = + let fundA = Guid.Parse("00000000-0000-0000-0000-000000000006") + let fundB = Guid.Parse("00000000-0000-0000-0000-000000000007") + let at = DateTimeOffset(2026, 1, 6, 0, 0, 0, TimeSpan.Zero) + + let state = + Ledger.empty + |> Ledger.initializeFund fundA "基金A" 0m 1.00000000m true + |> unwrap + |> Ledger.initializeFund fundB "基金B" 25.00m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundA (Guid.NewGuid()) "fund-a-deposit" 100.00m 1.00000000m at true + |> unwrap + + let first = Ledger.getFund fundA state |> unwrap + let second = Ledger.getFund fundB state |> unwrap + + Assert.Equal(100.00m, first.AvailableCash) + Assert.Equal(25.00m, second.AvailableCash) + Assert.True(first.IsSynthetic) + Assert.True(second.IsSynthetic) + Assert.All(first.Events, fun event -> Assert.True(event.IsSynthetic)) + Assert.All(first.CashFlows, fun flow -> Assert.True(flow.IsSynthetic)) + + [<Fact>] + let ``precision and residual ownership are explicit`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-000000000008") + let at = DateTimeOffset(2026, 1, 7, 0, 0, 0, TimeSpan.Zero) + let depositId = Guid.Parse("00000000-0000-0000-0000-000000000081") + let redemptionId = Guid.Parse("00000000-0000-0000-0000-000000000082") + + let initialized = + Ledger.empty + |> Ledger.initializeFund fundId "残差基金" 0m 1.00000000m true + |> unwrap + + let badPrecision = + initialized + |> Ledger.confirmExternalDeposit fundId depositId "bad-precision" 1.001m 1.00000000m at true + + let afterDeposit = + initialized + |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 1.00m 1.00000000m at true + |> unwrap + |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 1.00000000m 1.00500000m at true + |> unwrap + + let fund = Ledger.getFund fundId afterDeposit |> unwrap + let blocked = + afterDeposit + |> Ledger.confirmExternalDeposit fundId (Guid.NewGuid()) "deposit-2" 1.00m 1.00000000m at true + + let allocated = + afterDeposit + |> Ledger.allocateResidual fundId (Guid.Parse("00000000-0000-0000-0000-000000000083")) "residual-allocation-1" ResidualOwner.FundReserve at true + |> unwrap + |> Ledger.confirmExternalDeposit fundId (Guid.NewGuid()) "deposit-3" 1.00m 1.00000000m at true + |> unwrap + + let allocatedFund = Ledger.getFund fundId allocated |> unwrap + + Assert.Equal(Error(InvalidPrecision "external deposit cash"), badPrecision) + Assert.Equal(ResidualState.RequiresAllocation 0.00500000m, fund.ResidualState) + Assert.Equal(Error(ResidualOwnershipRequired 0.00500000m), blocked) + Assert.Equal(ResidualState.Allocated(0.00500000m, ResidualOwner.FundReserve), allocatedFund.ResidualState) + Assert.Equal(1, List.length allocatedFund.ResidualAllocations) + let residualEvent = allocatedFund.Events |> List.find (fun (event: LedgerEvent) -> event.EventType = "residual_allocated") + Assert.Equal(Some ResidualOwner.FundReserve, residualEvent.ResidualOwner) + Assert.Equal(2.00m, allocatedFund.AvailableCash) + + [<Fact>] + let ``residual allocation requires zero outstanding units`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000B") + let depositId = Guid.Parse("00000000-0000-0000-0000-0000000000B1") + let redemptionId = Guid.Parse("00000000-0000-0000-0000-0000000000B2") + let allocationId = Guid.Parse("00000000-0000-0000-0000-0000000000B3") + let at = DateTimeOffset(2026, 1, 9, 0, 0, 0, TimeSpan.Zero) + + let state = + Ledger.empty + |> Ledger.initializeFund fundId "残差未清" 0m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 1.00m 1.00000000m at true + |> unwrap + |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 0.50000000m 1.00500000m at true + |> unwrap + + let result = + state + |> Ledger.allocateResidual fundId allocationId "residual-allocation-1" ResidualOwner.FundReserve at true + + Assert.Equal(Error(InvalidState "residual allocation requires zero outstanding units"), result) + + [<Fact>] + let ``external redemption payment settles only the referenced redemption`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000C") + let depositId = Guid.Parse("00000000-0000-0000-0000-0000000000C1") + let firstRedemptionId = Guid.Parse("00000000-0000-0000-0000-0000000000C2") + let secondRedemptionId = Guid.Parse("00000000-0000-0000-0000-0000000000C3") + let at = DateTimeOffset(2026, 1, 10, 0, 0, 0, TimeSpan.Zero) + + let state = + Ledger.empty + |> Ledger.initializeFund fundId "逐笔付款" 0m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true + |> unwrap + |> Ledger.confirmExternalRedemption fundId firstRedemptionId "redemption-1" 10.00000000m 1.00000000m at true + |> unwrap + |> Ledger.confirmExternalRedemption fundId secondRedemptionId "redemption-2" 20.00000000m 1.00000000m at true + |> unwrap + |> Ledger.payExternalRedemption fundId firstRedemptionId "payment-1" at true + |> unwrap + + let fund = Ledger.getFund fundId state |> unwrap + let first = fund.ExternalRedemptions |> Map.find firstRedemptionId + let second = fund.ExternalRedemptions |> Map.find secondRedemptionId + let unknownPayment = + state + |> Ledger.payExternalRedemption fundId (Guid.Parse("00000000-0000-0000-0000-0000000000CF")) "payment-unknown" at true + + Assert.Equal(20.00m, fund.RedemptionPayable) + Assert.Equal(ExternalRedemptionStatus.Paid, first.Status) + Assert.Equal(ExternalRedemptionStatus.PendingPayment, second.Status) + Assert.Equal(Error(ExternalRedemptionNotFound(Guid.Parse("00000000-0000-0000-0000-0000000000CF"))), unknownPayment) + + [<Fact>] + let ``empty idempotency keys and identifiers are rejected`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000D") + let transactionId = Guid.Parse("00000000-0000-0000-0000-0000000000D1") + let at = DateTimeOffset(2026, 1, 11, 0, 0, 0, TimeSpan.Zero) + + let initialized = + Ledger.empty + |> Ledger.initializeFund fundId "输入校验" 0m 1.00000000m true + |> unwrap + + let emptyKey = + initialized + |> Ledger.confirmExternalDeposit fundId transactionId " " 1.00m 1.00000000m at true + + let emptyTransaction = + initialized + |> Ledger.confirmExternalDeposit fundId Guid.Empty "deposit-1" 1.00m 1.00000000m at true + + let emptyOrder = + initialized + |> Ledger.freezeUnderlyingPurchase fundId Guid.Empty "purchase-1" (Guid.NewGuid()) 1.00m at true + + Assert.Equal(Error(InvalidState "idempotency key cannot be empty"), emptyKey) + Assert.Equal(Error(InvalidIdentifier "transaction id"), emptyTransaction) + Assert.Equal(Error(InvalidIdentifier "order id"), emptyOrder) + + [<Fact>] + let ``duplicate transaction identifiers are rejected`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000E") + let transactionId = Guid.Parse("00000000-0000-0000-0000-0000000000E1") + let at = DateTimeOffset(2026, 1, 12, 0, 0, 0, TimeSpan.Zero) + + let state = + Ledger.empty + |> Ledger.initializeFund fundId "交易唯一" 0m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundId transactionId "deposit-1" 1.00m 1.00000000m at true + |> unwrap + + let duplicate = + state + |> Ledger.confirmExternalDeposit fundId transactionId "deposit-2" 1.00m 1.00000000m at true + + Assert.Equal(Error(TransactionAlreadyExists transactionId), duplicate) + + [<Fact>] + let ``time weighted return excludes external additional capital`` () = + let at day = DateTimeOffset(2026, 1, day, 0, 0, 0, TimeSpan.Zero) + let observations = + [ + { At = at 1; NetAssets = 100.00m; ExternalCashFlow = 0.00m } + { At = at 2; NetAssets = 200.00m; ExternalCashFlow = 100.00m } + ] + + let result = Performance.timeWeightedReturn observations |> unwrap + + Assert.Equal(0.00m, result) + + [<Fact>] + let ``invalid fund and reservation operations return typed errors`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-000000000009") + let missingFund = Ledger.getFund fundId Ledger.empty + + let initialized = + Ledger.empty + |> Ledger.initializeFund fundId "错误路径" 0m 1.00000000m true + |> unwrap + + let duplicate = + initialized + |> Ledger.initializeFund fundId "重复基金" 0m 1.00000000m true + + let insufficientCash = + initialized + |> Ledger.freezeUnderlyingPurchase fundId (Guid.NewGuid()) "purchase-1" (Guid.NewGuid()) 1.00m (DateTimeOffset.UtcNow) true + + let insufficientUnits = + initialized + |> Ledger.confirmExternalRedemption fundId (Guid.NewGuid()) "redemption-1" 1.00000000m 1.00000000m (DateTimeOffset.UtcNow) true + + Assert.Equal(Error(FundNotFound fundId), missingFund) + Assert.Equal(Error(FundAlreadyExists fundId), duplicate) + Assert.Equal(Error(InsufficientCash 1.00m), insufficientCash) + Assert.Equal(Error(InsufficientUnits 1.00000000m), insufficientUnits) + + [<Fact>] + let ``performance observations must be chronological`` () = + let at day = DateTimeOffset(2026, 1, day, 0, 0, 0, TimeSpan.Zero) + let observations = + [ + { At = at 2; NetAssets = 100.00m; ExternalCashFlow = 0.00m } + { At = at 1; NetAssets = 100.00m; ExternalCashFlow = 0.00m } + ] + + let result = Performance.timeWeightedReturn observations + + Assert.Equal(Error(InvalidObservation "observations must be strictly ordered"), result) + + [<Fact>] + let ``external redemption payment cannot spend frozen or invested cash`` () = + let fundId = Guid.Parse("00000000-0000-0000-0000-00000000000A") + let depositId = Guid.Parse("00000000-0000-0000-0000-0000000000A1") + let purchaseId = Guid.Parse("00000000-0000-0000-0000-0000000000A2") + let instrumentId = Guid.Parse("00000000-0000-0000-0000-0000000000A3") + let redemptionId = Guid.Parse("00000000-0000-0000-0000-0000000000A4") + let at = DateTimeOffset(2026, 1, 8, 0, 0, 0, TimeSpan.Zero) + + let state = + Ledger.empty + |> Ledger.initializeFund fundId "现金不足" 0m 1.00000000m true + |> unwrap + |> Ledger.confirmExternalDeposit fundId depositId "deposit-1" 100.00m 1.00000000m at true + |> unwrap + |> Ledger.freezeUnderlyingPurchase fundId purchaseId "purchase-1" instrumentId 100.00m at true + |> unwrap + |> Ledger.confirmUnderlyingPurchase fundId purchaseId "purchase-confirm-1" 100.00000000m 1.00000000m at true + |> unwrap + |> Ledger.confirmExternalRedemption fundId redemptionId "redemption-1" 50.00000000m 1.00000000m at true + |> unwrap + + let payment = state |> Ledger.payExternalRedemption fundId redemptionId "payment-1" at true + + Assert.Equal(Error(InsufficientCash 50.00m), payment) + + [<Fact>] + let ``time weighted return distinguishes investment growth from withdrawals`` () = + let at day = DateTimeOffset(2026, 1, day, 0, 0, 0, TimeSpan.Zero) + let growth = + [ + { At = at 1; NetAssets = 100.00m; ExternalCashFlow = 0.00m } + { At = at 2; NetAssets = 110.00m; ExternalCashFlow = 0.00m } + ] + |> Performance.timeWeightedReturn + |> unwrap + + let withdrawal = + [ + { At = at 1; NetAssets = 100.00m; ExternalCashFlow = 0.00m } + { At = at 2; NetAssets = 50.00m; ExternalCashFlow = -50.00m } + ] + |> Performance.timeWeightedReturn + |> unwrap + + Assert.Equal(0.10m, growth) + Assert.Equal(0.00m, withdrawal) + + [<Fact>] + let ``single invalid performance observations are rejected`` () = + let at = DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero) + let negative = + Performance.timeWeightedReturn [ { At = at; NetAssets = -1.00m; ExternalCashFlow = 0.00m } ] + + let zero = + Performance.timeWeightedReturn [ { At = at; NetAssets = 0.00m; ExternalCashFlow = 0.00m } ] + + Assert.Equal(Error(InvalidObservation "net assets cannot be negative"), negative) + Assert.Equal(Error(InvalidObservation "first net assets must be positive"), zero) diff --git a/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj new file mode 100644 index 0000000..2ba20b5 --- /dev/null +++ b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <OutputType>Library</OutputType> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <GenerateProgramFile>false</GenerateProgramFile> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\FundLab.Domain\FundLab.Domain.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="DomainTests.fs" /> + <Compile Include="Program.fs" /> + </ItemGroup> +</Project> diff --git a/tests/FundLab.Domain.Tests/Program.fs b/tests/FundLab.Domain.Tests/Program.fs new file mode 100644 index 0000000..9a47109 --- /dev/null +++ b/tests/FundLab.Domain.Tests/Program.fs @@ -0,0 +1,5 @@ +module FundLab.Domain.Tests.Program + +[<EntryPoint>] +let main _argv = + 0 |
