Capstone: Build Your Own Strategy End to End¶
Fundamentals filter + tested indicator + honest backtest + risk-managed sizing, real tickers start to finish.
Part 31 of 35 in the ServLoci algo/options trading notebook series — full index in notebooks/README.md.
Setup — no broker account needed¶
!pip install -q yfinance
import yfinance as yf
import pandas as pd
import numpy as np
# No broker account, no API key, no ServLoci setup needed for this chapter —
# yfinance reads public end-of-day data from Yahoo Finance. Indian tickers take
# an ".NS" suffix (NSE) or ".BO" (BSE); index tickers are prefixed with "^"
# (^NSEI = Nifty 50, ^BSESN = Sensex, ^GSPC = S&P 500).
NSE_TICKER = "RELIANCE.NS"
US_TICKER = "AAPL"
INDEX_TICKER = "^NSEI"
print("yfinance", yf.__version__)
The Stock Market School, end to end¶
Chapters 24-30 each covered one link in a chain, all on real data pulled live via yfinance — no ServLoci account needed to run any of it:
- Reading the market (24) — pull real OHLCV for an NSE stock, a US stock, and an index; understand adjusted close, dividends, splits.
- Returns & risk (25) — turn a price series into annualized return, volatility, Sharpe/Sortino and max drawdown.
- Fundamentals (26) — P/E, market cap, debt/equity as a sanity filter, not a signal on their own.
- Indicators, tested (27) — compute a technical signal and actually check whether it correlates with forward returns, instead of trusting it by default.
- Options against reality (28) — price and Greek an option off a real fetched spot, not a textbook number.
- Honest backtesting (29) — lag signals correctly, hold out a test window, subtract real costs.
- Risk of ruin (30) — size the position so a normal losing streak doesn't end the account, and see what happens when sizing gets emotional instead.
This capstone runs a condensed version of that whole chain on one real ticker, then prints a single decision-chain report — the same shape every stage above justified separately.
data = yf.download(NSE_TICKER, period="3y", interval="1d", progress=False, auto_adjust=True, multi_level_index=False)
close = data["Close"]
returns = close.pct_change().dropna()
ann_return = float(returns.mean() * 252)
ann_vol = float(returns.std() * np.sqrt(252))
sharpe = ann_return / ann_vol if ann_vol else float("nan")
print(f"[1] {NSE_TICKER}: annualized return {ann_return:.1%}, volatility {ann_vol:.1%}, Sharpe {sharpe:.2f}")
info = yf.Ticker(NSE_TICKER).info
pe = info.get("trailingPE")
market_cap = info.get("marketCap")
# Crude sanity band, not investment advice — a filter to skip obviously broken
# or unpriceable names, not a ranking of good vs. bad companies.
passes_fundamentals = pe is not None and 0 < pe < 60
print(f"[2] {NSE_TICKER}: trailing P/E={pe}, market cap={market_cap}, passes basic sanity filter={passes_fundamentals}")
sma_fast = close.rolling(20).mean()
sma_slow = close.rolling(50).mean()
cross = (sma_fast > sma_slow).astype(int).diff().fillna(0)
forward_5d_return = close.shift(-5) / close - 1
golden_cross_days = cross[cross == 1].index
if len(golden_cross_days):
edge = float(forward_5d_return.reindex(golden_cross_days).mean())
print(f"[3] Golden-cross days: {len(golden_cross_days)}, avg forward 5-day return: {edge:.2%}")
else:
print("[3] No golden-cross signals in this window — see notebook 27 before trusting any single signal.")
from scipy.stats import norm
import math
def bs_price(opt_type, spot, strike, t_years, vol, rate=0.065):
if t_years <= 0 or vol <= 0:
return max(spot - strike, 0) if opt_type == "CE" else max(strike - spot, 0)
d1 = (math.log(spot / strike) + (rate + vol * vol / 2) * t_years) / (vol * math.sqrt(t_years))
d2 = d1 - vol * math.sqrt(t_years)
if opt_type == "CE":
return spot * norm.cdf(d1) - strike * math.exp(-rate * t_years) * norm.cdf(d2)
return strike * math.exp(-rate * t_years) * norm.cdf(-d2) - spot * norm.cdf(-d1)
spot = float(close.iloc[-1])
strike = round(spot / 50) * 50 # nearest 50-point strike, illustrative only
premium = bs_price("CE", spot, strike, t_years=7 / 365, vol=ann_vol)
print(f"[4] Illustrative ATM {strike} CE, 7 DTE, {ann_vol:.0%} vol -> Black-Scholes premium Rs.{premium:.2f}")
STT_SELL = 0.001 # simplified equity-delivery STT approximation — confirm current rate before sizing real capital
BROKERAGE_PER_TRADE = 20 # flat per-order brokerage, typical Indian discount-broker cap
split = int(len(close) * 0.7)
train, test = close.iloc[:split], close.iloc[split:]
def backtest_sma_crossover(prices, fast=20, slow=50, capital=100_000):
sma_f = prices.rolling(fast).mean()
sma_s = prices.rolling(slow).mean()
position = (sma_f > sma_s).astype(int)
daily_ret = prices.pct_change().fillna(0)
strat_ret = position.shift(1).fillna(0) * daily_ret
n_trades = int(position.diff().abs().fillna(0).sum())
cost_drag = n_trades * (BROKERAGE_PER_TRADE / capital + STT_SELL)
gross_return = float((1 + strat_ret).prod() - 1)
return {"n_trades": n_trades, "gross_return": gross_return, "cost_drag": cost_drag, "net_return": gross_return - cost_drag}
train_result = backtest_sma_crossover(train)
test_result = backtest_sma_crossover(test)
print("[5] Train (in-sample): ", train_result)
print("[5] Test (out-of-sample):", test_result)
def position_size(capital, risk_pct, max_loss_per_share):
if max_loss_per_share <= 0:
return 0
return max(int((capital * risk_pct) // max_loss_per_share), 0)
def guard_max_loss(strategy_max_loss, capital, hard_cap_pct=0.05):
if abs(strategy_max_loss) > capital * hard_cap_pct:
raise ValueError(f"strategy max loss {strategy_max_loss} exceeds hard cap {capital * hard_cap_pct}")
return True
capital, risk_pct = 500_000, 0.02
assumed_max_loss_per_share = spot * 0.05 # illustrative 5% adverse-move assumption, not a stop-loss guarantee
qty = position_size(capital, risk_pct, assumed_max_loss_per_share)
guard_max_loss(qty * assumed_max_loss_per_share, capital)
print(f"[6] Risk-managed size for {NSE_TICKER}: {qty} shares, max assumed loss Rs.{qty * assumed_max_loss_per_share:,.0f}")
print("\n=== Decision chain summary ===")
print(f"Ticker: {NSE_TICKER}")
print(f"Annualized Sharpe: {sharpe:.2f}")
print(f"Fundamentals sanity: {'PASS' if passes_fundamentals else 'FAIL'}")
print(f"Out-of-sample net ret: {test_result['net_return']:.2%} over {test_result['n_trades']} trades")
print(f"Risk-managed size: {qty} shares (2% risk budget)")
print("Status: PAPER / DRY-RUN ONLY — no live order placed")
From here to a live system — and why this stops short of one¶
Everything above ran on public data with no broker account. Turning it into live automation means three things this capstone deliberately left out: a real broker session over a whitelisted static IP (notebooks 00-05), an order management layer that owns retries and idempotency instead of calling a broker endpoint directly (notebook 16), and a signal-to-dispatch boundary that can reject a bad decision before it becomes an order (notebook 18) — with real slippage, real fills, and real brokerage/STT/margin replacing every "illustrative" number used here.
That gap is intentional, not an oversight. Every backtest in this school, including [5] above, is a hypothesis about the past, not a guarantee about the future — and every risk-of-ruin number in notebook 30 assumed a win rate and payoff ratio that a live strategy has to earn through its own track record, not borrow from an index. Moving from this capstone to real capital is a separate, deliberate decision that needs its own risk review, monitoring for when live behavior diverges from the backtest, and capital you can afford to lose while you find out where the model was wrong.
Keep building at https://comm.servloci.in/tools/strategy-builder, or grab your own static IP at https://comm.servloci.in/register when you're ready to connect this to a broker.
« Previous: Position Sizing, Risk of Ruin & Trading Psychology
Next: Stock Correlation, Clusters and Pairs »
Try the concepts above interactively: Options Strategy Builder · Docs · Get your static IP