From 36ee23a0c406c16e975276238a861299899ec79a Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 23:47:50 +0800 Subject: Add rebalancing execution slice (3d-11) --- src/FundLab.Domain/Rebalance.fs | 124 ++++++++++++++++++++++++++++++---------- 1 file changed, 95 insertions(+), 29 deletions(-) (limited to 'src/FundLab.Domain/Rebalance.fs') diff --git a/src/FundLab.Domain/Rebalance.fs b/src/FundLab.Domain/Rebalance.fs index 5d1d508..0107513 100644 --- a/src/FundLab.Domain/Rebalance.fs +++ b/src/FundLab.Domain/Rebalance.fs @@ -33,6 +33,23 @@ module RebalancePolicy = Units: decimal option } + /// Per-code comparison of the current holding against its target share. This is the + /// read-only view shown as "proposed orders": no order is created from it. + type RebalanceWeightRow = + { + InstrumentCode: string + TargetPercent: decimal + CurrentValue: decimal + CurrentPercent: decimal + Action: RebalanceAction + DeltaAmount: decimal + DeltaUnits: decimal option + } + + /// A delta below one cent is treated as already on target; this is the rounding + /// tolerance for share/price arithmetic, not a policy choice. + let driftTolerance = 0.01m + let private roundDown (scale: int) (value: decimal) : decimal = let factor = decimal (pown 10 scale) Decimal.Truncate(value * factor) / factor @@ -69,17 +86,19 @@ module RebalancePolicy = else Ok () - /// Diff orders for one rebalance run. The universe is the union of target codes and - /// held codes (held codes without a target imply a 0%% target, i.e. full exit). - /// BUY diffs request the full shortfall amount; cash sufficiency is enforced by the - /// shared order pipeline, not silently trimmed. SELL diffs convert the excess value - /// into units at the current valuation NAV, clamped to available units; a held code - /// without a valuation NAV yields Units=None and the executor skips it visibly. - let computeOrders + /// Current vs target share per code, including the proposed action. This is the single + /// source of the rebalancing arithmetic; `computeOrders` is the order-shaped projection + /// of these rows. The universe is the union of target codes and held codes (held codes + /// without a target imply a 0% target, i.e. full exit). BUY diffs request the full + /// shortfall amount; cash sufficiency is enforced by the shared order pipeline, not + /// silently trimmed. SELL diffs convert the excess value into units at the current + /// valuation NAV, clamped to available units; a held code without a valuation NAV yields + /// no units and the executor skips it visibly. Deltas within `driftTolerance` hold. + let weightRows (targets: TargetAllocation list) (positions: RebalancePositionSnapshot list) (availableCash: decimal) - : Result = + : Result = match validateTargets targets with | Error message -> Error message | Ok() -> @@ -93,7 +112,7 @@ module RebalancePolicy = let equity = availableCash + (positions |> List.sumBy (fun position -> position.MarketValue)) - let results = ResizeArray() + let results = ResizeArray() let codes = (targets |> List.map (fun target -> target.InstrumentCode)) @@ -122,29 +141,76 @@ module RebalancePolicy = let targetValue = roundDown 2 (targetPercent / 100m * equity) let delta = targetValue - position.MarketValue - if delta > 0.01m then - results.Add({ InstrumentCode = code; Action = Buy; Amount = delta; Units = None }) - elif delta < -0.01m then - match position.ValuationNav with - | None -> - results.Add({ InstrumentCode = code; Action = Sell; Amount = -delta; Units = None }) - | Some nav when nav > 0m -> - let desiredUnits = roundDown 8 (-delta / nav) - - let units = - min desiredUnits position.AvailableUnits - - if units <= 0m then - results.Add({ InstrumentCode = code; Action = Hold; Amount = 0m; Units = Some 0m }) - else - results.Add({ InstrumentCode = code; Action = Sell; Amount = -delta; Units = Some units }) - | Some _ -> - results.Add({ InstrumentCode = code; Action = Sell; Amount = -delta; Units = None }) - else - results.Add({ InstrumentCode = code; Action = Hold; Amount = 0m; Units = None }) + let currentPercent = + if equity > 0m then + roundDown 2 (position.MarketValue / equity * 100m) + else + 0m + + let action, deltaUnits = + if delta > driftTolerance then + Buy, None + elif delta < -driftTolerance then + match position.ValuationNav with + | None -> Sell, None + | Some nav when nav > 0m -> + let desiredUnits = roundDown 8 (-delta / nav) + let units = min desiredUnits position.AvailableUnits + + if units <= 0m then Hold, Some 0m else Sell, Some units + | Some _ -> Sell, None + else + Hold, None + + results.Add( + { + InstrumentCode = code + TargetPercent = targetPercent + CurrentValue = position.MarketValue + CurrentPercent = currentPercent + Action = action + DeltaAmount = delta + DeltaUnits = deltaUnits + } + ) Ok(results |> Seq.toList) + /// Order-shaped projection of `weightRows`, used by the executor. + let computeOrders + (targets: TargetAllocation list) + (positions: RebalancePositionSnapshot list) + (availableCash: decimal) + : Result = + match weightRows targets positions availableCash with + | Error message -> Error message + | Ok rows -> + rows + |> List.map (fun row -> + match row.Action with + | Buy -> + { + InstrumentCode = row.InstrumentCode + Action = Buy + Amount = row.DeltaAmount + Units = None + } + | Sell -> + { + InstrumentCode = row.InstrumentCode + Action = Sell + Amount = -row.DeltaAmount + Units = row.DeltaUnits + } + | Hold -> + { + InstrumentCode = row.InstrumentCode + Action = Hold + Amount = 0m + Units = row.DeltaUnits + }) + |> Ok + /// Deterministic idempotency keys: one run per plan per date; per-code suffixes keep /// multiple orders of the same run under distinct keys. let runDateText (runDate: DateOnly) : string = runDate.ToString("yyyy-MM-dd") -- cgit v1.2.3