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 ++++++++++++++++++++++++++++------------- 1 file changed, 192 insertions(+), 84 deletions(-) (limited to 'src/FundLab.Api') 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 -- cgit v1.2.3