summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Domain')
-rw-r--r--src/FundLab.Domain/FundLab.Domain.fsproj1
-rw-r--r--src/FundLab.Domain/Sip.fs94
2 files changed, 95 insertions, 0 deletions
diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj
index 0daf3ea..5027edd 100644
--- a/src/FundLab.Domain/FundLab.Domain.fsproj
+++ b/src/FundLab.Domain/FundLab.Domain.fsproj
@@ -10,6 +10,7 @@
<Compile Include="Ledger.fs" />
<Compile Include="Redemption.fs" />
<Compile Include="Capital.fs" />
+ <Compile Include="Sip.fs" />
<Compile Include="Performance.fs" />
</ItemGroup>
</Project>
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