diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 179 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 86 |
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) |
