summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests/RebalanceStockTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 10:35:51 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 10:35:51 +0800
commite8d0a58b550f2d9336d75d9c25ac2724287172ba (patch)
tree7f79c2c2d098538ba457d48e52d889c4c571ebf5 /tests/FundLab.Api.Tests/RebalanceStockTests.fs
parentb9f877f3f337fc164dbcc5be0d9584a95d4cfd7b (diff)
downloadfund-lab-e8d0a58b550f2d9336d75d9c25ac2724287172ba.tar.gz
Add stock assets to rebalance with stock buy/sell legs (3d-31 B4b)
Diffstat (limited to 'tests/FundLab.Api.Tests/RebalanceStockTests.fs')
-rw-r--r--tests/FundLab.Api.Tests/RebalanceStockTests.fs209
1 files changed, 209 insertions, 0 deletions
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)