From b05e728ae23d088ca6c9ecccf6ed00d2ab6f3839 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 08:07:00 +0800 Subject: Add bond full milestone: profile probe, coupon/accrual rules, ledger and point-in-time valuation (3d-29) --- src/FundLab.Domain/BondRules.fs | 141 +++++++++++++++++++++++++++++++ src/FundLab.Domain/FundLab.Domain.fsproj | 1 + 2 files changed, 142 insertions(+) create mode 100644 src/FundLab.Domain/BondRules.fs (limited to 'src/FundLab.Domain') diff --git a/src/FundLab.Domain/BondRules.fs b/src/FundLab.Domain/BondRules.fs new file mode 100644 index 0000000..21f1245 --- /dev/null +++ b/src/FundLab.Domain/BondRules.fs @@ -0,0 +1,141 @@ +namespace FundLab.Domain + +open System + +/// Bond pricing/ledger terms. Every value is supplied by instrument metadata +/// (the AKShare profile probe); nothing about a specific bond is hardcoded here. +/// +/// Prices for Chinese exchange bonds are quoted per 100 yuan of face value, so +/// the cash amount of `quantity` 张 at a price `p` is `quantity * p * ParValue / 100`. +type BondTerms = + { + /// Face value of one 张, e.g. 100. + ParValue: decimal + /// Applicable annual coupon rate as a fraction, e.g. 0.02 for 2%. + CouponRate: decimal + /// Coupons per year (1 = annual, 2 = semiannual). + CouponFrequency: int + /// 起息日 (interest accrual start). + ValueDate: DateOnly + /// 到期日. + MaturityDate: DateOnly + /// Minimum tradable unit in 张 (exchange convertible bonds: 10). + MinUnit: decimal + /// Settlement lag in trading days (exchange bonds: 0 = T+0). + SettlementDays: int + /// Commission rate applied to the dirty cash amount. + FeeRate: decimal + } + +module BondTerms = + /// Conservative defaults used only when the probe cannot supply a value; + /// callers should override with real instrument metadata when available. + let create + (parValue: decimal) + (couponRate: decimal) + (couponFrequency: int) + (valueDate: DateOnly) + (maturityDate: DateOnly) + (minUnit: decimal) + (settlementDays: int) + (feeRate: decimal) + : BondTerms = + { + ParValue = parValue + CouponRate = couponRate + CouponFrequency = couponFrequency + ValueDate = valueDate + MaturityDate = maturityDate + MinUnit = minUnit + SettlementDays = settlementDays + FeeRate = feeRate + } + +/// Pure bond rules: coupon schedule, accrued interest (actual/actual), dirty +/// price, cash settlement and minimum-unit validation. +module BondRules = + let private dayCount (a: DateOnly) (b: DateOnly) = b.DayNumber - a.DayNumber + + /// Coupon/period boundary dates from 起息日 through 到期日 inclusive. + let couponSchedule (terms: BondTerms) : DateOnly list = + if terms.CouponFrequency <= 0 || terms.MaturityDate <= terms.ValueDate then + [] + else + let monthsPer = 12 / terms.CouponFrequency + + let rec loop (date: DateOnly) (acc: DateOnly list) = + if date >= terms.MaturityDate then + List.rev (terms.MaturityDate :: acc) + else + loop (date.AddMonths monthsPer) (date :: acc) + + loop terms.ValueDate [] + + /// Accrued interest as of `asOf`, actual/actual within the current coupon + /// period. Returns 0 when the bond has no coupon, has matured, or accrues + /// no interest yet. + let accruedInterest (terms: BondTerms) (asOf: DateOnly) : decimal = + let schedule = couponSchedule terms + + if schedule.IsEmpty || terms.ParValue <= 0m || terms.CouponRate <= 0m then + 0m + else + let couponPerPeriod = terms.ParValue * terms.CouponRate / decimal terms.CouponFrequency + let previous = schedule |> List.filter (fun date -> date <= asOf) |> List.tryLast + let next = schedule |> List.tryFind (fun date -> date > asOf) + + match previous, next with + | Some startDate, Some nextDate -> + let periodDays = dayCount startDate nextDate + + if periodDays <= 0 then + 0m + else + let accruedDays = dayCount startDate asOf + + Decimal.Round( + couponPerPeriod * decimal accruedDays / decimal periodDays, + 6, + MidpointRounding.AwayFromZero + ) + | _ -> 0m + + /// Dirty (全价) price = clean (净价) price + accrued interest. + let dirtyPrice (cleanPrice: decimal) (accrued: decimal) : decimal = cleanPrice + accrued + + /// Cash cost of buying `quantity` 张 at the given dirty price, including the + /// commission. `FeeRate` is taken from the terms, not hardcoded. + let tradeCost (terms: BondTerms) (quantity: decimal) (dirty: decimal) : decimal = + let gross = quantity * dirty * terms.ParValue / 100m + let rounded = Decimal.Round(gross, 2, MidpointRounding.AwayFromZero) + let fee = Decimal.Round(rounded * terms.FeeRate, 2, MidpointRounding.AwayFromZero) + rounded + fee + + /// Settlement date for a trade, skipping weekends. A real exchange trading + /// calendar would extend this; weekends are the minimum correctness bar and + /// `SettlementDays = 0` keeps T+0 exchange bonds unchanged. + let settlementDate (terms: BondTerms) (tradeDate: DateOnly) : DateOnly = + let rec advance (date: DateOnly) (remaining: int) = + if remaining <= 0 then + date + else + let next = date.AddDays 1 + + let adjusted = + match next.DayOfWeek with + | DayOfWeek.Saturday -> next.AddDays 2 + | DayOfWeek.Sunday -> next.AddDays 1 + | _ -> next + + advance adjusted (remaining - 1) + + advance tradeDate terms.SettlementDays + + /// Quantity must be positive and a whole multiple of the minimum unit. + let validateQuantity (terms: BondTerms) (quantity: decimal) : Result = + if quantity <= 0m then + Error "quantity must be positive" + elif terms.MinUnit > 0m && (quantity % terms.MinUnit) <> 0m then + Error(sprintf "quantity must be a multiple of %O 张" terms.MinUnit) + else + Ok quantity diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj index 1ecbd5d..9417a4c 100644 --- a/src/FundLab.Domain/FundLab.Domain.fsproj +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -14,6 +14,7 @@ + -- cgit v1.2.3