summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Domain')
-rw-r--r--src/FundLab.Domain/BondRules.fs141
-rw-r--r--src/FundLab.Domain/FundLab.Domain.fsproj1
2 files changed, 142 insertions, 0 deletions
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<decimal, string> =
+ 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 @@
<Compile Include="InvestmentPlan.fs" />
<Compile Include="Rebalance.fs" />
<Compile Include="Dividend.fs" />
+ <Compile Include="BondRules.fs" />
<Compile Include="Performance.fs" />
</ItemGroup>
</Project>