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