blob: 2db7fbd5a173ae2f1b595232459fc46f27b7a8ac (
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
|
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)
/// 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
|