diff options
Diffstat (limited to 'tests/FundLab.Api.Tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 179 |
1 files changed, 179 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) |
