Cash & Equity Strategies
Delivery-style trend following and momentum ranking on equities.
- ·Golden-cross trend
- ·Momentum ranking
- ·Donchian breakout
- ·Position vs CNC
- ·Equity curve preview
- ·Universe rotation
So far you've built signals and scanned the market with them. Now we assemble those pieces into actual strategies - complete rules for what to buy, when to sell, and how to hold. This chapter stays in the world of cash equities: ordinary shares of companies like Reliance or Infosys, bought for delivery and held for days, weeks or months. No leverage, no expiry, no overnight square-off - the patient end of trading, and the right place to learn strategy design before the faster styles in later chapters.
We'll build three classic delivery systems - a golden-cross trend follower, a momentum rotation across a universe, and a Donchian channel breakout - and preview each one's equity curve so you can see, roughly, whether it would have made money. That preview is a hand-rolled backtest; we keep it simple here and hand the job to the proper tool, VectorBT, in Chapter 26. We'll also settle the question that trips up every new equity trader: CNC versus MIS.
from openalgo import api, ta
The golden cross
The most famous trend signal in all of investing: the golden cross, when the 50-day simple moving average rises above the 200-day. It says the medium-term trend has overtaken the long-term one - the market has turned up. Its opposite, the 50 falling below the 200, is the death cross. Whole trend-following systems are built on nothing more.
# The golden cross: 50-day SMA crossing above the 200-day SMA. A classic trend filter.
import os
from datetime import datetime, timedelta
from openalgo import api, ta
client = api(
api_key=os.getenv("OPENALGO_API_KEY", "your_api_key_here"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
# 200-day SMA needs a long history -- ~400 calendar days gives plenty of bars.
end = datetime.now().strftime("%Y-%m-%d")
start = (datetime.now() - timedelta(days=400)).strftime("%Y-%m-%d")
df = client.history(symbol="RELIANCE", exchange="NSE", interval="D", start_date=start, end_date=end)
close = df["close"]
sma50 = ta.sma(close, 50)
sma200 = ta.sma(close, 200)
regime = "BULLISH (golden cross)" if sma50.iloc[-1] > sma200.iloc[-1] else "BEARISH (death cross)"
print("Latest close :", round(float(close.iloc[-1]), 2))
print("SMA50 :", round(float(sma50.iloc[-1]), 2))
print("SMA200 :", round(float(sma200.iloc[-1]), 2))
print("Current regime:", regime)Latest close : 1306.0 SMA50 : 1347.39 SMA200 : 1420.74 Current regime: BEARISH (death cross)
The 200-day average needs 200 trading days of data before it produces a single value - roughly ten calendar months. That's why these examples request around 400 calendar days of history. Ask for too little and ta.sma(close, 200) returns nothing but NaN (not-a-number) at the end, and your strategy silently does nothing.
The regime check above tells you where you stand today. To backtest, you need the dated moments the cross actually happened - and that's exactly the crossover / crossunder pair from Chapter 17.
# Turn the golden/death cross into dated entry and exit signals.
import os
from datetime import datetime, timedelta
import pandas as pd
from openalgo import api, ta
client = api(
api_key=os.getenv("OPENALGO_API_KEY", "your_api_key_here"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end = datetime.now().strftime("%Y-%m-%d")
start = (datetime.now() - timedelta(days=600)).strftime("%Y-%m-%d")
df = client.history(symbol="HDFCBANK", exchange="NSE", interval="D", start_date=start, end_date=end)
close = df["close"]
sma50, sma200 = ta.sma(close, 50), ta.sma(close, 200)
golden = pd.Series(ta.crossover(sma50, sma200), index=df.index) # 50 crosses above 200 -> buy
death = pd.Series(ta.crossunder(sma50, sma200), index=df.index) # 50 crosses below 200 -> exit
print("Golden crosses:", int(golden.sum()), "| Death crosses:", int(death.sum()))
events = pd.DataFrame({"close": close, "golden": golden, "death": death})
print(events[golden | death].tail(6))Golden crosses: 0 | Death crosses: 1
close golden death
timestamp
2026-01-28 932.7 False TruePreviewing an equity curve
A signal is a hypothesis. An equity curve is how you check it: take a starting stake of 1.0 and watch how it grows (or shrinks) if you'd followed the rule. The recipe is short and you'll use it constantly:
- Build an in-position flag with
ta.flip(buy, sell)-1while you hold,0while flat. - Take daily returns with
close.pct_change(). - Multiply returns by the flag shifted one bar (
.shift(1)) - you only earn a day's return if you were already in the trade at yesterday's close. - Compound the result with
(1 + strat_ret).cumprod().
# A quick equity-curve preview from signals -- no VectorBT yet (that's Chapter 26).
import os
from datetime import datetime, timedelta
import pandas as pd
from openalgo import api, ta
client = api(
api_key=os.getenv("OPENALGO_API_KEY", "your_api_key_here"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end = datetime.now().strftime("%Y-%m-%d")
start = (datetime.now() - timedelta(days=400)).strftime("%Y-%m-%d")
df = client.history(symbol="ICICIBANK", exchange="NSE", interval="D", start_date=start, end_date=end)
close = df["close"]
# A 20/50 EMA cross trades more often than 50/200 -- handy for showing a real curve.
fast, slow = ta.ema(close, 20), ta.ema(close, 50)
buy = pd.Series(ta.crossover(fast, slow), index=df.index)
sell = pd.Series(ta.crossunder(fast, slow), index=df.index)
# Hold a long while flip() is on; shift(1) so we earn TOMORROW's return, not today's.
position = pd.Series(ta.flip(buy, sell), index=df.index).astype(int)
daily_ret = close.pct_change().fillna(0)
strat_ret = daily_ret * position.shift(1).fillna(0)
strat_curve = (1 + strat_ret).cumprod()
buyhold_curve = (1 + daily_ret).cumprod()
print("Strategy total return: {:.1f}%".format((strat_curve.iloc[-1] - 1) * 100))
print("Buy & hold total return: {:.1f}%".format((buyhold_curve.iloc[-1] - 1) * 100))
print("Days in the market:", int(position.sum()), "of", len(position))
print("Final equity on 1.0 start:", round(float(strat_curve.iloc[-1]), 3))Strategy total return: -15.5% Buy & hold total return: -7.8% Days in the market: 56 of 273 Final equity on 1.0 start: 0.845
This preview ignores brokerage, slippage and taxes, so its numbers are optimistic - treat them as a smell test, not a verdict. A strategy that loses before costs will lose more after them. Chapter 26 redoes this properly with VectorBT and realistic costs; until then, use the preview only to sanity-check that a rule does roughly what you intended.
Notice the example compares the strategy to simple buy-and-hold. That comparison is the honest benchmark for any equity system: if you can't beat just owning the stock, the extra complexity isn't earning its keep.
Momentum ranking across a universe
Trend-following picks a side on one stock. Momentum rotation asks a sharper question across many: which names are strongest right now, and hold those. You rank the universe by its recent return - say the last 60 trading days - buy the leaders, and re-rank periodically, rotating out of laggards. It's the strategy logic behind countless equity funds, and it's a direct descendant of the ranking table you built in Chapter 18.
# Momentum ranking: sort a universe by N-day return and buy the leaders.
import os
from datetime import datetime, timedelta
import pandas as pd
from openalgo import api
client = api(
api_key=os.getenv("OPENALGO_API_KEY", "your_api_key_here"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
universe = ["RELIANCE", "TCS", "INFY", "HDFCBANK", "ICICIBANK", "SBIN",
"AXISBANK", "WIPRO", "ITC", "LT", "MARUTI", "KOTAKBANK"]
end = datetime.now().strftime("%Y-%m-%d")
start = (datetime.now() - timedelta(days=120)).strftime("%Y-%m-%d")
LOOKBACK = 60 # trading-bar momentum window
def daily(sym):
df = client.history(symbol=sym, exchange="NSE", interval="D", start_date=start, end_date=end)
return df if isinstance(df, pd.DataFrame) and not df.empty else None
rows = []
for sym in universe:
df = daily(sym)
if df is None or len(df) <= LOOKBACK:
continue
close = df["close"]
mom = (close.iloc[-1] / close.iloc[-1 - LOOKBACK] - 1) * 100
rows.append({"symbol": sym, "momentum_%": round(float(mom), 2)})
ranked = pd.DataFrame(rows).sort_values("momentum_%", ascending=False).reset_index(drop=True)
ranked.index += 1
print(f"Ranked by {LOOKBACK}-bar momentum. A rotation would hold the top 3:")
print(ranked.to_string())
print("\nTop 3 to hold this month:", list(ranked["symbol"].head(3)))Ranked by 60-bar momentum. A rotation would hold the top 3:
symbol momentum_%
1 LT 24.75
2 AXISBANK 16.01
3 KOTAKBANK 12.72
4 ICICIBANK 9.23
5 MARUTI 8.70
6 HDFCBANK 3.86
7 ITC 2.56
8 SBIN -0.80
9 WIPRO -7.01
10 RELIANCE -7.23
11 TCS -13.58
12 INFY -16.36
Top 3 to hold this month: ['LT', 'AXISBANK', 'KOTAKBANK']The output is a leaderboard. A real rotation would hold the top few names, then re-run this each month and swap any that have dropped off the top. Simple, mechanical, and completely free of opinion - which is rather the point.
Donchian channel breakout
The Donchian channel plots the highest high and lowest low of the last N days as an upper and lower band. The strategy is pure breakout: buy when price closes above the upper band (a new N-day high), exit when it closes below the lower band. It's the skeleton of one of the most famous trend systems ever traded, and ta.donchian builds the bands for you.
# Donchian channel breakout: buy a close above the 20-day high, exit below the 20-day low.
import os
from datetime import datetime, timedelta
import numpy as np
from openalgo import api, ta
client = api(
api_key=os.getenv("OPENALGO_API_KEY", "your_api_key_here"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end = datetime.now().strftime("%Y-%m-%d")
start = (datetime.now() - timedelta(days=200)).strftime("%Y-%m-%d")
df = client.history(symbol="INFY", exchange="NSE", interval="D", start_date=start, end_date=end)
# donchian returns three Series: upper (highest high), middle, lower (lowest low).
upper, middle, lower = ta.donchian(df["high"], df["low"], period=20)
close = df["close"].iloc[-1]
# Compare to the channel as it stood YESTERDAY ([-2]) so today's bar can break it.
upper_prev = np.asarray(upper)[-2]
lower_prev = np.asarray(lower)[-2]
print("Latest close :", round(float(close), 2))
print("20-day upper (prior bar):", round(float(upper_prev), 2))
print("20-day lower (prior bar):", round(float(lower_prev), 2))
if close > upper_prev:
print("Signal: BREAKOUT -- close cleared the 20-day high.")
elif close < lower_prev:
print("Signal: BREAKDOWN -- close broke the 20-day low.")
else:
print("Signal: inside the channel -- no trade.")Latest close : 1029.0 20-day upper (prior bar): 1251.8 20-day lower (prior bar): 1030.0 Signal: BREAKDOWN -- close broke the 20-day low.
As with the breakout scan in Chapter 18, we compare today's close to the band as it stood on the prior bar (.shift(1)), so today's own high can't contaminate the test. Wrapping that into a full held position and an equity-curve preview reuses the exact flip recipe from earlier:
# A full Donchian system: hold a long between breakout and breakdown, preview the curve.
import os
from datetime import datetime, timedelta
import pandas as pd
from openalgo import api, ta
client = api(
api_key=os.getenv("OPENALGO_API_KEY", "your_api_key_here"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
end = datetime.now().strftime("%Y-%m-%d")
start = (datetime.now() - timedelta(days=400)).strftime("%Y-%m-%d")
df = client.history(symbol="LT", exchange="NSE", interval="D", start_date=start, end_date=end)
close = df["close"]
upper, _, lower = ta.donchian(df["high"], df["low"], period=20)
upper = pd.Series(upper, index=df.index)
lower = pd.Series(lower, index=df.index)
# Enter when close clears the prior upper band; exit when it loses the prior lower band.
buy = close > upper.shift(1)
sell = close < lower.shift(1)
position = pd.Series(ta.flip(buy, sell), index=df.index).astype(int)
ret = close.pct_change().fillna(0)
strat = ret * position.shift(1).fillna(0)
print("Trades opened:", int((position.diff() == 1).sum()))
print("Days held :", int(position.sum()), "of", len(position))
print("Strategy total return: {:.1f}%".format(((1 + strat).cumprod().iloc[-1] - 1) * 100))
print("Buy & hold total return: {:.1f}%".format(((1 + ret).cumprod().iloc[-1] - 1) * 100))Trades opened: 4 Days held : 160 of 273 Strategy total return: -3.8% Buy & hold total return: 15.8%
CNC versus MIS: the product that defines the trade
Here's the detail that separates a delivery investor from a day trader, and it lives in one parameter: product. Every equity order carries one, and choosing wrong can wreck an otherwise good strategy.
# Delivery equity strategies use the CNC product. Here is how CNC and MIS differ.
import os
from openalgo import api
client = api(
api_key=os.getenv("OPENALGO_API_KEY", "your_api_key_here"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
product_notes = {
"CNC": "Cash & Carry -- delivery. Shares settle into your demat; hold for days, weeks or years.",
"MIS": "Margin Intraday Square-off -- auto-closed the same session; for day trades only.",
"NRML": "Normal -- the carry product for F&O positions held overnight.",
}
print("Equity strategy products at a glance:")
for code, note in product_notes.items():
print(f" {code:5s} {note}")
print("\nA golden-cross trade may hold for months, so it MUST use product='CNC'.")
print("Using 'MIS' would square off the position automatically at the close -- wrong tool.")Equity strategy products at a glance: CNC Cash & Carry -- delivery. Shares settle into your demat; hold for days, weeks or years. MIS Margin Intraday Square-off -- auto-closed the same session; for day trades only. NRML Normal -- the carry product for F&O positions held overnight. A golden-cross trade may hold for months, so it MUST use product='CNC'. Using 'MIS' would square off the position automatically at the close -- wrong tool.
- CNC (Cash & Carry) is for delivery - shares settle into your demat account and you can hold them indefinitely. Every strategy in this chapter is multi-day, so they all use
product="CNC". - MIS (Margin Intraday Square-off) is for intraday only - the broker force-closes the position at the day's end, no matter what. Use it for a golden-cross hold and your trade evaporates at 3:20 p.m. on day one.
Match the product to the holding period. Delivery strategy -> CNC. Day trade -> MIS (the focus of Chapter 20).
Finally, acting on the signal. With the server in analyze mode (your flight simulator from Chapter 1), placing an order is completely safe - nothing trades for real. Here we re-check the golden-cross regime and only fire a CNC buy if the trend is up.
# Acting on a signal: place a delivery (CNC) buy. Safe to run -- server is in analyze mode.
import os
from datetime import datetime, timedelta
from openalgo import api, ta
client = api(
api_key=os.getenv("OPENALGO_API_KEY", "your_api_key_here"),
host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
)
# Re-check the regime, then only buy if the golden cross is in force.
end = datetime.now().strftime("%Y-%m-%d")
start = (datetime.now() - timedelta(days=400)).strftime("%Y-%m-%d")
df = client.history(symbol="SBIN", exchange="NSE", interval="D", start_date=start, end_date=end)
close = df["close"]
bullish = ta.sma(close, 50).iloc[-1] > ta.sma(close, 200).iloc[-1]
if bullish:
resp = client.placeorder(
strategy="GoldenCross",
symbol="SBIN",
action="BUY",
exchange="NSE",
price_type="MARKET",
product="CNC", # delivery -- this is a multi-week hold
quantity=1,
)
print("Regime bullish -- placed CNC buy:", resp.get("status"), resp.get("orderid"))
else:
print("Regime not bullish -- staying flat, no order placed.")Regime bullish -- placed CNC buy: success 26062316063085
This is the natural endpoint of a strategy: a rule that checks a condition and acts only if it holds. The if bullish: guard means the bot never buys into a downtrend - it simply stays flat and reports why. That discipline, encoded once, never has a bad day or a strong opinion.
Try it yourself
- Change the golden-cross example to use 20/100 averages instead of 50/200. More trades, more whipsaws - is the equity curve any better?
- In the momentum ranking, shorten the lookback from 60 bars to 20. Does the leaderboard get more jumpy month to month?
- Take the Donchian system and widen the channel from 20 to 55 days (the classic long-term setting). How does "days held" change?
Recap
- Cash-equity strategies are delivery-style: held for days to months, no leverage, settled into demat.
- The golden cross (50 SMA over 200 SMA) is a textbook trend filter; the death cross is its exit.
- An equity-curve preview =
flipfor the position,.shift(1)for honesty,pct_changefor returns,cumprodto compound - always benchmarked against buy-and-hold. - Momentum rotation ranks a universe by recent return and holds the leaders - a strategy built straight from a scanner's leaderboard.
- The Donchian breakout trades new N-day highs and lows; compare to the prior bar's bands.
- Choose CNC for delivery and MIS for intraday - the product parameter must match your holding period.
These previews are deliberately rough. Before risking real capital you need a proper, cost-aware backtest - which is exactly what we build next, first by hand and then at full power with VectorBT.