summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain/Rebalance.fs
blob: 0107513257770577370ead69a247cc3103e38568 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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<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 ()

    /// 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<RebalanceWeightRow 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<RebalanceWeightRow>()

            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<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")

    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