summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Domain')
-rw-r--r--src/FundLab.Domain/FundLab.Domain.fsproj1
-rw-r--r--src/FundLab.Domain/StockRules.fs110
2 files changed, 111 insertions, 0 deletions
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 @@
<Compile Include="Rebalance.fs" />
<Compile Include="Dividend.fs" />
<Compile Include="BondRules.fs" />
+ <Compile Include="StockRules.fs" />
<Compile Include="Performance.fs" />
</ItemGroup>
</Project>
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<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))