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
|