From c60905e9e7f992a7f8c79c3812e92a44b2d606f5 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Sun, 20 Sep 2026 22:59:00 +0800 Subject: feat(core): 建立 fund-lab 可运行基线 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [变更性质] - 本提交冻结当前可构建、可测试的应用基线,不包含 PostgreSQL 持久化。 [新增功能] - 建立 F# Domain、API、Worker、Web 及测试项目。 - 增加 Bearer 认证、健康检查、账本领域模型和中文空状态页面。 [实现方案] - 使用环境变量模板注入认证配置,并排除数据、凭证和构建产物。 - 保留 19 个 Domain 测试和 5 个 API 测试作为后续变更基准。 [影响范围] - 为后续 3a PostgreSQL FOF 创建/读取切片提供可回滚基线。 - 当前仍不接入真实基金数据、真实交易或数据库。 --- src/FundLab.Domain/Performance.fs | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/FundLab.Domain/Performance.fs (limited to 'src/FundLab.Domain/Performance.fs') 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)) -- cgit v1.2.3