summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/DividendTests.fs250
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/PersistenceTests.fs2
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs75
4 files changed, 328 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/DividendTests.fs b/tests/FundLab.Api.Tests/DividendTests.fs
new file mode 100644
index 0000000..7645aff
--- /dev/null
+++ b/tests/FundLab.Api.Tests/DividendTests.fs
@@ -0,0 +1,250 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Globalization
+open Npgsql
+open Xunit
+open FundLab.Api
+
+[<Collection("postgres")>]
+type DividendTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let seedInstrument () =
+ let code = Random.Shared.Next(0, 1000000).ToString("D6")
+ let payload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero)
+ Instruments = [ { Code = code; Name = "分红测试基金"; FundType = None } ]
+ }
+
+ repository().UpsertInstruments(payload, "dividend-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "分红测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "div-fund-%s" (Guid.NewGuid().ToString("N")))
+
+ match repository().CreateFund(key, command) with
+ | FundWriteResult.Created fund -> fund.Id
+ | other -> failwithf "unexpected fund creation result: %A" other
+
+ let app () = App.createApplication (repository ())
+
+ let truncateMicroseconds (moment: DateTimeOffset) =
+ let utc = moment.ToUniversalTime()
+ DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero)
+
+ let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) =
+ let revision = sprintf "akshare-test/%O" (Guid.NewGuid())
+ let payload: MarketDataNavPayload =
+ {
+ Source = "akshare"
+ SourceRevision = revision
+ CollectedAt = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ Code = code
+ Observations =
+ [
+ {
+ NavDate = navDate
+ PublishedAt = None
+ Nav = nav
+ AccumulatedNav = Some nav
+ DailyReturn = Some 0.0m
+ }
+ ]
+ }
+
+ repository().UpsertNavObservations(payload, sprintf "dividend-hash/%s" revision)
+
+ let anchDate = DateOnly(2026, 9, 21)
+
+ let confirmedHolding fundId (code: string) (amount: decimal) =
+ insertQuoteOnDate code 2.5m anchDate
+
+ let orderKey = fixture.Key(sprintf "div-hold-%s" (Guid.NewGuid().ToString("N")))
+
+ let order =
+ match
+ repository().CreateSubscriptionOrder(
+ orderKey,
+ fundId,
+ { FundCode = code; Amount = amount; FeeAmount = 0m },
+ anchDate
+ )
+ with
+ | SubscriptionOrderWriteResult.OrderCreated order
+ | SubscriptionOrderWriteResult.OrderReplayed order -> order
+ | other -> failwithf "unexpected holding order result: %A" other
+
+ match
+ repository().ConfirmSubscriptionOrder(
+ fixture.Key(sprintf "div-confirm-%s" (Guid.NewGuid().ToString("N"))),
+ fundId,
+ order.Id
+ )
+ with
+ | SubscriptionConfirmResult.OrderConfirmed _ -> ()
+ | confirmResult -> failwithf "unexpected holding confirm: %A" confirmResult
+
+ code
+
+ let scalarDecimal (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- sql
+
+ for name, value, dbType in parameters do
+ let parameter = command.Parameters.Add(name, dbType)
+ parameter.Value <- value
+
+ command.ExecuteScalar() :?> decimal
+
+ let availableCash fundId =
+ scalarDecimal "SELECT available_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+
+ let positionUnits fundId code =
+ scalarDecimal "SELECT units FROM fund_positions WHERE fund_id = @fund_id AND instrument_code = @code" [
+ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid
+ "code", box code, NpgsqlTypes.NpgsqlDbType.Text
+ ]
+
+ let otherPositionUnits fundId code =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- "SELECT units FROM fund_positions WHERE fund_id = @fund_id AND instrument_code = @code"
+ let fundParameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ fundParameter.Value <- box fundId
+ let codeParameter = command.Parameters.Add("code", NpgsqlTypes.NpgsqlDbType.Text)
+ codeParameter.Value <- box code
+ use reader = command.ExecuteReader()
+
+ if reader.Read() then reader.GetDecimal(0) else 0m
+
+ let postDividend (fundId: Guid) (code: string) (navDate: string) (dps: string) (mode: string) =
+ let body =
+ sprintf
+ "{\"instrumentCode\":\"%s\",\"navDate\":\"%s\",\"dps\":\"%s\",\"mode\":\"%s\"}"
+ code
+ navDate
+ dps
+ mode
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/dividends" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", fixture.Key(sprintf "div-key-%s" (Guid.NewGuid().ToString("N")))
+ ]
+ body
+
+ [<Fact>]
+ member _.``cash dividend credits available cash exactly once per scheme``() =
+ let fundId = createFund 10000.00m
+ let codeA = confirmedHolding fundId (seedInstrument ()) 250.00m
+ let codeB = confirmedHolding fundId (seedInstrument ()) 250.00m
+
+ // holdings: A 250 -> 100 units; B 250 -> 100 units
+ let navDate = anchDate
+
+ Assert.Equal(100.00000000m, positionUnits fundId codeA)
+
+ let firstStatus, firstResponse =
+ postDividend fundId codeA (navDate.ToString("yyyy-MM-dd")) "0.10000000" "cash"
+
+ Assert.Equal(200, firstStatus)
+ Assert.Contains("\"status\":\"cash_credited\"", firstResponse)
+ Assert.Contains("\"grossCash\":\"10.00\"", firstResponse)
+
+ // 100 units * 0.10000000 = 10.00 credited; other holding untouched (cross-position invariant)
+ Assert.Equal(10000.00m - 250.00m - 250.00m + 10.00m, availableCash fundId)
+ Assert.Equal(100.00000000m, positionUnits fundId codeA)
+ Assert.Equal(100.00000000m, positionUnits fundId codeB)
+
+ let replayStatus, replayResponse = postDividend fundId codeA (navDate.ToString("yyyy-MM-dd")) "0.10000000" "cash"
+
+ Assert.Equal(200, replayStatus)
+ Assert.Equal(firstResponse, replayResponse)
+ Assert.Equal(10000.00m - 250.00m - 250.00m + 10.00m, availableCash fundId)
+
+ let conflictingStatus, conflictingResponse = postDividend fundId codeA (navDate.ToString("yyyy-MM-dd")) "0.20000000" "cash"
+
+ Assert.Equal(409, conflictingStatus)
+ Assert.Contains("IDEMPOTENCY_CONFLICT", conflictingResponse)
+ Assert.Equal(10000.00m - 250.00m - 250.00m + 10.00m, availableCash fundId)
+
+ [<Fact>]
+ member _.``reinvest dividend credits new units through the subscription pipeline``() =
+ let fundId = createFund 10000.00m
+ let codeA = confirmedHolding fundId (seedInstrument ()) 250.00m
+ let codeB = confirmedHolding fundId (seedInstrument ()) 100.00m
+
+ insertQuoteOnDate codeA 2.5m (ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow)
+
+ insertQuoteOnDate codeA 2.5m anchDate
+
+ let registerStatus, registerResponse = postDividend fundId codeA (anchDate.ToString("yyyy-MM-dd")) "0.10000000" "reinvest"
+
+ Assert.Contains("reinvest", registerResponse)
+ Assert.Contains("\"grossCash\":\"10.00\"", registerResponse)
+ Assert.Contains("\"creditedUnits\":\"4.00000000\"", registerResponse)
+ Assert.Contains("\"status\":\"succeeded\"", registerResponse)
+
+ // 100 units * 0.10000000 = 10.00 payout; re-invested at nav 2.5 = 4 new units
+ Assert.Equal(100.00000000m + 4.00000000m, positionUnits fundId codeA)
+ // cross-position invariant: B untouched (100.00 at nav 2.5 = 40 units)
+ Assert.Equal(40.00000000m, positionUnits fundId codeB)
+
+ let replayStatus, replayResponse =
+ postDividend fundId codeA (anchDate.ToString("yyyy-MM-dd")) "0.10000000" "reinvest"
+
+ Assert.Equal(200, replayStatus)
+ Assert.Equal(registerResponse, replayResponse)
+ Assert.Equal(100.00000000m + 4.00000000m, positionUnits fundId codeA)
+
+ [<Fact>]
+ member _.``future dividend dates are rejected without look-ahead``() =
+ let fundId = createFund 10000.00m
+ let codeA = confirmedHolding fundId (seedInstrument ()) 250.00m
+
+ let status, response = postDividend fundId codeA "2027-01-01" "0.10000000" "cash"
+
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_DIVIDEND_REQUEST", response)
+
+ [<Fact>]
+ member _.``dividend history lists registered records``() =
+ let fundId = createFund 10000.00m
+ let codeA = confirmedHolding fundId (seedInstrument ()) 250.00m
+ let _, _ = postDividend fundId codeA (anchDate.ToString("yyyy-MM-dd")) "0.10000000" "cash"
+
+ let listStatus, listResponse =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/dividends" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, listStatus)
+ Assert.Contains("\"instrumentCode\":\"" + codeA + "\"", listResponse)
+ Assert.Contains("\"mode\":\"cash\"", listResponse)
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index bd45410..552a9bd 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -25,6 +25,7 @@
<Compile Include="PersistenceTests.fs" />
<Compile Include="OrderTests.fs" />
<Compile Include="SipAdvanceTests.fs" />
+ <Compile Include="DividendTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/PersistenceTests.fs b/tests/FundLab.Api.Tests/PersistenceTests.fs
index 5a049af..61d10df 100644
--- a/tests/FundLab.Api.Tests/PersistenceTests.fs
+++ b/tests/FundLab.Api.Tests/PersistenceTests.fs
@@ -108,6 +108,8 @@ type PostgresFixture() =
waitForDatabase ()
Environment.SetEnvironmentVariable("FUND_LAB_AUTH_TOKEN", "test-token")
+ // pin the trading-date clock so suites are independent of the wall clock when they run
+ Environment.SetEnvironmentVariable("FUND_LAB_TEST_TRADE_DATE", "2026-09-21")
member _.ConnectionString = connectionString
member _.Key(suffix: string) = sprintf "fund-lab-test-%s-%s" runId suffix
diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs
index b2a2f0b..ff5245e 100644
--- a/tests/FundLab.Domain.Tests/DomainTests.fs
+++ b/tests/FundLab.Domain.Tests/DomainTests.fs
@@ -757,3 +757,78 @@ module RebalancePolicyTests =
Assert.Equal("rebalance-redeem:7c9e6679-7425-40de-944b-e07fc1f90ae7:2026-09-21:000001", RebalancePolicy.redemptionKey planId runDate "000001")
Assert.Equal(RebalancePolicy.orderKey planId runDate "000001", RebalancePolicy.orderKey planId runDate "000001")
+
+module DividendPolicyTests =
+
+ open Xunit
+ open FundLab.Domain
+
+ let private unwrap result =
+ match result with
+ | Ok value -> value
+ | Error error -> failwithf "%A" error
+
+ [<Fact>]
+ let ``dividend per unit validation rejects non positive or imprecise values`` () =
+ Assert.Equal(Error "dividend per unit must be positive", DividendPolicy.validateDps 0m)
+ Assert.Equal(Error "dividend per unit must be positive", DividendPolicy.validateDps -0.05m)
+ Assert.Equal(Error "dividend per unit exceeds supported precision", DividendPolicy.validateDps 0.015000001m)
+ Assert.Equal(Ok(), DividendPolicy.validateDps 0.01500000m)
+
+ [<Fact>]
+ let ``future dividend dates are rejected without look-ahead`` () =
+ let today = System.DateOnly(2026, 9, 21)
+
+ Assert.Equal(
+ Error "dividend nav date is in the future",
+ DividendPolicy.validateNavDate (System.DateOnly(2026, 9, 22)) today
+ )
+ Assert.Equal(Ok(), DividendPolicy.validateNavDate today today)
+
+ [<Fact>]
+ let ``cash dividend payout truncates to two decimals and keeps units untouched`` () =
+ let payout: DividendPolicy.CashDividendOutcome =
+ DividendPolicy.computeCashPayout 500.00000000m 0.1m
+ |> unwrap
+
+ Assert.Equal(50.00m, payout.GrossCash)
+
+ let uneven: DividendPolicy.CashDividendOutcome =
+ DividendPolicy.computeCashPayout 123.45678900m 0.12345678m
+ |> unwrap
+
+ Assert.Equal(15.24m, uneven.GrossCash)
+ Assert.Equal(Error "held units must be positive for a cash dividend", DividendPolicy.computeCashPayout 0m 0.1m)
+ Assert.Equal(Error "dividend per unit must be positive", DividendPolicy.computeCashPayout 500m -0.1m)
+
+ [<Fact>]
+ let ``reinvestment follows the subscription math`` () =
+ let reinvest: DividendPolicy.ReinvestDividendOutcome =
+ DividendPolicy.computeReinvest 500.00000000m 0.1m 2.5m
+ |> unwrap
+
+ // gross 50.00 -> units 20.00000000 at nav 2.5
+ Assert.Equal(50.00m, 50.00m)
+ Assert.Equal(20.00000000m, reinvest.RequestedUnits)
+ Assert.Equal(2.5m, reinvest.NAV)
+
+ let proceedsZero =
+ DividendPolicy.computeReinvest 1.00000000m 0.00000001m 2.5m
+
+ Assert.Equal(Error "dividend proceeds round to zero", proceedsZero)
+
+ let badNav =
+ DividendPolicy.computeReinvest 500.00000000m 0.1m 0m
+
+ Assert.Equal(Error "unit nav must be positive", badNav)
+
+ [<Fact>]
+ let ``mode parsing roundtrips and record keys are deterministic`` () =
+ Assert.Equal(Some DividendPolicy.Cash, DividendPolicy.parseMode "cash")
+ Assert.Equal(Some DividendPolicy.Reinvest, DividendPolicy.parseMode "reinvest")
+ Assert.Equal(None, DividendPolicy.parseMode "stock")
+
+ let recordId = System.Guid "7c9e6679-7425-40de-944b-e07fc1f90ae7"
+
+ Assert.Equal("dividend:7c9e6679-7425-40de-944b-e07fc1f90ae7", DividendPolicy.recordKey recordId)
+ Assert.Equal("dividend-confirm:7c9e6679-7425-40de-944b-e07fc1f90ae7", DividendPolicy.confirmKey recordId)