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))
|