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 } /// 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 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 () /// 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 = 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 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") 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