summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-20 22:59:00 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-20 22:59:00 +0800
commitc60905e9e7f992a7f8c79c3812e92a44b2d606f5 (patch)
treea895bc6f0ebe669407ffe4d1a11439e03a6fe0d4 /tests
downloadfund-lab-c60905e9e7f992a7f8c79c3812e92a44b2d606f5.tar.gz
feat(core): 建立 fund-lab 可运行基线
[变更性质] - 本提交冻结当前可构建、可测试的应用基线,不包含 PostgreSQL 持久化。 [新增功能] - 建立 F# Domain、API、Worker、Web 及测试项目。 - 增加 Bearer 认证、健康检查、账本领域模型和中文空状态页面。 [实现方案] - 使用环境变量模板注入认证配置,并排除数据、凭证和构建产物。 - 保留 19 个 Domain 测试和 5 个 API 测试作为后续变更基准。 [影响范围] - 为后续 3a PostgreSQL FOF 创建/读取切片提供可回滚基线。 - 当前仍不接入真实基金数据、真实交易或数据库。
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/ApiTests.fs24
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj25
-rw-r--r--tests/FundLab.Api.Tests/Program.fs5
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs493
-rw-r--r--tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj25
-rw-r--r--tests/FundLab.Domain.Tests/Program.fs5
6 files changed, 577 insertions, 0 deletions
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