From 72f466b9b3ea6ece726e6b182cfc36bbd3f6fa15 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 08:58:52 +0800 Subject: Add stock probe market/adjust/suspension fields and StockRules (3d-30 B1-B2) --- src/FundLab.Domain/FundLab.Domain.fsproj | 1 + src/FundLab.Domain/StockRules.fs | 110 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/FundLab.Domain/StockRules.fs (limited to 'src/FundLab.Domain') diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj index 9417a4c..6d6a8ac 100644 --- a/src/FundLab.Domain/FundLab.Domain.fsproj +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -15,6 +15,7 @@ + diff --git a/src/FundLab.Domain/StockRules.fs b/src/FundLab.Domain/StockRules.fs new file mode 100644 index 0000000..0102e5b --- /dev/null +++ b/src/FundLab.Domain/StockRules.fs @@ -0,0 +1,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 = + 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)) -- cgit v1.2.3