namespace FundLab.Api.Tests open System open System.Text.Json open Npgsql open Xunit open FundLab.Api [] 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() [] 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) [] 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()) [] 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()) [] 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()) [] 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)