summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain/BondRules.fs
blob: 21f1245de2547216acaa03e18f3832f9a56be3dc (plain)
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