namespace FundLab.Api.Tests open System open System.Text.Json open Xunit open FundLab.Api open FundLab.Domain [] type RebalanceTests(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, "rebalance-test-hash") code let createFund (initialCash: decimal) = let command = { Name = "再平衡测试 FOF" InitialCash = initialCash InitialUnitNav = 1.00000000m IsSynthetic = true } let key = fixture.Key(sprintf "rebalance-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 "rebalance-hash/%s" revision) let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow let buyHolding fundId (code: string) (amount: decimal) (nav: decimal) = insertQuoteOnDate code nav today let order = match repository().CreateSubscriptionOrder( fixture.Key(sprintf "rebalance-hold-%s" (Guid.NewGuid().ToString("N"))), fundId, { FundCode = code; Amount = amount; FeeAmount = 0m }, today ) with | SubscriptionOrderWriteResult.OrderCreated order | SubscriptionOrderWriteResult.OrderReplayed order -> order | other -> failwithf "unexpected holding order result: %A" other match repository().ConfirmSubscriptionOrder( fixture.Key(sprintf "rebalance-confirm-%s" (Guid.NewGuid().ToString("N"))), fundId, order.Id ) with | SubscriptionConfirmResult.OrderConfirmed _ -> () | confirmResult -> failwithf "unexpected holding confirm: %A" confirmResult let createPlanViaApi fundId (targets: (string * string) list) = let body = targets |> List.map (fun (code, percent) -> sprintf "{\"instrumentCode\":\"%s\",\"targetPercent\":\"%s\"}" code percent) |> String.concat "," |> sprintf "{\"targets\":[%s]}" PersistenceTestHelpers.invoke (app ()) "POST" (sprintf "/api/funds/%O/rebalance/plans" fundId) [ "Authorization", "Bearer test-token" "Idempotency-Key", fixture.Key(sprintf "rebalance-plan-%s" (Guid.NewGuid().ToString("N"))) ] body let createPlanId fundId targets = match createPlanViaApi fundId targets with | 201, body -> use document = JsonDocument.Parse(body) Guid.Parse(document.RootElement.GetProperty("id").GetString()) | status, body -> failwithf "unexpected plan status %d: %s" status body let previewViaApi fundId planId = PersistenceTestHelpers.invoke (app ()) "GET" (sprintf "/api/funds/%O/rebalance/plans/%O/preview" fundId planId) [ "Authorization", "Bearer test-token" ] "" let executeViaApi fundId planId = PersistenceTestHelpers.invoke (app ()) "POST" (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId) [ "Authorization", "Bearer test-token" ] "{}" let executionsViaApi fundId = PersistenceTestHelpers.invoke (app ()) "GET" (sprintf "/api/funds/%O/rebalance/executions" fundId) [ "Authorization", "Bearer test-token" ] "" let rowOn (code: string) (body: string) = use document = JsonDocument.Parse(body) document.RootElement.GetProperty("rows").EnumerateArray() |> Seq.find (fun row -> row.GetProperty("instrumentCode").GetString() = code) |> fun row -> row.Clone() let unitsFor fundId (code: string) = repository().GetFundPositions fundId |> List.tryFind (fun position -> position.InstrumentCode = code) |> Option.map (fun position -> position.Units) |> Option.defaultValue 0m let availableCash fundId = match repository().GetFund fundId with | Some fund -> fund.AvailableCash | None -> failwith "fund was not found" [] member _.``preview reports target versus current weights without creating orders``() = let fundId = createFund 10000.00m let codeA = seedInstrument () let codeB = seedInstrument () buyHolding fundId codeA 100.00m 2.0m let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] let status, body = previewViaApi fundId planId Assert.Equal(200, status) let rowA = rowOn codeA body Assert.Equal("50.00", rowA.GetProperty("targetPercent").GetString()) Assert.Equal("100.00", rowA.GetProperty("currentValue").GetString()) Assert.Equal("1.00", rowA.GetProperty("currentPercent").GetString()) Assert.Equal("buy", rowA.GetProperty("action").GetString()) Assert.Equal("4900.00", rowA.GetProperty("deltaAmount").GetString()) let rowB = rowOn codeB body Assert.Equal("buy", rowB.GetProperty("action").GetString()) Assert.Equal("5000.00", rowB.GetProperty("deltaAmount").GetString()) // preview is read-only: the holding is untouched Assert.Equal(50.00000000m, unitsFor fundId codeA) Assert.Equal(9900.00m, availableCash fundId) [] member _.``execute applies buy and sell through the shared pipeline and records executions``() = let fundId = createFund 10000.00m let codeA = seedInstrument () let codeB = seedInstrument () buyHolding fundId codeA 8000.00m 5.0m insertQuoteOnDate codeB 2.5m today let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] let status, body = executeViaApi fundId planId Assert.Equal(200, status) Assert.Contains("\"action\":\"sell\"", body) Assert.Contains("\"action\":\"buy\"", body) Assert.Contains("\"status\":\"succeeded\"", body) Assert.Equal(1000.00000000m, unitsFor fundId codeA) Assert.Equal(2000.00000000m, unitsFor fundId codeB) Assert.Equal(0.00m, availableCash fundId) let executionsStatus, executionsBody = executionsViaApi fundId Assert.Equal(200, executionsStatus) Assert.Contains("\"instrumentCode\":\"" + codeA + "\"", executionsBody) Assert.Contains("\"instrumentCode\":\"" + codeB + "\"", executionsBody) Assert.Contains("\"action\":\"sell\"", executionsBody) Assert.Contains("\"action\":\"buy\"", executionsBody) use executionsDocument = JsonDocument.Parse(executionsBody) Assert.Equal(2, executionsDocument.RootElement.GetArrayLength()) [] member _.``re-executing a balanced plan produces no new operations``() = let fundId = createFund 10000.00m let codeA = seedInstrument () let codeB = seedInstrument () buyHolding fundId codeA 8000.00m 5.0m insertQuoteOnDate codeB 2.5m today let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] let firstStatus, _ = executeViaApi fundId planId Assert.Equal(200, firstStatus) let secondStatus, secondBody = executeViaApi fundId planId Assert.Equal(200, secondStatus) Assert.Contains("\"outcomes\":[]", secondBody) Assert.Equal(1000.00000000m, unitsFor fundId codeA) Assert.Equal(2000.00000000m, unitsFor fundId codeB) let _, executionsBody = executionsViaApi fundId use executionsDocument = JsonDocument.Parse(executionsBody) Assert.Equal(2, executionsDocument.RootElement.GetArrayLength()) [] member _.``preview for a plan owned by another fund is not found``() = let fundId = createFund 10000.00m let otherFundId = createFund 10000.00m let codeA = seedInstrument () let codeB = seedInstrument () let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] let status, body = previewViaApi otherFundId planId Assert.Equal(404, status) Assert.Contains("PLAN_NOT_FOUND", body) [] member _.``preview and executions for unknown identifiers are not found``() = let fundId = createFund 10000.00m let previewStatus, previewBody = previewViaApi fundId (Guid.NewGuid()) Assert.Equal(404, previewStatus) Assert.Contains("PLAN_NOT_FOUND", previewBody) let executionsStatus, executionsBody = executionsViaApi (Guid.NewGuid()) Assert.Equal(404, executionsStatus) Assert.Contains("FUND_NOT_FOUND", executionsBody) [] member _.``executions are empty for a fund without a run``() = let fundId = createFund 10000.00m let status, body = executionsViaApi fundId Assert.Equal(200, status) Assert.Equal("[]", body)