summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain/Sip.fs
blob: 28e0896db9976d2c36874958687c6f2b55f9fbfa (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
namespace FundLab.Domain

open System

type SipFrequency =
    | Weekly
    | BiWeekly
    | Monthly

module SipPolicy =
    let cashMaximum = 999999999999999999.99m

    let validateAmount (amount: decimal) : Result<unit, string> =
        if amount <= 0m then
            Error "sip amount must be positive"
        elif Decimal.Round(amount, 2) <> amount then
            Error "sip amount exceeds cash precision"
        elif amount > cashMaximum then
            Error "sip amount exceeds database precision"
        else
            Ok ()

    let parseFrequency (text: string) : SipFrequency option =
        match text with
        | null -> None
        | "weekly" -> Some Weekly
        | "biweekly" -> Some BiWeekly
        | "monthly" -> Some Monthly
        | _ -> None

    let frequencyText (frequency: SipFrequency) : string =
        match frequency with
        | Weekly -> "weekly"
        | BiWeekly -> "biweekly"
        | Monthly -> "monthly"

    let private isWeekend (date: DateOnly) =
        date.DayOfWeek = DayOfWeek.Saturday || date.DayOfWeek = DayOfWeek.Sunday

    let private rollToWeekday (date: DateOnly) : DateOnly =
        let mutable candidate = date

        while isWeekend candidate do
            candidate <- candidate.AddDays 1

        candidate

    let private intervalDays (frequency: SipFrequency) : int =
        match frequency with
        | Weekly -> 7
        | BiWeekly -> 14
        | Monthly -> 0

    let private addMonthsPreservingDay (anchor: DateOnly) (months: int) : DateOnly =
        let targetMonthIndex = anchor.Year * 12 + (anchor.Month - 1) + months
        let year = targetMonthIndex / 12
        let month = targetMonthIndex % 12 + 1
        let daysInTargetMonth = DateTime.DaysInMonth(year, month)
        DateOnly(year, month, min anchor.Day daysInTargetMonth)

    /// Next due trade date for a plan: the first run date of the plan's cadence that is
    /// on or after `fromDate`, rolled forward off weekends (no-lookahead weekday rule).
    let nextTradeDate (frequency: SipFrequency) (anchorDate: DateOnly) (fromDate: DateOnly) : DateOnly =
        match frequency with
        | Weekly
        | BiWeekly ->
            let interval = intervalDays frequency
            let anchorTicks = anchorDate.DayNumber
            let fromTicks = fromDate.DayNumber
            let steps = max 0 ((fromTicks - anchorTicks + interval - 1) / interval)
            rollToWeekday (anchorDate.AddDays(steps * interval))
        | Monthly ->
            let monthsBetween =
                (fromDate.Year - anchorDate.Year) * 12 + (fromDate.Month - anchorDate.Month)

            let candidateMonths =
                if fromDate.Day <= anchorDate.Day || monthsBetween < 0 then
                    monthsBetween
                else
                    monthsBetween + 1

            let months = max 0 candidateMonths
            rollToWeekday (addMonthsPreservingDay anchorDate months)

    /// Due trade dates a plan must run through, from its current nextTradeDate up to and
    /// including endDate. Calendar approximation: only weekends are skipped (Chinese fund
    /// trading calendar approximated by weekdays; public holidays are not modeled here —
    /// noted per the no-fabrication rule, holidays may be added to the calendar later).
    let advancePlan
        (frequency: SipFrequency)
        (anchorDate: DateOnly)
        (dueFrom: DateOnly)
        (endDate: DateOnly)
        : DateOnly list =
        let rec loop (current: DateOnly) (acc: DateOnly list) =
            if current > endDate then
                List.rev acc
            else
                let following = nextTradeDate frequency anchorDate (current.AddDays 1)
                loop following (current :: acc)

        if dueFrom > endDate then [] else loop dueFrom []

    /// Execution is out of scope for this slice. These pure helpers define the
    /// due/settlement contract that the (future) executor will implement: when a
    /// plan is due, the net amount debited equals the plan amount exactly, and no
    /// units, NAV or realized income are touched until that executor lands (3d-6+).
    let isDue (nextTradeDate: DateOnly) (today: DateOnly) : bool = today >= nextTradeDate

    let executionNetAmount (plannedAmount: decimal) : Result<decimal, string> =
        match validateAmount plannedAmount with
        | Error message -> Error message
        | Ok() -> Ok plannedAmount