summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain/Rebalance.fs
blob: 5d1d50889cf12f76f6070cd1b092a40ae685e9ab (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
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