diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/DividendTests.fs | 250 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/PersistenceTests.fs | 2 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/DomainTests.fs | 75 |
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) |
