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