diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 22:43:26 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 22:43:26 +0800 |
| commit | 12c4d3625458c830a2746a1f277fb684e9c498cb (patch) | |
| tree | 5e1d9f7751c54eda81e805fe9deef0fc7e749430 /tests/FundLab.Api.Tests/ReturnsTests.fs | |
| parent | 4c60174fc66927db5536bc2fe06574c753140055 (diff) | |
| download | fund-lab-12c4d3625458c830a2746a1f277fb684e9c498cb.tar.gz | |
Add returns and charts slice (3d-9b)
Diffstat (limited to 'tests/FundLab.Api.Tests/ReturnsTests.fs')
| -rw-r--r-- | tests/FundLab.Api.Tests/ReturnsTests.fs | 233 |
1 files changed, 233 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/ReturnsTests.fs b/tests/FundLab.Api.Tests/ReturnsTests.fs new file mode 100644 index 0000000..01a5e10 --- /dev/null +++ b/tests/FundLab.Api.Tests/ReturnsTests.fs @@ -0,0 +1,233 @@ +namespace FundLab.Api.Tests + +open System +open System.Text.Json +open Npgsql +open Xunit +open FundLab.Api + +[<Collection("postgres")>] +type ReturnsTests(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, "returns-test-hash") + code + + let createFund (initialCash: decimal) = + let command = + { + Name = "收益测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "returns-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 "returns-hash/%s" revision) + + let confirmedHolding fundId (code: string) (amount: decimal) (tradeDate: DateOnly) = + insertQuoteOnDate code 2.5m tradeDate + + let orderKey = fixture.Key(sprintf "returns-hold-%s" (Guid.NewGuid().ToString("N"))) + + let order = + match + repository().CreateSubscriptionOrder( + orderKey, + fundId, + { FundCode = code; Amount = amount; FeeAmount = 0m }, + tradeDate + ) + with + | SubscriptionOrderWriteResult.OrderCreated order + | SubscriptionOrderWriteResult.OrderReplayed order -> order + | other -> failwithf "unexpected holding order result: %A" other + + match + repository().ConfirmSubscriptionOrder( + fixture.Key(sprintf "returns-confirm-%s" (Guid.NewGuid().ToString("N"))), + fundId, + order.Id + ) + with + | SubscriptionConfirmResult.OrderConfirmed _ -> () + | confirmResult -> failwithf "unexpected holding confirm: %A" confirmResult + + order + + let insertConfirmedOrderWithoutNav fundId (code: string) (tradeDate: DateOnly) (units: decimal) (amount: decimal) = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + + command.CommandText <- + """ + INSERT INTO subscription_orders + (id, fund_id, fund_code, amount, fee_amount, reserved_total, status, is_synthetic, + submitted_at, trade_date, confirmed_at, confirmed_units, confirmed_invested_cash, + confirmed_residual_cash) + VALUES + (@id, @fund_id, @code, @amount, 0.00, @amount, 'confirmed', false, + now(), @trade_date, now(), @units, @amount, 0.00) + """ + + let idParameter = command.Parameters.Add("id", NpgsqlTypes.NpgsqlDbType.Uuid) + idParameter.Value <- box (Guid.NewGuid()) + 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 + let amountParameter = command.Parameters.Add("amount", NpgsqlTypes.NpgsqlDbType.Numeric) + amountParameter.Value <- box amount + let unitsParameter = command.Parameters.Add("units", NpgsqlTypes.NpgsqlDbType.Numeric) + unitsParameter.Value <- box units + let dateParameter = command.Parameters.Add("trade_date", NpgsqlTypes.NpgsqlDbType.Date) + dateParameter.Value <- box tradeDate + command.ExecuteNonQuery() |> ignore + + let getReturns (fundId: Guid) = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/returns" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let pointOn (date: string) (body: string) = + use document = JsonDocument.Parse(body) + + document.RootElement.GetProperty("points").EnumerateArray() + |> Seq.find (fun point -> point.GetProperty("date").GetString() = date) + |> fun point -> point.Clone() + + [<Fact>] + member _.``returns series is empty for a fund without activity``() = + let fundId = createFund 10000.00m + let status, body = getReturns fundId + + Assert.Equal(200, status) + Assert.Contains("\"points\":[]", body) + Assert.Contains("\"pending\":false", body) + Assert.Contains("\"dataUpdatedAt\":null", body) + + [<Fact>] + member _.``external deposits issue units without moving the unit nav or creating profit``() = + let fundId = createFund 10000.00m + + match + repository().CreateCapitalDeposit( + fixture.Key(sprintf "returns-deposit-%s" (Guid.NewGuid().ToString("N"))), + fundId, + { Amount = 5000.00m; Note = None } + ) + with + | CapitalDepositWriteResult.CapitalDepositCreated _ -> () + | other -> failwithf "unexpected deposit result: %A" other + + let status, body = getReturns fundId + Assert.Equal(200, status) + + let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + let point = pointOn (today.ToString("yyyy-MM-dd")) body + + Assert.False(point.GetProperty("pending").GetBoolean()) + Assert.Equal("15000.00", point.GetProperty("totalAssets").GetString()) + Assert.Equal("15000.00", point.GetProperty("netExternalFlow").GetString()) + Assert.Equal("0.00", point.GetProperty("cumulativeReturn").GetString()) + Assert.Equal("1.00000000", point.GetProperty("unitNav").GetString()) + + [<Fact>] + member _.``returns series revalues holdings on later nav dates``() = + let fundId = createFund 10000.00m + let code = seedInstrument () + let tradeDate = DateOnly(2026, 9, 15) + let _ = confirmedHolding fundId code 250.00m tradeDate + insertQuoteOnDate code 3.0m (DateOnly(2026, 9, 16)) + + let status, body = getReturns fundId + Assert.Equal(200, status) + + let first = pointOn "2026-09-15" body + Assert.Equal("10000.00", first.GetProperty("totalAssets").GetString()) + Assert.Equal("0.00", first.GetProperty("cumulativeReturn").GetString()) + Assert.Equal("1.00000000", first.GetProperty("unitNav").GetString()) + + let second = pointOn "2026-09-16" body + Assert.False(second.GetProperty("pending").GetBoolean()) + Assert.Equal("10050.00", second.GetProperty("totalAssets").GetString()) + Assert.Equal("50.00", second.GetProperty("cumulativeReturn").GetString()) + Assert.Equal("1.00500000", second.GetProperty("unitNav").GetString()) + + [<Fact>] + member _.``held instrument without a valuation nav is pending and never zero filled``() = + let fundId = createFund 10000.00m + let code = seedInstrument () + let tradeDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + insertConfirmedOrderWithoutNav fundId code tradeDate 100.00000000m 250.00m + + let status, body = getReturns fundId + Assert.Equal(200, status) + Assert.Contains("\"pending\":true", body) + Assert.Contains("\"totalAssets\":null", body) + Assert.Contains("\"holdingsValue\":null", body) + Assert.Contains("\"cumulativeReturn\":null", body) + + let point = pointOn (tradeDate.ToString("yyyy-MM-dd")) body + Assert.True(point.GetProperty("pending").GetBoolean()) + Assert.Equal(JsonValueKind.Null, point.GetProperty("totalAssets").ValueKind) + Assert.Equal("9750.00", point.GetProperty("cash").GetString()) + + [<Fact>] + member _.``returns request for an unknown fund is not found``() = + let status, body = getReturns (Guid.NewGuid()) + Assert.Equal(404, status) + Assert.Contains("FUND_NOT_FOUND", body) |
