diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 13:28:02 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 13:28:02 +0800 |
| commit | a3dddc28f33254317d07323037ba5d1db35df3d6 (patch) | |
| tree | c2a13404bda8a8b62d07559c2ab95b5491034f70 /src/FundLab.Domain | |
| parent | d2a547b6a60ea769092894fff1f2b5105954f226 (diff) | |
| download | fund-lab-a3dddc28f33254317d07323037ba5d1db35df3d6.tar.gz | |
Add rebalance execution through shared order pipeline (3d-7)
Diffstat (limited to 'src/FundLab.Domain')
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Domain/Rebalance.fs | 162 |
2 files changed, 163 insertions, 0 deletions
diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj index 5027edd..bbdd6b5 100644 --- a/src/FundLab.Domain/FundLab.Domain.fsproj +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -11,6 +11,7 @@ <Compile Include="Redemption.fs" /> <Compile Include="Capital.fs" /> <Compile Include="Sip.fs" /> + <Compile Include="Rebalance.fs" /> <Compile Include="Performance.fs" /> </ItemGroup> </Project> 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<unit, string> = + 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<RebalanceDiff list, string> = + 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<RebalanceDiff>() + + 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 |
