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