diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 23:21:54 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 23:21:54 +0800 |
| commit | d4b539a2791a0cd4d098fecfece0039073a9f7e2 (patch) | |
| tree | dfd314b37fa24379ef13dcd66a1de76e2a923bf2 /src/FundLab.Domain | |
| parent | 12c4d3625458c830a2746a1f277fb684e9c498cb (diff) | |
| download | fund-lab-d4b539a2791a0cd4d098fecfece0039073a9f7e2.tar.gz | |
Add scheduled investment plans (3d-10)
Diffstat (limited to 'src/FundLab.Domain')
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Domain/InvestmentPlan.fs | 97 |
2 files changed, 98 insertions, 0 deletions
diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj index ef72c53..1ecbd5d 100644 --- a/src/FundLab.Domain/FundLab.Domain.fsproj +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -11,6 +11,7 @@ <Compile Include="Redemption.fs" /> <Compile Include="Capital.fs" /> <Compile Include="Sip.fs" /> + <Compile Include="InvestmentPlan.fs" /> <Compile Include="Rebalance.fs" /> <Compile Include="Dividend.fs" /> <Compile Include="Performance.fs" /> 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 [] |
