friction-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ """friction-engine: realistic backtest friction modeling.
2
+
3
+ Four orthogonal pieces, composable:
4
+
5
+ - :mod:`friction_engine.slippage` -- volume-aware slippage models
6
+ - :mod:`friction_engine.fees` -- multi-market fee & tax schedules (US/TW/JP)
7
+ - :mod:`friction_engine.liquidity` -- participation-rate liquidity constraints
8
+ - :mod:`friction_engine.mae_mfe` -- MAE/MFE excursion analytics
9
+
10
+ Pure standard library, no third-party dependencies.
11
+ """
12
+
13
+ from .fees import FeeSchedule, Market, fee_for, FEE_SCHEDULES
14
+ from .liquidity import LiquidityConstraint, ParticipationCap
15
+ from .mae_mfe import Excursion, ExcursionStats, TradePath, excursion, excursion_stats, mae, mfe
16
+ from .models import Fill, Order, Side
17
+ from .slippage import (
18
+ FixedSlippage,
19
+ SlippageModel,
20
+ VolumeShareSlippage,
21
+ ZeroSlippage,
22
+ )
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ "FEE_SCHEDULES",
28
+ "Excursion",
29
+ "ExcursionStats",
30
+ "FeeSchedule",
31
+ "Fill",
32
+ "FixedSlippage",
33
+ "LiquidityConstraint",
34
+ "Market",
35
+ "Order",
36
+ "ParticipationCap",
37
+ "Side",
38
+ "SlippageModel",
39
+ "TradePath",
40
+ "VolumeShareSlippage",
41
+ "ZeroSlippage",
42
+ "excursion",
43
+ "excursion_stats",
44
+ "fee_for",
45
+ "mae",
46
+ "mfe",
47
+ "__version__",
48
+ ]
@@ -0,0 +1,131 @@
1
+ """Multi-market fee & tax schedules (US / TW / JP presets, all configurable).
2
+
3
+ Rates below are publicly documented, commonly-cited figures as of mid-2026,
4
+ chosen as *conservative defaults* — they are NOT a substitute for checking
5
+ your broker's current schedule. Every number is a plain constructor argument
6
+ so you can (and should) override them.
7
+
8
+ Design notes:
9
+ - Commissions are per-side (charged on both buy and sell).
10
+ - Transaction taxes are per-market and usually sell-side only.
11
+ - US: no transaction tax on stocks; regulatory fees (SEC fee on sells,
12
+ FINRA TAF on sells) are tiny but included for completeness.
13
+ - TW: broker commission 0.1425% (statutory max, discounts common) +
14
+ securities transaction tax 0.3% on stock sells (0.1% for ETF sells).
15
+ - JP: no transaction tax on shares; commission is broker-negotiated, so the
16
+ preset is a representative flat bps; consumption tax (10%) applies to the
17
+ *commission*, modeled via `commission_tax_bps`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import enum
23
+ from dataclasses import dataclass, replace
24
+
25
+ from .models import Order, Side
26
+
27
+
28
+ class Market(enum.Enum):
29
+ US = "US"
30
+ TW = "TW"
31
+ JP = "JP"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class FeeSchedule:
36
+ """A complete per-market cost schedule.
37
+
38
+ Attributes:
39
+ commission_bps: Broker commission in basis points of gross, per side.
40
+ min_commission: Minimum commission per order (account currency).
41
+ sell_tax_bps: Transaction tax on sells, in basis points of gross.
42
+ buy_tax_bps: Transaction tax on buys, in basis points of gross
43
+ (0 for US/TW/JP stocks; present for markets that need it).
44
+ sell_reg_fee_bps: Regulatory fees on sells in basis points
45
+ (e.g. US SEC fee + TAF combined approximation).
46
+ commission_tax_bps: Tax levied on the commission itself
47
+ (e.g. JP consumption tax = 1000 bps = 10%).
48
+ """
49
+
50
+ commission_bps: float
51
+ min_commission: float = 0.0
52
+ sell_tax_bps: float = 0.0
53
+ buy_tax_bps: float = 0.0
54
+ sell_reg_fee_bps: float = 0.0
55
+ commission_tax_bps: float = 0.0
56
+
57
+ def fee_and_tax(self, order: Order, gross_amount: float) -> tuple[float, float]:
58
+ """Return (fee, tax) in account currency for a fill of `gross_amount`."""
59
+ if gross_amount < 0:
60
+ raise ValueError("gross_amount must be >= 0")
61
+ raw_commission = gross_amount * self.commission_bps / 10_000.0
62
+ commission = max(raw_commission, self.min_commission) if gross_amount > 0 else 0.0
63
+ commission_tax = commission * self.commission_tax_bps / 10_000.0
64
+ fee = commission + commission_tax
65
+
66
+ if order.side is Side.SELL:
67
+ tax = gross_amount * (self.sell_tax_bps + self.sell_reg_fee_bps) / 10_000.0
68
+ else:
69
+ tax = gross_amount * self.buy_tax_bps / 10_000.0
70
+ return fee, tax
71
+
72
+ def with_overrides(self, **changes: float) -> "FeeSchedule":
73
+ """Return a new schedule with selected fields replaced (immutable update)."""
74
+ return replace(self, **changes)
75
+
76
+
77
+ # --- Presets (publicly documented typical values; verify before live use) ---
78
+
79
+ # US: zero-commission brokers exist; 0 default + tiny sell-side regulatory
80
+ # fees (SEC fee ~0.278 bps of sell gross + FINRA TAF; rounded, mid-2020s).
81
+ _US = FeeSchedule(
82
+ commission_bps=0.0,
83
+ min_commission=0.0,
84
+ sell_tax_bps=0.0,
85
+ sell_reg_fee_bps=0.3,
86
+ )
87
+
88
+ # TW stocks: commission 0.1425% (14.25 bps, statutory max before discounts),
89
+ # sell-side securities transaction tax 0.3% (30 bps).
90
+ _TW_STOCK = FeeSchedule(
91
+ commission_bps=14.25,
92
+ min_commission=0.0, # many TW brokers have a NT$20 minimum — set it if yours does
93
+ sell_tax_bps=30.0,
94
+ )
95
+
96
+ # TW ETFs: same commission, reduced 0.1% (10 bps) sell tax.
97
+ _TW_ETF = replace(_TW_STOCK, sell_tax_bps=10.0)
98
+
99
+ # JP: representative commission 10 bps (broker-negotiated), 10% consumption
100
+ # tax on commission, no share transaction tax.
101
+ _JP = FeeSchedule(
102
+ commission_bps=10.0,
103
+ min_commission=0.0,
104
+ sell_tax_bps=0.0,
105
+ commission_tax_bps=1000.0,
106
+ )
107
+
108
+ FEE_SCHEDULES: dict[Market, FeeSchedule] = {
109
+ Market.US: _US,
110
+ Market.TW: _TW_STOCK,
111
+ Market.JP: _JP,
112
+ }
113
+
114
+ TW_ETF_SCHEDULE = _TW_ETF
115
+
116
+
117
+ def fee_for(
118
+ order: Order,
119
+ gross_amount: float,
120
+ market: Market | None = None,
121
+ schedule: FeeSchedule | None = None,
122
+ ) -> tuple[float, float]:
123
+ """Compute (fee, tax) for an order.
124
+
125
+ Pass either a `market` (uses the built-in preset) or an explicit
126
+ `schedule` (recommended for anything real). Exactly one is required.
127
+ """
128
+ if (market is None) == (schedule is None):
129
+ raise ValueError("pass exactly one of market= or schedule=")
130
+ sched = schedule if schedule is not None else FEE_SCHEDULES[market] # type: ignore[index]
131
+ return sched.fee_and_tax(order, gross_amount)
@@ -0,0 +1,41 @@
1
+ """Liquidity constraints: how much of an order can realistically fill.
2
+
3
+ The classic backtest lie is "buy 1,000,000 shares of a stock that trades
4
+ 200,000 shares a day, at the close price." These constraints cap executable
5
+ quantity at a fraction of observed market volume (participation rate), so
6
+ your backtest can't trade size the market couldn't absorb.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+
13
+ from .models import Order
14
+
15
+
16
+ class LiquidityConstraint(ABC):
17
+ """Interface: cap an order's quantity given market volume."""
18
+
19
+ @abstractmethod
20
+ def cap(self, order: Order, *, bar_volume: float) -> float:
21
+ """Return the maximum executable quantity for `order` (>= 0)."""
22
+
23
+
24
+ class ParticipationCap(LiquidityConstraint):
25
+ """Cap quantity at a fixed fraction of bar volume.
26
+
27
+ Args:
28
+ max_participation: Maximum share of bar volume your order may be,
29
+ e.g. 0.10 = at most 10% of the day's volume. Industry heuristics
30
+ for "low-impact" execution typically live in the 1-20% range.
31
+ """
32
+
33
+ def __init__(self, max_participation: float = 0.10) -> None:
34
+ if not 0 < max_participation <= 1:
35
+ raise ValueError("max_participation must be in (0, 1]")
36
+ self.max_participation = max_participation
37
+
38
+ def cap(self, order: Order, *, bar_volume: float) -> float:
39
+ if bar_volume < 0:
40
+ raise ValueError("bar_volume must be >= 0")
41
+ return min(order.quantity, self.max_participation * bar_volume)
@@ -0,0 +1,184 @@
1
+ """MAE / MFE excursion analytics.
2
+
3
+ MAE (Maximum Adverse Excursion): the worst open-loss a trade experienced
4
+ while it was on. MFE (Maximum Favorable Excursion): the best open-profit.
5
+ Together they answer questions a single entry/exit pair cannot:
6
+
7
+ - "My stop is -5%; how often did winners dip below -5% first?" (MAE distribution)
8
+ - "My target is +8%; how often did trades reach +8% but close lower?" (MFE distribution)
9
+ - "Am I exiting too early/late?" (compare exit return to MFE — give-back ratio)
10
+
11
+ These are descriptive statistics over a trade's price path — a measurement
12
+ tool, not a strategy. Everything here is computed from post-entry highs/lows
13
+ the caller supplies, so point-in-time correctness is the caller's job (use
14
+ quant-lint on the strategy that produced the paths).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import statistics
20
+ from dataclasses import dataclass
21
+ from typing import Sequence
22
+
23
+ from .models import Side
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TradePath:
28
+ """One trade's lifecycle for excursion analysis.
29
+
30
+ Attributes:
31
+ entry_price: Actual fill price at entry.
32
+ side: BUY for long trades, SELL for short trades.
33
+ highs: Post-entry period high prices, in time order (include the
34
+ entry bar's high if the trade was open for the rest of that bar).
35
+ lows: Post-entry period low prices, aligned with `highs`.
36
+ exit_price: Actual fill price at exit. If omitted, the last period's
37
+ low/high is NOT used as a fallback — realized-return fields are
38
+ simply unavailable (None).
39
+ """
40
+
41
+ entry_price: float
42
+ side: Side
43
+ highs: Sequence[float]
44
+ lows: Sequence[float]
45
+ exit_price: float | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ if self.entry_price <= 0:
49
+ raise ValueError("entry_price must be positive")
50
+ if len(self.highs) == 0:
51
+ raise ValueError("highs/lows must contain at least one period")
52
+ if len(self.highs) != len(self.lows):
53
+ raise ValueError("highs and lows must have equal length")
54
+ for h, l in zip(self.highs, self.lows):
55
+ if h < l:
56
+ raise ValueError(f"period high {h} below period low {l}")
57
+
58
+
59
+ def _signed_return(price: float, entry: float, side: Side) -> float:
60
+ """Return from `entry` to `price`, signed so profit is positive."""
61
+ raw = (price - entry) / entry
62
+ return raw if side is Side.BUY else -raw
63
+
64
+
65
+ def mae(path: TradePath) -> float:
66
+ """Maximum Adverse Excursion as a signed return (<= 0 for any losing dip).
67
+
68
+ Long: worst case is the minimum post-entry low.
69
+ Short: worst case is the maximum post-entry high.
70
+ """
71
+ if path.side is Side.BUY:
72
+ worst = min(path.lows)
73
+ else:
74
+ worst = max(path.highs)
75
+ return min(0.0, _signed_return(worst, path.entry_price, path.side))
76
+
77
+
78
+ def mfe(path: TradePath) -> float:
79
+ """Maximum Favorable Excursion as a signed return (>= 0 for any gain)."""
80
+ if path.side is Side.BUY:
81
+ best = max(path.highs)
82
+ else:
83
+ best = min(path.lows)
84
+ return max(0.0, _signed_return(best, path.entry_price, path.side))
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Excursion:
89
+ """Full excursion record for one trade.
90
+
91
+ Attributes:
92
+ mae: Maximum Adverse Excursion (signed return, <= 0).
93
+ mfe: Maximum Favorable Excursion (signed return, >= 0).
94
+ realized_return: Exit-vs-entry signed return, or None if no exit_price.
95
+ giveback: Fraction of MFE surrendered at exit,
96
+ (mfe - realized) / mfe; None when mfe == 0 or exit unknown.
97
+ 0.0 = exited at the peak; 1.0 = gave back everything; >1 = the
98
+ trade round-tripped through zero into a loss.
99
+ end_up_winning: True when realized_return > 0; None if exit unknown.
100
+ """
101
+
102
+ mae: float
103
+ mfe: float
104
+ realized_return: float | None
105
+ giveback: float | None
106
+ end_up_winning: bool | None
107
+
108
+
109
+ def excursion(path: TradePath) -> Excursion:
110
+ """Compute the full excursion record for one trade."""
111
+ a = mae(path)
112
+ f = mfe(path)
113
+ realized: float | None = None
114
+ giveback: float | None = None
115
+ winning: bool | None = None
116
+ if path.exit_price is not None:
117
+ realized = _signed_return(path.exit_price, path.entry_price, path.side)
118
+ winning = realized > 0
119
+ if f > 0:
120
+ giveback = (f - realized) / f
121
+ return Excursion(mae=a, mfe=f, realized_return=realized, giveback=giveback, end_up_winning=winning)
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class ExcursionStats:
126
+ """Aggregate statistics over a set of trades."""
127
+
128
+ n: int
129
+ mae_mean: float
130
+ mae_median: float
131
+ mae_p05: float # 5th percentile (worst tail) of MAE
132
+ mfe_mean: float
133
+ mfe_median: float
134
+ mfe_p95: float # 95th percentile (best tail) of MFE
135
+ giveback_mean: float | None # over trades with mfe > 0 and known exit
136
+ win_rate: float | None # over trades with known exit
137
+ mae_of_winners_mean: float | None # how deep winners dipped first
138
+ mfe_of_losers_mean: float | None # how high losers climbed before failing
139
+
140
+
141
+ def _percentile(sorted_vals: Sequence[float], q: float) -> float:
142
+ """Linear-interpolation percentile; q in [0, 1]."""
143
+ if not sorted_vals:
144
+ raise ValueError("empty sequence")
145
+ if len(sorted_vals) == 1:
146
+ return float(sorted_vals[0])
147
+ pos = q * (len(sorted_vals) - 1)
148
+ lo = int(pos)
149
+ hi = min(lo + 1, len(sorted_vals) - 1)
150
+ frac = pos - lo
151
+ return float(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
152
+
153
+
154
+ def excursion_stats(paths: Sequence[TradePath]) -> ExcursionStats:
155
+ """Aggregate MAE/MFE statistics over many trades.
156
+
157
+ Use it to sanity-check stops/targets: if `mae_of_winners_mean` is deeper
158
+ than your stop, your stop would have killed trades that later won.
159
+ """
160
+ if not paths:
161
+ raise ValueError("paths must be non-empty")
162
+ recs = [excursion(p) for p in paths]
163
+
164
+ maes = sorted(r.mae for r in recs)
165
+ mfes = sorted(r.mfe for r in recs)
166
+
167
+ with_exit = [r for r in recs if r.realized_return is not None]
168
+ wins = [r for r in with_exit if r.end_up_winning]
169
+ losses = [r for r in with_exit if not r.end_up_winning]
170
+ givebacks = [r.giveback for r in with_exit if r.giveback is not None]
171
+
172
+ return ExcursionStats(
173
+ n=len(recs),
174
+ mae_mean=statistics.fmean(maes),
175
+ mae_median=statistics.median(maes),
176
+ mae_p05=_percentile(maes, 0.05),
177
+ mfe_mean=statistics.fmean(mfes),
178
+ mfe_median=statistics.median(mfes),
179
+ mfe_p95=_percentile(mfes, 0.95),
180
+ giveback_mean=statistics.fmean(givebacks) if givebacks else None,
181
+ win_rate=len(wins) / len(with_exit) if with_exit else None,
182
+ mae_of_winners_mean=statistics.fmean(r.mae for r in wins) if wins else None,
183
+ mfe_of_losers_mean=statistics.fmean(r.mfe for r in losses) if losses else None,
184
+ )
@@ -0,0 +1,88 @@
1
+ """Core value types shared across friction-engine modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass
7
+
8
+
9
+ class Side(enum.Enum):
10
+ """Order/trade direction."""
11
+
12
+ BUY = "buy"
13
+ SELL = "sell"
14
+
15
+ @property
16
+ def sign(self) -> int:
17
+ """+1 for BUY, -1 for SELL — handy for signed cash-flow math."""
18
+ return 1 if self is Side.BUY else -1
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Order:
23
+ """A desired trade, before friction is applied.
24
+
25
+ Attributes:
26
+ symbol: Instrument identifier (free-form string).
27
+ side: BUY or SELL.
28
+ quantity: Number of shares/units requested (positive).
29
+ limit_price: Optional limit price; informational only — this library
30
+ models cost, it does not decide whether a limit order fills.
31
+ """
32
+
33
+ symbol: str
34
+ side: Side
35
+ quantity: float
36
+ limit_price: float | None = None
37
+
38
+ def __post_init__(self) -> None:
39
+ if self.quantity <= 0:
40
+ raise ValueError(f"quantity must be positive, got {self.quantity}")
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Fill:
45
+ """A trade after friction has been applied.
46
+
47
+ Attributes:
48
+ symbol: Instrument identifier.
49
+ side: BUY or SELL.
50
+ quantity: Shares/units actually filled (may be capped by liquidity).
51
+ base_price: Reference price before slippage (e.g. bar close).
52
+ fill_price: Effective execution price after slippage.
53
+ slippage_cost: Total slippage cost in account currency (always >= 0;
54
+ for sells the fill price moves *down*, but cost stays positive).
55
+ fee: Commission/fee amount in account currency.
56
+ tax: Transaction tax amount in account currency.
57
+ """
58
+
59
+ symbol: str
60
+ side: Side
61
+ quantity: float
62
+ base_price: float
63
+ fill_price: float
64
+ slippage_cost: float
65
+ fee: float
66
+ tax: float
67
+
68
+ @property
69
+ def gross_amount(self) -> float:
70
+ """fill_price * quantity — cash before fee/tax."""
71
+ return self.fill_price * self.quantity
72
+
73
+ @property
74
+ def total_cost(self) -> float:
75
+ """All friction in account currency: slippage + fee + tax."""
76
+ return self.slippage_cost + self.fee + self.tax
77
+
78
+ @property
79
+ def net_cash_flow(self) -> float:
80
+ """Signed cash impact on the account.
81
+
82
+ BUY: negative (cash out) = -(gross + fee + tax).
83
+ SELL: positive (cash in) = gross - fee - tax.
84
+ Slippage is already embedded in fill_price, so it is not added again.
85
+ """
86
+ if self.side is Side.BUY:
87
+ return -(self.gross_amount + self.fee + self.tax)
88
+ return self.gross_amount - self.fee - self.tax
@@ -0,0 +1,132 @@
1
+ """Slippage models.
2
+
3
+ Slippage = the gap between the reference price your backtest *assumes* and
4
+ the price you would actually get. Models here are deliberately simple,
5
+ public, and auditable. The volume-aware model is the classic square-root
6
+ market-impact form published widely in the market-microstructure literature
7
+ (e.g. Almgren-style impact): impact grows with the square root of your
8
+ participation rate in the day's volume.
9
+
10
+ Convention: slippage always works *against* the trader — buys fill higher,
11
+ sells fill lower.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from abc import ABC, abstractmethod
18
+
19
+ from .models import Order, Side
20
+
21
+
22
+ class SlippageModel(ABC):
23
+ """Interface: given an order and market context, return the fill price."""
24
+
25
+ @abstractmethod
26
+ def fill_price(
27
+ self,
28
+ order: Order,
29
+ reference_price: float,
30
+ *,
31
+ bar_volume: float | None = None,
32
+ volatility: float | None = None,
33
+ ) -> float:
34
+ """Return the effective execution price for `order`.
35
+
36
+ Args:
37
+ order: The desired trade.
38
+ reference_price: Price the backtest would naively use (e.g. close).
39
+ bar_volume: Total market volume in the execution bar/day, in the
40
+ same units as order quantity. Required by volume-aware models.
41
+ volatility: Daily return volatility (e.g. 0.02 for 2%). Used by
42
+ impact-style models; ignored by fixed models.
43
+ """
44
+
45
+
46
+ class ZeroSlippage(SlippageModel):
47
+ """No slippage — the naive-backtest default, provided for completeness."""
48
+
49
+ def fill_price(
50
+ self,
51
+ order: Order,
52
+ reference_price: float,
53
+ *,
54
+ bar_volume: float | None = None,
55
+ volatility: float | None = None,
56
+ ) -> float:
57
+ return reference_price
58
+
59
+
60
+ class FixedSlippage(SlippageModel):
61
+ """Constant basis-point slippage per trade.
62
+
63
+ Args:
64
+ bps: Slippage in basis points (1 bps = 0.01%). Applied against the
65
+ trader on every fill.
66
+ """
67
+
68
+ def __init__(self, bps: float) -> None:
69
+ if bps < 0:
70
+ raise ValueError("bps must be >= 0")
71
+ self.bps = bps
72
+
73
+ def fill_price(
74
+ self,
75
+ order: Order,
76
+ reference_price: float,
77
+ *,
78
+ bar_volume: float | None = None,
79
+ volatility: float | None = None,
80
+ ) -> float:
81
+ direction = 1.0 if order.side is Side.BUY else -1.0
82
+ return reference_price * (1.0 + direction * self.bps / 10_000.0)
83
+
84
+
85
+ class VolumeShareSlippage(SlippageModel):
86
+ """Volume-aware slippage: half-spread + square-root temporary impact.
87
+
88
+ fill_price = reference * (1 + dir * (spread/2 + eta * sigma * sqrt(q/V)))
89
+
90
+ where ``q/V`` is your participation rate (order quantity over bar volume),
91
+ ``sigma`` is daily volatility, and ``eta`` is an impact coefficient you
92
+ calibrate to your market/execution style (0.1 = gentle, 1.0 = harsh).
93
+
94
+ Why square root: it is the most widely published empirically-supported
95
+ shape for temporary market impact across equities markets. It punishes
96
+ large orders convexly in *rate* terms while keeping small orders near
97
+ the half-spread floor — exactly the behavior naive backtests ignore.
98
+
99
+ Args:
100
+ spread_bps: Assumed bid-ask spread in basis points. You pay half.
101
+ eta: Impact coefficient (dimensionless), >= 0.
102
+ """
103
+
104
+ def __init__(self, spread_bps: float = 5.0, eta: float = 0.5) -> None:
105
+ if spread_bps < 0:
106
+ raise ValueError("spread_bps must be >= 0")
107
+ if eta < 0:
108
+ raise ValueError("eta must be >= 0")
109
+ self.spread_bps = spread_bps
110
+ self.eta = eta
111
+
112
+ def fill_price(
113
+ self,
114
+ order: Order,
115
+ reference_price: float,
116
+ *,
117
+ bar_volume: float | None = None,
118
+ volatility: float | None = None,
119
+ ) -> float:
120
+ if bar_volume is None or bar_volume <= 0:
121
+ raise ValueError(
122
+ "VolumeShareSlippage requires positive bar_volume; "
123
+ "use FixedSlippage if volume data is unavailable"
124
+ )
125
+ sigma = volatility if volatility is not None else 0.02
126
+ if sigma < 0:
127
+ raise ValueError("volatility must be >= 0")
128
+ participation = order.quantity / bar_volume
129
+ half_spread = self.spread_bps / 2.0 / 10_000.0
130
+ impact = self.eta * sigma * math.sqrt(participation)
131
+ direction = 1.0 if order.side is Side.BUY else -1.0
132
+ return reference_price * (1.0 + direction * (half_spread + impact))
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: friction-engine
3
+ Version: 0.1.0
4
+ Summary: Realistic backtest friction modeling: volume-aware slippage, multi-market fees & taxes (US/TW/JP), liquidity constraints, and MAE/MFE trade analytics.
5
+ Author: friction-engine contributors
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/jalano0i9u8y7-lab/friction-engine
8
+ Keywords: backtest,slippage,transaction-costs,MAE,MFE,trading
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Financial and Insurance Industry
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Office/Business :: Financial :: Investment
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # friction-engine
26
+
27
+ **Your backtest is lying to you — this library measures how much.**
28
+
29
+ `friction-engine` models the four costs naive backtests ignore, as small,
30
+ composable, fully-auditable pieces:
31
+
32
+ | Module | What it fixes | The naive-backtest lie |
33
+ |---|---|---|
34
+ | `slippage` | Volume-aware slippage (half-spread + square-root impact) | "I filled 200k shares at the close price" |
35
+ | `fees` | Multi-market fees & taxes (US / TW / JP presets, all configurable) | "Commissions are round-off error" |
36
+ | `liquidity` | Participation-rate caps on executable quantity | "The market absorbed my 10× ADV order instantly" |
37
+ | `mae_mfe` | MAE/MFE excursion analytics | "The trade returned +5%" — but it dipped -8% first; would your stop have survived? |
38
+
39
+ Pure Python standard library. **Zero third-party dependencies.** Every model
40
+ is one short file you can read end-to-end before trusting it.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install friction-engine
46
+ ```
47
+
48
+ Or from source:
49
+
50
+ ```bash
51
+ pip install .
52
+ pip install ".[dev]" # with pytest for running the test suite
53
+ ```
54
+
55
+ ## Quick start
56
+
57
+ ```python
58
+ from friction_engine import (
59
+ Market, Order, ParticipationCap, Side, TradePath,
60
+ VolumeShareSlippage, excursion, fee_for,
61
+ )
62
+
63
+ order = Order(symbol="2330.TW", side=Side.BUY, quantity=20_000)
64
+
65
+ # 1. Liquidity: can the market absorb this?
66
+ allowed = ParticipationCap(0.10).cap(order, bar_volume=150_000)
67
+
68
+ # 2. Slippage: volume-aware fill price (not the close you assumed)
69
+ slip = VolumeShareSlippage(spread_bps=5, eta=0.5)
70
+ fill_price = slip.fill_price(order, 100.0, bar_volume=150_000, volatility=0.02)
71
+
72
+ # 3. Fees & taxes: TW stocks = 14.25 bps commission + 30 bps sell tax
73
+ fee, tax = fee_for(order, fill_price * order.quantity, market=Market.TW)
74
+
75
+ # 4. MAE/MFE: what happened inside the trade, not just entry→exit
76
+ path = TradePath(
77
+ entry_price=fill_price, side=Side.BUY,
78
+ highs=[101, 103, 106], lows=[97.5, 99, 102], exit_price=105.0,
79
+ )
80
+ rec = excursion(path)
81
+ print(rec.mae, rec.mfe, rec.giveback) # -0.025 … +0.06 … how much you gave back
82
+ ```
83
+
84
+ Run the full worked example:
85
+
86
+ ```bash
87
+ python examples/basic_usage.py
88
+ ```
89
+
90
+ ## The models, honestly
91
+
92
+ **Volume-aware slippage** — `fill = ref × (1 + dir × (spread/2 + η·σ·√(q/V)))`.
93
+ The square-root temporary-impact shape is the most widely published
94
+ empirical form in the market-microstructure literature. `η` (eta) is a
95
+ calibration knob: 0.1 gentle, 0.5 typical, 1.0 harsh. If you don't know your
96
+ `η`, run all three and treat the spread of outcomes as your uncertainty.
97
+
98
+ **Fee presets** — US (zero commission + ~0.3 bps sell-side regulatory fees),
99
+ TW stocks (14.25 bps commission both sides + 30 bps sell tax), TW ETF
100
+ (10 bps sell tax), JP (10 bps commission + 10% consumption tax *on the
101
+ commission*, no share transaction tax). These are publicly documented
102
+ typical values as of mid-2026 and **will go stale** — brokers discount,
103
+ regulators re-price. Treat presets as starting points:
104
+
105
+ ```python
106
+ from friction_engine import FEE_SCHEDULES, Market
107
+ my_tw = FEE_SCHEDULES[Market.TW].with_overrides(commission_bps=6.0, min_commission=20.0)
108
+ ```
109
+
110
+ **Liquidity cap** — `min(quantity, max_participation × bar_volume)`. Simple,
111
+ brutal, and the single most common way backtests overstate capacity.
112
+
113
+ **MAE/MFE** — descriptive statistics over a trade's post-entry high/low
114
+ path (Sweeney-style excursion analysis). Aggregates (`excursion_stats`)
115
+ answer: *is my stop inside the noise band of my winners?* (`mae_of_winners_mean`)
116
+ and *how much do my losers climb before failing?* (`mfe_of_losers_mean`).
117
+
118
+ ## What this library is NOT
119
+
120
+ - Not a backtester. It computes friction; you bring the loop, the data, and
121
+ the point-in-time discipline. (Pair it with `quant-lint` to audit the
122
+ strategy code feeding it.)
123
+ - Not a broker simulator. Fill *decisions* (partial fills, limit queues,
124
+ halts) are out of scope; it prices the fills you assume.
125
+ - Not financial advice, and presets are not a fee-quote service. Verify
126
+ current rates with your broker/exchange before trading real money.
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ python -m pytest # run the test suite
132
+ ```
133
+
134
+ Layout:
135
+
136
+ ```
137
+ src/friction_engine/
138
+ models.py # Order / Fill / Side value types
139
+ slippage.py # Zero / Fixed / VolumeShare slippage models
140
+ fees.py # FeeSchedule + US/TW/JP presets
141
+ liquidity.py # ParticipationCap
142
+ mae_mfe.py # TradePath, mae/mfe, excursion, excursion_stats
143
+ tests/ # pytest suite, no network, no data files
144
+ examples/ # runnable worked example
145
+ ```
146
+
147
+ ## License
148
+
149
+ Apache-2.0 — see [LICENSE](LICENSE). Clean-room provenance: see
150
+ [CLEANROOM.md](CLEANROOM.md).
@@ -0,0 +1,11 @@
1
+ friction_engine/__init__.py,sha256=p2BCX9finMbVrAcZLA5H0ZHkynZ27bzSJPzfNxzL1us,1233
2
+ friction_engine/fees.py,sha256=NZU3VYjqFCZUXyNSMJOdugIip9tngBYutJp-yET6o28,4775
3
+ friction_engine/liquidity.py,sha256=0RscV4VCMork3qVJp_AzvXrZiFyX_Au_tthosNlnuaw,1510
4
+ friction_engine/mae_mfe.py,sha256=EUNoE4aQtFsfHzvqOr5IcT9Y6YVqnFER-DcZfzooC7s,6836
5
+ friction_engine/models.py,sha256=YJtKG5IN6m2mWuM8rNAjLjDrOLUGXQ84piEox7vF-es,2646
6
+ friction_engine/slippage.py,sha256=TZkSq_PX7OOQrnmxt47SFvCWZ7cBGiJfSXm6yvBnJ54,4534
7
+ friction_engine-0.1.0.dist-info/licenses/LICENSE,sha256=v0c51XRi_dXfvYrmUzGU47xaSEMVLNH_w1IeddCzq_k,10550
8
+ friction_engine-0.1.0.dist-info/METADATA,sha256=3zC9dD-dcPwxMeLRYVUXV2PW7am89cyv2TmJOOx6CuU,5832
9
+ friction_engine-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ friction_engine-0.1.0.dist-info/top_level.txt,sha256=8ytBfkfhKRfEqqGhKZKcY_mHHSYyomF_ADq5CWnAIOA,16
11
+ friction_engine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,189 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate as
87
+ of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, provided that You
91
+ meet the following conditions:
92
+
93
+ (a) You must give any other recipients of the Work or
94
+ Derivative Works a copy of this License; and
95
+
96
+ (b) You must cause any modified files to carry prominent notices
97
+ stating that You changed the files; and
98
+
99
+ (c) You must retain, in the Source form of any Derivative Works
100
+ that You distribute, all copyright, patent, trademark, and
101
+ attribution notices from the Source form of the Work,
102
+ excluding those notices that do not pertain to any part of
103
+ the Derivative Works; and
104
+
105
+ (d) If the Work includes a "NOTICE" text file as part of its
106
+ distribution, then any Derivative Works that You distribute must
107
+ include a readable copy of the attribution notices contained
108
+ within such NOTICE file, excluding those notices that do not
109
+ pertain to any part of the Derivative Works, in at least one
110
+ of the following places: within a NOTICE text file distributed
111
+ as part of the Derivative Works; within the Source form or
112
+ documentation, if provided along with the Derivative Works; or,
113
+ within a file titled "NOTICE" file in the root directory of
114
+ the Derivative Works; or within a file titled "NOTICE" file in
115
+ the root directory of the Source form. You may copy and
116
+ distribute Derivative Works of the Work only under this License,
117
+ in which the Licensor agrees to place attribution notices
118
+ contained in such NOTICE file. You may reproduce and distribute
119
+ copies of the Work for any purpose, provided that You also meet
120
+ the conditions of the License for Derivative Works as set forth
121
+ in the NOTICE file.
122
+
123
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
124
+ any Contribution intentionally submitted for inclusion in the Work
125
+ by You to the Licensor shall be under the terms and conditions of this
126
+ License, without any additional terms or conditions.
127
+ Notwithstanding the above, nothing herein shall supersede or modify
128
+ the terms of any separate license agreement you may have executed
129
+ with Licensor regarding such Contributions.
130
+
131
+ 6. Trademarks. This License does not grant permission to use the trade
132
+ names, trademarks, service marks, or product names of the Licensor,
133
+ except as required for reasonable and customary use in describing the
134
+ origin of the Work and reproducing the content of the NOTICE file.
135
+
136
+ 7. Disclaimer of Warranty. Unless required by applicable law or
137
+ agreed to in writing, Licensor provides the Work (and each
138
+ Contributor provides its Work) on an "AS IS" BASIS, WITHOUT
139
+ WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
140
+ including, without limitation, any warranties or conditions of
141
+ TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
142
+ PARTICULAR PURPOSE. You are solely responsible for determining the
143
+ appropriateness of using or redistributing the Work and assume any
144
+ risks associated with Your exercise of permissions under this License.
145
+
146
+ 8. Limitation of Liability. In no event and under no legal theory,
147
+ whether in tort (including negligence), contract, or otherwise,
148
+ unless required by applicable law (such as deliberate and grossly
149
+ negligent acts) or agreed to in writing, will any Contributor be
150
+ liable to You for any loss or damage, as a result of any such
151
+ Contributor's exercise of permissions under this License, even if
152
+ such Contributor has been advised of the possibility of such damages.
153
+
154
+ 9. Accepting Warranty or Additional Liability. While redistributing
155
+ the Work or Derivative Works thereof, You may choose to offer,
156
+ and charge a fee for, acceptance of support, warranty, indemnity,
157
+ or other liability obligations. However, in accepting such
158
+ obligations, You may act only on Your own behalf and on Your sole
159
+ responsibility, not on behalf of any Contributor, and only if You
160
+ agree to indemnify, defend, and hold each Contributor harmless for
161
+ any liability incurred by each such Contributor in performing its
162
+ obligations under this License.
163
+
164
+ END OF TERMS AND CONDITIONS
165
+
166
+ APPENDIX: How to apply the Apache License to your work.
167
+
168
+ To apply the Apache License to your work, attach the following
169
+ boilerplate notice, with the fields enclosed by brackets "[]"
170
+ replaced with your own identifying information. (Don't include
171
+ the brackets!) The text should be enclosed in the appropriate
172
+ comment syntax for the file format. We also recommend that a
173
+ file or class name and description of purpose be included on the
174
+ same "printed page" as the copyright notice for easier
175
+ identification within third-party archives.
176
+
177
+ Keith Infinity
178
+
179
+ Licensed under the Apache License, Version 2.0 (the "License");
180
+ you may not use this file except in compliance with the License.
181
+ You may obtain a copy of the License at
182
+
183
+ http://www.apache.org/licenses/LICENSE-2.0
184
+
185
+ Unless required by applicable law or agreed to in writing, software
186
+ distributed under the License is distributed on an "AS IS" BASIS,
187
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
188
+ See the License for the specific language governing permissions and
189
+ limitations under the License.
@@ -0,0 +1 @@
1
+ friction_engine