summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain/Rebalance.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 23:47:50 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 23:47:50 +0800
commit36ee23a0c406c16e975276238a861299899ec79a (patch)
tree7085f24566b7c53ad75c1ddf8130e563513f673a /src/FundLab.Domain/Rebalance.fs
parentd4b539a2791a0cd4d098fecfece0039073a9f7e2 (diff)
downloadfund-lab-36ee23a0c406c16e975276238a861299899ec79a.tar.gz
Add rebalancing execution slice (3d-11)
Diffstat (limited to 'src/FundLab.Domain/Rebalance.fs')
-rw-r--r--src/FundLab.Domain/Rebalance.fs124
1 files changed, 95 insertions, 29 deletions
diff --git a/src/FundLab.Domain/Rebalance.fs b/src/FundLab.Domain/Rebalance.fs
index 5d1d508..0107513 100644
--- a/src/FundLab.Domain/Rebalance.fs
+++ b/src/FundLab.Domain/Rebalance.fs
@@ -33,6 +33,23 @@ module RebalancePolicy =
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
@@ -69,17 +86,19 @@ module RebalancePolicy =
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
+ /// 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<RebalanceDiff list, string> =
+ : Result<RebalanceWeightRow list, string> =
match validateTargets targets with
| Error message -> Error message
| Ok() ->
@@ -93,7 +112,7 @@ module RebalancePolicy =
let equity =
availableCash + (positions |> List.sumBy (fun position -> position.MarketValue))
- let results = ResizeArray<RebalanceDiff>()
+ let results = ResizeArray<RebalanceWeightRow>()
let codes =
(targets |> List.map (fun target -> target.InstrumentCode))
@@ -122,29 +141,76 @@ module RebalancePolicy =
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 })
+ 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<RebalanceDiff list, string> =
+ 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")