summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain/InvestmentPlan.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 23:21:54 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 23:21:54 +0800
commitd4b539a2791a0cd4d098fecfece0039073a9f7e2 (patch)
treedfd314b37fa24379ef13dcd66a1de76e2a923bf2 /src/FundLab.Domain/InvestmentPlan.fs
parent12c4d3625458c830a2746a1f277fb684e9c498cb (diff)
downloadfund-lab-d4b539a2791a0cd4d098fecfece0039073a9f7e2.tar.gz
Add scheduled investment plans (3d-10)
Diffstat (limited to 'src/FundLab.Domain/InvestmentPlan.fs')
-rw-r--r--src/FundLab.Domain/InvestmentPlan.fs97
1 files changed, 97 insertions, 0 deletions
diff --git a/src/FundLab.Domain/InvestmentPlan.fs b/src/FundLab.Domain/InvestmentPlan.fs
new file mode 100644
index 0000000..ecc3425
--- /dev/null
+++ b/src/FundLab.Domain/InvestmentPlan.fs
@@ -0,0 +1,97 @@
+namespace FundLab.Domain
+
+open System
+
+type InvestmentFrequency =
+ | Daily
+ | Weekly
+ | Monthly
+
+module InvestmentPlanPolicy =
+ let cashMaximum = 999999999999999999.99m
+
+ let validateAmount (amount: decimal) : Result<unit, string> =
+ if amount <= 0m then
+ Error "investment plan amount must be positive"
+ elif Decimal.Round(amount, 2) <> amount then
+ Error "investment plan amount exceeds cash precision"
+ elif amount > cashMaximum then
+ Error "investment plan amount exceeds database precision"
+ else
+ Ok()
+
+ let parseFrequency (text: string) : InvestmentFrequency option =
+ match text with
+ | null -> None
+ | "daily" -> Some InvestmentFrequency.Daily
+ | "weekly" -> Some InvestmentFrequency.Weekly
+ | "monthly" -> Some InvestmentFrequency.Monthly
+ | _ -> None
+
+ let frequencyText (frequency: InvestmentFrequency) : string =
+ match frequency with
+ | InvestmentFrequency.Daily -> "daily"
+ | InvestmentFrequency.Weekly -> "weekly"
+ | InvestmentFrequency.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 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)
+
+ /// First scheduled run date on or after `fromDate`, rolled forward off weekends.
+ /// The anchor date is the plan's first scheduled run date. Calendar approximation:
+ /// only weekends are skipped; public holidays are not modeled yet (no fabrication).
+ let nextRunDate (frequency: InvestmentFrequency) (anchorDate: DateOnly) (fromDate: DateOnly) : DateOnly =
+ let baseDate = if fromDate < anchorDate then anchorDate else fromDate
+
+ match frequency with
+ | InvestmentFrequency.Daily -> rollToWeekday baseDate
+ | InvestmentFrequency.Weekly ->
+ let steps = max 0 ((baseDate.DayNumber - anchorDate.DayNumber + 6) / 7)
+ rollToWeekday (anchorDate.AddDays(steps * 7))
+ | InvestmentFrequency.Monthly ->
+ let monthsBetween = (baseDate.Year - anchorDate.Year) * 12 + (baseDate.Month - anchorDate.Month)
+
+ let candidateMonths =
+ if baseDate.Day <= anchorDate.Day || monthsBetween < 0 then
+ monthsBetween
+ else
+ monthsBetween + 1
+
+ rollToWeekday (addMonthsPreservingDay anchorDate (max 0 candidateMonths))
+
+ /// Due run dates from `dueFrom` through `endDate` inclusive, skipping weekends.
+ /// Empty when nothing is due in the window. This is the "calendar truncation":
+ /// a run never fires past `endDate`, and a repeat drive over the same window
+ /// yields the same dates.
+ let dueDates
+ (frequency: InvestmentFrequency)
+ (anchorDate: DateOnly)
+ (dueFrom: DateOnly)
+ (endDate: DateOnly)
+ : DateOnly list =
+ let rec loop (current: DateOnly) (acc: DateOnly list) =
+ if current > endDate then
+ List.rev acc
+ else
+ loop (nextRunDate frequency anchorDate (current.AddDays 1)) (current :: acc)
+
+ if dueFrom > endDate then
+ []
+ else
+ let start = nextRunDate frequency anchorDate dueFrom
+ if start > endDate then [] else loop start []