From a3dddc28f33254317d07323037ba5d1db35df3d6 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 13:28:02 +0800 Subject: Add rebalance execution through shared order pipeline (3d-7) --- src/FundLab.Domain/Rebalance.fs | 162 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/FundLab.Domain/Rebalance.fs (limited to 'src/FundLab.Domain/Rebalance.fs') diff --git a/src/FundLab.Domain/Rebalance.fs b/src/FundLab.Domain/Rebalance.fs new file mode 100644 index 0000000..5d1d508 --- /dev/null +++ b/src/FundLab.Domain/Rebalance.fs @@ -0,0 +1,162 @@ +namespace FundLab.Domain + +open System + +module RebalancePolicy = + let cashMaximum = 999999999999999999.99m + + type TargetAllocation = + { + InstrumentCode: string + TargetPercent: decimal + } + + type RebalancePositionSnapshot = + { + InstrumentCode: string + MarketValue: decimal + Units: decimal + AvailableUnits: decimal + ValuationNav: decimal option + } + + type RebalanceAction = + | Buy + | Sell + | Hold + + type RebalanceDiff = + { + InstrumentCode: string + Action: RebalanceAction + Amount: decimal + Units: decimal option + } + + let private roundDown (scale: int) (value: decimal) : decimal = + let factor = decimal (pown 10 scale) + Decimal.Truncate(value * factor) / factor + + let validateTargets (targets: TargetAllocation list) : Result = + if List.isEmpty targets then + Error "at least one target allocation is required" + else + let duplicates = + targets + |> List.map (fun target -> target.InstrumentCode) + |> List.groupBy id + |> List.filter (fun (_, group) -> List.length group > 1) + |> List.map fst + + if not (List.isEmpty duplicates) then + Error (sprintf "duplicate instrument code %s in target allocation" (List.head duplicates)) + else + let invalid = + targets + |> List.tryFind (fun target -> + target.TargetPercent <= 0m + || target.TargetPercent > 100m + || Decimal.Round(target.TargetPercent, 2) <> target.TargetPercent) + + match invalid with + | Some target -> + Error (sprintf "target percent for %s must be a positive two-decimal share of 100" target.InstrumentCode) + | None -> + let total = targets |> List.sumBy (fun target -> target.TargetPercent) + + if total <> 100m then + Error (sprintf "target percents must sum to 100 but sum to %s" (total.ToString("0.##", Globalization.CultureInfo.InvariantCulture))) + 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 + (targets: TargetAllocation list) + (positions: RebalancePositionSnapshot list) + (availableCash: decimal) + : Result = + match validateTargets targets with + | Error message -> Error message + | Ok() -> + let heldByCode = + positions + |> List.map (fun position -> position.InstrumentCode, position) + |> Map.ofList + + // total equity = deployable cash + current holdings value; targets describe the + // split of that equity across the codes (held codes without a target = 0%) + let equity = + availableCash + (positions |> List.sumBy (fun position -> position.MarketValue)) + + let results = ResizeArray() + + let codes = + (targets |> List.map (fun target -> target.InstrumentCode)) + @ (positions |> List.map (fun position -> position.InstrumentCode)) + |> List.distinct + + for code in codes do + let targetPercent = + targets + |> List.tryFind (fun target -> target.InstrumentCode = code) + |> Option.map (fun target -> target.TargetPercent) + |> Option.defaultValue 0m + + let position = + heldByCode + |> Map.tryFind code + |> Option.defaultWith (fun () -> + { + InstrumentCode = code + MarketValue = 0m + Units = 0m + AvailableUnits = 0m + ValuationNav = None + }) + + 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 }) + + Ok(results |> Seq.toList) + + /// 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") + + let orderKey (planId: Guid) (runDate: DateOnly) (code: string) : string = + sprintf "rebalance:%O:%s:%s" planId (runDateText runDate) code + + let confirmKey (planId: Guid) (runDate: DateOnly) (code: string) : string = + sprintf "rebalance-confirm:%O:%s:%s" planId (runDateText runDate) code + + let redemptionKey (planId: Guid) (runDate: DateOnly) (code: string) : string = + sprintf "rebalance-redeem:%O:%s:%s" planId (runDateText runDate) code + + let redemptionConfirmKey (planId: Guid) (runDate: DateOnly) (code: string) : string = + sprintf "rebalance-redeem-confirm:%O:%s:%s" planId (runDateText runDate) code -- cgit v1.2.3