diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 21:47:59 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 21:47:59 +0800 |
| commit | 722ec6c19c0c64ea5348c6bc3aefc0061bd96307 (patch) | |
| tree | 65fc127dcb808e05ab332d15332b5cb69ccf6620 /tests/FundLab.Api.Tests/DividendTests.fs | |
| parent | 98c41c37896a41c5f2f15ed187e55ab617735342 (diff) | |
| download | fund-lab-722ec6c19c0c64ea5348c6bc3aefc0061bd96307.tar.gz | |
Add dividend slice (3d-8)
Book cash dividends to available cash exactly once and reinvest dividends through the shared subscription pipeline, with deterministic scheme idempotency and cross-position isolation.
Diffstat (limited to 'tests/FundLab.Api.Tests/DividendTests.fs')
| -rw-r--r-- | tests/FundLab.Api.Tests/DividendTests.fs | 250 |
1 files changed, 250 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) |
