summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 23:47:50 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 23:47:50 +0800
commit36ee23a0c406c16e975276238a861299899ec79a (patch)
tree7085f24566b7c53ad75c1ddf8130e563513f673a /tests
parentd4b539a2791a0cd4d098fecfece0039073a9f7e2 (diff)
downloadfund-lab-36ee23a0c406c16e975276238a861299899ec79a.tar.gz
Add rebalancing execution slice (3d-11)
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/RebalanceTests.fs278
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs48
3 files changed, 327 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index ca5bde8..374c63e 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -28,6 +28,7 @@
<Compile Include="DividendTests.fs" />
<Compile Include="ReturnsTests.fs" />
<Compile Include="InvestmentPlanTests.fs" />
+ <Compile Include="RebalanceTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/RebalanceTests.fs b/tests/FundLab.Api.Tests/RebalanceTests.fs
new file mode 100644
index 0000000..88ec479
--- /dev/null
+++ b/tests/FundLab.Api.Tests/RebalanceTests.fs
@@ -0,0 +1,278 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Text.Json
+open Xunit
+open FundLab.Api
+open FundLab.Domain
+
+[<Collection("postgres")>]
+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"
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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())
+
+ [<Fact>]
+ 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())
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs
index ff5245e..63bcab2 100644
--- a/tests/FundLab.Domain.Tests/DomainTests.fs
+++ b/tests/FundLab.Domain.Tests/DomainTests.fs
@@ -748,6 +748,54 @@ module RebalancePolicyTests =
Assert.All(diffs, fun diff -> Assert.Equal(RebalancePolicy.Hold, diff.Action))
[<Fact>]
+ let ``weight rows expose current percent and proposed buy`` () =
+ let targets = [ target "000001" 60.00m; target "000002" 40.00m ]
+ let positions = [ position "000001" 5000.00m 1000m 1000m (Some 5m) ]
+
+ let rows: RebalancePolicy.RebalanceWeightRow list =
+ RebalancePolicy.weightRows targets positions 7000.00m
+ |> unwrap
+
+ let row = rows |> List.find (fun item -> item.InstrumentCode = "000001")
+
+ Assert.Equal(60.00m, row.TargetPercent)
+ Assert.Equal(5000.00m, row.CurrentValue)
+ Assert.Equal(41.66m, row.CurrentPercent)
+ Assert.Equal(RebalancePolicy.Buy, row.Action)
+ Assert.Equal(2200.00m, row.DeltaAmount)
+ Assert.Equal(None, row.DeltaUnits)
+
+ [<Fact>]
+ let ``weight rows propose a sell clamped to available units`` () =
+ let targets = [ target "000001" 50.00m; target "000002" 50.00m ]
+ let positions = [ position "000001" 1000.00m 400m 400m (Some 2.5m) ]
+
+ let rows: RebalancePolicy.RebalanceWeightRow list =
+ RebalancePolicy.weightRows targets positions 0m
+ |> unwrap
+
+ let row = rows |> List.find (fun item -> item.InstrumentCode = "000001")
+
+ Assert.Equal(100.00m, row.CurrentPercent)
+ Assert.Equal(RebalancePolicy.Sell, row.Action)
+ Assert.Equal(-500.00m, row.DeltaAmount)
+ Assert.Equal(200.00000000m, row.DeltaUnits.Value)
+
+ [<Fact>]
+ let ``weight rows hold when the drift is within the one cent tolerance`` () =
+ let targets = [ target "000001" 100.00m ]
+ let positions = [ position "000001" 199.99m 100m 100m (Some 1.9999m) ]
+
+ let rows: RebalancePolicy.RebalanceWeightRow list =
+ RebalancePolicy.weightRows targets positions 0.01m
+ |> unwrap
+
+ let row = rows |> List.find (fun item -> item.InstrumentCode = "000001")
+
+ Assert.Equal(RebalancePolicy.Hold, row.Action)
+ Assert.Equal(0.01m, row.DeltaAmount)
+
+ [<Fact>]
let ``idempotency keys are deterministic per plan run and code`` () =
let planId = Guid "7c9e6679-7425-40de-944b-e07fc1f90ae7"
let runDate = DateOnly(2026, 9, 21)