How to Build Technical Analysis and Backtesting Workflow with pandas-ta-classic, Strategy Signals, and Performance Metrics

In this tutorial, we implement how to use pandas-ta-classic to build a complete technical analysis and trading strategy workflow. We start by installing the required libraries, downloading historical OHLCV stock data with yfinance, cleaning the returned data structure, and inspecting the available indicator categories inside the library. We then calculate popular indicators such as SMA, EMA, RSI, ATR, MACD, Bollinger Bands, candlestick patterns, and a custom distance-from-EMA feature. Also, we combine daily and weekly signals, create entry and exit logic, backtest the strategy with shifted positions, calculate performance metrics, run a parameter sweep, and visualize price action, RSI behavior, trade signals, and equity curves in a structured way.

import subprocess, sys
def _pip(pkgs):
subprocess.check_call([sys.executable, “-m”, “pip”, “install”, “-q”, *pkgs])
_pip([“pandas-ta-classic”, “yfinance”, “matplotlib”])
import numpy as np
import pandas as pd
import yfinance as yf
import pandas_ta_classic as ta
import matplotlib.pyplot as plt
from itertools import product
pd.set_option(“display.max_columns”, 80)
pd.set_option(“display.width”, 200)
TICKER, START, END = “AAPL”, “2018-01-01”, “2024-12-31”
raw = yf.download(TICKER, start=START, end=END, auto_adjust=True, progress=False)
if isinstance(raw.columns, pd.MultiIndex):
raw.columns = raw.columns.get_level_values(0)
df = (raw.rename(columns=str.lower)
[[“open”, “high”, “low”, “close”, “volume”]]
.dropna()
.copy())
df.index.name = “date”
print(f”[data] {TICKER}: {len(df)} rows “
f”{df.index.min().date()} → {df.index.max().date()}”)
print(“[lib] Categories:”, list(ta.Category.keys()))
for cat in (“momentum”, “overlap”, “trend”, “volatility”, “volume”):
names = ta.Category.get(cat, [])
print(f”[lib] {cat:<11} ({len(names):>3}): “
f”{‘, ‘.join(names[:8])}{‘ …’ if len(names) > 8 else ”}”)

We install the required packages and import the main libraries needed for technical analysis, data handling, plotting, and parameter combinations. We download Apple’s historical OHLCV data using yfinance, clean the returned DataFrame, and convert column names to lowercase for easier processing. We also review the available pandas-ta-classic indicator categories to understand which technical indicators we can use in the tutorial.

df.ta.sma(length=20, append=True)
df.ta.sma(length=50, append=True)
df.ta.ema(length=200, append=True)
df.ta.rsi(length=14, append=True)
df.ta.atr(length=14, append=True)
df.ta.macd(append=True)
df.ta.bbands(length=20, std=2.0, append=True)
my_strategy = ta.Strategy(
name=”AdvancedDemo”,
description=”Trend + momentum + volume + volatility in one shot”,
ta=[
{“kind”: “hma”, “length”: 30},
{“kind”: “adx”, “length”: 14},
{“kind”: “aroon”, “length”: 14},
{“kind”: “stoch”, “k”: 14, “d”: 3},
{“kind”: “obv”},
{“kind”: “mfi”, “length”: 14},
{“kind”: “willr”, “length”: 14},
{“kind”: “cci”, “length”: 20},
{“kind”: “kc”, “length”: 20, “scalar”: 2},
],
)
df.ta.strategy(my_strategy)
print(f”[strat] DataFrame now has {df.shape[1]} columns”)
df[“dist_ema200_pct”] = (df[“close”] / df[“EMA_200”] – 1.0) * 100
df.ta.cdl_doji(append=True)
df.ta.cdl_inside(append=True)
doji_col = next((c for c in df.columns if c.startswith(“CDL_DOJI”)), None)
print(f”[cdl] Doji days detected: {int((df[doji_col] == 100).sum())}”)

We apply several commonly used technical indicators directly through the .ta DataFrame extension. We calculate moving averages, RSI, ATR, MACD, Bollinger Bands, and then run a custom multi-indicator strategy using ta.Strategy. We also create a custom EMA-distance feature and detect candlestick patterns such as Doji and Inside candles.

weekly = (df[[“open”, “high”, “low”, “close”, “volume”]]
.resample(“W-FRI”)
.agg({“open”:”first”,”high”:”max”,”low”:”min”,”close”:”last”,”volume”:”sum”})
.dropna())
weekly[“RSI_W_14”] = ta.rsi(weekly[“close”], length=14)
df = df.join(weekly[[“RSI_W_14”]])
df[“RSI_W_14”] = df[“RSI_W_14”].ffill().shift(1)
trend = df[“SMA_20”] > df[“SMA_50”]
mom_cross = (df[“RSI_14”] > 50) & (df[“RSI_14”].shift(1) <= 50)
mtf_ok = df[“RSI_W_14”] > 50
exit_cond = (df[“RSI_14”] < 45) | (df[“SMA_20”] < df[“SMA_50”])
position = np.zeros(len(df), dtype=int)
in_pos = False
for i in range(len(df)):
if not in_pos and trend.iat[i] and mom_cross.iat[i] and bool(mtf_ok.iat[i]):
in_pos = True
elif in_pos and exit_cond.iat[i]:
in_pos = False
position[i] = 1 if in_pos else 0
df[“pos”] = position
df[“ret”] = df[“close”].pct_change().fillna(0.0)
df[“strat_ret”] = df[“pos”].shift(1).fillna(0) * df[“ret”]

We create a weekly version of the daily OHLCV data and calculate weekly RSI for higher-timeframe confirmation. We join the weekly RSI back to the daily DataFrame and shift it to avoid using future information in our trading logic. We then define the trend, momentum, multi-timeframe filter, exit condition, position state, daily returns, and strategy returns.

def perf(returns, ppy=252):
r = returns.dropna()
if len(r) == 0 or r.std() == 0:
return {}
cum = (1 + r).cumprod()
cagr = cum.iloc[-1] ** (ppy / len(r)) – 1
vol = r.std() * np.sqrt(ppy)
sharpe = (r.mean() / r.std()) * np.sqrt(ppy)
downside = r[r < 0].std() * np.sqrt(ppy)
sortino = (r.mean() * ppy) / downside if downside > 0 else np.nan
mdd = (cum / cum.cummax() – 1).min()
nz = r[r != 0]
win = (nz > 0).mean() if len(nz) else 0.0
return {“CAGR”: cagr, “Vol”: vol, “Sharpe”: sharpe,
“Sortino”: sortino, “MaxDD”: mdd, “WinRate”: win,
“FinalEquity”: cum.iloc[-1]}
summary = pd.DataFrame({
“Buy & Hold”: perf(df[“ret”]),
“Strategy”: perf(df[“strat_ret”]),
}).T
print(“n[perf] —————————————-“)
print(summary.round(4))
def quick_bt(prices, fast, slow, rsi_thr=50):
if fast >= slow:
return None
d = prices.copy()
d[“SMAf”] = ta.sma(d[“close”], length=fast)
d[“SMAs”] = ta.sma(d[“close”], length=slow)
d[“RSI”] = ta.rsi(d[“close”], length=14)
sig = ((d[“SMAf”] > d[“SMAs”]) & (d[“RSI”] > rsi_thr)).astype(int)
sret = sig.shift(1).fillna(0) * d[“close”].pct_change().fillna(0)
return perf(sret)
prices = df[[“open”, “high”, “low”, “close”, “volume”]]
rows = []
for fast, slow in product([5, 10, 20, 30], [50, 100, 150, 200]):
m = quick_bt(prices, fast, slow)
if m:
rows.append({“fast”: fast, “slow”: slow, **m})
sweep = (pd.DataFrame(rows)
.sort_values(“Sharpe”, ascending=False)
.reset_index(drop=True))
print(“n[sweep] Top 5 (fast SMA, slow SMA) by Sharpe:”)
print(sweep.head().round(4))

We define a performance function that calculates key metrics, including CAGR, volatility, Sharpe ratio, Sortino ratio, maximum drawdown, win rate, and final equity. We compare the strategy performance against a simple buy-and-hold baseline to see whether our signal logic adds value. We also run a parameter sweep across different fast and slow SMA combinations and rank the results by Sharpe ratio.

entries = df.index[(df[“pos”].diff() == 1)]
exits = df.index[(df[“pos”].diff() == -1)]
fig, (ax1, ax2, ax3) = plt.subplots(
3, 1, figsize=(13, 10), sharex=True,
gridspec_kw={“height_ratios”: [3, 1, 2]},
)
ax1.plot(df.index, df[“close”], lw=1.1, color=”black”, label=”Close”)
ax1.plot(df.index, df[“SMA_20″], lw=0.9, label=”SMA 20”)
ax1.plot(df.index, df[“SMA_50″], lw=0.9, label=”SMA 50”)
bbu, bbl = “BBU_20_2.0”, “BBL_20_2.0”
if bbu in df and bbl in df:
ax1.fill_between(df.index, df[bbl], df[bbu], alpha=0.12, label=”Bollinger 20,2″)
ax1.scatter(entries, df.loc[entries, “close”], marker=”^”, s=70,
color=”green”, zorder=5, label=”Entry”)
ax1.scatter(exits, df.loc[exits, “close”], marker=”v”, s=70,
color=”red”, zorder=5, label=”Exit”)
ax1.set_title(f”{TICKER} — price, MAs, Bollinger, signals”)
ax1.legend(loc=”upper left”); ax1.grid(alpha=0.3)
ax2.plot(df.index, df[“RSI_14″], lw=0.9, label=”RSI 14”)
ax2.axhline(70, color=”red”, ls=”–“, lw=0.6)
ax2.axhline(30, color=”green”, ls=”–“, lw=0.6)
ax2.set_title(“RSI 14″); ax2.legend(loc=”upper left”); ax2.grid(alpha=0.3)
ax3.plot(df.index, (1 + df[“ret”]).cumprod(), lw=1.1, label=”Buy & Hold”)
ax3.plot(df.index, (1 + df[“strat_ret”]).cumprod(), lw=1.1, label=”Strategy”)
ax3.set_title(“Equity curves ($1 start)”)
ax3.legend(loc=”upper left”); ax3.grid(alpha=0.3)
plt.tight_layout(); plt.show()
print(“nTweak TICKER, the Strategy list, or the sweep grid to keep exploring.”)

We identify the strategy’s entry and exit points from changes in the position column. We then create a three-panel chart showing price action with moving averages and Bollinger Bands, RSI behavior, and equity curves for both buy-and-hold and the strategy. We use these visuals to understand where trades happen, how momentum behaves, and how the strategy performs over time.

In conclusion, we built an end-to-end technical analysis pipeline that shows how pandas-ta-classic can support both quick indicator generation and more advanced strategy development. We used the library to compute individual indicators and also to create custom strategies, add multi-timeframe confirmation, reduce look-ahead bias, evaluate returns, and compare the strategy against buy-and-hold performance. We also ran a simple parameter sweep to understand how different moving-average combinations affect results and to help us identify stronger configurations. Also, we gained a foundation for experimenting with technical indicators, trading signals, backtesting logic, performance evaluation, and financial data visualization.

Check out the Codes with NotebookAlso, feel free to follow us on Twitter and don’t forget to join our 150k+ ML SubReddit and Subscribe to our Newsletter. Wait! are you on telegram? now you can join us on telegram as well.

Need to partner with us for promoting your GitHub Repo OR Hugging Face Page OR Product Release OR Webinar etc.? Connect with us

The post How to Build Technical Analysis and Backtesting Workflow with pandas-ta-classic, Strategy Signals, and Performance Metrics appeared first on MarkTechPost.

By

Leave a Reply

Your email address will not be published. Required fields are marked *