From e8d0a58b550f2d9336d75d9c25ac2724287172ba Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 10:35:51 +0800 Subject: Add stock assets to rebalance with stock buy/sell legs (3d-31 B4b) --- src/FundLab.Api/Persistence.fs | 276 ++++++++++++++++------- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Api.Tests/RebalanceStockTests.fs | 209 +++++++++++++++++ 3 files changed, 402 insertions(+), 84 deletions(-) create mode 100644 tests/FundLab.Api.Tests/RebalanceStockTests.fs diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index d1b8250..267eff3 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -1074,11 +1074,13 @@ type FundRepository(connectionString: string) = CREATE TABLE IF NOT EXISTS rebalance_targets ( plan_id uuid NOT NULL REFERENCES rebalance_plans(id), - instrument_code text NOT NULL REFERENCES instruments(code), + instrument_code text NOT NULL, target_percent numeric(9, 2) NOT NULL CHECK (target_percent > 0 AND target_percent <= 100), PRIMARY KEY (plan_id, instrument_code) ); + ALTER TABLE rebalance_targets DROP CONSTRAINT IF EXISTS rebalance_targets_instrument_code_fkey; + CREATE TABLE IF NOT EXISTS rebalance_executions ( plan_id uuid NOT NULL REFERENCES rebalance_plans(id), run_date date NOT NULL, @@ -3378,6 +3380,12 @@ type FundRepository(connectionString: string) = | Error message -> Error message | Ok() -> Ok() + /// A target code outside the fund universe that is a well-formed A-share code is + /// treated as a stock leg; the rebalance executor routes it through the stock + /// buy/sell pipelines instead of fund subscription/redemption. + let isStockInstrumentCode (code: string) = + not (isNull code) && code.Length = 6 && code |> Seq.forall Char.IsDigit + let rebalanceExecutionRecordFromReader (reader: DbDataReader) : RebalanceExecutionRecord = { PlanId = reader.GetGuid(0) @@ -5094,7 +5102,9 @@ type FundRepository(connectionString: string) = | Some isSynthetic -> let missingTarget = command.Targets - |> List.tryFind (fun target -> not (instrumentExists connection (Some transaction) target.InstrumentCode)) + |> List.tryFind (fun target -> + not (instrumentExists connection (Some transaction) target.InstrumentCode) + && not (isStockInstrumentCode target.InstrumentCode)) match missingTarget with | Some target -> @@ -5191,6 +5201,51 @@ type FundRepository(connectionString: string) = raise error + /// Rebalance universe: fund positions priced at their latest valuation NAV, plus stock + /// positions priced at their latest persisted snapshot (unpriced holdings stay visible at + /// zero value with no NAV, exactly like the fund path). The stock price map is returned + /// alongside so the executor can size stock legs without a second source of truth. + member private this.RebalanceInputs(fundId: Guid, asOfDate: DateOnly) = + let fundSnapshots = + this.GetFundPositions fundId + |> List.map (fun position -> + let marketValue = + match position.ValuationNav with + | Some nav -> Decimal.Round(position.Units * nav, 2) + | None -> 0m + + { + RebalancePolicy.RebalancePositionSnapshot.InstrumentCode = position.InstrumentCode + RebalancePolicy.RebalancePositionSnapshot.MarketValue = marketValue + RebalancePolicy.RebalancePositionSnapshot.Units = position.Units + RebalancePolicy.RebalancePositionSnapshot.AvailableUnits = position.Units - position.ReservedUnits + RebalancePolicy.RebalancePositionSnapshot.ValuationNav = position.ValuationNav + }) + + let stockPrices = + this.GetLatestSnapshots(fundId, "stock", asOfDate) + |> Map.map (fun _ snapshot -> snapshot.Price) + + let stockSnapshots = + this.GetStockPositions fundId + |> List.map (fun position -> + let price = stockPrices |> Map.tryFind position.InstrumentCode + + let marketValue = + price + |> Option.map (fun value -> Decimal.Round(position.Quantity * value, 2)) + |> Option.defaultValue 0m + + { + RebalancePolicy.RebalancePositionSnapshot.InstrumentCode = position.InstrumentCode + RebalancePolicy.RebalancePositionSnapshot.MarketValue = marketValue + RebalancePolicy.RebalancePositionSnapshot.Units = position.Quantity + RebalancePolicy.RebalancePositionSnapshot.AvailableUnits = position.Quantity + RebalancePolicy.RebalancePositionSnapshot.ValuationNav = price + }) + + fundSnapshots @ stockSnapshots, stockPrices + member this.ExecuteRebalancePlan(planId: Guid) : Result = use connection = new NpgsqlConnection(connectionString) connection.Open() @@ -5232,23 +5287,20 @@ type FundRepository(connectionString: string) = match fund with | None -> Error "fund was not found" | Some fund -> - let positions = this.GetFundPositions fundId + let snapshots, stockPrices = this.RebalanceInputs(fundId, runDate) - let buildSnapshot (position: FundPositionRecord) : RebalancePolicy.RebalancePositionSnapshot = - let marketValue = - match position.ValuationNav with - | Some nav -> Decimal.Round(position.Units * nav, 2) - | None -> 0m + let stockPositions = this.GetStockPositions fundId - { - RebalancePolicy.RebalancePositionSnapshot.InstrumentCode = position.InstrumentCode - RebalancePolicy.RebalancePositionSnapshot.MarketValue = marketValue - RebalancePolicy.RebalancePositionSnapshot.Units = position.Units - RebalancePolicy.RebalancePositionSnapshot.AvailableUnits = position.Units - position.ReservedUnits - RebalancePolicy.RebalancePositionSnapshot.ValuationNav = position.ValuationNav - } + let stockCodes = + stockPositions |> List.map (fun position -> position.InstrumentCode) |> Set.ofList + + let stockNames = + stockPositions + |> List.choose (fun position -> position.StockName |> Option.map (fun name -> position.InstrumentCode, name)) + |> Map.ofList - let snapshots = positions |> List.map buildSnapshot + let isStockLeg (code: string) = + stockCodes.Contains code || not (instrumentExists connection None code) let diffs = RebalancePolicy.computeOrders plan.Targets snapshots fund.AvailableCash @@ -5271,65 +5323,136 @@ type FundRepository(connectionString: string) = match diff.Action with | RebalancePolicy.Hold -> () | RebalancePolicy.Buy -> - let orderKey = RebalancePolicy.orderKey plan.Id runDate diff.InstrumentCode + if isStockLeg diff.InstrumentCode then + match stockPrices |> Map.tryFind diff.InstrumentCode with + | Some price when price > 0m -> + let lot = StockTerms.aShareDefault.MinUnit + let lots = Decimal.Floor(diff.Amount / (price * lot)) + let quantity = lots * lot - match - this.CreateSubscriptionOrder( - orderKey, - fundId, - { FundCode = diff.InstrumentCode; Amount = diff.Amount; FeeAmount = 0m } - ) - with - | SubscriptionOrderWriteResult.OrderCreated order - | SubscriptionOrderWriteResult.OrderReplayed order -> - let confirmKey = RebalancePolicy.confirmKey plan.Id runDate diff.InstrumentCode - - match this.ConfirmSubscriptionOrder(confirmKey, fundId, order.Id) with - | SubscriptionConfirmResult.OrderConfirmed _ - | SubscriptionConfirmResult.ConfirmReplayed _ -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some order.Id; PendingReason = None }) - | SubscriptionConfirmResult.ConfirmPendingNav record -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "pending_nav"; OrderId = Some order.Id; PendingReason = record.PendingReason }) - | SubscriptionConfirmResult.ConfirmIdempotencyConflict -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "idempotency_conflict"; OrderId = Some order.Id; PendingReason = None }) - | other -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = Some order.Id; PendingReason = Some (sprintf "%A" other) }) - | SubscriptionOrderWriteResult.OrderInsufficientFunds -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "insufficient_cash"; OrderId = None; PendingReason = Some "available cash is not enough for the rebalance buy" }) - | other -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) }) - | RebalancePolicy.Sell -> - match diff.Units with - | None -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_valuation"; OrderId = None; PendingReason = Some "holding has no valuation NAV to price the sell" }) - | Some units when units <= 0m -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_available_units"; OrderId = None; PendingReason = Some "no available units to redeem" }) - | Some units -> - let redeemKey = RebalancePolicy.redemptionKey plan.Id runDate diff.InstrumentCode + if quantity <= 0m then + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "skipped_below_lot"; OrderId = None; PendingReason = Some "target buy is below one board lot at the persisted price" }) + else + let buyKey = RebalancePolicy.orderKey plan.Id runDate diff.InstrumentCode + let executedAt = DateTimeOffset(runDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero) + + let command: StockTradeCommand = + { + InstrumentCode = diff.InstrumentCode + StockName = stockNames |> Map.tryFind diff.InstrumentCode + Quantity = quantity + Price = price + } + + match + this.CreateStockTrade( + buyKey, + fundId, + command, + executedAtOverride = executedAt, + debitAvailableCash = true + ) + with + | StockTradeWriteResult.StockTradeCreated trade + | StockTradeWriteResult.StockTradeReplayed trade -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some trade.Id; PendingReason = None }) + | StockTradeWriteResult.StockTradeInsufficientFunds reason -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "insufficient_cash"; OrderId = None; PendingReason = Some reason }) + | other -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) }) + | _ -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "skipped_no_quote"; OrderId = None; PendingReason = Some "holding has no persisted stock price to size the buy" }) + else + let orderKey = RebalancePolicy.orderKey plan.Id runDate diff.InstrumentCode match - this.CreateRedemptionOrder( - redeemKey, + this.CreateSubscriptionOrder( + orderKey, fundId, - { InstrumentCode = diff.InstrumentCode; Units = units; FeeAmount = 0m } + { FundCode = diff.InstrumentCode; Amount = diff.Amount; FeeAmount = 0m } ) with - | RedemptionWriteResult.RedemptionCreated order - | RedemptionWriteResult.RedemptionReplayed order -> - let confirmKey = RebalancePolicy.redemptionConfirmKey plan.Id runDate diff.InstrumentCode - - match this.ConfirmRedemptionOrder(confirmKey, fundId, order.Id) with - | RedemptionConfirmResult.RedemptionConfirmed _ - | RedemptionConfirmResult.RedemptionConfirmReplayed _ -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some order.Id; PendingReason = None }) - | RedemptionConfirmResult.RedemptionPendingNav record -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "pending_nav"; OrderId = Some order.Id; PendingReason = record.PendingReason }) + | SubscriptionOrderWriteResult.OrderCreated order + | SubscriptionOrderWriteResult.OrderReplayed order -> + let confirmKey = RebalancePolicy.confirmKey plan.Id runDate diff.InstrumentCode + + match this.ConfirmSubscriptionOrder(confirmKey, fundId, order.Id) with + | SubscriptionConfirmResult.OrderConfirmed _ + | SubscriptionConfirmResult.ConfirmReplayed _ -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some order.Id; PendingReason = None }) + | SubscriptionConfirmResult.ConfirmPendingNav record -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "pending_nav"; OrderId = Some order.Id; PendingReason = record.PendingReason }) + | SubscriptionConfirmResult.ConfirmIdempotencyConflict -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "idempotency_conflict"; OrderId = Some order.Id; PendingReason = None }) | other -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = Some order.Id; PendingReason = Some (sprintf "%A" other) }) - | RedemptionWriteResult.RedemptionInsufficientUnits -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "insufficient_units"; OrderId = None; PendingReason = Some "available units are not enough for the rebalance sell" }) + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = Some order.Id; PendingReason = Some (sprintf "%A" other) }) + | SubscriptionOrderWriteResult.OrderInsufficientFunds -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "insufficient_cash"; OrderId = None; PendingReason = Some "available cash is not enough for the rebalance buy" }) | other -> - outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) }) + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) }) + | RebalancePolicy.Sell -> + if isStockLeg diff.InstrumentCode then + match stockPrices |> Map.tryFind diff.InstrumentCode, diff.Units with + | None, _ -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_quote"; OrderId = None; PendingReason = Some "holding has no persisted stock price to price the sell" }) + | Some _, None -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_valuation"; OrderId = None; PendingReason = Some "holding has no persisted stock price to price the sell" }) + | Some _, Some units when Decimal.Floor units <= 0m -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_available_units"; OrderId = None; PendingReason = Some "no available stock shares to sell" }) + | Some price, Some units -> + let quantity = Decimal.Floor units + let sellKey = RebalancePolicy.redemptionKey plan.Id runDate diff.InstrumentCode + let executedAt = DateTimeOffset(runDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero) + + let command: StockSellCommand = + { + InstrumentCode = diff.InstrumentCode + StockName = stockNames |> Map.tryFind diff.InstrumentCode + Quantity = quantity + Price = price + FeeAmount = 0m + } + + match this.CreateStockSell(sellKey, fundId, command, executedAtOverride = executedAt) with + | StockSellWriteResult.StockSellCreated sell + | StockSellWriteResult.StockSellReplayed sell -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some sell.Id; PendingReason = None }) + | StockSellWriteResult.StockSellInsufficientHoldings reason -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "insufficient_units"; OrderId = None; PendingReason = Some reason }) + | other -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) }) + else + match diff.Units with + | None -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_valuation"; OrderId = None; PendingReason = Some "holding has no valuation NAV to price the sell" }) + | Some units when units <= 0m -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_available_units"; OrderId = None; PendingReason = Some "no available units to redeem" }) + | Some units -> + let redeemKey = RebalancePolicy.redemptionKey plan.Id runDate diff.InstrumentCode + + match + this.CreateRedemptionOrder( + redeemKey, + fundId, + { InstrumentCode = diff.InstrumentCode; Units = units; FeeAmount = 0m } + ) + with + | RedemptionWriteResult.RedemptionCreated order + | RedemptionWriteResult.RedemptionReplayed order -> + let confirmKey = RebalancePolicy.redemptionConfirmKey plan.Id runDate diff.InstrumentCode + + match this.ConfirmRedemptionOrder(confirmKey, fundId, order.Id) with + | RedemptionConfirmResult.RedemptionConfirmed _ + | RedemptionConfirmResult.RedemptionConfirmReplayed _ -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some order.Id; PendingReason = None }) + | RedemptionConfirmResult.RedemptionPendingNav record -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "pending_nav"; OrderId = Some order.Id; PendingReason = record.PendingReason }) + | other -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = Some order.Id; PendingReason = Some (sprintf "%A" other) }) + | RedemptionWriteResult.RedemptionInsufficientUnits -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "insufficient_units"; OrderId = None; PendingReason = Some "available units are not enough for the rebalance sell" }) + | other -> + outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) }) let outcomeList = outcomes |> Seq.toList @@ -5392,23 +5515,8 @@ type FundRepository(connectionString: string) = match this.GetFund plan.FundId with | None -> Error "fund was not found" | Some fund -> - let positions = this.GetFundPositions plan.FundId - - let buildSnapshot (position: FundPositionRecord) : RebalancePolicy.RebalancePositionSnapshot = - let marketValue = - match position.ValuationNav with - | Some nav -> Decimal.Round(position.Units * nav, 2) - | None -> 0m - - { - RebalancePolicy.RebalancePositionSnapshot.InstrumentCode = position.InstrumentCode - RebalancePolicy.RebalancePositionSnapshot.MarketValue = marketValue - RebalancePolicy.RebalancePositionSnapshot.Units = position.Units - RebalancePolicy.RebalancePositionSnapshot.AvailableUnits = position.Units - position.ReservedUnits - RebalancePolicy.RebalancePositionSnapshot.ValuationNav = position.ValuationNav - } - - let snapshots = positions |> List.map buildSnapshot + let asOfDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + let snapshots, _ = this.RebalanceInputs(plan.FundId, asOfDate) match RebalancePolicy.weightRows plan.Targets snapshots fund.AvailableCash with | Error message -> Error message diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index b2e587f..f37dce4 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -42,6 +42,7 @@ + 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 + +[] +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" + + [] + 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()) + + [] + 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) + + [] + 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) + + [] + 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) -- cgit v1.2.3