summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain/StockRules.fs
blob: 0102e5ba340ca7a3f933b8cc7875a6df1ea74ef6 (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
namespace FundLab.Domain

open System

/// Stock trading terms. Every rate/limit is supplied by the caller (config),
/// nothing about a specific regime is hardcoded into the functions below.
///
/// A-share conventions the caller typically supplies: 100-share lot, commission
/// rate with a per-order minimum, sell-side stamp duty, transfer fee, a 15:00
/// same-day cutoff and T+1 settlement.
type StockTerms =
    {
        /// Minimum tradable unit in shares (A shares: 100).
        MinUnit: decimal
        /// Brokerage commission rate applied to the gross amount.
        CommissionRate: decimal
        /// Minimum commission per order (e.g. 5.00).
        CommissionMin: decimal
        /// Stamp duty rate, charged on sells only (e.g. 0.0005).
        StampDutyRate: decimal
        /// Transfer fee rate applied to the gross amount (both sides).
        TransferFeeRate: decimal
        /// Latest same-day order time; at/after this the order is next-day.
        CutoffTime: TimeOnly
        /// Settlement lag in trading days (T+1 => 1).
        SettlementDays: int
    }

module StockTerms =
    let create
        (minUnit: decimal)
        (commissionRate: decimal)
        (commissionMin: decimal)
        (stampDutyRate: decimal)
        (transferFeeRate: decimal)
        (cutoffTime: TimeOnly)
        (settlementDays: int)
        : StockTerms =
        {
            MinUnit = minUnit
            CommissionRate = commissionRate
            CommissionMin = commissionMin
            StampDutyRate = stampDutyRate
            TransferFeeRate = transferFeeRate
            CutoffTime = cutoffTime
            SettlementDays = settlementDays
        }

    /// Common A-share defaults; callers may override any field.
    let aShareDefault: StockTerms =
        create 100m 0.00025m 5.00m 0.0005m 0.00001m (TimeOnly(15, 0)) 1

/// Pure stock trading rules: cutoff handling, T+1 settlement, fee bounds and
/// lot-size validation.
module StockRules =
    let private nextTradingDay (date: DateOnly) =
        let next = date.AddDays 1

        match next.DayOfWeek with
        | DayOfWeek.Saturday -> next.AddDays 2
        | DayOfWeek.Sunday -> next.AddDays 1
        | _ -> next

    /// Order date after applying the same-day cutoff: an order at/after the
    /// cutoff is treated as placed on the next trading day.
    let effectiveTradeDate (terms: StockTerms) (tradeDate: DateOnly) (at: TimeOnly) : DateOnly =
        if at >= terms.CutoffTime then nextTradingDay tradeDate else tradeDate

    /// Settlement date for a trade, skipping weekends. A real exchange trading
    /// calendar would extend this; weekends are the minimum correctness bar.
    let settlementDate (terms: StockTerms) (tradeDate: DateOnly) (at: TimeOnly) : DateOnly =
        let rec advance (date: DateOnly) (remaining: int) =
            if remaining <= 0 then date else advance (nextTradingDay date) (remaining - 1)

        advance (effectiveTradeDate terms tradeDate at) terms.SettlementDays

    let private commission (terms: StockTerms) (gross: decimal) =
        let raw = Decimal.Round(gross * terms.CommissionRate, 2, MidpointRounding.AwayFromZero)
        if gross > 0m && raw < terms.CommissionMin then terms.CommissionMin else raw

    let private transferFee (terms: StockTerms) (gross: decimal) =
        Decimal.Round(gross * terms.TransferFeeRate, 2, MidpointRounding.AwayFromZero)

    /// Cash cost of buying `quantity` shares: gross plus commission and
    /// transfer fee (no stamp duty on buys).
    let buyCost (terms: StockTerms) (quantity: decimal) (price: decimal) : decimal =
        let gross = Decimal.Round(quantity * price, 2, MidpointRounding.AwayFromZero)
        let fees = commission terms gross + transferFee terms gross
        gross + fees

    /// Cash proceeds of selling `quantity` shares: gross minus commission,
    /// stamp duty and transfer fee.
    let sellProceeds (terms: StockTerms) (quantity: decimal) (price: decimal) : decimal =
        let gross = Decimal.Round(quantity * price, 2, MidpointRounding.AwayFromZero)
        let fees = commission terms gross + transferFee terms gross + Decimal.Round(gross * terms.StampDutyRate, 2, MidpointRounding.AwayFromZero)
        gross - fees

    /// Quantity must be positive and a whole multiple of the minimum unit.
    let validateQuantity (terms: StockTerms) (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 shares" terms.MinUnit)
        else
            Ok quantity

    /// A stock cannot be sold on the same day it was bought (T+1): given a buy
    /// date and a sell date, returns true only once settlement has elapsed.
    let isSellAllowed (terms: StockTerms) (buyDate: DateOnly) (sellDate: DateOnly) : bool =
        sellDate >= settlementDate terms buyDate (TimeOnly(0, 0))