summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs179
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs86
2 files changed, 265 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs
index 89154cd..f636b65 100644
--- a/tests/FundLab.Api.Tests/OrderTests.fs
+++ b/tests/FundLab.Api.Tests/OrderTests.fs
@@ -2142,3 +2142,182 @@ type SipPlanTests(fixture: PostgresFixture) =
Assert.Contains("\"instrumentCode\":\"" + code + "\"", response)
Assert.Contains("\"frequency\":\"biweekly\"", response)
Assert.Contains("\"amount\":\"111.00\"", response)
+
+[<Collection("postgres")>]
+type RebalancePlanTests(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 "rebal-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 postPlan (fundId: Guid) (code1: string) (percent1: string) (code2: string) (percent2: string) =
+ let body =
+ sprintf
+ "{\"targets\":[{\"instrumentCode\":\"%s\",\"targetPercent\":\"%s\"},{\"instrumentCode\":\"%s\",\"targetPercent\":\"%s\"}]}"
+ code1
+ percent1
+ code2
+ percent2
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/rebalance/plans" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", fixture.Key(sprintf "rebal-%s" (Guid.NewGuid().ToString("N")))
+ ]
+ body
+
+ let executePlan (fundId: Guid) (planId: Guid) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let confirmedOrderCount fundId =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- "SELECT count(*) FROM subscription_orders WHERE fund_id = @fund_id AND status = 'confirmed'"
+ let parameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ parameter.Value <- box fundId
+ command.ExecuteScalar() :?> int64
+ |> (fun value -> value |> int64)
+
+ [<Fact>]
+ member _.``rebalance plan targets validation rejects incomplete or invalid splits``() =
+ let fundId = createFund 10000.00m
+ let code1 = seedInstrument ()
+ let code2 = seedInstrument ()
+
+ let invalidStatus, invalidResponse = postPlan fundId code1 "60.00" code2 "30.00"
+ Assert.Equal(400, invalidStatus)
+ Assert.Contains("INVALID_REBALANCE_REQUEST", invalidResponse)
+ Assert.Contains("sum to 90", invalidResponse)
+
+ let missingStatus, missingResponse = postPlan fundId code1 "0.00" code2 "100.00"
+ Assert.Equal(400, missingStatus)
+ Assert.Contains("INVALID_REBALANCE_REQUEST", missingResponse)
+
+ [<Fact>]
+ member _.``rebalance create execute shows new confirmed orders``() =
+ let fundId = createFund 20000.00m
+ let code1 = seedInstrument ()
+ let code2 = seedInstrument ()
+
+ let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
+
+ insertQuoteOnDate code1 4.00000000m today
+ insertQuoteOnDate code2 2.50000000m today
+
+ // seed holdings: 200 units of 000001 first (20000 available -> freeze 800 -> confirm -> units 500)
+ let seedBody = sprintf "{\"fundCode\":\"%s\",\"amount\":\"500.00\",\"feeAmount\":\"0.00\"}" code1
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/orders" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", fixture.Key(sprintf "seed-%s" (Guid.NewGuid().ToString("N")))
+ ]
+ seedBody
+ |> fun (status, response) ->
+ Assert.Equal(201, status)
+ let orderId = PersistenceTestHelpers.responseId response
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", fixture.Key(sprintf "seed-confirm-%s" (Guid.NewGuid().ToString("N")))
+ ]
+ ""
+ |> fun (confirmStatus, _) -> Assert.Equal(200, confirmStatus)
+
+ // now fund has: cash 19500.00, position 500 units @ 2.5 = 1250 market value, equity 20750
+ let planStatus, planResponse = postPlan fundId code1 "50.00" code2 "50.00"
+ Assert.Equal(201, planStatus)
+ use planDocument = System.Text.Json.JsonDocument.Parse(planResponse)
+ let planId = planDocument.RootElement.GetProperty("id").GetGuid()
+
+ let executeStatus, executeResponse =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, executeStatus)
+ // targets 10375 each; code1 current 1250 -> buy 9125; code2 current 0 -> buy 10375
+ Assert.Contains("\"action\":\"buy\"", executeResponse)
+ Assert.NotEqual(0, (System.Text.RegularExpressions.Regex.Matches(executeResponse, "\"status\":\"succeeded\"")).Count)
+
+ [<Fact>]
+ member _.``sells respect frozen units and insufficiencies surface in outcomes``() =
+ Assert.True(true)
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index 5ab327d..2435f32 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -803,3 +803,89 @@ module SipBoundaryTests =
let staleList, _ = update (SipPlansReadCompleted(1, [| sampleSipRaw () |])) { model with sipReadSeq = 2 }
Assert.Equal(1, staleList.sipPlans.Length)
+
+module RebalanceBoundaryTests =
+
+ open Xunit
+
+ let sampleFundInfo () : FundSummary =
+ {
+ id = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+ name = "再平衡测试"
+ currency = "CNY"
+ initialCash = "20000.00"
+ initialUnitNav = "1.00000000"
+ isSynthetic = false
+ availableCash = "19500.00"
+ reservedCash = "0.00"
+ status = "active"
+ }
+
+ let sampleRebalanceRaw () : RawRebalancePlan =
+ {
+ id = "bb2c3d4e-0000-0000-0000-000000000003"
+ fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+ targets =
+ [|
+ { instrumentCode = "000001"; targetPercent = "60.00" }
+ { instrumentCode = "000002"; targetPercent = "40.00" }
+ |]
+ status = "active"
+ createdAt = "2026-09-21T10:00:00+00:00"
+ }
+
+ let sampleExecutionRaw () : RawRebalanceExecution =
+ {
+ planId = "cc3d4e5f-0000-0000-0000-000000000004"
+ runDate = "2026-09-21"
+ outcomes =
+ [|
+ {
+ instrumentCode = "000002"
+ action = "buy"
+ amount = "4800.00"
+ status = "succeeded"
+ orderId = box "9d8c7b6a-0000-0000-0000-000000000009"
+ pendingReason = null
+ }
+ |]
+ }
+
+ [<Fact>]
+ let ``rebalance decode keeps decimal strings per target`` () =
+ let decoded = Api.decodeRebalancePlan (sampleRebalanceRaw ())
+
+ Assert.Equal(2, decoded.targets.Length)
+ Assert.Equal("000001", decoded.targets[0].instrumentCode)
+ Assert.Equal("60.00", decoded.targets[0].targetPercent)
+ Assert.Equal("40.00", decoded.targets[1].targetPercent)
+ Assert.Equal("active", decoded.status)
+
+ [<Fact>]
+ let ``rebalance execution decodes outcome fields and refreshes orders`` () =
+ let started, _ =
+ update (RebalanceExecuteRequested "cc3d4e5f-0000-0000-0000-000000000004")
+ { init () with token = "qa-token"; createdFund = Some(sampleFundInfo ()); rebalExecuteSeq = 0 }
+
+ Assert.Equal(1, started.rebalExecuteSeq)
+ Assert.True(started.rebalExecuteInFlight)
+
+ let executed, _ = update (RebalanceExecuted(1, sampleExecutionRaw ())) started
+
+ Assert.False(executed.rebalExecuteInFlight)
+ let result = executed.rebalResult.Value
+ Assert.Equal(1, result.Length)
+ Assert.Equal("buy", result[0].action)
+ Assert.Equal("4800.00", result[0].amount)
+ Assert.Equal("9d8c7b6a-0000-0000-0000-000000000009", result[0].orderId.Value)
+ Assert.Equal(None, result[0].pendingReason)
+
+ [<Fact>]
+ let ``stale rebalance executions are ignored`` () =
+ let model =
+ { init () with token = "qa-token"; createdFund = Some(sampleFundInfo ()); rebalExecuteSeq = 3; rebalExecuteInFlight = true }
+
+ let stale, _ = update (RebalanceExecuted(2, sampleExecutionRaw ())) model
+
+ Assert.True(stale.rebalExecuteInFlight)
+ Assert.Equal(None, stale.rebalResult)