
Vectorbt Expert
- 2.2k installs
- 179 repo stars
- Updated July 12, 2026
- marketcalls/vectorbt-backtesting-skills
vectorbt-expert is an agent skill that VectorBT backtesting expert. Use when user asks to backtest strategies, create entry/exit signals, analyze portfolio per.
About
Python with vectorbt pandas numpy plotly Data sources OpenAlgo Indian markets DuckDB direct database yfinance US Global CCXT Crypto custom providers DuckDB support supports both custom DuckDB and OpenAlgo Historify format API keys loaded from single root env via python dotenv find_dotenv never hardcode keys Technical indicators TA Lib ALWAYS never use VectorBT built in indicators Specialty indicators openalgo ta for Supertrend Donchian Ichimoku HMA KAMA ALMA ZLEMA VWMA Signal cleaning openalgo ta for exrem crossover crossunder flip Fee model Indian market standard STT statutory charges Rs 20 order Benchmark NIFTY 50 via OpenAlgo NSE_INDEX by default Charts Plotly with template plotly_dark Environment variables loaded from single env at project root via find_dotenv walks up from script dir Scripts go in backtesting strategy_name directories created on demand not pre created Never use icons emojis in code or logger output 1 ALWAYS use TA Lib for ALL technical indicators EMA SMA RSI MACD BBANDS ATR ADX STDDEV MOM NEVER use vbt MA run vbt RSI run or any VectorBT built in
- description: VectorBT backtesting expert. Use when user asks to backtest strategies, create entry/exit signals, analyze
- - Python with vectorbt, pandas, numpy, plotly
- - Data sources: OpenAlgo (Indian markets), DuckDB (direct database), yfinance (US/Global), CCXT (Crypto), custom provide
- Follow vectorbt-expert SKILL.md steps and documented constraints.
- Follow vectorbt-expert SKILL.md steps and documented constraints.
Vectorbt Expert by the numbers
- 2,167 all-time installs (skills.sh)
- +83 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #479 of 16,659 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
vectorbt-expert capabilities & compatibility
- Capabilities
- description: vectorbt backtesting expert. use wh · python with vectorbt, pandas, numpy, plotly · data sources: openalgo (indian markets), duckd · follow vectorbt expert skill.md steps and docume
- Use cases
- orchestration
What vectorbt-expert says it does
description: VectorBT backtesting expert. Use when user asks to backtest strategies, create entry/exit signals, analyze portfolio performance, optimize parameters, fetch historical data, use VectorBT/
- Python with vectorbt, pandas, numpy, plotly
- Data sources: OpenAlgo (Indian markets), DuckDB (direct database), yfinance (US/Global), CCXT (Crypto), custom providers
npx skills add https://github.com/marketcalls/vectorbt-backtesting-skills --skill vectorbt-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.2k |
|---|---|
| repo stars | ★ 179 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 12, 2026 |
| Repository | marketcalls/vectorbt-backtesting-skills ↗ |
When should an agent use vectorbt-expert and what problem does it solve?
VectorBT backtesting expert. Use when user asks to backtest strategies, create entry/exit signals, analyze portfolio performance, optimize parameters, fetch historical data, use VectorBT/vectorbt, com
Who is it for?
Developers invoking vectorbt-expert as documented in the skill source.
Skip if: Skip when requirements fall outside vectorbt-expert documented scope.
When should I use this skill?
VectorBT backtesting expert. Use when user asks to backtest strategies, create entry/exit signals, analyze portfolio performance, optimize parameters, fetch historical data, use VectorBT/vectorbt, com
What you get
Outputs aligned with the vectorbt-expert SKILL.md workflow and stated deliverables.
- vectorbt backtest report
- Plotly performance charts
- benchmark comparison tables
By the numbers
- Models Indian delivery fees at 0.111% plus Rs 20 per order
- Example allocation: 60% NIFTYBEES and 40% GOLDBEES buy-and-hold
Files
VectorBT Backtesting Expert Skill
Environment
- Python with vectorbt, pandas, numpy, plotly
- Data sources: OpenAlgo (Indian markets), DuckDB (direct database), yfinance (US/Global), CCXT (Crypto), custom providers
- DuckDB support: supports both custom DuckDB and OpenAlgo Historify format
- API keys loaded from single root
.envviapython-dotenv+find_dotenv()— never hardcode keys - Technical indicators: TA-Lib (ALWAYS - never use VectorBT built-in indicators)
- Specialty indicators:
openalgo.tafor Supertrend, Donchian, Ichimoku, HMA, KAMA, ALMA, ZLEMA, VWMA - Signal cleaning:
openalgo.tafor exrem, crossover, crossunder, flip - Fee model: Indian market standard (STT + statutory charges + Rs 20/order)
- Benchmark: NIFTY 50 via OpenAlgo (
NSE_INDEX) by default - Charts: Plotly with
template="plotly_dark" - Environment variables loaded from single
.envat project root viafind_dotenv()(walks up from script dir) - Scripts go in
backtesting/{strategy_name}/directories (created on-demand, not pre-created) - Never use icons/emojis in code or logger output
Critical Rules
1. ALWAYS use TA-Lib for ALL technical indicators (EMA, SMA, RSI, MACD, BBANDS, ATR, ADX, STDDEV, MOM). NEVER use vbt.MA.run(), vbt.RSI.run(), or any VectorBT built-in indicator. 2. Use OpenAlgo ta for indicators NOT in TA-Lib: Supertrend, Donchian, Ichimoku, HMA, KAMA, ALMA, ZLEMA, VWMA. 3. Use OpenAlgo ta for signal utilities: ta.exrem(), ta.crossover(), ta.crossunder(), ta.flip(). If openalgo.ta is not importable (standalone DuckDB), use inline exrem() fallback. See duckdb-data. 4. Always clean signals with ta.exrem() after generating raw buy/sell signals. Always .fillna(False) before exrem. 5. Market-specific fees: India (indian-market-costs), US (us-market-costs), Crypto (crypto-market-costs). Auto-select based on user's market. 6. Default benchmarks: India=NIFTY via OpenAlgo, US=S&P 500 (^GSPC), Crypto=Bitcoin (BTC-USD). See data-fetching Market Selection Guide. 7. Always produce a Strategy vs Benchmark comparison table after every backtest. 8. Always explain the backtest report in plain language so even normal traders understand risk and strength. 9. Plotly candlestick charts must use xaxis type="category" to avoid weekend gaps. 10. Whole shares: Always set min_size=1, size_granularity=1 for equities. 11. DuckDB data loading: When user provides a DuckDB path, load data directly using duckdb.connect() with read_only=True. Auto-detect format: OpenAlgo Historify (table market_data, epoch timestamps) vs custom (table ohlcv, date+time columns). See duckdb-data.
Modular Rule Files
Detailed reference for each topic is in rules/:
| Rule File | Topic |
|---|---|
| data-fetching | OpenAlgo (India), yfinance (US), CCXT (Crypto), custom providers, .env setup |
| simulation-modes | from_signals, from_orders, from_holding, direction types |
| position-sizing | Amount/Value/Percent/TargetPercent sizing |
| indicators-signals | TA-Lib indicator reference, signal generation |
| openalgo-ta-helpers | OpenAlgo ta: exrem, crossover, Supertrend, Donchian, Ichimoku, MAs |
| stop-loss-take-profit | Fixed SL, TP, trailing stop |
| parameter-optimization | Broadcasting and loop-based optimization |
| performance-analysis | Stats, metrics, benchmark comparison, CAGR |
| plotting | Candlestick (category x-axis), VectorBT plots, custom Plotly |
| indian-market-costs | Indian market fee model by segment |
| us-market-costs | US market fee model (stocks, options, futures) |
| crypto-market-costs | Crypto fee model (spot, USDT-M, COIN-M futures) |
| futures-backtesting | Lot sizes (SEBI revised Dec 2025), value sizing |
| long-short-trading | Simultaneous long/short, direction comparison |
| duckdb-data | DuckDB direct loading, Historify format, auto-detect, resampling, multi-symbol |
| csv-data-resampling | Loading CSV, resampling with Indian market alignment |
| walk-forward | Walk-forward analysis, WFE ratio |
| robustness-testing | Monte Carlo, noise test, parameter sensitivity, delay test |
| pitfalls | Common mistakes and checklist before going live |
| strategy-catalog | Strategy reference with code snippets |
| quantstats-tearsheet | QuantStats HTML reports, metrics, plots, Monte Carlo |
Strategy Templates (in rules/assets/)
Production-ready scripts with realistic fees, NIFTY benchmark, comparison table, and plain-language report:
| Template | Path | Description |
|---|---|---|
| EMA Crossover | assets/ema_crossover/backtest.py | EMA 10/20 crossover |
| RSI | assets/rsi/backtest.py | RSI(14) oversold/overbought |
| Donchian | assets/donchian/backtest.py | Donchian channel breakout |
| Supertrend | assets/supertrend/backtest.py | Supertrend with intraday sessions |
| MACD | assets/macd/backtest.py | MACD signal-candle breakout |
| SDA2 | assets/sda2/backtest.py | SDA2 trend following |
| Momentum | assets/momentum/backtest.py | Double momentum (MOM + MOM-of-MOM) |
| Dual Momentum | assets/dual_momentum/backtest.py | Quarterly ETF rotation |
| Buy & Hold | assets/buy_hold/backtest.py | Static multi-asset allocation |
| RSI Accumulation | assets/rsi_accumulation/backtest.py | Weekly RSI slab-wise accumulation |
| Walk-Forward | assets/walk_forward/template.py | Walk-forward analysis template |
| Realistic Costs | assets/realistic_costs/template.py | Transaction cost impact comparison |
Quick Template: Standard Backtest Script
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "D"
INIT_CASH = 1_000_000
FEES = 0.00111 # Indian delivery equity (STT + statutory)
FIXED_FEES = 20 # Rs 20 per order
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 3)
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
# --- Strategy: EMA Crossover (TA-Lib) ---
ema_fast = pd.Series(tl.EMA(close.values, timeperiod=10), index=close.index)
ema_slow = pd.Series(tl.EMA(close.values, timeperiod=20), index=close.index)
buy_raw = (ema_fast > ema_slow) & (ema_fast.shift(1) <= ema_slow.shift(1))
sell_raw = (ema_fast < ema_slow) & (ema_fast.shift(1) >= ema_slow.shift(1))
entries = ta.exrem(buy_raw.fillna(False), sell_raw.fillna(False))
exits = ta.exrem(sell_raw.fillna(False), buy_raw.fillna(False))
# --- Backtest ---
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=INIT_CASH, size=ALLOCATION, size_type="percent",
fees=FEES, fixed_fees=FIXED_FEES, direction="longonly",
min_size=1, size_granularity=1, freq="1D",
)
# --- Benchmark ---
df_bench = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_bench.columns:
df_bench["timestamp"] = pd.to_datetime(df_bench["timestamp"])
df_bench = df_bench.set_index("timestamp")
else:
df_bench.index = pd.to_datetime(df_bench.index)
df_bench = df_bench.sort_index()
if df_bench.index.tz is not None:
df_bench.index = df_bench.index.tz_convert(None)
bench_close = df_bench["close"].reindex(close.index).ffill().bfill()
pf_bench = vbt.Portfolio.from_holding(bench_close, init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print(pf.stats())
# --- Strategy vs Benchmark ---
comparison = pd.DataFrame({
"Strategy": [
f"{pf.total_return() * 100:.2f}%", f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}", f"{pf.max_drawdown() * 100:.2f}%",
f"{pf.trades.win_rate() * 100:.1f}%", f"{pf.trades.count()}",
f"{pf.trades.profit_factor():.2f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{pf_bench.total_return() * 100:.2f}%", f"{pf_bench.sharpe_ratio():.2f}",
f"{pf_bench.sortino_ratio():.2f}", f"{pf_bench.max_drawdown() * 100:.2f}%",
"-", "-", "-",
],
}, index=["Total Return", "Sharpe Ratio", "Sortino Ratio", "Max Drawdown",
"Win Rate", "Total Trades", "Profit Factor"])
print(comparison.to_string())
# --- Explain ---
print(f"* Total Return: {pf.total_return() * 100:.2f}% vs NIFTY {pf_bench.total_return() * 100:.2f}%")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}%")
print(f" -> On Rs {INIT_CASH:,}, worst temporary loss = Rs {abs(pf.max_drawdown()) * INIT_CASH:,.0f}")
# --- Plot ---
fig = pf.plot(subplots=['value', 'underwater', 'cum_returns'], template="plotly_dark")
fig.show()
# --- Export ---
pf.positions.records_readable.to_csv(script_dir / f"{SYMBOL}_trades.csv", index=False)Quick Template: DuckDB Backtest Script
import datetime as dt
from pathlib import Path
import duckdb
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
try:
from openalgo import ta
exrem = ta.exrem
except ImportError:
def exrem(signal1, signal2):
result = signal1.copy()
active = False
for i in range(len(signal1)):
if active:
result.iloc[i] = False
if signal1.iloc[i] and not active:
active = True
if signal2.iloc[i]:
active = False
return result
# --- Config ---
SYMBOL = "SBIN"
DB_PATH = r"path/to/market_data.duckdb"
INIT_CASH = 1_000_000
FEES = 0.000225 # Intraday equity
FIXED_FEES = 20
# --- Load from DuckDB ---
con = duckdb.connect(DB_PATH, read_only=True)
df = con.execute("""
SELECT date, time, open, high, low, close, volume
FROM ohlcv WHERE symbol = ? ORDER BY date, time
""", [SYMBOL]).fetchdf()
con.close()
df["datetime"] = pd.to_datetime(df["date"].astype(str) + " " + df["time"].astype(str))
df = df.set_index("datetime").sort_index()
df = df.drop(columns=["date", "time"])
# --- Resample to 5min ---
df_5m = df.resample("5min", origin="start_day", offset="9h15min",
label="right", closed="right").agg({
"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"
}).dropna()
close = df_5m["close"]
# --- Strategy + Backtest (same as OpenAlgo template) ---"""
Buy & Hold Portfolio Backtest - VectorBT + OpenAlgo
Strategy: Static allocation across ETFs (e.g., 60% NIFTYBEES + 40% GOLDBEES).
One-time purchase on Day 1, hold forever.
Fees: Indian delivery equity model (0.111% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX) + HDFC FD rate.
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import vectorbt as vbt
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from dotenv import find_dotenv, load_dotenv
from openalgo import api
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOLS = ["NIFTYBEES", "GOLDBEES"]
WEIGHTS = {"NIFTYBEES": 0.60, "GOLDBEES": 0.40}
EXCHANGE = "NSE"
INTERVAL = "D"
INIT_CASH = 10_00_000
FEES = 0.00111 # Indian delivery equity
FIXED_FEES = 20 # Rs 20 per order
FD_CAGR = 0.0645 # HDFC Bank FD rate 6.45%
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
def calc_cagr(start_val, end_val, years):
"""Calculate CAGR given start value, end value, and number of years."""
if start_val <= 0 or end_val <= 0 or years <= 0:
return 0.0
return (end_val / start_val) ** (1 / years) - 1
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = "2018-01-01"
print(f"Fetching data for {SYMBOLS} ({EXCHANGE}) from {start_date} to {end_date}")
dfs = {}
for sym in SYMBOLS:
df = client.history(
symbol=sym, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date, end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
dfs[sym] = df
print(f" {sym}: {len(df)} bars from {df.index[0].date()} to {df.index[-1].date()}")
# --- Build Price Panel ---
close_prices = pd.DataFrame({sym: dfs[sym]["close"] for sym in SYMBOLS}).dropna()
print(f"\nAligned data: {len(close_prices)} bars")
# --- Buy & Hold: One-time allocation on Day 1 ---
size_df = pd.DataFrame(np.nan, index=close_prices.index, columns=SYMBOLS)
size_df.iloc[0] = [WEIGHTS[sym] for sym in SYMBOLS]
pf = vbt.Portfolio.from_orders(
close=close_prices, size=size_df, size_type="targetpercent",
fees=FEES, fixed_fees=FIXED_FEES, init_cash=INIT_CASH,
cash_sharing=True, call_seq="auto", group_by=True,
freq="1D", min_size=1, size_granularity=1,
)
# --- Benchmark ---
print("\nFetching NIFTY 50 index data for benchmark...")
df_nifty = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date, end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_nifty.columns:
df_nifty["timestamp"] = pd.to_datetime(df_nifty["timestamp"])
df_nifty = df_nifty.set_index("timestamp")
else:
df_nifty.index = pd.to_datetime(df_nifty.index)
df_nifty = df_nifty.sort_index()
if df_nifty.index.tz is not None:
df_nifty.index = df_nifty.index.tz_convert(None)
nifty_close = df_nifty["close"].reindex(close_prices.index).ffill().bfill()
pf_niftybees = vbt.Portfolio.from_holding(
close_prices["NIFTYBEES"], init_cash=INIT_CASH, fees=FEES, freq="1D")
pf_goldbees = vbt.Portfolio.from_holding(
close_prices["GOLDBEES"], init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print("\n" + "=" * 60)
print(f" Buy & Hold: {WEIGHTS['NIFTYBEES']*100:.0f}% NIFTYBEES + {WEIGHTS['GOLDBEES']*100:.0f}% GOLDBEES")
print("=" * 60)
print(pf.stats())
# --- CAGR Calculations ---
equity = pf.value()
n_days = (close_prices.index[-1] - close_prices.index[0]).days
n_years = n_days / 365.25
cagr_portfolio = calc_cagr(INIT_CASH, equity.iloc[-1], n_years)
cagr_nifty50 = calc_cagr(nifty_close.iloc[0], nifty_close.iloc[-1], n_years)
fd_final = INIT_CASH * (1 + FD_CAGR) ** n_years
# --- Strategy vs Benchmark ---
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
f"Portfolio ({WEIGHTS['NIFTYBEES']*100:.0f}/{WEIGHTS['GOLDBEES']*100:.0f})": [
f"{pf.total_return() * 100:.2f}%",
f"{cagr_portfolio * 100:.2f}%",
f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}",
f"{pf.max_drawdown() * 100:.2f}%",
f"Rs {equity.iloc[-1]:,.0f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{(nifty_close.iloc[-1] / nifty_close.iloc[0] - 1) * 100:.2f}%",
f"{cagr_nifty50 * 100:.2f}%",
"-", "-", "-",
"-",
],
"NIFTYBEES B&H": [
f"{pf_niftybees.total_return() * 100:.2f}%",
"-",
f"{pf_niftybees.sharpe_ratio():.2f}",
f"{pf_niftybees.sortino_ratio():.2f}",
f"{pf_niftybees.max_drawdown() * 100:.2f}%",
f"Rs {pf_niftybees.value().iloc[-1]:,.0f}",
],
"GOLDBEES B&H": [
f"{pf_goldbees.total_return() * 100:.2f}%",
"-",
f"{pf_goldbees.sharpe_ratio():.2f}",
f"{pf_goldbees.sortino_ratio():.2f}",
f"{pf_goldbees.max_drawdown() * 100:.2f}%",
f"Rs {pf_goldbees.value().iloc[-1]:,.0f}",
],
f"HDFC FD ({FD_CAGR*100:.2f}%)": [
f"{(fd_final / INIT_CASH - 1) * 100:.2f}%",
f"{FD_CAGR * 100:.2f}%",
"-", "-", "0.00%",
f"Rs {fd_final:,.0f}",
],
}, index=["Total Return", "CAGR", "Sharpe Ratio", "Sortino Ratio",
"Max Drawdown", "Final Value"])
print(comparison.to_string())
# --- Explain ---
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: Your portfolio made {pf.total_return() * 100:.2f}%")
print(f" while NIFTY 50 made {(nifty_close.iloc[-1] / nifty_close.iloc[0] - 1) * 100:.2f}%")
alpha = pf.total_return() - (nifty_close.iloc[-1] / nifty_close.iloc[0] - 1)
if alpha > 0:
print(f" -> BEAT the market by {alpha * 100:.2f}%")
else:
print(f" -> UNDERPERFORMED by {abs(alpha) * 100:.2f}%")
print(f"* CAGR: {cagr_portfolio * 100:.2f}% annualized vs FD at {FD_CAGR * 100:.2f}%")
if cagr_portfolio > FD_CAGR:
print(f" -> BEAT the FD by {(cagr_portfolio - FD_CAGR) * 100:.2f}% per year")
else:
print(f" -> UNDERPERFORMED FD by {(FD_CAGR - cagr_portfolio) * 100:.2f}% per year")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}% - the biggest drop from peak")
print(f" -> On Rs {INIT_CASH:,} capital, the worst temporary loss = Rs {abs(pf.max_drawdown()) * INIT_CASH:,.0f}")
print(f"* This is a PASSIVE strategy - buy once and hold, no rebalancing needed")
print(f"* Gold (GOLDBEES) provides diversification when equity markets fall")
# --- Plot ---
cum_strat = equity / equity.iloc[0] - 1
cum_nifty = nifty_close / nifty_close.iloc[0] - 1
cum_niftybees = close_prices["NIFTYBEES"] / close_prices["NIFTYBEES"].iloc[0] - 1
cum_goldbees = close_prices["GOLDBEES"] / close_prices["GOLDBEES"].iloc[0] - 1
drawdown = equity / equity.cummax() - 1
fd_daily_rate = (1 + FD_CAGR) ** (1 / 365.25) - 1
fd_equity = pd.Series(
INIT_CASH * (1 + fd_daily_rate) ** np.arange(len(close_prices)),
index=close_prices.index,
)
cum_fd = fd_equity / fd_equity.iloc[0] - 1
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
row_heights=[0.65, 0.35], vertical_spacing=0.07)
fig.add_trace(go.Scatter(x=cum_strat.index, y=cum_strat * 100,
name="Portfolio", line=dict(color="#00d4aa", width=2.5)), row=1, col=1)
fig.add_trace(go.Scatter(x=cum_nifty.index, y=cum_nifty * 100,
name="NIFTY 50", line=dict(color="#ff6688", width=1.5, dash="dash")), row=1, col=1)
fig.add_trace(go.Scatter(x=cum_niftybees.index, y=cum_niftybees * 100,
name="NIFTYBEES", line=dict(color="#4488ff", width=1, dash="dot")), row=1, col=1)
fig.add_trace(go.Scatter(x=cum_goldbees.index, y=cum_goldbees * 100,
name="GOLDBEES", line=dict(color="#ffaa00", width=1, dash="dot")), row=1, col=1)
fig.add_trace(go.Scatter(x=cum_fd.index, y=cum_fd * 100,
name=f"HDFC FD {FD_CAGR*100:.2f}%", line=dict(color="#888888", width=1.5, dash="dashdot")), row=1, col=1)
fig.add_trace(go.Scatter(x=drawdown.index, y=drawdown * 100,
name="Drawdown", fill="tozeroy", line=dict(color="#ff4444", width=1)), row=2, col=1)
fig.update_yaxes(ticksuffix="%", side="right", row=1, col=1)
fig.update_yaxes(title_text="Drawdown %", ticksuffix="%", side="right", row=2, col=1)
fig.update_layout(template="plotly_dark",
title=f"Buy & Hold: {WEIGHTS['NIFTYBEES']*100:.0f}% NIFTYBEES + {WEIGHTS['GOLDBEES']*100:.0f}% GOLDBEES vs Benchmarks",
height=700)
fig.show()
# --- Export ---
orders_file = script_dir / "buy_hold_orders.csv"
pf.orders.records_readable.to_csv(orders_file, index=False)
print(f"\nOrders exported to {orders_file}")
"""
Donchian Channel Breakout Backtest - VectorBT + OpenAlgo
Strategy: Buy when price breaks above N-period high, sell when breaks below N-period low.
Indicators: openalgo.ta.donchian (shifted by 1 to avoid lookahead).
Fees: Indian delivery equity model (0.111% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import vectorbt as vbt
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "D"
DONCHIAN_PERIOD = 20
INIT_CASH = 1_000_000
FEES = 0.00111
FIXED_FEES = 20
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 3)
print(f"Fetching {SYMBOL} ({EXCHANGE}) {INTERVAL} data from {start_date} to {end_date}")
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
high = df["high"]
low = df["low"]
print(f"Data loaded: {len(df)} bars from {df.index[0].date()} to {df.index[-1].date()}")
# --- Strategy: Donchian Channel Breakout ---
upper, middle, lower = ta.donchian(high, low, period=DONCHIAN_PERIOD)
# Shift by 1 to avoid lookahead bias
upper_shifted = upper.shift(1)
lower_shifted = lower.shift(1)
buy_raw = pd.Series(ta.crossover(close, upper_shifted), index=df.index).fillna(False)
sell_raw = pd.Series(ta.crossunder(close, lower_shifted), index=df.index).fillna(False)
entries = ta.exrem(buy_raw, sell_raw)
exits = ta.exrem(sell_raw, buy_raw)
print(f"Signals - Entries: {entries.sum()}, Exits: {exits.sum()}")
# --- Backtest ---
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=INIT_CASH,
size=ALLOCATION,
size_type="percent",
fees=FEES,
fixed_fees=FIXED_FEES,
direction="longonly",
min_size=1,
size_granularity=1,
freq="1D",
)
# --- Benchmark: NIFTY 50 Index ---
print(f"\nFetching benchmark: {BENCHMARK_SYMBOL} ({BENCHMARK_EXCHANGE})")
df_bench = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_bench.columns:
df_bench["timestamp"] = pd.to_datetime(df_bench["timestamp"])
df_bench = df_bench.set_index("timestamp")
else:
df_bench.index = pd.to_datetime(df_bench.index)
df_bench = df_bench.sort_index()
if df_bench.index.tz is not None:
df_bench.index = df_bench.index.tz_convert(None)
bench_close = df_bench["close"].reindex(close.index).ffill().bfill()
pf_bench = vbt.Portfolio.from_holding(bench_close, init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print("\n" + "=" * 60)
print(f" Donchian({DONCHIAN_PERIOD}) Channel Breakout - {SYMBOL} ({EXCHANGE})")
print("=" * 60)
print(pf.stats())
# --- Strategy vs Benchmark Metrics ---
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
"Strategy": [
f"{pf.total_return() * 100:.2f}%",
f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}",
f"{pf.max_drawdown() * 100:.2f}%",
f"{pf.trades.win_rate() * 100:.1f}%",
f"{pf.trades.count()}",
f"{pf.trades.profit_factor():.2f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{pf_bench.total_return() * 100:.2f}%",
f"{pf_bench.sharpe_ratio():.2f}",
f"{pf_bench.sortino_ratio():.2f}",
f"{pf_bench.max_drawdown() * 100:.2f}%",
"-", "-", "-",
],
}, index=["Total Return", "Sharpe Ratio", "Sortino Ratio", "Max Drawdown",
"Win Rate", "Total Trades", "Profit Factor"])
print(comparison.to_string())
# --- Explain Backtest Report ---
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: {pf.total_return() * 100:.2f}% vs benchmark {pf_bench.total_return() * 100:.2f}%")
alpha = pf.total_return() - pf_bench.total_return()
print(f" -> Alpha: {alpha * 100:.2f}%")
print(f"* Sharpe Ratio: {pf.sharpe_ratio():.2f} - risk-adjusted return (>1 good, >2 excellent)")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}% - worst peak-to-trough decline")
print(f"* Win Rate: {pf.trades.win_rate() * 100:.1f}% - Donchian breakout typically 30-40% (trend-following)")
print(f"* Profit Factor: {pf.trades.profit_factor():.2f} - gross profit / gross loss (>1.5 good)")
print(f"* Total Trades: {pf.trades.count()} - "
f"{'sufficient' if pf.trades.count() >= 30 else 'too few for statistical significance'}")
# --- Plot ---
fig = pf.plot(
subplots=["value", "underwater", "cum_returns"],
template="plotly_dark",
title=f"Donchian({DONCHIAN_PERIOD}) Breakout - {SYMBOL} ({EXCHANGE} {INTERVAL})",
)
fig.show()
# --- Export ---
trades_file = script_dir / f"{SYMBOL}_donchian_trades.csv"
pf.positions.records_readable.to_csv(trades_file, index=False)
print(f"\nTrades exported to {trades_file}")
"""
Dual Momentum (ETF Rotation) Backtest - VectorBT + OpenAlgo
Strategy: Quarterly momentum rotation between NIFTYBEES and GOLDBEES.
Buys previous quarter's outperformer for the next quarter.
Fees: Indian delivery equity model (0.111% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import vectorbt as vbt
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from dotenv import find_dotenv, load_dotenv
from openalgo import api
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOLS = ["NIFTYBEES", "GOLDBEES"]
EXCHANGE = "NSE"
INTERVAL = "D"
INIT_CASH = 10_00_000
FEES = 0.00111 # Indian delivery equity
FIXED_FEES = 20 # Rs 20 per order
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = "2018-01-01"
print(f"Fetching data for {SYMBOLS} ({EXCHANGE}) from {start_date} to {end_date}")
dfs = {}
for sym in SYMBOLS:
df = client.history(
symbol=sym, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date, end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
dfs[sym] = df
print(f" {sym}: {len(df)} bars from {df.index[0].date()} to {df.index[-1].date()}")
# --- Build Panel ---
close_prices = pd.DataFrame({sym: dfs[sym]["close"] for sym in SYMBOLS}).dropna()
open_prices = pd.DataFrame({sym: dfs[sym]["open"] for sym in SYMBOLS})
open_prices = open_prices.reindex(close_prices.index).ffill().bfill()
# --- Quarterly Returns & Winner Selection ---
quarterly_close = close_prices.resample("QE").last().dropna(how="all")
quarterly_returns = quarterly_close.pct_change()
# Winner shifted by 1 quarter (use PREVIOUS quarter's winner)
winner = quarterly_returns.idxmax(axis=1)
winner_shifted = winner.shift(1).dropna()
# --- Build Daily Allocation Weights ---
alloc_daily = pd.Series(index=close_prices.index, dtype="object")
for dt, sym in winner_shifted.items():
next_idx_pos = close_prices.index.searchsorted(dt, side="right")
if next_idx_pos < len(close_prices.index):
alloc_daily.loc[close_prices.index[next_idx_pos]] = sym
alloc_daily = alloc_daily.ffill()
alloc_daily = alloc_daily.loc[alloc_daily.first_valid_index():]
weights = pd.DataFrame(index=alloc_daily.index, columns=SYMBOLS, dtype=float)
weights["NIFTYBEES"] = np.where(alloc_daily == "NIFTYBEES", ALLOCATION, 0.0)
weights["GOLDBEES"] = np.where(alloc_daily == "GOLDBEES", ALLOCATION, 0.0)
switch_mask = alloc_daily.ne(alloc_daily.shift(1))
switch_mask.iloc[0] = True
target_on_switch = weights.where(switch_mask, np.nan)
# --- Backtest ---
price_df = open_prices.loc[alloc_daily.index]
pf = vbt.Portfolio.from_orders(
close=price_df, size=target_on_switch, size_type="targetpercent",
fees=FEES, fixed_fees=FIXED_FEES, init_cash=INIT_CASH,
cash_sharing=True, call_seq="auto", group_by=True,
freq="1D", min_size=1, size_granularity=1,
)
# --- Benchmark ---
df_nifty = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date, end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_nifty.columns:
df_nifty["timestamp"] = pd.to_datetime(df_nifty["timestamp"])
df_nifty = df_nifty.set_index("timestamp")
else:
df_nifty.index = pd.to_datetime(df_nifty.index)
df_nifty = df_nifty.sort_index()
if df_nifty.index.tz is not None:
df_nifty.index = df_nifty.index.tz_convert(None)
nifty_close = df_nifty["close"].reindex(alloc_daily.index).ffill().bfill()
pf_bench = vbt.Portfolio.from_holding(nifty_close, init_cash=INIT_CASH, fees=FEES, freq="1D")
pf_bench_bees = vbt.Portfolio.from_holding(
close_prices.loc[alloc_daily.index, "NIFTYBEES"], init_cash=INIT_CASH, fees=FEES, freq="1D")
pf_bench_gold = vbt.Portfolio.from_holding(
close_prices.loc[alloc_daily.index, "GOLDBEES"], init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print("\n" + "=" * 60)
print(" Dual Momentum - NIFTYBEES vs GOLDBEES (Quarterly Rebalance)")
print("=" * 60)
print(pf.stats())
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
"Dual Momentum": [
f"{pf.total_return() * 100:.2f}%", f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}", f"{pf.max_drawdown() * 100:.2f}%",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{pf_bench.total_return() * 100:.2f}%", f"{pf_bench.sharpe_ratio():.2f}",
f"{pf_bench.sortino_ratio():.2f}", f"{pf_bench.max_drawdown() * 100:.2f}%",
],
"NIFTYBEES B&H": [
f"{pf_bench_bees.total_return() * 100:.2f}%", f"{pf_bench_bees.sharpe_ratio():.2f}",
f"{pf_bench_bees.sortino_ratio():.2f}", f"{pf_bench_bees.max_drawdown() * 100:.2f}%",
],
"GOLDBEES B&H": [
f"{pf_bench_gold.total_return() * 100:.2f}%", f"{pf_bench_gold.sharpe_ratio():.2f}",
f"{pf_bench_gold.sortino_ratio():.2f}", f"{pf_bench_gold.max_drawdown() * 100:.2f}%",
],
}, index=["Total Return", "Sharpe Ratio", "Sortino Ratio", "Max Drawdown"])
print(comparison.to_string())
# --- Explain ---
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: Your strategy made {pf.total_return() * 100:.2f}%")
print(f" while NIFTY 50 made {pf_bench.total_return() * 100:.2f}%")
alpha = pf.total_return() - pf_bench.total_return()
if alpha > 0:
print(f" -> BEAT the market by {alpha * 100:.2f}%")
else:
print(f" -> UNDERPERFORMED by {abs(alpha) * 100:.2f}%")
print(f"* The strategy rotates quarterly into whichever ETF performed better last quarter")
print(f"* This captures momentum - assets that did well recently tend to continue")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}% - the biggest drop from peak")
print(f" -> On Rs {INIT_CASH:,} capital, the worst temporary loss = Rs {abs(pf.max_drawdown()) * INIT_CASH:,.0f}")
print(f"* Rebalances: {switch_mask.sum()} times over the period")
# --- Plot ---
equity = pf.value()
cum_strat = equity / equity.iloc[0] - 1
cum_nifty = nifty_close / nifty_close.iloc[0] - 1
drawdown = equity / equity.cummax() - 1
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
row_heights=[0.65, 0.35], vertical_spacing=0.07)
fig.add_trace(go.Scatter(x=cum_strat.index, y=cum_strat * 100,
name="Dual Momentum", line=dict(color="#00d4aa", width=2.5)), row=1, col=1)
fig.add_trace(go.Scatter(x=cum_nifty.index, y=cum_nifty * 100,
name="NIFTY 50", line=dict(color="#ff6688", width=1.5, dash="dash")), row=1, col=1)
fig.add_trace(go.Scatter(x=drawdown.index, y=drawdown * 100,
name="Drawdown", fill="tozeroy", line=dict(color="#ff4444", width=1)), row=2, col=1)
fig.update_yaxes(ticksuffix="%", side="right", row=1, col=1)
fig.update_yaxes(title_text="Drawdown %", ticksuffix="%", side="right", row=2, col=1)
fig.update_layout(template="plotly_dark",
title="Dual Momentum vs NIFTY 50", height=700)
fig.show()
# --- Export ---
rebalance_log = pd.DataFrame({
"date": alloc_daily.index[switch_mask],
"buy_etf": alloc_daily[switch_mask].values,
})
rebalance_log.to_csv(script_dir / "dual_momentum_rebalance_log.csv", index=False)
pf.orders.records_readable.to_csv(script_dir / "dual_momentum_orders.csv", index=False)
print(f"\nExported rebalance log and orders to {script_dir}")
"""
EMA Crossover Backtest - VectorBT + OpenAlgo
Strategy: Buy when Fast EMA crosses above Slow EMA, sell on cross below.
Indicators: TA-Lib EMA exclusively.
Fees: Indian delivery equity model (0.111% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "D"
FAST_EMA = 10
SLOW_EMA = 20
INIT_CASH = 1_000_000
FEES = 0.00111 # 0.111% per side (Indian delivery: STT + statutory)
FIXED_FEES = 20 # Rs 20 per order (entry and exit)
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 3)
print(f"Fetching {SYMBOL} ({EXCHANGE}) {INTERVAL} data from {start_date} to {end_date}")
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
print(f"Data loaded: {len(df)} bars from {df.index[0].date()} to {df.index[-1].date()}")
# --- Strategy: EMA Crossover (TA-Lib) ---
ema_fast = pd.Series(tl.EMA(close.values, timeperiod=FAST_EMA), index=close.index)
ema_slow = pd.Series(tl.EMA(close.values, timeperiod=SLOW_EMA), index=close.index)
buy_raw = (ema_fast > ema_slow) & (ema_fast.shift(1) <= ema_slow.shift(1))
sell_raw = (ema_fast < ema_slow) & (ema_fast.shift(1) >= ema_slow.shift(1))
entries = ta.exrem(buy_raw.fillna(False), sell_raw.fillna(False))
exits = ta.exrem(sell_raw.fillna(False), buy_raw.fillna(False))
print(f"Signals - Entries: {entries.sum()}, Exits: {exits.sum()}")
# --- Backtest ---
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=INIT_CASH,
size=ALLOCATION,
size_type="percent",
fees=FEES,
fixed_fees=FIXED_FEES,
direction="longonly",
min_size=1,
size_granularity=1,
freq="1D",
)
# --- Benchmark: NIFTY 50 Index ---
print(f"\nFetching benchmark: {BENCHMARK_SYMBOL} ({BENCHMARK_EXCHANGE})")
df_bench = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_bench.columns:
df_bench["timestamp"] = pd.to_datetime(df_bench["timestamp"])
df_bench = df_bench.set_index("timestamp")
else:
df_bench.index = pd.to_datetime(df_bench.index)
df_bench = df_bench.sort_index()
if df_bench.index.tz is not None:
df_bench.index = df_bench.index.tz_convert(None)
bench_close = df_bench["close"].reindex(close.index).ffill().bfill()
pf_bench = vbt.Portfolio.from_holding(bench_close, init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print("\n" + "=" * 60)
print(f" EMA {FAST_EMA}/{SLOW_EMA} Crossover Backtest - {SYMBOL} ({EXCHANGE})")
print("=" * 60)
print(pf.stats())
# --- Strategy vs Benchmark Metrics ---
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
"Strategy": [
f"{pf.total_return() * 100:.2f}%",
f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}",
f"{pf.max_drawdown() * 100:.2f}%",
f"{pf.trades.win_rate() * 100:.1f}%",
f"{pf.trades.count()}",
f"{pf.trades.profit_factor():.2f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{pf_bench.total_return() * 100:.2f}%",
f"{pf_bench.sharpe_ratio():.2f}",
f"{pf_bench.sortino_ratio():.2f}",
f"{pf_bench.max_drawdown() * 100:.2f}%",
"-",
"-",
"-",
],
}, index=["Total Return", "Sharpe Ratio", "Sortino Ratio", "Max Drawdown",
"Win Rate", "Total Trades", "Profit Factor"])
print(comparison.to_string())
# --- Explain Backtest Report ---
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: Strategy returned {pf.total_return() * 100:.2f}% vs "
f"benchmark {pf_bench.total_return() * 100:.2f}%")
alpha = pf.total_return() - pf_bench.total_return()
print(f" -> Alpha (excess return): {alpha * 100:.2f}%")
print(f"* Sharpe Ratio: {pf.sharpe_ratio():.2f} "
f"({'Good' if pf.sharpe_ratio() > 1 else 'Below 1 - needs improvement'})")
print(f" -> Measures risk-adjusted return. >1 acceptable, >2 excellent.")
print(f"* Sortino Ratio: {pf.sortino_ratio():.2f} "
f"({'Good' if pf.sortino_ratio() > 1.5 else 'Moderate'})")
print(f" -> Like Sharpe but only penalizes downside volatility.")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}%")
print(f" -> Worst peak-to-trough decline. Can you stomach this loss?")
print(f"* Win Rate: {pf.trades.win_rate() * 100:.1f}%")
print(f" -> Percentage of winning trades. EMA crossover typically 35-45%.")
print(f"* Profit Factor: {pf.trades.profit_factor():.2f} "
f"({'Good' if pf.trades.profit_factor() > 1.5 else 'Marginal' if pf.trades.profit_factor() > 1 else 'Unprofitable'})")
print(f" -> Gross profit / gross loss. >1.5 is good, >2 is excellent.")
print(f"* Total Trades: {pf.trades.count()}")
print(f" -> {'Sufficient for statistical significance' if pf.trades.count() >= 30 else 'Too few trades - results unreliable'}")
# --- Plot ---
fig = pf.plot(
subplots=["value", "underwater", "cum_returns"],
template="plotly_dark",
title=f"EMA {FAST_EMA}/{SLOW_EMA} Crossover - {SYMBOL} ({EXCHANGE} {INTERVAL})",
)
fig.show()
# --- Export Trades ---
trades_file = script_dir / f"{SYMBOL}_ema_crossover_trades.csv"
pf.positions.records_readable.to_csv(trades_file, index=False)
print(f"\nTrades exported to {trades_file}")
"""
MACD Signal-Candle Breakout Backtest - VectorBT + OpenAlgo
Strategy: MACD zero-line defines bull/bear regimes. Entry on breakout of signal candle.
Indicators: TA-Lib MACD, openalgo.ta helpers.
Fees: Indian delivery equity model (0.111% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "D"
MACD_FAST = 12
MACD_SLOW = 26
MACD_SIGNAL = 9
INIT_CASH = 1_000_000
FEES = 0.00111
FIXED_FEES = 20
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 3)
print(f"Fetching {SYMBOL} ({EXCHANGE}) {INTERVAL} data from {start_date} to {end_date}")
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
high = df["high"]
low = df["low"]
print(f"Data loaded: {len(df)} bars from {df.index[0].date()} to {df.index[-1].date()}")
# --- Strategy: MACD Signal-Candle Breakout (TA-Lib) ---
macd, macd_signal_line, macd_hist = tl.MACD(
close.values, fastperiod=MACD_FAST, slowperiod=MACD_SLOW, signalperiod=MACD_SIGNAL
)
macd_series = pd.Series(macd, index=close.index)
zero = pd.Series(0.0, index=close.index)
# MACD zero-line flips define regimes
bull_flip = ta.crossover(macd_series, zero)
bear_flip = ta.crossunder(macd_series, zero)
bull_regime = ta.flip(bull_flip, bear_flip)
bear_regime = ta.flip(bear_flip, bull_flip)
# Signal candle levels (capture and carry forward)
sig_high = high.where(bull_flip).ffill()
sig_low = low.where(bear_flip).ffill()
# Entries: price breaks signal candle level during matching regime
long_entry_raw = (ta.crossover(high, sig_high) & bull_regime).fillna(False)
# Only first entry per regime; exit on bear regime flip
entries = ta.exrem(long_entry_raw, bear_flip.fillna(False))
exits = ta.exrem(bear_flip.fillna(False), long_entry_raw)
print(f"Signals - Entries: {entries.sum()}, Exits: {exits.sum()}")
# --- Backtest ---
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=INIT_CASH,
size=ALLOCATION,
size_type="percent",
fees=FEES,
fixed_fees=FIXED_FEES,
direction="longonly",
min_size=1,
size_granularity=1,
freq="1D",
)
# --- Benchmark: NIFTY 50 ---
print(f"\nFetching benchmark: {BENCHMARK_SYMBOL} ({BENCHMARK_EXCHANGE})")
df_bench = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_bench.columns:
df_bench["timestamp"] = pd.to_datetime(df_bench["timestamp"])
df_bench = df_bench.set_index("timestamp")
else:
df_bench.index = pd.to_datetime(df_bench.index)
df_bench = df_bench.sort_index()
if df_bench.index.tz is not None:
df_bench.index = df_bench.index.tz_convert(None)
bench_close = df_bench["close"].reindex(close.index).ffill().bfill()
pf_bench = vbt.Portfolio.from_holding(bench_close, init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print("\n" + "=" * 60)
print(f" MACD Signal-Candle Breakout - {SYMBOL} ({EXCHANGE})")
print("=" * 60)
print(pf.stats())
# --- Strategy vs Benchmark Metrics ---
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
"Strategy": [
f"{pf.total_return() * 100:.2f}%",
f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}",
f"{pf.max_drawdown() * 100:.2f}%",
f"{pf.trades.win_rate() * 100:.1f}%",
f"{pf.trades.count()}",
f"{pf.trades.profit_factor():.2f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{pf_bench.total_return() * 100:.2f}%",
f"{pf_bench.sharpe_ratio():.2f}",
f"{pf_bench.sortino_ratio():.2f}",
f"{pf_bench.max_drawdown() * 100:.2f}%",
"-", "-", "-",
],
}, index=["Total Return", "Sharpe Ratio", "Sortino Ratio", "Max Drawdown",
"Win Rate", "Total Trades", "Profit Factor"])
print(comparison.to_string())
# --- Explain Backtest Report ---
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: Your strategy made {pf.total_return() * 100:.2f}% "
f"while NIFTY 50 made {pf_bench.total_return() * 100:.2f}%")
alpha = pf.total_return() - pf_bench.total_return()
if alpha > 0:
print(f" -> Your strategy BEAT the market by {alpha * 100:.2f}% (positive alpha)")
else:
print(f" -> Your strategy UNDERPERFORMED the market by {abs(alpha) * 100:.2f}%")
print(f"* Sharpe Ratio: {pf.sharpe_ratio():.2f}")
print(f" -> This measures return per unit of risk. Think of it as 'bang for your buck'.")
print(f" Below 1 = not enough return for the risk taken")
print(f" 1-2 = decent, 2+ = very good")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}%")
print(f" -> The biggest drop from peak. If you invested 10L, you would have temporarily")
print(f" lost up to Rs {abs(pf.max_drawdown()) * INIT_CASH:,.0f} before recovering.")
print(f"* Win Rate: {pf.trades.win_rate() * 100:.1f}% of trades were profitable")
print(f"* Profit Factor: {pf.trades.profit_factor():.2f}")
print(f" -> For every Rs 1 lost, you made Rs {pf.trades.profit_factor():.2f} in profits.")
# --- Plot ---
fig = pf.plot(
subplots=["value", "underwater", "cum_returns"],
template="plotly_dark",
title=f"MACD Signal-Candle Breakout - {SYMBOL} ({EXCHANGE} {INTERVAL})",
)
fig.show()
# --- Export ---
trades_file = script_dir / f"{SYMBOL}_macd_trades.csv"
pf.positions.records_readable.to_csv(trades_file, index=False)
print(f"\nTrades exported to {trades_file}")
"""
Double Momentum Backtest - VectorBT + OpenAlgo
Strategy: MOM + MOM-of-MOM for directional confirmation with next-bar fill.
Indicators: TA-Lib MOM.
Fees: Indian delivery equity model (0.111% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "D"
MOM_LENGTH = 12
INIT_CASH = 1_000_000
FEES = 0.00111
FIXED_FEES = 20
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 3)
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
high = df["high"]
low = df["low"]
# --- Strategy: Double Momentum (TA-Lib) ---
mom0 = pd.Series(tl.MOM(close.values, timeperiod=MOM_LENGTH), index=close.index)
mom1 = pd.Series(tl.MOM(mom0.values, timeperiod=1), index=close.index)
cond_long = (mom0 > 0) & (mom1 > 0)
# Next-bar fill with previous bar's high breakout
prev_high = high.shift(1)
MINTICK = 0.05
entries_raw = (cond_long.shift(1) & (high >= (prev_high + MINTICK))).fillna(False)
# Exit on opposite condition
cond_short = (mom0 < 0) & (mom1 < 0)
exits_raw = (cond_short.shift(1) & (low <= (low.shift(1) - MINTICK))).fillna(False)
entries = ta.exrem(entries_raw, exits_raw)
exits = ta.exrem(exits_raw, entries)
# --- Backtest ---
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=INIT_CASH, size=ALLOCATION, size_type="percent",
fees=FEES, fixed_fees=FIXED_FEES, direction="longonly",
min_size=1, size_granularity=1, freq="1D",
)
# --- Benchmark ---
df_bench = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_bench.columns:
df_bench["timestamp"] = pd.to_datetime(df_bench["timestamp"])
df_bench = df_bench.set_index("timestamp")
else:
df_bench.index = pd.to_datetime(df_bench.index)
df_bench = df_bench.sort_index()
if df_bench.index.tz is not None:
df_bench.index = df_bench.index.tz_convert(None)
bench_close = df_bench["close"].reindex(close.index).ffill().bfill()
pf_bench = vbt.Portfolio.from_holding(bench_close, init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print("\n" + "=" * 60)
print(f" Double Momentum(MOM{MOM_LENGTH}) - {SYMBOL} ({EXCHANGE})")
print("=" * 60)
print(pf.stats())
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
"Strategy": [
f"{pf.total_return() * 100:.2f}%", f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}", f"{pf.max_drawdown() * 100:.2f}%",
f"{pf.trades.win_rate() * 100:.1f}%", f"{pf.trades.count()}",
f"{pf.trades.profit_factor():.2f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{pf_bench.total_return() * 100:.2f}%", f"{pf_bench.sharpe_ratio():.2f}",
f"{pf_bench.sortino_ratio():.2f}", f"{pf_bench.max_drawdown() * 100:.2f}%",
"-", "-", "-",
],
}, index=["Total Return", "Sharpe Ratio", "Sortino Ratio", "Max Drawdown",
"Win Rate", "Total Trades", "Profit Factor"])
print(comparison.to_string())
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: Your strategy made {pf.total_return() * 100:.2f}% "
f"while NIFTY 50 made {pf_bench.total_return() * 100:.2f}%")
alpha = pf.total_return() - pf_bench.total_return()
if alpha > 0:
print(f" -> BEAT the market by {alpha * 100:.2f}%")
else:
print(f" -> UNDERPERFORMED the market by {abs(alpha) * 100:.2f}%")
print(f"* Sharpe Ratio: {pf.sharpe_ratio():.2f} (return per unit of risk, >1 decent, >2 excellent)")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}%")
print(f" -> If you invested Rs {INIT_CASH:,}, the biggest temporary loss = Rs {abs(pf.max_drawdown()) * INIT_CASH:,.0f}")
print(f"* Win Rate: {pf.trades.win_rate() * 100:.1f}%")
print(f"* Profit Factor: {pf.trades.profit_factor():.2f} - for every Rs 1 lost, you made Rs {pf.trades.profit_factor():.2f}")
fig = pf.plot(
subplots=["value", "underwater", "cum_returns"],
template="plotly_dark",
title=f"Double Momentum(MOM{MOM_LENGTH}) - {SYMBOL} ({EXCHANGE} {INTERVAL})",
)
fig.show()
trades_file = script_dir / f"{SYMBOL}_momentum_trades.csv"
pf.positions.records_readable.to_csv(trades_file, index=False)
print(f"\nTrades exported to {trades_file}")
"""
Realistic Transaction Cost Analysis Template - VectorBT + OpenAlgo
Compares strategy performance across different cost models:
1. Zero fees (unrealistic upper bound)
2. Simplified flat fee
3. Indian delivery equity (realistic)
4. Indian intraday equity
5. Indian F&O futures
Shows the real impact of transaction costs on strategy profitability.
Indicators: TA-Lib exclusively.
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
import plotly.graph_objects as go
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "D"
FAST_EMA = 10
SLOW_EMA = 20
INIT_CASH = 1_000_000
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Indian Market Fee Models ---
FEE_MODELS = {
"Zero Fees (Unrealistic)": {
"fees": 0, "fixed_fees": 0, "slippage": 0,
},
"Simplified 0.1%": {
"fees": 0.001, "fixed_fees": 0, "slippage": 0,
},
"Delivery Equity": {
"fees": 0.00111, # 0.111% (STT 0.1% both + statutory)
"fixed_fees": 20, # Rs 20 per order
"slippage": 0.0005, # 0.05% slippage
},
"Intraday Equity": {
"fees": 0.000225, # 0.0225% (STT 0.025% sell + statutory)
"fixed_fees": 20, # Rs 20 per order
"slippage": 0.0005,
},
"F&O Futures": {
"fees": 0.00018, # 0.018% (STT 0.02% sell + statutory)
"fixed_fees": 20,
"slippage": 0.0002, # Lower slippage for liquid futures
},
}
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 3)
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
# --- Generate Signals (TA-Lib EMA) ---
ema_fast = pd.Series(tl.EMA(close.values, timeperiod=FAST_EMA), index=close.index)
ema_slow = pd.Series(tl.EMA(close.values, timeperiod=SLOW_EMA), index=close.index)
buy_raw = (ema_fast > ema_slow) & (ema_fast.shift(1) <= ema_slow.shift(1))
sell_raw = (ema_fast < ema_slow) & (ema_fast.shift(1) >= ema_slow.shift(1))
entries = ta.exrem(buy_raw.fillna(False), sell_raw.fillna(False))
exits = ta.exrem(sell_raw.fillna(False), buy_raw.fillna(False))
# --- Benchmark ---
df_bench = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_bench.columns:
df_bench["timestamp"] = pd.to_datetime(df_bench["timestamp"])
df_bench = df_bench.set_index("timestamp")
else:
df_bench.index = pd.to_datetime(df_bench.index)
df_bench = df_bench.sort_index()
if df_bench.index.tz is not None:
df_bench.index = df_bench.index.tz_convert(None)
bench_close = df_bench["close"].reindex(close.index).ffill().bfill()
# --- Run Backtest for Each Fee Model ---
results = {}
portfolios = {}
for model_name, params in FEE_MODELS.items():
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=INIT_CASH,
size=ALLOCATION,
size_type="percent",
fees=params["fees"],
fixed_fees=params["fixed_fees"],
slippage=params.get("slippage", 0),
direction="longonly",
min_size=1,
size_granularity=1,
freq="1D",
)
portfolios[model_name] = pf
results[model_name] = {
"Total Return": f"{pf.total_return() * 100:.2f}%",
"Sharpe Ratio": f"{pf.sharpe_ratio():.2f}",
"Max Drawdown": f"{pf.max_drawdown() * 100:.2f}%",
"Win Rate": f"{pf.trades.win_rate() * 100:.1f}%",
"Total Trades": str(pf.trades.count()),
"Profit Factor": f"{pf.trades.profit_factor():.2f}",
"Total Fees Paid": f"Rs {pf.orders.records['fees'].sum():,.0f}",
}
# Add benchmark
pf_bench = vbt.Portfolio.from_holding(bench_close, init_cash=INIT_CASH, freq="1D")
results[f"Benchmark ({BENCHMARK_SYMBOL})"] = {
"Total Return": f"{pf_bench.total_return() * 100:.2f}%",
"Sharpe Ratio": f"{pf_bench.sharpe_ratio():.2f}",
"Max Drawdown": f"{pf_bench.max_drawdown() * 100:.2f}%",
"Win Rate": "-",
"Total Trades": "-",
"Profit Factor": "-",
"Total Fees Paid": "-",
}
# --- Print Results ---
print("\n" + "=" * 80)
print(f" Transaction Cost Impact Analysis - EMA {FAST_EMA}/{SLOW_EMA} on {SYMBOL}")
print("=" * 80)
results_df = pd.DataFrame(results)
print(results_df.to_string())
# --- Explain for Traders ---
zero_return = portfolios["Zero Fees (Unrealistic)"].total_return()
delivery_return = portfolios["Delivery Equity"].total_return()
cost_drag = zero_return - delivery_return
print("\n--- What This Means for You ---")
print(f"* With zero fees, this strategy returns {zero_return * 100:.2f}%")
print(f"* With realistic delivery fees, it returns {delivery_return * 100:.2f}%")
print(f"* Transaction costs eat {cost_drag * 100:.2f}% of your returns")
print(f" -> That is Rs {cost_drag * INIT_CASH:,.0f} lost to fees on Rs {INIT_CASH:,} capital")
if delivery_return > 0:
print(f"\n* Strategy is STILL PROFITABLE after realistic costs")
else:
print(f"\n* Strategy becomes UNPROFITABLE with realistic costs!")
print(f" -> The edge is not large enough to overcome transaction costs")
print(f" -> Consider: fewer trades, higher timeframe, or a different strategy")
# --- Plot: Equity Curves for All Fee Models ---
fig = go.Figure()
for model_name, pf in portfolios.items():
equity = pf.value()
cum_ret = equity / equity.iloc[0] - 1
fig.add_trace(go.Scatter(
x=cum_ret.index, y=cum_ret * 100,
name=model_name, mode='lines',
))
# Add benchmark
bench_cum = bench_close / bench_close.iloc[0] - 1
fig.add_trace(go.Scatter(
x=bench_cum.index, y=bench_cum * 100,
name=f"Benchmark ({BENCHMARK_SYMBOL})", mode='lines',
line=dict(dash='dash', color='gray'),
))
fig.update_layout(
template="plotly_dark",
title=f"Impact of Transaction Costs - EMA {FAST_EMA}/{SLOW_EMA} on {SYMBOL}",
xaxis_title="Date",
yaxis_title="Cumulative Return (%)",
yaxis=dict(ticksuffix="%"),
height=600,
legend=dict(x=0.01, y=0.99),
)
fig.update_yaxes(side="right")
fig.show()
# --- Export ---
export_file = script_dir / f"{SYMBOL}_cost_analysis.csv"
results_df.to_csv(export_file)
print(f"\nResults exported to {export_file}")
"""
RSI Accumulation Backtest - VectorBT + OpenAlgo
Strategy: Weekly RSI-based slab-wise accumulation of NIFTYBEES.
Buy on Friday 3:15 PM when weekly NIFTY RSI < 68.
Slab allocation: RSI 50-68 = 5%, RSI 30-50 = 10%, RSI <30 = 20%.
Exit all when weekly RSI > 70.
Indicators: TA-Lib RSI.
Fees: Indian delivery equity model (0.111% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta, time
from pathlib import Path
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from dotenv import find_dotenv, load_dotenv
from openalgo import api
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "NIFTYBEES"
INDEX_SYMBOL = "NIFTY"
EXCHANGE = "NSE"
INDEX_EXCHANGE = "NSE_INDEX"
INTERVAL = "15m"
INIT_CASH = 10_00_000
FEES = 0.00111 # Indian delivery equity
FIXED_FEES = 20 # Rs 20 per order
RSI_WINDOW = 14 # Weekly RSI period
RSI_BUY_THRESHOLD = 68 # Buy if weekly RSI < 68
RSI_EXIT_THRESHOLD = 70 # Exit all if weekly RSI > 70
FD_CAGR = 0.0645 # HDFC FD benchmark
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
def calc_cagr(start_val, end_val, years):
if start_val <= 0 or end_val <= 0 or years <= 0:
return 0.0
return (end_val / start_val) ** (1 / years) - 1
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date_intraday = "2020-01-01"
start_date_daily = "2019-06-01" # Extra warmup for 14-week RSI
print("Fetching data...")
# 15m NIFTYBEES data (for trading)
df_15m = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date_intraday, end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_15m.columns:
df_15m["timestamp"] = pd.to_datetime(df_15m["timestamp"])
df_15m = df_15m.set_index("timestamp")
else:
df_15m.index = pd.to_datetime(df_15m.index)
df_15m = df_15m.sort_index()
if df_15m.index.tz is not None:
df_15m.index = df_15m.index.tz_convert(None)
print(f" NIFTYBEES 15m: {len(df_15m)} bars from {df_15m.index[0]} to {df_15m.index[-1]}")
# Daily NIFTY index data (for weekly RSI computation)
df_nifty = client.history(
symbol=INDEX_SYMBOL, exchange=INDEX_EXCHANGE, interval="D",
start_date=start_date_daily, end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_nifty.columns:
df_nifty["timestamp"] = pd.to_datetime(df_nifty["timestamp"])
df_nifty = df_nifty.set_index("timestamp")
else:
df_nifty.index = pd.to_datetime(df_nifty.index)
df_nifty = df_nifty.sort_index()
if df_nifty.index.tz is not None:
df_nifty.index = df_nifty.index.tz_convert(None)
print(f" NIFTY Daily: {len(df_nifty)} bars from {df_nifty.index[0]} to {df_nifty.index[-1]}")
# --- Compute Weekly RSI (TA-Lib) ---
nifty_weekly_close = df_nifty["close"].resample("W-FRI").last().dropna()
rsi_weekly = pd.Series(
tl.RSI(nifty_weekly_close.values, timeperiod=RSI_WINDOW),
index=nifty_weekly_close.index,
)
# Shift by 1 week: use PREVIOUS week's completed RSI (avoid lookahead)
rsi_weekly_prev = rsi_weekly.shift(1)
print(f"\n--- Weekly RSI (last 10 weeks) ---")
recent_rsi = rsi_weekly.dropna().tail(10)
for dt, val in recent_rsi.items():
marker = ""
if val < RSI_BUY_THRESHOLD:
marker = " [BUY ZONE]"
elif val > RSI_EXIT_THRESHOLD:
marker = " [EXIT ZONE]"
print(f" {dt.date()}: RSI = {val:.2f}{marker}")
# --- Map Weekly RSI to 15m Bars ---
rsi_mapped = rsi_weekly_prev.reindex(df_15m.index, method="ffill")
close_15m = df_15m["close"]
# --- Identify Friday 3:15 PM Bars ---
bar_time = df_15m.index.time
bar_dow = df_15m.index.dayofweek
is_friday = bar_dow == 4
is_315pm = pd.Series([t == time(15, 15) for t in bar_time], index=df_15m.index)
friday_315 = is_friday & is_315pm
total_fridays = friday_315.sum()
print(f"\nTotal Friday 3:15 PM bars found: {total_fridays}")
# --- Build Entry/Exit Signals with Slab-wise Sizing ---
rsi_valid = friday_315 & rsi_mapped.notna()
buy_mask = rsi_valid & (rsi_mapped < RSI_BUY_THRESHOLD)
exit_mask = rsi_valid & (rsi_mapped > RSI_EXIT_THRESHOLD)
size_arr = pd.Series(np.inf, index=close_15m.index)
slab_counts = {"RSI 50-68 (5%)": 0, "RSI 30-50 (10%)": 0, "RSI <30 (20%)": 0}
for dt in close_15m.index[buy_mask]:
rsi_val = rsi_mapped.loc[dt]
if rsi_val >= 50:
size_arr.loc[dt] = INIT_CASH * 0.05
slab_counts["RSI 50-68 (5%)"] += 1
elif rsi_val >= 30:
size_arr.loc[dt] = INIT_CASH * 0.10
slab_counts["RSI 30-50 (10%)"] += 1
else:
size_arr.loc[dt] = INIT_CASH * 0.20
slab_counts["RSI <30 (20%)"] += 1
buy_count = buy_mask.sum()
exit_count = exit_mask.sum()
no_action = friday_315.sum() - buy_count - exit_count
print(f"Buy signals (RSI < {RSI_BUY_THRESHOLD}): {buy_count}")
print(f"Exit signals (RSI > {RSI_EXIT_THRESHOLD}): {exit_count}")
print(f"No action (RSI {RSI_BUY_THRESHOLD}-{RSI_EXIT_THRESHOLD}): {no_action}")
print(f"\n--- Slab-wise Buy Breakdown ---")
for slab, count in slab_counts.items():
print(f" {slab}: {count} buys")
# --- Backtest ---
pf = vbt.Portfolio.from_signals(
close=close_15m, entries=buy_mask, exits=exit_mask,
size=size_arr, size_type="value", accumulate=True,
direction="longonly", init_cash=INIT_CASH,
fees=FEES, fixed_fees=FIXED_FEES,
freq="15min", min_size=1, size_granularity=1,
)
# --- Results ---
print("\n" + "=" * 60)
print(" RSI Accumulation Strategy - NIFTYBEES (Slab-wise)")
print(f" RSI 50-68: 5% | RSI 30-50: 10% | RSI <30: 20%")
print(f" Exit all if RSI > {RSI_EXIT_THRESHOLD}")
print("=" * 60)
print(pf.stats())
equity = pf.value()
n_days = (close_15m.index[-1] - close_15m.index[0]).days
n_years = n_days / 365.25
cagr_strat = calc_cagr(INIT_CASH, equity.iloc[-1], n_years)
fd_final = INIT_CASH * (1 + FD_CAGR) ** n_years
# --- Benchmark ---
nifty_start = df_nifty["close"].loc[df_nifty.index >= close_15m.index[0].normalize()].iloc[0]
nifty_end = df_nifty["close"].iloc[-1]
cagr_nifty = calc_cagr(nifty_start, nifty_end, n_years)
niftybees_start = close_15m.iloc[0]
niftybees_end = close_15m.iloc[-1]
cagr_niftybees_bh = calc_cagr(niftybees_start, niftybees_end, n_years)
total_ret_bh = niftybees_end / niftybees_start - 1
# --- Strategy vs Benchmark ---
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
"RSI Accumulation": [
f"{pf.total_return() * 100:.2f}%",
f"{cagr_strat * 100:.2f}%",
f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}",
f"{pf.max_drawdown() * 100:.2f}%",
f"Rs {equity.iloc[-1]:,.0f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{(nifty_end / nifty_start - 1) * 100:.2f}%",
f"{cagr_nifty * 100:.2f}%",
"-", "-", "-", "-",
],
"NIFTYBEES B&H": [
f"{total_ret_bh * 100:.2f}%",
f"{cagr_niftybees_bh * 100:.2f}%",
"-", "-", "-", "-",
],
f"HDFC FD ({FD_CAGR*100:.2f}%)": [
f"{(fd_final / INIT_CASH - 1) * 100:.2f}%",
f"{FD_CAGR * 100:.2f}%",
"-", "-", "0.00%",
f"Rs {fd_final:,.0f}",
],
}, index=["Total Return", "CAGR", "Sharpe Ratio", "Sortino Ratio",
"Max Drawdown", "Final Value"])
print(comparison.to_string())
# --- Explain ---
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: Your strategy made {pf.total_return() * 100:.2f}%")
print(f" while NIFTY 50 made {(nifty_end / nifty_start - 1) * 100:.2f}%")
alpha = pf.total_return() - (nifty_end / nifty_start - 1)
if alpha > 0:
print(f" -> BEAT the market by {alpha * 100:.2f}%")
else:
print(f" -> UNDERPERFORMED by {abs(alpha) * 100:.2f}%")
print(f"* CAGR: {cagr_strat * 100:.2f}% annualized vs FD at {FD_CAGR * 100:.2f}%")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}% - the biggest drop from peak")
print(f" -> On Rs {INIT_CASH:,} capital, the worst temporary loss = Rs {abs(pf.max_drawdown()) * INIT_CASH:,.0f}")
print(f"* This is a SYSTEMATIC ACCUMULATION strategy")
print(f" -> Buys more when market is fearful (low RSI) and less when greedy (high RSI)")
print(f" -> Exits everything when RSI signals extreme overbought conditions")
print(f"* Total buy signals: {buy_count}, Exit signals: {exit_count}")
# --- Plot ---
equity_daily = equity.resample("D").last().dropna()
running_max = equity_daily.cummax()
drawdown_daily = equity_daily / running_max - 1
fd_daily_rate = (1 + FD_CAGR) ** (1 / 365.25) - 1
fd_equity = pd.Series(
INIT_CASH * (1 + fd_daily_rate) ** np.arange(len(equity_daily)),
index=equity_daily.index,
)
niftybees_daily = close_15m.resample("D").last().dropna()
niftybees_bh_equity = INIT_CASH * (niftybees_daily / niftybees_daily.iloc[0])
rsi_plot = rsi_weekly.dropna()
rsi_plot = rsi_plot[rsi_plot.index >= close_15m.index[0]]
fig = make_subplots(
rows=3, cols=1, shared_xaxes=True,
row_heights=[0.45, 0.25, 0.30], vertical_spacing=0.06,
subplot_titles=[
"Portfolio Value: RSI Accumulation vs Benchmarks",
"Drawdown",
"NIFTY Weekly RSI (Decision Basis)",
],
)
fig.add_trace(go.Scatter(x=equity_daily.index, y=equity_daily.values,
name="RSI Accumulation", line=dict(color="#00d4aa", width=2.5)), row=1, col=1)
fig.add_trace(go.Scatter(x=niftybees_bh_equity.index, y=niftybees_bh_equity.values,
name="NIFTYBEES B&H", line=dict(color="#4488ff", width=1.5, dash="dot")), row=1, col=1)
fig.add_trace(go.Scatter(x=fd_equity.index, y=fd_equity.values,
name=f"HDFC FD {FD_CAGR*100:.2f}%", line=dict(color="#888888", width=1.5, dash="dashdot")), row=1, col=1)
fig.add_trace(go.Scatter(x=drawdown_daily.index, y=drawdown_daily.values,
name="Drawdown", fill="tozeroy", line=dict(color="#ff4444", width=1)), row=2, col=1)
fig.add_trace(go.Scatter(x=rsi_plot.index, y=rsi_plot.values,
name="Weekly RSI", line=dict(color="#aa88ff", width=1.5)), row=3, col=1)
fig.add_hline(y=RSI_BUY_THRESHOLD, line_dash="dash", line_color="#00d4aa",
annotation_text=f"Buy < {RSI_BUY_THRESHOLD}", row=3, col=1)
fig.add_hline(y=RSI_EXIT_THRESHOLD, line_dash="dash", line_color="#ff4444",
annotation_text=f"Exit > {RSI_EXIT_THRESHOLD}", row=3, col=1)
fig.update_yaxes(tickformat=",", side="right", row=1, col=1)
fig.update_yaxes(tickformat=".1%", side="right", row=2, col=1)
fig.update_yaxes(title_text="RSI", side="right", row=3, col=1)
fig.update_layout(template="plotly_dark",
title=f"RSI Accumulation: Buy NIFTYBEES Fri 3:15 PM (RSI<{RSI_BUY_THRESHOLD}), Exit (RSI>{RSI_EXIT_THRESHOLD})",
height=850)
fig.show()
# --- Export ---
orders_file = script_dir / "niftybees_rsi_accumulation_orders.csv"
pf.orders.records_readable.to_csv(orders_file, index=False)
print(f"\nOrders exported to {orders_file}")
"""
RSI Backtest - VectorBT + OpenAlgo
Strategy: Buy when RSI crosses below oversold, sell when RSI crosses above overbought.
Indicators: TA-Lib RSI exclusively.
Fees: Indian delivery equity model (0.111% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "D"
RSI_PERIOD = 14
RSI_OVERSOLD = 30
RSI_OVERBOUGHT = 70
INIT_CASH = 1_000_000
FEES = 0.00111 # Indian delivery equity
FIXED_FEES = 20 # Rs 20 per order
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 3)
print(f"Fetching {SYMBOL} ({EXCHANGE}) {INTERVAL} data from {start_date} to {end_date}")
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
print(f"Data loaded: {len(df)} bars from {df.index[0].date()} to {df.index[-1].date()}")
# --- Strategy: RSI (TA-Lib) ---
rsi = pd.Series(tl.RSI(close.values, timeperiod=RSI_PERIOD), index=close.index)
buy_raw = (rsi < RSI_OVERSOLD) & (rsi.shift(1) >= RSI_OVERSOLD)
sell_raw = (rsi > RSI_OVERBOUGHT) & (rsi.shift(1) <= RSI_OVERBOUGHT)
entries = ta.exrem(buy_raw.fillna(False), sell_raw.fillna(False))
exits = ta.exrem(sell_raw.fillna(False), buy_raw.fillna(False))
print(f"Signals - Entries: {entries.sum()}, Exits: {exits.sum()}")
# --- Backtest ---
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=INIT_CASH,
size=ALLOCATION,
size_type="percent",
fees=FEES,
fixed_fees=FIXED_FEES,
direction="longonly",
min_size=1,
size_granularity=1,
freq="1D",
)
# --- Benchmark: NIFTY 50 Index ---
print(f"\nFetching benchmark: {BENCHMARK_SYMBOL} ({BENCHMARK_EXCHANGE})")
df_bench = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_bench.columns:
df_bench["timestamp"] = pd.to_datetime(df_bench["timestamp"])
df_bench = df_bench.set_index("timestamp")
else:
df_bench.index = pd.to_datetime(df_bench.index)
df_bench = df_bench.sort_index()
if df_bench.index.tz is not None:
df_bench.index = df_bench.index.tz_convert(None)
bench_close = df_bench["close"].reindex(close.index).ffill().bfill()
pf_bench = vbt.Portfolio.from_holding(bench_close, init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print("\n" + "=" * 60)
print(f" RSI({RSI_PERIOD}) Backtest - {SYMBOL} ({EXCHANGE})")
print(f" Oversold: {RSI_OVERSOLD} | Overbought: {RSI_OVERBOUGHT}")
print("=" * 60)
print(pf.stats())
# --- Strategy vs Benchmark Metrics ---
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
"Strategy": [
f"{pf.total_return() * 100:.2f}%",
f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}",
f"{pf.max_drawdown() * 100:.2f}%",
f"{pf.trades.win_rate() * 100:.1f}%",
f"{pf.trades.count()}",
f"{pf.trades.profit_factor():.2f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{pf_bench.total_return() * 100:.2f}%",
f"{pf_bench.sharpe_ratio():.2f}",
f"{pf_bench.sortino_ratio():.2f}",
f"{pf_bench.max_drawdown() * 100:.2f}%",
"-", "-", "-",
],
}, index=["Total Return", "Sharpe Ratio", "Sortino Ratio", "Max Drawdown",
"Win Rate", "Total Trades", "Profit Factor"])
print(comparison.to_string())
# --- Explain Backtest Report ---
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: Strategy returned {pf.total_return() * 100:.2f}% vs "
f"benchmark {pf_bench.total_return() * 100:.2f}%")
alpha = pf.total_return() - pf_bench.total_return()
print(f" -> Alpha (excess return): {alpha * 100:.2f}%")
print(f"* Sharpe Ratio: {pf.sharpe_ratio():.2f} "
f"({'Good' if pf.sharpe_ratio() > 1 else 'Below 1 - needs improvement'})")
print(f" -> Measures risk-adjusted return. >1 acceptable, >2 excellent.")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}%")
print(f" -> Worst peak-to-trough decline. RSI strategies tend to have lower drawdowns.")
print(f"* Win Rate: {pf.trades.win_rate() * 100:.1f}%")
print(f" -> RSI strategies typically have higher win rates (50-65%) but smaller gains.")
print(f"* Profit Factor: {pf.trades.profit_factor():.2f} "
f"({'Good' if pf.trades.profit_factor() > 1.5 else 'Marginal' if pf.trades.profit_factor() > 1 else 'Unprofitable'})")
print(f"* Total Trades: {pf.trades.count()}")
print(f" -> {'Sufficient' if pf.trades.count() >= 30 else 'Too few trades - results unreliable'}")
# --- Plot ---
fig = pf.plot(
subplots=["value", "underwater", "cum_returns"],
template="plotly_dark",
title=f"RSI({RSI_PERIOD}) Strategy - {SYMBOL} ({EXCHANGE} {INTERVAL})",
)
fig.show()
# --- Export ---
trades_file = script_dir / f"{SYMBOL}_rsi_trades.csv"
pf.positions.records_readable.to_csv(trades_file, index=False)
print(f"\nTrades exported to {trades_file}")
"""
SDA2 Trend Following Backtest - VectorBT + OpenAlgo
Strategy: WMA-based channel with STDDEV and ATR bands.
Indicators: TA-Lib WMA, STDDEV, ATR.
Fees: Indian delivery equity model (0.111% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "D"
INIT_CASH = 1_000_000
FEES = 0.00111
FIXED_FEES = 20
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 3)
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
high = df["high"]
low = df["low"]
open_price = df["open"]
# --- Strategy: SDA2 Trend Following (TA-Lib) ---
base = ((high + low) / 2.0) + (open_price - close)
derived = pd.Series(tl.WMA(base.astype(float).values, timeperiod=3), index=close.index)
sd7 = pd.Series(tl.STDDEV(derived.values, timeperiod=7, nbdev=1.0), index=close.index)
atr2 = pd.Series(tl.ATR(high.values, low.values, close.values, timeperiod=2), index=close.index)
upper = derived + sd7 + (atr2 / 1.5)
lower = derived - sd7 - (atr2 / 1.0)
buy_raw = (close > upper) & (close.shift(1) <= upper.shift(1))
sell_raw = (lower > close) & (lower.shift(1) <= close.shift(1))
entries = ta.exrem(buy_raw.fillna(False), sell_raw.fillna(False))
exits = ta.exrem(sell_raw.fillna(False), entries)
# --- Backtest ---
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=INIT_CASH, size=ALLOCATION, size_type="percent",
fees=FEES, fixed_fees=FIXED_FEES, direction="longonly",
min_size=1, size_granularity=1, freq="1D",
)
# --- Benchmark ---
df_bench = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_bench.columns:
df_bench["timestamp"] = pd.to_datetime(df_bench["timestamp"])
df_bench = df_bench.set_index("timestamp")
else:
df_bench.index = pd.to_datetime(df_bench.index)
df_bench = df_bench.sort_index()
if df_bench.index.tz is not None:
df_bench.index = df_bench.index.tz_convert(None)
bench_close = df_bench["close"].reindex(close.index).ffill().bfill()
pf_bench = vbt.Portfolio.from_holding(bench_close, init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print("\n" + "=" * 60)
print(f" SDA2 Trend Following - {SYMBOL} ({EXCHANGE})")
print("=" * 60)
print(pf.stats())
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
"Strategy": [
f"{pf.total_return() * 100:.2f}%", f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}", f"{pf.max_drawdown() * 100:.2f}%",
f"{pf.trades.win_rate() * 100:.1f}%", f"{pf.trades.count()}",
f"{pf.trades.profit_factor():.2f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{pf_bench.total_return() * 100:.2f}%", f"{pf_bench.sharpe_ratio():.2f}",
f"{pf_bench.sortino_ratio():.2f}", f"{pf_bench.max_drawdown() * 100:.2f}%",
"-", "-", "-",
],
}, index=["Total Return", "Sharpe Ratio", "Sortino Ratio", "Max Drawdown",
"Win Rate", "Total Trades", "Profit Factor"])
print(comparison.to_string())
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: Your strategy made {pf.total_return() * 100:.2f}% "
f"while NIFTY 50 made {pf_bench.total_return() * 100:.2f}%")
alpha = pf.total_return() - pf_bench.total_return()
if alpha > 0:
print(f" -> BEAT the market by {alpha * 100:.2f}%")
else:
print(f" -> UNDERPERFORMED the market by {abs(alpha) * 100:.2f}%")
print(f"* Sharpe Ratio: {pf.sharpe_ratio():.2f} (return per unit of risk, >1 decent, >2 excellent)")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}%")
print(f" -> If you invested Rs {INIT_CASH:,}, the biggest temporary loss = Rs {abs(pf.max_drawdown()) * INIT_CASH:,.0f}")
print(f"* Win Rate: {pf.trades.win_rate() * 100:.1f}% - SDA2 is trend-following, expect 35-45%")
print(f"* Profit Factor: {pf.trades.profit_factor():.2f} - for every Rs 1 lost, you made Rs {pf.trades.profit_factor():.2f}")
fig = pf.plot(
subplots=["value", "underwater", "cum_returns"],
template="plotly_dark",
title=f"SDA2 Trend Following - {SYMBOL} ({EXCHANGE} {INTERVAL})",
)
fig.show()
trades_file = script_dir / f"{SYMBOL}_sda2_trades.csv"
pf.positions.records_readable.to_csv(trades_file, index=False)
print(f"\nTrades exported to {trades_file}")
"""
Supertrend Backtest - VectorBT + OpenAlgo
Strategy: Buy on uptrend crossover, sell on downtrend crossover.
Intraday: entries 9:30-15:00, forced exit at 15:15.
Indicators: openalgo.ta.supertrend.
Fees: Indian intraday equity model (0.0225% + Rs 20/order).
Benchmark: NIFTY 50 Index via OpenAlgo (NSE_INDEX).
"""
import os
from datetime import datetime, timedelta, time
from pathlib import Path
import numpy as np
import pandas as pd
import vectorbt as vbt
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "5m"
ST_PERIOD = 10
ST_MULTIPLIER = 3.0
INIT_CASH = 1_000_000
FEES = 0.000225 # Indian intraday equity
FIXED_FEES = 20 # Rs 20 per order
ALLOCATION = 0.75
BENCHMARK_SYMBOL = "NIFTY"
BENCHMARK_EXCHANGE = "NSE_INDEX"
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=90) # ~3 months for intraday
print(f"Fetching {SYMBOL} ({EXCHANGE}) {INTERVAL} data from {start_date} to {end_date}")
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
high = df["high"]
low = df["low"]
print(f"Data loaded: {len(df)} bars from {df.index[0]} to {df.index[-1]}")
# --- Strategy: Supertrend ---
st_line, st_direction = ta.supertrend(high, low, close, period=ST_PERIOD, multiplier=ST_MULTIPLIER)
t = df.index.time
cross_up = (close > st_line) & (close.shift(1) <= st_line.shift(1))
cross_down = (close < st_line) & (close.shift(1) >= st_line.shift(1))
# Indian market session windows
entry_window = (t >= time(9, 30)) & (t <= time(15, 0))
at_1515 = (t == time(15, 15))
long_entries = (cross_up & entry_window).fillna(False)
long_exits = (cross_down | at_1515).fillna(False)
entries = ta.exrem(long_entries, long_exits)
exits = ta.exrem(long_exits, entries)
print(f"Signals - Entries: {entries.sum()}, Exits: {exits.sum()}")
# --- Backtest ---
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=INIT_CASH,
size=ALLOCATION,
size_type="percent",
fees=FEES,
fixed_fees=FIXED_FEES,
direction="longonly",
min_size=1,
size_granularity=1,
freq="5min",
)
# --- Benchmark: NIFTY 50 Index (daily for comparison) ---
print(f"\nFetching benchmark: {BENCHMARK_SYMBOL} ({BENCHMARK_EXCHANGE})")
df_bench = client.history(
symbol=BENCHMARK_SYMBOL, exchange=BENCHMARK_EXCHANGE, interval="D",
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df_bench.columns:
df_bench["timestamp"] = pd.to_datetime(df_bench["timestamp"])
df_bench = df_bench.set_index("timestamp")
else:
df_bench.index = pd.to_datetime(df_bench.index)
df_bench = df_bench.sort_index()
if df_bench.index.tz is not None:
df_bench.index = df_bench.index.tz_convert(None)
bench_close = df_bench["close"]
pf_bench = vbt.Portfolio.from_holding(bench_close, init_cash=INIT_CASH, fees=FEES, freq="1D")
# --- Results ---
print("\n" + "=" * 60)
print(f" Supertrend({ST_PERIOD}, {ST_MULTIPLIER}) Backtest - {SYMBOL} ({EXCHANGE} {INTERVAL})")
print("=" * 60)
print(pf.stats())
# --- Strategy vs Benchmark Metrics ---
print("\n--- Strategy vs Benchmark ---")
comparison = pd.DataFrame({
"Strategy": [
f"{pf.total_return() * 100:.2f}%",
f"{pf.sharpe_ratio():.2f}",
f"{pf.sortino_ratio():.2f}",
f"{pf.max_drawdown() * 100:.2f}%",
f"{pf.trades.win_rate() * 100:.1f}%",
f"{pf.trades.count()}",
f"{pf.trades.profit_factor():.2f}",
],
f"Benchmark ({BENCHMARK_SYMBOL})": [
f"{pf_bench.total_return() * 100:.2f}%",
f"{pf_bench.sharpe_ratio():.2f}",
f"{pf_bench.sortino_ratio():.2f}",
f"{pf_bench.max_drawdown() * 100:.2f}%",
"-", "-", "-",
],
}, index=["Total Return", "Sharpe Ratio", "Sortino Ratio", "Max Drawdown",
"Win Rate", "Total Trades", "Profit Factor"])
print(comparison.to_string())
# --- Explain Backtest Report ---
print("\n--- Backtest Report Explanation ---")
print(f"* Total Return: {pf.total_return() * 100:.2f}% (intraday, squared off at 15:15)")
print(f"* Sharpe Ratio: {pf.sharpe_ratio():.2f} - risk-adjusted return")
print(f"* Max Drawdown: {pf.max_drawdown() * 100:.2f}% - worst intraday drawdown")
print(f"* Win Rate: {pf.trades.win_rate() * 100:.1f}% - Supertrend typically 35-45%")
print(f"* Profit Factor: {pf.trades.profit_factor():.2f} (>1.5 good)")
print(f"* Total Trades: {pf.trades.count()} - "
f"{'sufficient' if pf.trades.count() >= 30 else 'too few - extend backtest period'}")
# --- Plot ---
fig = pf.plot(
subplots=["value", "underwater", "cum_returns"],
template="plotly_dark",
title=f"Supertrend({ST_PERIOD},{ST_MULTIPLIER}) - {SYMBOL} ({EXCHANGE} {INTERVAL})",
)
fig.show()
# --- Export ---
trades_file = script_dir / f"{SYMBOL}_supertrend_trades.csv"
pf.positions.records_readable.to_csv(trades_file, index=False)
print(f"\nTrades exported to {trades_file}")
"""
Walk-Forward Analysis Template - VectorBT + OpenAlgo
Optimizes EMA parameters on in-sample, validates on out-of-sample, rolls forward.
Indicators: TA-Lib exclusively.
Fees: Indian delivery equity model (0.111% + Rs 20/order).
"""
import os
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
import talib as tl
import vectorbt as vbt
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from tqdm import tqdm
from dotenv import find_dotenv, load_dotenv
from openalgo import api, ta
# --- Config ---
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "D"
INIT_CASH = 1_000_000
FEES = 0.00111 # Indian delivery equity
FIXED_FEES = 20
ALLOCATION = 0.75
# Walk-forward parameters
TRAIN_DAYS = 252 * 2 # 2 years training (in-sample)
TEST_DAYS = 63 # 3 months testing (out-of-sample)
STEP_DAYS = 63 # Roll forward by 1 quarter
# EMA optimization grid
FAST_RANGE = range(5, 25) # Fast EMA: 5 to 24
SLOW_RANGE = range(20, 50) # Slow EMA: 20 to 49
# --- Fetch Data ---
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 5) # 5 years for walk-forward
print(f"Fetching {SYMBOL} ({EXCHANGE}) {INTERVAL} data from {start_date} to {end_date}")
df = client.history(
symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
close = df["close"]
print(f"Data loaded: {len(df)} bars from {df.index[0].date()} to {df.index[-1].date()}")
def run_ema_backtest(close_slice, fast_period, slow_period):
"""Run a single EMA crossover backtest on a data slice."""
ema_f = pd.Series(tl.EMA(close_slice.values, timeperiod=fast_period), index=close_slice.index)
ema_s = pd.Series(tl.EMA(close_slice.values, timeperiod=slow_period), index=close_slice.index)
buy_raw = (ema_f > ema_s) & (ema_f.shift(1) <= ema_s.shift(1))
sell_raw = (ema_f < ema_s) & (ema_f.shift(1) >= ema_s.shift(1))
entries = ta.exrem(buy_raw.fillna(False), sell_raw.fillna(False))
exits = ta.exrem(sell_raw.fillna(False), buy_raw.fillna(False))
pf = vbt.Portfolio.from_signals(
close_slice, entries, exits,
init_cash=INIT_CASH, size=ALLOCATION, size_type="percent",
fees=FEES, fixed_fees=FIXED_FEES, direction="longonly",
min_size=1, size_granularity=1, freq="1D",
)
return pf
# --- Walk-Forward Analysis ---
print(f"\nRunning walk-forward analysis...")
print(f" Training window: {TRAIN_DAYS} bars ({TRAIN_DAYS // 252:.1f} years)")
print(f" Testing window: {TEST_DAYS} bars (~{TEST_DAYS // 21:.0f} months)")
print(f" Step size: {STEP_DAYS} bars (~{STEP_DAYS // 21:.0f} months)")
oos_results = []
# Count total windows for progress bar
total_windows = 0
s = 0
while s + TRAIN_DAYS + TEST_DAYS <= len(close):
total_windows += 1
s += STEP_DAYS
start = 0
for window_num in tqdm(range(total_windows), desc="Walk-Forward Windows"):
train_slice = close.iloc[start:start + TRAIN_DAYS]
test_slice = close.iloc[start + TRAIN_DAYS:start + TRAIN_DAYS + TEST_DAYS]
# --- Optimize on Training Data ---
best_sharpe = -np.inf
best_fast, best_slow = 10, 20
best_is_return = 0
for fast in FAST_RANGE:
for slow in SLOW_RANGE:
if fast >= slow:
continue
pf_train = run_ema_backtest(train_slice, fast, slow)
sharpe = pf_train.sharpe_ratio()
if not np.isnan(sharpe) and sharpe > best_sharpe:
best_sharpe = sharpe
best_fast = fast
best_slow = slow
best_is_return = pf_train.total_return()
# Skip window if no valid parameters found (all NaN sharpe)
if best_sharpe == -np.inf:
print(f" Window {window_num + 1}: No valid parameters found, skipping")
start += STEP_DAYS
continue
# --- Validate on Test Data ---
pf_test = run_ema_backtest(test_slice, best_fast, best_slow)
oos_results.append({
'window': window_num + 1,
'train_start': train_slice.index[0].strftime('%Y-%m-%d'),
'train_end': train_slice.index[-1].strftime('%Y-%m-%d'),
'test_start': test_slice.index[0].strftime('%Y-%m-%d'),
'test_end': test_slice.index[-1].strftime('%Y-%m-%d'),
'best_fast': best_fast,
'best_slow': best_slow,
'is_return': best_is_return,
'is_sharpe': best_sharpe,
'oos_return': pf_test.total_return(),
'oos_sharpe': pf_test.sharpe_ratio(),
'oos_max_dd': pf_test.max_drawdown(),
'oos_trades': pf_test.trades.count(),
})
start += STEP_DAYS
results_df = pd.DataFrame(oos_results)
# --- Walk-Forward Report ---
print("\n" + "=" * 80)
print(" WALK-FORWARD ANALYSIS REPORT")
print("=" * 80)
print(results_df[['window', 'test_start', 'test_end', 'best_fast', 'best_slow',
'is_return', 'oos_return', 'oos_sharpe']].to_string(index=False,
float_format=lambda x: f"{x:.2%}" if abs(x) < 10 else f"{x:.2f}"))
# --- Summary Statistics ---
print("\n--- Walk-Forward Summary ---")
avg_oos_return = results_df['oos_return'].mean()
oos_win_rate = (results_df['oos_return'] > 0).mean()
avg_is_return = results_df['is_return'].mean()
wfe = avg_oos_return / avg_is_return if avg_is_return != 0 else 0
print(f"Total windows: {len(results_df)}")
print(f"Avg In-Sample Return: {avg_is_return:.2%}")
print(f"Avg Out-of-Sample Return: {avg_oos_return:.2%}")
print(f"OOS Win Rate: {oos_win_rate:.0%} of windows profitable")
print(f"Walk-Forward Efficiency (WFE): {wfe:.2%}")
print(f" -> WFE > 50% is acceptable, > 70% is good")
# --- Explain for Traders ---
print("\n--- What This Means ---")
if oos_win_rate >= 0.7 and wfe > 0.5:
print("ROBUST: Strategy consistently profitable on unseen data.")
print(" The parameters found during training continue to work on new data.")
elif oos_win_rate >= 0.5:
print("MODERATE: Strategy shows some edge on unseen data but inconsistent.")
print(" Consider additional filters or a different strategy.")
else:
print("WEAK: Strategy fails on unseen data. Likely overfit to training period.")
print(" Do NOT trade this strategy live without significant changes.")
print(f"\nParameter stability: Fast EMA ranged {results_df['best_fast'].min()}-{results_df['best_fast'].max()}, "
f"Slow EMA ranged {results_df['best_slow'].min()}-{results_df['best_slow'].max()}")
param_stable = (results_df['best_fast'].std() < 5) and (results_df['best_slow'].std() < 5)
if param_stable:
print(" -> Parameters are STABLE across windows (good sign)")
else:
print(" -> Parameters VARY widely across windows (strategy may be fragile)")
# --- Plot ---
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
row_heights=[0.6, 0.4], vertical_spacing=0.1,
subplot_titles=["OOS Return per Window", "Cumulative OOS Return"])
colors = ['#00d4aa' if r > 0 else '#ff4444' for r in results_df['oos_return']]
fig.add_trace(go.Bar(
x=results_df['window'], y=results_df['oos_return'] * 100,
marker_color=colors, name="OOS Return %",
), row=1, col=1)
cum_oos = (1 + results_df['oos_return']).cumprod() - 1
fig.add_trace(go.Scatter(
x=results_df['window'], y=cum_oos * 100,
name="Cumulative OOS Return %", line=dict(color="#00d4aa", width=2),
), row=2, col=1)
fig.update_layout(template="plotly_dark", title="Walk-Forward Analysis Results",
height=600, showlegend=True)
fig.update_yaxes(title_text="Return %", ticksuffix="%", side="right", row=1, col=1)
fig.update_yaxes(title_text="Cumulative %", ticksuffix="%", side="right", row=2, col=1)
fig.update_xaxes(title_text="Window #", row=2, col=1)
fig.show()
# --- Export ---
results_file = script_dir / f"{SYMBOL}_walk_forward_results.csv"
results_df.to_csv(results_file, index=False)
print(f"\nResults exported to {results_file}")
Crypto Market Transaction Costs
All fee calculations are based on standard crypto exchange fee tiers (base/regular tier). These defaults represent the most common fee structure across major exchanges.
Fee Summary by Segment
| Component | Spot (Base) | Spot (BNB Discount) | USDT-M Futures | COIN-M Futures |
|---|---|---|---|---|
| Maker Fee | 0.1000% | 0.0750% | 0.0200% | 0.0100% |
| Taker Fee | 0.1000% | 0.0750% | 0.0500% | 0.0500% |
| Funding Rate | N/A | N/A | Variable (~0.01% / 8h) | Variable (~0.01% / 8h) |
| Withdrawal | Varies by coin | Varies by coin | N/A | N/A |
Simplified Percentage Fees for VectorBT
VectorBT's fees parameter is a percentage applied to both buy and sell turnover. Use the taker fee for conservative modeling (market orders).
Crypto Spot (Base Tier)
Maker and taker both 0.1% at VIP 0. Most backtest fills simulate market orders (taker).
# Crypto Spot (Base Tier): 0.1% taker fee per side
fees = 0.001 # 0.1% per side (taker)
fixed_fees = 0 # No fixed fee on most crypto exchanges
pf = vbt.Portfolio.from_signals(
close, entries, exits,
fees=fees,
fixed_fees=fixed_fees,
init_cash=10_000, # $10K USDT
freq="1D",
min_size=0, # Crypto allows fractional units
size_granularity=0, # No rounding needed
)Crypto Spot (BNB Discount)
25% discount when paying fees with BNB. Reduces 0.1% to 0.075%.
# Crypto Spot (Token Discount): 0.075% taker fee per side
fees = 0.00075 # 0.075% per side (taker with BNB)
fixed_fees = 0
pf = vbt.Portfolio.from_signals(
close, entries, exits,
fees=fees,
fixed_fees=fixed_fees,
init_cash=10_000,
freq="1D",
min_size=0,
size_granularity=0,
)USDT-Margined Futures
Maker 0.02%, Taker 0.05% at VIP 0. Use taker fee for conservative backtests.
# USDT-M Futures (Taker): 0.05% per side
fees = 0.0005 # 0.05% per side (taker)
fixed_fees = 0
pf = vbt.Portfolio.from_signals(
close, entries, exits,
fees=fees,
fixed_fees=fixed_fees,
init_cash=10_000,
freq="1D",
min_size=0,
size_granularity=0,
)USDT-M Futures (Maker Orders)
For strategies using limit orders, the lower maker fee applies.
# USDT-M Futures (Maker): 0.02% per side
fees = 0.0002 # 0.02% per side (maker)
fixed_fees = 0
pf = vbt.Portfolio.from_signals(
close, entries, exits,
fees=fees,
fixed_fees=fixed_fees,
init_cash=10_000,
freq="1D",
min_size=0,
size_granularity=0,
)COIN-Margined Futures
Maker 0.01%, Taker 0.05% at VIP 0. Settled in the base cryptocurrency.
# COIN-M Futures (Taker): 0.05% per side
fees = 0.0005 # 0.05% per side (taker)
fixed_fees = 0
pf = vbt.Portfolio.from_signals(
close, entries, exits,
fees=fees,
fixed_fees=fixed_fees,
init_cash=1.0, # 1 BTC for BTC-margined
freq="1D",
min_size=0,
size_granularity=0,
)Quick Reference: Default Fee Constants
Use these constants at the top of every crypto backtest script:
# --- Fee Constants (Crypto Exchange Standard - Base Tier) ---
# Spot Trading
FEES_CRYPTO_SPOT = 0.001 # 0.1% per side (taker)
FEES_CRYPTO_SPOT_BNB = 0.00075 # 0.075% per side (taker with BNB discount)
FEES_CRYPTO_SPOT_MAKER = 0.001 # 0.1% per side (maker)
# USDT-Margined Futures
FEES_CRYPTO_FUTURES_TAKER = 0.0005 # 0.05% per side (taker)
FEES_CRYPTO_FUTURES_MAKER = 0.0002 # 0.02% per side (maker)
# COIN-Margined Futures
FEES_CRYPTO_COINM_TAKER = 0.0005 # 0.05% per side (taker)
FEES_CRYPTO_COINM_MAKER = 0.0001 # 0.01% per side (maker)
# No fixed fees on most crypto exchanges
FIXED_FEES_CRYPTO = 0Exchange VIP Tier Fee Schedule (Typical)
Spot Trading
| VIP Level | 30d Volume (USDT) | Maker | Taker | With BNB Maker | With BNB Taker |
|---|---|---|---|---|---|
| VIP 0 | < 1M | 0.1000% | 0.1000% | 0.0750% | 0.0750% |
| VIP 1 | >= 1M | 0.0900% | 0.1000% | 0.0675% | 0.0750% |
| VIP 2 | >= 5M | 0.0800% | 0.1000% | 0.0600% | 0.0750% |
| VIP 3 | >= 20M | 0.0420% | 0.0660% | 0.0315% | 0.0495% |
| VIP 4 | >= 100M | 0.0420% | 0.0540% | 0.0315% | 0.0405% |
| VIP 5 | >= 150M | 0.0360% | 0.0480% | 0.0270% | 0.0360% |
| VIP 6 | >= 400M | 0.0300% | 0.0420% | 0.0225% | 0.0315% |
| VIP 7 | >= 800M | 0.0240% | 0.0360% | 0.0180% | 0.0270% |
| VIP 8 | >= 2B | 0.0180% | 0.0300% | 0.0135% | 0.0225% |
| VIP 9 | >= 4B | 0.0120% | 0.0240% | 0.0090% | 0.0180% |
USDT-M Futures
| VIP Level | 30d Volume (USDT) | Maker | Taker |
|---|---|---|---|
| VIP 0 | < 5M | 0.0200% | 0.0500% |
| VIP 1 | >= 5M | 0.0160% | 0.0400% |
| VIP 2 | >= 25M | 0.0140% | 0.0350% |
| VIP 3 | >= 100M | 0.0120% | 0.0320% |
| VIP 4 | >= 250M | 0.0100% | 0.0300% |
| VIP 5 | >= 1B | 0.0080% | 0.0270% |
| VIP 6 | >= 5B | 0.0060% | 0.0250% |
| VIP 7 | >= 10B | 0.0040% | 0.0220% |
| VIP 8 | >= 25B | 0.0020% | 0.0200% |
| VIP 9 | >= 50B | 0.0000% | 0.0170% |
Funding Rate (Futures Only)
Perpetual futures contracts have a funding rate exchanged between longs and shorts every 8 hours. This is a hidden cost that significantly impacts longer-duration futures backtests.
Modeling Funding Rate in VectorBT
Funding rate is NOT modeled by VectorBT's fees parameter. For short-term backtests (<1 week), it can be ignored. For longer holding periods, account for it separately:
# Approximate funding rate impact on a futures backtest
FUNDING_RATE = 0.0001 # 0.01% per 8 hours (typical neutral market)
FUNDING_PERIODS_PER_DAY = 3 # Every 8 hours
# For a position held for N days:
# funding_cost = position_value × FUNDING_RATE × FUNDING_PERIODS_PER_DAY × N_days
# Conservative approach: add estimated daily funding to fees
# 0.01% × 3 = 0.03% per day → for daily bars, add to fees
fees_with_funding = 0.0005 + 0.0003 # taker + daily funding estimate
pf = vbt.Portfolio.from_signals(
close, entries, exits,
fees=fees_with_funding, # 0.08% per side (taker + funding)
fixed_fees=0,
init_cash=10_000,
freq="1D",
)Funding Rate Notes
- Positive funding rate: Longs pay shorts (bullish market). Hurts long positions.
- Negative funding rate: Shorts pay longs (bearish market). Hurts short positions.
- Typical range: -0.05% to +0.05% per 8h, with 0.01% being the baseline
- In strong trends, funding can reach 0.1%+ per 8h, which is significant
- For backtests with average holding period > 1 day, always consider funding rate impact
Data Source for Crypto Markets
Use yfinance or CCXT for crypto market data:
import yfinance as yf
# Bitcoin (daily)
df = yf.download("BTC-USD", start="2022-01-01", end="2025-01-01", interval="1d")
# Ethereum (daily)
df = yf.download("ETH-USD", start="2022-01-01", end="2025-01-01", interval="1d")
# Benchmark: Bitcoin
benchmark = yf.download("BTC-USD", start="2022-01-01", end="2025-01-01", interval="1d")For higher resolution data or futures data, consider using CCXT:
# pip install ccxt
import ccxt
import pandas as pd
exchange = ccxt.binance() # or any supported exchange
ohlcv = exchange.fetch_ohlcv("BTC/USDT", timeframe="1h", limit=1000)
df = pd.DataFrame(ohlcv, columns=["timestamp", "open", "high", "low", "close", "volume"])
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
df = df.set_index("timestamp")Crypto-Specific VectorBT Settings
# Crypto allows fractional units - do NOT set min_size=1
pf = vbt.Portfolio.from_signals(
close, entries, exits,
fees=0.001, # Crypto spot taker
fixed_fees=0,
init_cash=10_000, # USDT
size=0.5, # 50% per trade
size_type="percent",
direction="longonly", # or "both" for spot + short
freq="1D",
min_size=0, # Fractional crypto allowed
size_granularity=0, # No rounding
)Cost Comparison Across Markets
| Market | Segment | Per-Side Fee | Fixed Fee | Round-Trip Cost on $10K |
|---|---|---|---|---|
| India | Delivery Equity | 0.111% | Rs 20 | ~Rs 2,265 (~$27) |
| India | Intraday Equity | 0.0225% | Rs 20 | ~Rs 485 (~$6) |
| India | F&O Futures | 0.018% | Rs 20 | ~Rs 400 (~$5) |
| US (Per-Share) | Stocks | 0.01% | $1.00 | ~$4.10 |
| US (Comm-Free) | Stocks | ~0.001% | $0 | ~$0.20 |
| US | E-mini Futures | ~0.001% | $2.25 | ~$4.52 |
| Crypto | Spot | 0.1% | $0 | ~$20.00 |
| Crypto | Spot (Discounted) | 0.075% | $0 | ~$15.00 |
| Crypto | USDT-M Futures | 0.05% | $0 | ~$10.00 |
Best Practices
- Spot trading: Always use taker fees (0.1%) for conservative backtesting. Most signals result in market orders.
- Futures: Model both taker fees AND funding rate for positions held > 1 day
- Token discount: Only apply exchange token discounts if you realistically hold them. Use base fees for conservative modeling.
- Slippage: Crypto markets can have significant slippage on larger orders. Add
slippage=0.001(0.1%) for realistic modeling on altcoins or low-liquidity pairs. - Fractional units: Unlike stocks, crypto allows fractional trading. Do NOT set
min_size=1orsize_granularity=1for crypto. - 24/7 markets: Crypto trades 24/7 - no weekend gaps. Use
freq="1D"for daily, but note that weekends are trading days. - When in doubt, use spot taker (0.1%) as a safe default for any crypto backtest
- Default crypto benchmark: Bitcoin (
BTC-USD) from yfinance
CSV Data Loading & Resampling
Load Minute-Level CSV Data
import pandas as pd
from pathlib import Path
csv_file = Path("data") / "NIFTYF.csv"
df = pd.read_csv(
csv_file,
usecols=["Ticker", "Date", "Time", "Open", "High", "Low", "Close", "Volume"]
)
# Build datetime index
df["datetime"] = pd.to_datetime(df["Date"] + " " + df["Time"])
df = df.set_index("datetime").sort_index()
df = df.drop(columns=["Date", "Time", "Ticker"])Resample to Different Timeframes
def resample_df(df, tf="D"):
if tf == "D":
return df.resample("D").agg({
"Open": "first", "High": "max", "Low": "min", "Close": "last", "Volume": "sum"
}).dropna()
elif tf == "H":
# 60-min bars aligned to Indian market open (09:15)
return df.resample("60min", origin="start_day", offset="9h15min").agg({
"Open": "first", "High": "max", "Low": "min", "Close": "last", "Volume": "sum"
}).dropna()
elif tf == "5min":
return df.resample("5min", origin="start_day", offset="9h15min",
label="right", closed="right").agg({
"Open": "first", "High": "max", "Low": "min", "Close": "last", "Volume": "sum"
}).dropna()
else:
raise ValueError("Unsupported timeframe")
timeframe = "H"
df_resampled = resample_df(df, tf=timeframe)
close = df_resampled["Close"]Best Practices
- Always use
origin="start_day"withoffset="9h15min"for Indian market bar alignment - Use
label="right", closed="right"for intraday bars (a 9:15-9:20 bar is labeled 9:20) - Apply
.dropna()after resampling to remove empty bars (weekends, holidays) - Verify bar count after resampling matches expected trading sessions
Load from DuckDB and Resample
For DuckDB-stored 1-minute data (faster than CSV):
import duckdb
import pandas as pd
DB_PATH = r"path/to/market_data.duckdb"
con = duckdb.connect(DB_PATH, read_only=True)
df = con.execute("""
SELECT date, time, open, high, low, close, volume
FROM ohlcv WHERE symbol = 'RELIANCE' ORDER BY date, time
""").fetchdf()
con.close()
# Build datetime index
df["datetime"] = pd.to_datetime(df["date"].astype(str) + " " + df["time"].astype(str))
df = df.set_index("datetime").sort_index()
df = df.drop(columns=["date", "time"])
# Resample to 5-min
df_5m = df.resample("5min", origin="start_day", offset="9h15min",
label="right", closed="right").agg({
"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"
}).dropna()
close = df_5m["close"]OpenAlgo Historify DuckDB Format
Historify stores timestamps as Unix epoch seconds:
HISTORIFY_DB = r"path/to/openalgo/db/historify.duckdb"
con = duckdb.connect(HISTORIFY_DB, read_only=True)
df = con.execute("""
SELECT timestamp, open, high, low, close, volume
FROM market_data
WHERE symbol = 'RELIANCE' AND exchange = 'NSE' AND interval = '1m'
ORDER BY timestamp
""").fetchdf()
con.close()
df["datetime"] = pd.to_datetime(df["timestamp"], unit="s")
df = df.set_index("datetime").sort_index()
df = df.drop(columns=["timestamp"])
# Resample same as above
df_5m = df.resample("5min", origin="start_day", offset="9h15min",
label="right", closed="right").agg({
"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"
}).dropna()Data Fetching
Environment Setup (All Markets)
Every backtest script must load API keys from the single root .env using python-dotenv + find_dotenv(). Never hardcode API keys.
import os
from datetime import datetime, timedelta
from pathlib import Path
import pandas as pd
from dotenv import find_dotenv, load_dotenv
# Load .env from project root (find_dotenv walks up from script dir)
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)A .env.sample is provided at the project root. Copy it and fill in your keys:
cp .env.sample .env---
1. Indian Markets — OpenAlgo (Primary)
Requires: OPENALGO_API_KEY and OPENALGO_HOST in .env.
from openalgo import api
client = api(
api_key=os.getenv("OPENALGO_API_KEY"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end_date = datetime.now().date()
start_date = end_date - timedelta(days=365 * 3)
df = client.history(
symbol="SBIN",
exchange="NSE",
interval="D",
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)Data Source: Broker API vs DuckDB
The history() method supports a source parameter to choose between broker API and local DuckDB/Historify database:
# Default: fetch from broker API (rate-limited ~3 req/s)
df = client.history(
symbol="SBIN", exchange="NSE", interval="D",
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
source="api",
)
# Fetch from OpenAlgo DuckDB/Historify database (no rate limit)
df = client.history(
symbol="SBIN", exchange="NSE", interval="D",
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
source="db",
)
# Custom intervals only available with source="db"
df = client.history(
symbol="SBIN", exchange="NSE", interval="3m",
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
source="db",
)| Source | Description | Rate Limit | Intervals |
|---|---|---|---|
"api" | Broker API (default) | ~3 req/s | 1m, 3m, 5m, 10m, 15m, 30m, 1h, D |
"db" | DuckDB/Historify local DB | None | All standard + any custom interval (see below) |
Use source="db" for:
- Backtesting and bulk data analysis (no rate limiting)
- Multi-symbol scans without hitting rate limits
- Custom interval aggregation (
2m,3m,4h,W,M,Q,Y)
Use source="api" (default) for:
- Real-time or near real-time data
- When DuckDB database is not configured
OpenAlgo Intervals (source="api")
| Interval | Code |
|---|---|
| 1 minute | 1m |
| 3 minutes | 3m |
| 5 minutes | 5m |
| 10 minutes | 10m |
| 15 minutes | 15m |
| 30 minutes | 30m |
| 1 hour | 1h |
| Daily | D |
DuckDB Custom Intervals (source="db")
DuckDB stores only 1m and D data physically. All other intervals are computed on-the-fly via SQL aggregation with exchange-aware candle alignment (e.g., NSE candles align to 9:15 AM market open).
Intraday (aggregated from 1m data):
| Category | Examples | Format |
|---|---|---|
| Standard minutes | 1m, 5m, 15m, 30m | {N}m |
| Custom minutes | 2m, 3m, 4m, 6m, 7m, 10m, 12m, 20m, 25m, 45m | {N}m |
| Standard hours | 1h | {N}h |
| Custom hours | 2h, 3h, 4h, 6h | {N}h |
Daily-based (aggregated from D data):
| Category | Examples | Format |
|---|---|---|
| Daily | D | D |
| Weekly | W, 2W, 3W | {N}W |
| Monthly | M, 2M, 3M, 6M | {N}M |
| Quarterly | Q, 2Q | {N}Q |
| Yearly | Y, 2Y | {N}Y |
Not supported with source="db": seconds intervals (1s, 5s), custom days (2D, 3D)
OpenAlgo Exchange Codes
| Exchange | Code | Description |
|---|---|---|
| NSE | NSE | National Stock Exchange equities |
| BSE | BSE | Bombay Stock Exchange equities |
| NFO | NFO | NSE Futures and Options |
| BFO | BFO | BSE Futures and Options |
| CDS | CDS | NSE Currency Derivatives |
| BCD | BCD | BSE Currency Derivatives |
| MCX | MCX | Multi Commodity Exchange |
| NSE_INDEX | NSE_INDEX | NSE Indices |
| BSE_INDEX | BSE_INDEX | BSE Indices |
OpenAlgo Symbol Format
- Equity:
SBIN,RELIANCE,INFY,HDFCBANK - Futures:
BANKNIFTY24APR24FUT(BaseSymbol + ExpiryDate + FUT) - Options:
NIFTY28MAR2420800CE(BaseSymbol + ExpiryDate + StrikePrice + CE/PE) - Index:
NIFTY,BANKNIFTY,FINNIFTY(with exchange=NSE_INDEX)
Indian Market Benchmarks
| Benchmark | Source | Symbol |
|---|---|---|
| NIFTY 50 (primary) | OpenAlgo | symbol="NIFTY", exchange="NSE_INDEX" |
| NIFTY 50 (fallback) | yfinance | ^NSEI |
---
2. US Markets — yfinance (No API Key Needed)
yfinance uses public Yahoo Finance data. No API key or .env config required.
import yfinance as yf
# US Stock
df = yf.download("AAPL", start="2022-01-01", end="2025-01-01",
interval="1d", auto_adjust=True, multi_level_index=False)
close = df["Close"]
# US ETF
df = yf.download("SPY", start="2022-01-01", end="2025-01-01",
interval="1d", auto_adjust=True, multi_level_index=False)yfinance Ticker Format
| Asset | Ticker | Example |
|---|---|---|
| US Stock | AAPL, MSFT, TSLA | Direct ticker |
| US ETF | SPY, QQQ, IWM | Direct ticker |
| Indian Stock | RELIANCE.NS, SBIN.NS | Ticker + .NS (NSE) or .BO (BSE) |
| Crypto | BTC-USD, ETH-USD | Symbol + -USD |
| US Index | ^GSPC (S&P 500), ^NDX (NASDAQ 100) | ^ prefix |
| Indian Index | ^NSEI (NIFTY 50), ^BSESN (Sensex) | ^ prefix |
yfinance Intervals
| Interval | Code | Max History |
|---|---|---|
| 1 minute | 1m | 7 days |
| 2 minutes | 2m | 60 days |
| 5 minutes | 5m | 60 days |
| 15 minutes | 15m | 60 days |
| 30 minutes | 30m | 60 days |
| 1 hour | 1h | 730 days |
| 1 day | 1d | Unlimited |
| 1 week | 1wk | Unlimited |
| 1 month | 1mo | Unlimited |
US Market Benchmarks
| Benchmark | Ticker | Description |
|---|---|---|
| S&P 500 (index) | ^GSPC | Broad US market |
| S&P 500 (ETF) | SPY | Tradeable ETF |
| NASDAQ 100 | ^NDX or QQQ | Tech-heavy |
| Dow Jones | ^DJI or DIA | Blue chips |
| Russell 2000 | ^RUT or IWM | Small caps |
Multi-Asset US Data Fetch
import yfinance as yf
import pandas as pd
tickers = ["AAPL", "MSFT", "GOOGL", "AMZN"]
df = yf.download(tickers, start="2022-01-01", end="2025-01-01",
interval="1d", auto_adjust=True, multi_level_index=False)
# For multi-ticker, columns are multi-level: ("Close", "AAPL"), etc.
# With multi_level_index=False on single ticker it flattens---
3. Crypto Markets — yfinance or CCXT
yfinance (Simple — No API Key)
import yfinance as yf
# Bitcoin daily
df = yf.download("BTC-USD", start="2022-01-01", end="2025-01-01",
interval="1d", auto_adjust=True, multi_level_index=False)
close = df["Close"]
# Ethereum daily
df = yf.download("ETH-USD", start="2022-01-01", end="2025-01-01",
interval="1d", auto_adjust=True, multi_level_index=False)CCXT (Higher Resolution — Optional API Key)
For intraday crypto data or exchange-specific pairs. Public OHLCV data does NOT require API keys. Only private endpoints (account, orders) need keys.
# pip install ccxt
import ccxt
import pandas as pd
# Public data - no API key needed
exchange = ccxt.binance()
# Fetch OHLCV
ohlcv = exchange.fetch_ohlcv("BTC/USDT", timeframe="1h", limit=1000)
df = pd.DataFrame(ohlcv, columns=["timestamp", "open", "high", "low", "close", "volume"])
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
df = df.set_index("timestamp")
close = df["close"]For authenticated CCXT (account data):
# Authenticated — requires CRYPTO_API_KEY and CRYPTO_SECRET_KEY in .env
exchange = ccxt.binance({
"apiKey": os.getenv("CRYPTO_API_KEY"),
"secret": os.getenv("CRYPTO_SECRET_KEY"),
})Crypto Tickers
| Pair | yfinance | CCXT |
|---|---|---|
| BTC/USD | BTC-USD | BTC/USDT |
| ETH/USD | ETH-USD | ETH/USDT |
| SOL/USD | SOL-USD | SOL/USDT |
| BNB/USD | BNB-USD | BNB/USDT |
Crypto Benchmarks
| Benchmark | Ticker | Source |
|---|---|---|
| Bitcoin | BTC-USD | yfinance |
| Ethereum | ETH-USD | yfinance |
---
Data Normalization (All Markets)
Always normalize the datetime index after fetching from any source:
# OpenAlgo returns "timestamp" column or datetime index
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")
else:
df.index = pd.to_datetime(df.index)
df = df.sort_index()
# Remove timezone info (VectorBT works best with tz-naive)
if df.index.tz is not None:
df.index = df.index.tz_convert(None)For yfinance, column names are capitalized (Close, Open, High, Low, Volume). Normalize if needed:
# Lowercase column names to match OpenAlgo convention
df.columns = df.columns.str.lower()
close = df["close"]Multiple Timeframe Data Fetch
# Daily data (OpenAlgo)
df_daily = client.history(symbol="RELIANCE", exchange="NSE", interval="D",
start_date="2024-01-01", end_date="2025-02-25")
# 5-minute intraday data (OpenAlgo)
df_5m = client.history(symbol="RELIANCE", exchange="NSE", interval="5m",
start_date="2025-02-01", end_date="2025-02-25")
# Custom intervals via DuckDB (source="db")
df_weekly = client.history(symbol="RELIANCE", exchange="NSE", interval="W",
start_date="2024-01-01", end_date="2025-02-25",
source="db")
df_3m = client.history(symbol="RELIANCE", exchange="NSE", interval="3m",
start_date="2025-02-01", end_date="2025-02-25",
source="db")Multi-Asset Data Fetch (OpenAlgo)
symbols = ["RELIANCE", "HDFCBANK", "INFY", "TCS"]
dfs = {}
for sym in symbols:
# Use source="db" for bulk fetching (no rate limit)
dfs[sym] = client.history(symbol=sym, exchange="NSE", interval="D",
start_date="2024-01-01", end_date="2025-02-25",
source="db")
close_prices = pd.DataFrame({sym: dfs[sym]["close"] for sym in symbols})Market Selection Guide
| If the user says... | Market | Data Source | Fee Model |
|---|---|---|---|
| NSE, BSE, NIFTY, BANKNIFTY, Indian stock names | India | OpenAlgo | indian-market-costs |
| AAPL, SPY, S&P 500, NASDAQ, US stock names | US | yfinance | us-market-costs |
| BTC, ETH, crypto, USDT | Crypto | yfinance or CCXT | crypto-market-costs |
| DuckDB path provided | India | DuckDB direct | indian-market-costs |
3b. DuckDB Direct Loading (Fastest — No API)
For backtesting with local DuckDB databases. No API key or network needed. Supports two formats:
Custom DuckDB
import duckdb
import pandas as pd
DB_PATH = r"path/to/market_data.duckdb"
con = duckdb.connect(DB_PATH, read_only=True)
df = con.execute("""
SELECT date, time, open, high, low, close, volume
FROM ohlcv WHERE symbol = 'SBIN' ORDER BY date, time
""").fetchdf()
con.close()
df["datetime"] = pd.to_datetime(df["date"].astype(str) + " " + df["time"].astype(str))
df = df.set_index("datetime").sort_index()
df = df.drop(columns=["date", "time"])
close = df["close"]OpenAlgo Historify DuckDB
import duckdb
import pandas as pd
HISTORIFY_DB = r"path/to/openalgo/db/historify.duckdb"
con = duckdb.connect(HISTORIFY_DB, read_only=True)
df = con.execute("""
SELECT timestamp, open, high, low, close, volume
FROM market_data
WHERE symbol = 'SBIN' AND exchange = 'NSE' AND interval = '1m'
ORDER BY timestamp
""").fetchdf()
con.close()
df["datetime"] = pd.to_datetime(df["timestamp"], unit="s")
df = df.set_index("datetime").sort_index()
df = df.drop(columns=["timestamp"])
close = df["close"]See duckdb-data for full reference including auto-detection, resampling, and multi-symbol loading.
---
4. Custom Data Provider (Extensible)
Users can plug in any data source as long as it returns a pandas DataFrame with a DatetimeIndex and OHLCV columns. Follow this pattern:
Adding a Custom Data Provider
1. Add API key(s) to `.env`:
# Custom data provider
CUSTOM_API_KEY=your_key_here
CUSTOM_API_URL=https://api.example.com2. Create a fetch function that returns a normalized DataFrame:
import os
import requests
import pandas as pd
from dotenv import find_dotenv, load_dotenv
from pathlib import Path
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
def fetch_custom_data(symbol, start_date, end_date, interval="1d"):
"""Fetch data from a custom provider.
Must return a DataFrame with:
- DatetimeIndex (tz-naive)
- Columns: open, high, low, close, volume (lowercase)
"""
api_key = os.getenv("CUSTOM_API_KEY")
base_url = os.getenv("CUSTOM_API_URL")
# --- Replace this block with your provider's API call ---
response = requests.get(f"{base_url}/history", params={
"symbol": symbol,
"from": start_date,
"to": end_date,
"interval": interval,
"apikey": api_key,
})
data = response.json()
df = pd.DataFrame(data)
# --- End custom block ---
# Normalize to standard format
df.index = pd.to_datetime(df.index)
df = df.sort_index()
if df.index.tz is not None:
df.index = df.index.tz_convert(None)
df.columns = df.columns.str.lower()
return df3. Use it in backtest scripts the same way as any other source:
df = fetch_custom_data("AAPL", "2022-01-01", "2025-01-01")
close = df["close"]Custom Provider Checklist
- [ ] API keys loaded from
.envviapython-dotenv(never hardcoded) - [ ] Returns DataFrame with DatetimeIndex (tz-naive)
- [ ] Columns lowercase:
open,high,low,close,volume - [ ] Data sorted by index (
df.sort_index()) - [ ] Timezone stripped (
tz_convert(None)) - [ ] Add your provider's API key variable to
.env.sample
Example: Alpaca Markets
import os
import pandas as pd
from dotenv import find_dotenv, load_dotenv
from pathlib import Path
# pip install alpaca-trade-api
from alpaca_trade_api.rest import REST
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
alpaca = REST(
key_id=os.getenv("ALPACA_API_KEY"),
secret_key=os.getenv("ALPACA_SECRET_KEY"),
base_url=os.getenv("ALPACA_BASE_URL", "https://paper-api.alpaca.markets"),
)
bars = alpaca.get_bars("AAPL", "1Day", start="2022-01-01", end="2025-01-01").df
bars.columns = bars.columns.str.lower()
bars.index = bars.index.tz_convert(None)
close = bars["close"]Example: Twelve Data
import os
import pandas as pd
from dotenv import find_dotenv, load_dotenv
from pathlib import Path
# pip install twelvedata
from twelvedata import TDClient
script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)
td = TDClient(apikey=os.getenv("TWELVEDATA_API_KEY"))
ts = td.time_series(symbol="AAPL", interval="1day", start_date="2022-01-01",
end_date="2025-01-01", outputsize=5000).as_pandas()
ts = ts.sort_index()
ts.columns = ts.columns.str.lower()
close = ts["close"]---
NEVER Do This
- Never hardcode API keys in scripts — always use
.env+python-dotenv - Never hardcode dates without making them configurable
- Never skip
sort_index()— data must be chronologically ordered - Never ignore timezone handling — mismatch causes silent alignment errors
- Never fetch more intraday data than the source provides (see interval tables above)
- Never use yfinance for Indian market backtests when OpenAlgo is available (OpenAlgo has more accurate data)
Futures Backtesting (Lot Size)
For NIFTY/BANKNIFTY futures, use min_size and size_granularity set to the lot size, and size_type="value" for fixed capital deployment:
lot_size = 65 # NIFTY Futures lot size (effective 31 Dec 2025)
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=30_00_000, # 30 lakh
size=20_00_000, # Deploy 20L of 30L per trade
size_type="value",
direction="longonly",
fees=0.0003, # 0.03% for futures
min_size=lot_size, # Minimum = 1 lot
size_granularity=lot_size, # Round to lot multiples
freq="1D" if timeframe == "D" else "1h",
)Index Futures & Options Lot Sizes (SEBI Revised, Effective 31 Dec 2025)
SEBI mandates contract value between Rs 15-20 lakh. Lot sizes are periodically revised.
| Index | Exchange | Lot Size | OpenAlgo Symbol |
|---|---|---|---|
| Nifty 50 | NFO | 65 | NIFTY |
| Nifty Bank | NFO | 30 | BANKNIFTY |
| Nifty Financial Services | NFO | 60 | FINNIFTY |
| Nifty Midcap Select | NFO | 120 | MIDCPNIFTY |
| Nifty Next 50 | NFO | 25 | NIFTYNXT50 |
| BSE Sensex | BFO | 20 | SENSEX |
| BSE Bankex | BFO | 30 | BANKEX |
| BSE Sensex 50 | BFO | 70 | SENSEX50 |
Historical lot sizes (for backtesting older periods):
| Index | Before Jan 2025 | Jan-Jun 2025 | Jun-Dec 2025 | From 31 Dec 2025 |
|---|---|---|---|---|
| Nifty 50 | 50 | 75 | 75 | 65 |
| Nifty Bank | 25 | 30 | 35 | 30 |
| Nifty Financial Services | 40 | 65 | 65 | 60 |
| Nifty Midcap Select | 75 | 120 | 140 | 120 |
Stock Futures: Lot sizes vary per stock (see exchange LOTSIZE.csv or OpenAlgo instruments API).
Futures Fee Structure
| Component | Rate |
|---|---|
| Brokerage | Flat or 0.01-0.03% |
| STT | 0.0125% on sell side (futures) |
| Exchange Txn | 0.00173% (NSE) |
| GST | 18% on (brokerage + exchange txn) |
| SEBI Fee | 0.0001% |
| Stamp Duty | 0.002% (buy side) |
For a simplified model, use fees=0.0003 (0.03%) which approximates total round-trip costs for futures.
See indian-market-costs for the complete fee model.
Long + Short Backtesting
Simultaneous Long and Short
Use short_entries and short_exits for simultaneous long/short:
pf_both = vbt.Portfolio.from_signals(
close,
entries=entries_long,
exits=exits_long,
short_entries=entries_short,
short_exits=exits_short,
init_cash=30_00_000,
size=20_00_000,
size_type="value",
fees=0.0003,
min_size=lot_size,
size_granularity=lot_size,
freq="1h",
)
# Note: direction="both" is ignored when short_entries/short_exits are providedCompare Long-Only vs Short-Only vs Both
common_kwargs = dict(
init_cash=1_000_000,
size=500_000,
size_type="value",
fees=0.00022,
freq="5min",
)
EMPTY = pd.Series(False, index=close.index)
pf_long = vbt.Portfolio.from_signals(close, entries=LE, exits=LX,
direction="longonly", **common_kwargs)
pf_short = vbt.Portfolio.from_signals(close, short_entries=SE, short_exits=SX,
direction="shortonly", **common_kwargs)
pf_both = vbt.Portfolio.from_signals(close, entries=LE, exits=LX,
short_entries=SE, short_exits=SX, **common_kwargs)
# Side-by-side comparison
stats = pd.concat([
pf_long.stats().to_frame("Long Only"),
pf_short.stats().to_frame("Short Only"),
pf_both.stats().to_frame("Both"),
], axis=1)
print(stats)Best Practices
- Test long-only first before adding short side
- Short strategies need separate signal logic (not just inverted long signals)
- For Indian equities, shorting is only available intraday (MIS/CO product types)
- Futures/options can be shorted for positional trades
- When comparing, always use
common_kwargsto ensure identical conditions
Related skills
How it compares
Choose vectorbt-expert over generic vectorbt guides when backtests require OpenAlgo NSE data and Indian delivery fee modeling for ETF portfolios.
FAQ
What is vectorbt-expert?
VectorBT backtesting expert. Use when user asks to backtest strategies, create entry/exit signals, analyze portfolio performance, optimize parameters, fetch historical data, use Ve
When should I use vectorbt-expert?
VectorBT backtesting expert. Use when user asks to backtest strategies, create entry/exit signals, analyze portfolio performance, optimize parameters, fetch historical data, use Ve
Is vectorbt-expert safe to install?
Review the Security Audits panel on this page before production use.