summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Domain')
-rw-r--r--src/FundLab.Domain/FundLab.Domain.fsproj1
-rw-r--r--src/FundLab.Domain/Rebalance.fs162
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