1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
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
|