Skip to content

Commit 5d4540d

Browse files
authored
Merge pull request #34 from AKKI0511/add-configurable-transaction-costs-and-slippage
[backtest] Add configurable transaction costs, slippage, and liquidity cap; net performance metrics; CLI and docs updates
2 parents 9fc3623 + ec17903 commit 5d4540d

File tree

10 files changed

+410
-47
lines changed

10 files changed

+410
-47
lines changed

config/backtest_config.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
execution:
2+
transaction_costs:
3+
enabled: false
4+
mode: bps
5+
value: 0
6+
apply_on: notional
7+
slippage:
8+
enabled: false
9+
mode: bps
10+
value: 0
11+
reference_price: close
12+
liquidity:
13+
enabled: false
14+
max_participation: 0.1
15+
volume_source: bar_volume
16+
# sample data path for CLI backtest
17+
data_path: data/backtest_sample.csv

docs/configuration.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ Learn how to configure QuantTradeAI for your specific needs.
44

55
## 📁 Configuration Files
66

7-
The framework uses two main configuration files:
7+
The framework uses several configuration files:
88

99
- **`config/model_config.yaml`** - Model parameters and data settings
1010
- **`config/features_config.yaml`** - Feature engineering settings
11+
- **`config/backtest_config.yaml`** - Execution settings for backtests
1112

1213
## 🔧 Model Configuration
1314

@@ -83,6 +84,26 @@ trading:
8384
transaction_cost: 0.001
8485
```
8586

87+
### Backtest Execution
88+
89+
```yaml
90+
execution:
91+
transaction_costs:
92+
enabled: true
93+
mode: bps # bps or fixed
94+
value: 5 # 5 bps = 0.05%
95+
apply_on: notional
96+
slippage:
97+
enabled: true
98+
mode: bps
99+
value: 10
100+
reference_price: close # or mid if available
101+
liquidity:
102+
enabled: false
103+
max_participation: 0.1
104+
volume_source: bar_volume
105+
```
106+
86107
## 🔧 Feature Configuration
87108

88109
### Price Features

docs/examples/execution-costs.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Execution Costs Example
2+
3+
Run a simple backtest with and without execution costs and slippage.
4+
5+
```bash
6+
poetry run quanttradeai backtest --config config/backtest_config.yaml
7+
poetry run quanttradeai backtest --cost-bps 5 --slippage-bps 10
8+
```
9+
10+
The second command applies 5 bps transaction costs and 10 bps slippage, producing lower Sharpe ratio and PnL compared to the gross run.

docs/quick-reference.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ poetry run quanttradeai train
1717

1818
# Evaluate model
1919
poetry run quanttradeai evaluate -m models/trained/AAPL
20+
21+
# Run backtest
22+
poetry run quanttradeai backtest --config config/backtest_config.yaml
23+
poetry run quanttradeai backtest --cost-bps 5 --slippage-bps 10
2024
```
2125

2226
## 📊 Python API Patterns

quanttradeai/backtest/backtester.py

Lines changed: 138 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
"""
1919

2020
import pandas as pd
21-
2221
from quanttradeai.utils.metrics import sharpe_ratio, max_drawdown
2322
from quanttradeai.trading.risk import apply_stop_loss_take_profit
2423
from quanttradeai.trading.portfolio import PortfolioManager
@@ -28,24 +27,125 @@ def _simulate_single(
2827
df: pd.DataFrame,
2928
stop_loss_pct: float | None = None,
3029
take_profit_pct: float | None = None,
31-
transaction_cost: float = 0.0,
32-
slippage: float = 0.0,
30+
execution: dict | None = None,
3331
) -> pd.DataFrame:
34-
"""Simulate trades for a single symbol."""
32+
"""Simulate trades for a single symbol with execution effects."""
3533
data = df.copy()
3634
if stop_loss_pct is not None or take_profit_pct is not None:
3735
data = apply_stop_loss_take_profit(data, stop_loss_pct, take_profit_pct)
38-
data["price_return"] = data["Close"].pct_change()
39-
data["strategy_return"] = data["price_return"].shift(-1) * data["label"]
40-
data["strategy_return"] = data["strategy_return"].fillna(0.0)
41-
42-
trade_cost = transaction_cost + slippage
43-
if trade_cost > 0:
44-
trades = data["label"].diff().abs()
45-
trades.iloc[0] = abs(data["label"].iloc[0])
46-
data["strategy_return"] -= trades * trade_cost
4736

37+
exec_cfg = execution or {}
38+
tc = exec_cfg.get("transaction_costs", {})
39+
sl = exec_cfg.get("slippage", {})
40+
liq = exec_cfg.get("liquidity", {})
41+
42+
ref_col = "Close"
43+
if sl.get("reference_price", "close") == "mid" and "Mid" in data.columns:
44+
ref_col = "Mid"
45+
prices = data[ref_col].astype(float)
46+
volumes = data.get("Volume", pd.Series(float("inf"), index=data.index))
47+
48+
position = 0.0
49+
carry = 0.0
50+
entry_price: float | None = None
51+
gross_returns: list[float] = [0.0] * len(data)
52+
net_returns: list[float] = [0.0] * len(data)
53+
ledger: list[dict] = []
54+
55+
for i in range(len(data)):
56+
price = prices.iloc[i]
57+
volume = volumes.iloc[i] if i < len(volumes) else float("inf")
58+
desired = data["label"].iloc[i] + carry
59+
60+
diff = desired - position
61+
side = 1 if diff > 0 else -1 if diff < 0 else 0
62+
qty = abs(diff)
63+
total_cost = 0.0
64+
65+
if side != 0 and qty > 0:
66+
if liq.get("enabled", False):
67+
max_part = liq.get("max_participation", 0.0)
68+
max_qty = max_part * float(volume)
69+
exec_qty = min(qty, max_qty)
70+
carry = qty - exec_qty
71+
else:
72+
exec_qty = qty
73+
carry = 0.0
74+
75+
if exec_qty > 0:
76+
slip_amt = 0.0
77+
slip_bps = 0.0
78+
if sl.get("enabled", False) and sl.get("value", 0) > 0:
79+
if sl.get("mode", "bps") == "bps":
80+
slip_bps = sl["value"]
81+
slip_amt = price * slip_bps / 10000
82+
else:
83+
slip_amt = sl["value"]
84+
slip_bps = slip_amt / price * 10000
85+
fill_price = price + slip_amt if side > 0 else price - slip_amt
86+
87+
t_cost = 0.0
88+
if tc.get("enabled", False) and tc.get("value", 0) > 0:
89+
if tc.get("mode", "bps") == "bps":
90+
t_cost = price * exec_qty * tc["value"] / 10000
91+
else:
92+
if tc.get("apply_on", "notional") == "shares":
93+
t_cost = tc["value"] * exec_qty
94+
else:
95+
t_cost = tc["value"]
96+
97+
sl_cost = abs(slip_amt) * exec_qty
98+
total_cost = t_cost + sl_cost
99+
100+
gross_pnl = 0.0
101+
if position != 0 and side != (1 if position > 0 else -1):
102+
close_qty = min(abs(position), exec_qty)
103+
if position > 0:
104+
gross_pnl = (fill_price - entry_price) * close_qty
105+
else:
106+
gross_pnl = (entry_price - fill_price) * close_qty
107+
if exec_qty > close_qty:
108+
entry_price = fill_price
109+
elif position == 0:
110+
entry_price = fill_price
111+
elif side == (1 if position > 0 else -1):
112+
entry_price = (
113+
entry_price * abs(position) + fill_price * exec_qty
114+
) / (abs(position) + exec_qty)
115+
116+
position += side * exec_qty
117+
if position == 0:
118+
entry_price = None
119+
120+
ledger.append(
121+
{
122+
"timestamp": data.index[i],
123+
"side": "buy" if side > 0 else "sell",
124+
"qty": exec_qty,
125+
"reference_price": price,
126+
"fill_price": fill_price,
127+
"gross_pnl_contrib": gross_pnl,
128+
"transaction_cost": t_cost,
129+
"slippage_cost": sl_cost,
130+
"costs": total_cost,
131+
"slippage_bps_applied": slip_bps,
132+
"net_pnl_contrib": gross_pnl - total_cost,
133+
}
134+
)
135+
136+
if i < len(data) - 1:
137+
price_next = prices.iloc[i + 1]
138+
gross_ret = (price_next - price) / price * position
139+
cost_return = total_cost / price if price else 0.0
140+
gross_returns[i] = gross_ret
141+
net_returns[i] = gross_ret - cost_return
142+
gross_returns[-1] = 0.0
143+
net_returns[-1] = 0.0
144+
data["gross_return"] = pd.Series(gross_returns, index=data.index)
145+
data["strategy_return"] = pd.Series(net_returns, index=data.index)
146+
data["gross_equity_curve"] = (1 + data["gross_return"]).cumprod()
48147
data["equity_curve"] = (1 + data["strategy_return"]).cumprod()
148+
data.attrs["ledger"] = pd.DataFrame(ledger)
49149
return data
50150

51151

@@ -55,6 +155,7 @@ def simulate_trades(
55155
take_profit_pct: float | None = None,
56156
transaction_cost: float = 0.0,
57157
slippage: float = 0.0,
158+
execution: dict | None = None,
58159
portfolio: PortfolioManager | None = None,
59160
) -> pd.DataFrame | dict[str, pd.DataFrame]:
60161
"""Simulate trades using label signals.
@@ -72,9 +173,14 @@ def simulate_trades(
72173
Take profit percentage applied to each trade. ``None`` disables take
73174
profits.
74175
transaction_cost : float, optional
75-
Fixed cost applied every time a position is opened or closed.
176+
Legacy cost per trade (as fraction of notional). Converted to execution
177+
config if provided.
76178
slippage : float, optional
77-
Additional cost applied on each trade to model slippage.
179+
Legacy slippage per trade (as fraction of notional). Converted to
180+
execution config if provided.
181+
execution : dict, optional
182+
Execution configuration controlling transaction costs, slippage and
183+
liquidity limits.
78184
portfolio : PortfolioManager or None, optional
79185
Portfolio manager used to allocate capital when backtesting multiple
80186
symbols. Required if ``df`` is a dictionary.
@@ -87,6 +193,18 @@ def simulate_trades(
87193
dictionary, returns a dictionary with per-symbol results as well as an
88194
aggregated ``"portfolio"`` entry containing the combined equity curve.
89195
"""
196+
exec_cfg = execution.copy() if execution else {}
197+
if transaction_cost:
198+
exec_cfg.setdefault("transaction_costs", {})
199+
exec_cfg["transaction_costs"].update(
200+
{"enabled": True, "mode": "bps", "value": transaction_cost * 10000}
201+
)
202+
if slippage:
203+
exec_cfg.setdefault("slippage", {})
204+
exec_cfg["slippage"].update(
205+
{"enabled": True, "mode": "bps", "value": slippage * 10000}
206+
)
207+
90208
if isinstance(df, dict):
91209
if portfolio is None:
92210
raise ValueError("portfolio manager required for multiple symbols")
@@ -97,8 +215,7 @@ def simulate_trades(
97215
data,
98216
stop_loss_pct=stop_loss_pct,
99217
take_profit_pct=take_profit_pct,
100-
transaction_cost=transaction_cost,
101-
slippage=slippage,
218+
execution=exec_cfg,
102219
)
103220
results[symbol] = res
104221
qty = portfolio.open_position(symbol, data["Close"].iloc[0], stop_loss_pct)
@@ -120,35 +237,12 @@ def simulate_trades(
120237
df,
121238
stop_loss_pct=stop_loss_pct,
122239
take_profit_pct=take_profit_pct,
123-
transaction_cost=transaction_cost,
124-
slippage=slippage,
240+
execution=exec_cfg,
125241
)
126242

127243

128244
def compute_metrics(data: pd.DataFrame, risk_free_rate: float = 0.0) -> dict:
129-
"""Calculate basic performance metrics for a strategy.
245+
"""Return gross and net performance summary."""
246+
from quanttradeai.utils.metrics import compute_performance
130247

131-
Parameters
132-
----------
133-
data : pd.DataFrame
134-
Output from :func:`simulate_trades` containing ``strategy_return`` and
135-
``equity_curve`` columns.
136-
risk_free_rate : float, optional
137-
Annual risk free rate used in Sharpe ratio, by default 0.0.
138-
139-
Returns
140-
-------
141-
dict
142-
Dictionary with ``cumulative_return``, ``sharpe_ratio`` and
143-
``max_drawdown`` keys.
144-
"""
145-
returns = data["strategy_return"]
146-
equity = data["equity_curve"]
147-
cumulative_return = equity.iloc[-1] - 1
148-
sharpe = sharpe_ratio(returns, risk_free_rate)
149-
mdd = max_drawdown(equity)
150-
return {
151-
"cumulative_return": cumulative_return,
152-
"sharpe_ratio": sharpe,
153-
"max_drawdown": mdd,
154-
}
248+
return compute_performance(data, risk_free_rate)

quanttradeai/main.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from quanttradeai.data.processor import DataProcessor
1616
from quanttradeai.data.datasource import WebSocketDataSource
1717
import pandas as pd
18+
from quanttradeai.backtest.backtester import simulate_trades, compute_metrics
1819
from quanttradeai.models.classifier import MomentumClassifier
1920
from sklearn.model_selection import train_test_split
2021
import yaml
@@ -188,6 +189,25 @@ def main():
188189
"-m", "--model-path", required=True, help="Directory containing saved model"
189190
)
190191

192+
backtest_parser = subparsers.add_parser("backtest", help="Run backtest")
193+
backtest_parser.add_argument(
194+
"-c",
195+
"--config",
196+
default="config/backtest_config.yaml",
197+
help="Path to backtest config",
198+
)
199+
grp_cost = backtest_parser.add_mutually_exclusive_group()
200+
grp_cost.add_argument("--cost-bps", type=float, help="Transaction cost in bps")
201+
grp_cost.add_argument("--cost-fixed", type=float, help="Fixed transaction cost")
202+
grp_slip = backtest_parser.add_mutually_exclusive_group()
203+
grp_slip.add_argument("--slippage-bps", type=float, help="Slippage in bps")
204+
grp_slip.add_argument("--slippage-fixed", type=float, help="Fixed slippage amount")
205+
backtest_parser.add_argument(
206+
"--liquidity-max-participation",
207+
type=float,
208+
help="Override liquidity max participation",
209+
)
210+
191211
live_parser = subparsers.add_parser(
192212
"live-trade", help="Run real-time trading pipeline"
193213
)
@@ -206,6 +226,39 @@ def main():
206226
run_pipeline(args.config)
207227
elif args.command == "evaluate":
208228
evaluate_model(args.config, args.model_path)
229+
elif args.command == "backtest":
230+
with open(args.config, "r") as f:
231+
cfg = yaml.safe_load(f)
232+
exec_cfg = cfg.get("execution", {})
233+
if args.cost_bps is not None:
234+
exec_cfg.setdefault("transaction_costs", {})
235+
exec_cfg["transaction_costs"].update(
236+
{"enabled": True, "mode": "bps", "value": args.cost_bps}
237+
)
238+
if args.cost_fixed is not None:
239+
exec_cfg.setdefault("transaction_costs", {})
240+
exec_cfg["transaction_costs"].update(
241+
{"enabled": True, "mode": "fixed", "value": args.cost_fixed}
242+
)
243+
if args.slippage_bps is not None:
244+
exec_cfg.setdefault("slippage", {})
245+
exec_cfg["slippage"].update(
246+
{"enabled": True, "mode": "bps", "value": args.slippage_bps}
247+
)
248+
if args.slippage_fixed is not None:
249+
exec_cfg.setdefault("slippage", {})
250+
exec_cfg["slippage"].update(
251+
{"enabled": True, "mode": "fixed", "value": args.slippage_fixed}
252+
)
253+
if args.liquidity_max_participation is not None:
254+
exec_cfg.setdefault("liquidity", {})
255+
exec_cfg["liquidity"].update(
256+
{"enabled": True, "max_participation": args.liquidity_max_participation}
257+
)
258+
df = pd.read_csv(cfg["data_path"])
259+
result = simulate_trades(df, execution=exec_cfg)
260+
metrics = compute_metrics(result)
261+
print(metrics)
209262
elif args.command == "live-trade":
210263
import asyncio
211264

0 commit comments

Comments
 (0)