diff options
Diffstat (limited to 'src/FundLab.Domain/Sip.fs')
| -rw-r--r-- | src/FundLab.Domain/Sip.fs | 94 |
1 files changed, 94 insertions, 0 deletions
diff --git a/src/FundLab.Domain/Sip.fs b/src/FundLab.Domain/Sip.fs new file mode 100644 index 0000000..2db7fbd --- /dev/null +++ b/src/FundLab.Domain/Sip.fs @@ -0,0 +1,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 |
