diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/RebalanceStockTests.fs | 209 |
2 files changed, 210 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index b2e587f..f37dce4 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -42,6 +42,7 @@ <Compile Include="FundValuationTests.fs" /> <Compile Include="SipPlanStatusTests.fs" /> <Compile Include="RebalanceArchiveTests.fs" /> + <Compile Include="RebalanceStockTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> diff --git a/tests/FundLab.Api.Tests/RebalanceStockTests.fs b/tests/FundLab.Api.Tests/RebalanceStockTests.fs new file mode 100644 index 0000000..3aed5ae --- /dev/null +++ b/tests/FundLab.Api.Tests/RebalanceStockTests.fs @@ -0,0 +1,209 @@ +namespace FundLab.Api.Tests + +open System +open System.Text.Json +open Xunit +open FundLab.Api +open FundLab.Domain + +[<Collection("postgres")>] +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" + + [<Fact>] + 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()) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) |
