namespace FundLab.Api.Tests open System open System.Text.Json open Xunit open FundLab.Api open FundLab.Domain [] type RebalanceStockTests(fixture: PostgresFixture) = let sharedRepository = lazy let value = FundRepository(fixture.ConnectionString) value.EnsureSchema() value let repository () = sharedRepository.Value let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow let createFund (initialCash: decimal) = let command = { Name = "股票再平衡测试 FOF" InitialCash = initialCash InitialUnitNav = 1.00000000m IsSynthetic = true } let key = fixture.Key(sprintf "rebalance-stock-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 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-stock-test-hash") code let seedStockSnapshot (code: string) (price: decimal) = let snapshot: InstrumentSnapshotRecord = { InstrumentCode = code AssetClass = "stock" SnapshotDate = today Price = price Source = "akshare" SourceRevision = "akshare-test/stock-daily" SourceCollectedAt = DateTimeOffset(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero) SourcePayloadHash = sprintf "rebalance-stock-hash/%s/%O" code (Guid.NewGuid()) } repository().UpsertInstrumentSnapshots [ snapshot ] let buyStock fundId code quantity price = let key = fixture.Key(sprintf "rebalance-stock-buy-%s" (Guid.NewGuid().ToString("N"))) match repository().CreateStockTrade( key, fundId, { InstrumentCode = code; StockName = Some "贵州茅台"; Quantity = quantity; Price = price } ) with | StockTradeWriteResult.StockTradeCreated _ -> () | other -> failwithf "unexpected stock trade result: %A" other let app () = App.createApplication (repository ()) let createPlanId fundId targets = let body = targets |> List.map (fun (code, percent) -> sprintf "{\"instrumentCode\":\"%s\",\"targetPercent\":\"%s\"}" code percent) |> String.concat "," |> sprintf "{\"targets\":[%s]}" let status, response = PersistenceTestHelpers.invoke (app ()) "POST" (sprintf "/api/funds/%O/rebalance/plans" fundId) [ "Authorization", "Bearer test-token" "Idempotency-Key", fixture.Key(sprintf "rebalance-stock-plan-%s" (Guid.NewGuid().ToString("N"))) ] body match status with | 201 -> use document = JsonDocument.Parse(response) Guid.Parse(document.RootElement.GetProperty("id").GetString()) | _ -> failwithf "unexpected plan status %d: %s" status response 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 stockQuantity fundId code = repository().GetStockPositions fundId |> List.filter (fun position -> position.InstrumentCode = code) |> List.sumBy (fun position -> position.Quantity) let availableCash fundId = match repository().GetFund fundId with | Some fund -> fund.AvailableCash | None -> failwith "fund was not found" [] member _.``preview counts a stock holding in the rebalance universe``() = let fundId = createFund 5000.00m buyStock fundId "600519" 100m 100.00m seedStockSnapshot "600519" 100.00m let planId = createPlanId fundId [ "600519", "100.00" ] let status, body = previewViaApi fundId planId Assert.Equal(200, status) use document = JsonDocument.Parse(body) Assert.Equal("15000.00", document.RootElement.GetProperty("equity").GetString()) Assert.Equal("5000.00", document.RootElement.GetProperty("availableCash").GetString()) let row = document.RootElement.GetProperty("rows").EnumerateArray() |> Seq.find (fun row -> row.GetProperty("instrumentCode").GetString() = "600519") Assert.Equal("10000.00", row.GetProperty("currentValue").GetString()) Assert.Equal("66.66", row.GetProperty("currentPercent").GetString()) Assert.Equal("buy", row.GetProperty("action").GetString()) Assert.Equal("5000.00", row.GetProperty("deltaAmount").GetString()) [] member _.``a stock buy target is executed through the stock trade pipeline and debits cash``() = let fundId = createFund 10000.00m seedStockSnapshot "600519" 10.00m let planId = createPlanId fundId [ "600519", "100.00" ] let status, body = executeViaApi fundId planId Assert.Equal(200, status) Assert.Contains("\"instrumentCode\":\"600519\"", body) Assert.Contains("\"action\":\"buy\"", body) Assert.Contains("\"status\":\"succeeded\"", body) Assert.Equal(1000m, stockQuantity fundId "600519") Assert.Equal(0.00m, availableCash fundId) let buyEvents = repository().GetStockCashflows fundId |> List.filter (fun record -> record.EventType = "buy" && record.InstrumentCode = "600519") Assert.Single(buyEvents) |> ignore Assert.Equal(10000.00m, buyEvents.Head.Amount) [] member _.``an overweight stock holding is sold through the stock sell pipeline``() = let fundId = createFund 0.00m let fundCode = seedInstrument () buyStock fundId "600519" 100m 100.00m seedStockSnapshot "600519" 100.00m let planId = createPlanId fundId [ fundCode, "100.00" ] let status, body = executeViaApi fundId planId Assert.Equal(200, status) Assert.Contains("\"instrumentCode\":\"600519\"", body) Assert.Contains("\"action\":\"sell\"", body) Assert.Contains("\"status\":\"succeeded\"", body) Assert.Equal(0m, stockQuantity fundId "600519") let sellEvents = repository().GetStockCashflows fundId |> List.filter (fun record -> record.EventType = "sell" && record.InstrumentCode = "600519") Assert.Single(sellEvents) |> ignore Assert.Equal(10000.00m, sellEvents.Head.Amount) [] member _.``a stock target below one board lot is skipped without trading``() = let fundId = createFund 500.00m seedStockSnapshot "600519" 10.00m let planId = createPlanId fundId [ "600519", "100.00" ] let status, body = executeViaApi fundId planId Assert.Equal(200, status) Assert.Contains("\"status\":\"skipped_below_lot\"", body) Assert.Equal(0m, stockQuantity fundId "600519") Assert.Equal(500.00m, availableCash fundId)