This beginner tutorial shows how to build a cross-sectional momentum trading strategy on the ALGOGENE cloud backtest platform. You will learn what momentum means, the exact return formula used to rank symbols, and how to turn that ranking into a monthly long-short portfolio in Python. When you finish, you can paste the script into a free ALGOGENE workspace, subscribe a stock universe, and backtest the idea on historical market data.
Momentum is one of the most studied ideas in quantitative trading: assets that have risen recently often keep rising for a while, and assets that have fallen often keep falling. A cross-sectional version does not bet on the whole market. It compares symbols with each other, buys the strongest, and sells the weakest. That is the strategy we code below.
What this momentum strategy does
Once a month, the algorithm looks at every instrument in your subscribe list and scores each one by its recent price trend. It then:
- Goes long the top 3 symbols (highest 30-day momentum)
- Goes short the bottom 3 symbols (lowest 30-day momentum)
- Sizes each leg to the same dollar amount, so no single name dominates the book
- Closes a position if price moves 10% against the entry
- Repeats the ranking on the 2nd trading day of the next month
Holding winners and losers at the same time is a classic long-short momentum (or relative-strength) portfolio. If the whole market rises or falls together, the long book and the short book can offset part of that market move. What remains is the spread between strong names and weak names. That is why researchers often call this a cross-sectional, rather than a time-series, momentum model.
The math: 30-day momentum score
For each symbol we do not use a single percentage change from 30 days ago. We sum the last 30 daily simple returns. Let Ct be the daily closing price. The daily return is:
rt = Ct / Ct−1 − 1
The momentum score over a 30-day lookback is:
M = rt−29 + rt−28 + … + rt = Σi=1…30 (Ci / Ci−1 − 1)
To compute 30 returns you need 31 closes: the close before the window, plus the 30 closes inside the window. That is why the code requests mom_days + 1 daily bars from getHistoricalBar.
A small example makes the formula concrete. Suppose three daily closes are 100, 110, and 99.
- Day 1 return = 110 / 100 − 1 = +10%
- Day 2 return = 99 / 110 − 1 = −10%
- Momentum score = 0.10 + (−0.10) = 0
Symbols are then sorted by M from high to low. With long_n = 3 and short_n = 3, the first three names are buy candidates and the last three names are short candidates. If a symbol does not have 31 daily bars yet (a newly listed stock, for example), it is skipped for that month so a missing history cannot distort the ranking.
Position size and the 10% stop
Each rebalance splits account NAV into equal legs:
leg notional = NAV / (long_n + short_n) = NAV / 6
Order size is that notional divided by the latest price, rounded down to 4 decimal places so the volume matches a typical lot step on the platform:
volume = floor(leg notional / price × 10,000) / 10,000
The stop-loss is measured from the fill price stored by the platform, not from the signal price you hoped to get.
- Long: close when last price ≤ open price × (1 − 0.10)
- Short: close when last price ≥ open price × (1 + 0.10)
A long bought at 100 is closed if price prints 90 or lower. A short sold at 100 is closed if price prints 110 or higher. The check runs on each data callback, so a fast adverse move does not wait until the next monthly rebalance.
Trading logic
- Subscribe a universe of instruments in the ALGOGENE backtest settings (for example a basket of US stocks).
- On every synced bulk data callback, count how many unique trading dates have occurred in the current calendar month.
- For every open position, compare last price with entry price and close the trade if the 10% stop is hit.
- If today is not the 2nd trading day of the month, stop. No new ranking is done.
- On the 2nd trading day, score every symbol that has enough daily history.
- If fewer than 6 symbols have a valid score, skip the month.
- Target side: +1 for the top 3, −1 for the bottom 3, and 0 for everyone else.
- Close any holding that is no longer a target, or that is held on the wrong side.
- Open a market order only when that symbol is flat. Same-side positions are kept, so you do not churn a name that is still a winner or still a loser.
- Repeat until the backtest ends.
Now, let's write the trading algorithm step-by-step.
Step 1. Set the strategy parameters
ALGOGENE strategies live inside a class named AlgoEvent. Put the knobs you may want to tune in __init__. Keeping them as attributes means you can later sweep lookback, basket size, or stop distance without rewriting the order logic.
1 2 3 4 5 6 7 8 9 10 11 12 13 | class AlgoEvent: def __init__(self): self.long_n = 3 # long top 3 by 30d momentum self.short_n = 3 # short bottom 3 by 30d momentum self.mom_days = 30 # lookback: sum of past 30 daily returns self.stoploss = 0.10 # close position on 10% adverse move self.rebalance_day = 2 # rebalance on 2nd trading day of each month self.cur_month = None self.last_date = None self.day_count = 0 self.tradeID = {} # symbol -> tradeID of the open trade self.net_pos = {} # symbol -> signed net volume self.openinfo = {} # symbol -> entry side and price |
tradeID is how we close a specific ticket later. net_pos tells us whether we are already long, short, or flat. openinfo stores the entry price used by the stop-loss.
Step 2. Start the backtest and read the universe
The platform calls start once. mEvt['subscribeList'] is the instrument list you ticked in the backtest UI, so the same script works for 10 stocks or 50 stocks without hard-coding tickers.
1 2 3 4 | def start(self, mEvt): self.evt = AlgoAPI_Backtest.AlgoEvtHandler(self, mEvt) self.universe = mEvt['subscribeList'] self.evt.start() |
Step 3. Calculate momentum from daily bars
getHistoricalBar returns recent OHLC bars. We pass the symbol, the number of bars, and 'D' for the daily timeframe. The comprehension res[t]['c'] pulls each closing price. The loop is the summation formula from the previous section.
1 2 3 4 5 6 7 8 9 10 | def momentum(self, symbol): res = self.evt.getHistoricalBar( {"instrument": symbol}, self.mom_days + 1, "D") if len(res) < self.mom_days + 1: return None closes = [res[t]["c"] for t in res] total = 0.0 for i in range(1, len(closes)): total += closes[i] / closes[i - 1] - 1.0 return total |
Returning None is important. A symbol such as a stock that listed halfway through your backtest should not receive a fake score of zero, because zero would look like a “middle” name and could still be selected.
Step 4. Helper functions to open and close orders
Two small wrappers keep the rebalance code readable. A market order uses ordertype = 0. Buy is buysell = 1. Sell (including a short open) is buysell = -1. Closing does not need a new volume: you pass the platform tradeID and set openclose = 'close'.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def send_close(self, symbol): if symbol not in self.tradeID: return order = AlgoAPIUtil.OrderObject() order.tradeID = self.tradeID[symbol] order.openclose = "close" self.evt.sendOrder(order) def send_open(self, symbol, buysell, volume): order = AlgoAPIUtil.OrderObject() order.instrument = symbol order.openclose = "open" order.buysell = buysell # 1 = buy, -1 = sell order.ordertype = 0 # 0 = market order order.volume = volume self.evt.sendOrder(order) |
Step 5. Count trading days and apply the stop-loss
The platform streams the whole universe together through on_bulkdatafeed. We only act when isSync is true, which means the snapshot is aligned. bd is a dictionary of the latest quote per symbol. ab['NAV'] is the account net asset value we will use for sizing.
Calendar months and trading days are not the same thing. The first of the month might be a holiday. This block resets a counter when the year-month changes, then adds one when the calendar date changes. On a daily backtest, day_count == 2 is the second bar of that month.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | def on_bulkdatafeed(self, isSync, bd, ab): if not isSync or self.universe[0] not in bd: return ts = bd[self.universe[0]]["timestamp"] d = ts.date() if self.cur_month != (d.year, d.month): self.cur_month = (d.year, d.month) self.day_count = 0 if d != self.last_date: self.last_date = d self.day_count += 1 for symbol in list(self.openinfo.keys()): if symbol not in bd or symbol not in self.tradeID: continue price = bd[symbol]["lastPrice"] info = self.openinfo[symbol] if info["buysell"] == 1 and price <= info["openprice"] * (1 - self.stoploss): self.send_close(symbol) elif info["buysell"] == -1 and price >= info["openprice"] * (1 + self.stoploss): self.send_close(symbol) |
Step 6. Rank the universe and rebalance
Ranking happens only when day_count equals rebalance_day. sorted(..., reverse=True) puts the highest momentum first. Slicing [:3] takes winners. Slicing [-3:] takes losers.
The close rule uses the sign of net volume. If we want a symbol and we already hold it on that same side (want * cur > 0), we leave it alone. If the name dropped out of the top and bottom baskets, or flipped from long to short, we close it. A flipped name is opened on a later callback once the position feed shows it is flat. That avoids sending an open and a close for the same symbol in one shot.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | if self.day_count != self.rebalance_day: return mom = {} for symbol in self.universe: m = self.momentum(symbol) if m is not None: mom[symbol] = m if len(mom) < self.long_n + self.short_n: return ranked = sorted(mom.keys(), key=lambda s: mom[s], reverse=True) longs = ranked[:self.long_n] shorts = ranked[-self.short_n:] targets = {} for s in longs: targets[s] = 1 for s in shorts: targets[s] = -1 nav = ab["NAV"] leg_notional = nav / (self.long_n + self.short_n) for symbol in list(self.net_pos.keys()): cur = self.net_pos[symbol] want = targets.get(symbol, 0) if cur != 0 and (want == 0 or want * cur < 0): self.send_close(symbol) for symbol, side in targets.items(): if self.net_pos.get(symbol, 0) != 0: continue if symbol not in bd: continue price = bd[symbol]["lastPrice"] volume = math.floor(leg_notional / price * 10000) / 10000.0 if volume < 0.0001: continue self.send_open(symbol, side, volume) |
Step 7. Track fills and open positions
Orders are asynchronous. on_orderfeed fires when the backtest engine accepts or rejects a ticket. We store tradeID only after status == 'success', and we delete it after a successful close. A reject (for example a short sale when short selling is turned off) is ignored by the position map, so the strategy will not think it is short when it is not.
1 2 3 4 5 6 | def on_orderfeed(self, of): if of.status == "success": if of.openclose == "open": self.tradeID[of.instrument] = of.tradeID elif of.openclose == "close": self.tradeID.pop(of.instrument, None) |
on_openPositionfeed is the source of truth for what is actually open. op is net exposure by symbol. oo is the open-order table, which carries buysell and openprice for the stop-loss.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | def on_openPositionfeed(self, op, oo, uo): for symbol in op: self.net_pos[symbol] = op[symbol]["netVolume"] for symbol in list(self.net_pos.keys()): if symbol not in op: self.net_pos.pop(symbol) for tid in oo: rec = oo[tid] if rec["instrument"] in self.universe: self.openinfo[rec["instrument"]] = { "buysell": rec["buysell"], "openprice": rec["openprice"], } for symbol in list(self.openinfo.keys()): if symbol not in op: self.openinfo.pop(symbol) |
Full script
Copy the script below into a new ALGOGENE strategy. The imports at the top are required: AlgoAPI is the backtest SDK, and math is only used to floor the order size.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | from AlgoAPI import AlgoAPIUtil, AlgoAPI_Backtest import math class AlgoEvent: def __init__(self): self.long_n = 3 # long top 3 by 30d momentum self.short_n = 3 # short bottom 3 by 30d momentum self.mom_days = 30 # lookback: sum of past 30 daily returns self.stoploss = 0.10 # close position on 10% adverse move self.rebalance_day = 2 # rebalance on 2nd trading day of each month self.cur_month = None self.last_date = None self.day_count = 0 self.tradeID = {} self.net_pos = {} self.openinfo = {} def start(self, mEvt): self.evt = AlgoAPI_Backtest.AlgoEvtHandler(self, mEvt) self.universe = mEvt['subscribeList'] self.evt.start() def momentum(self, symbol): res = self.evt.getHistoricalBar({"instrument": symbol}, self.mom_days + 1, 'D') if len(res) < self.mom_days + 1: return None closes = [res[t]['c'] for t in res] total = 0.0 for i in range(1, len(closes)): total += closes[i] / closes[i - 1] - 1.0 return total def send_close(self, symbol): if symbol not in self.tradeID: return order = AlgoAPIUtil.OrderObject() order.tradeID = self.tradeID[symbol] order.openclose = 'close' self.evt.sendOrder(order) def send_open(self, symbol, buysell, volume): order = AlgoAPIUtil.OrderObject() order.instrument = symbol order.openclose = 'open' order.buysell = buysell order.ordertype = 0 order.volume = volume self.evt.sendOrder(order) def on_bulkdatafeed(self, isSync, bd, ab): if not isSync: return if self.universe[0] not in bd: return ts = bd[self.universe[0]]['timestamp'] d = ts.date() if self.cur_month != (d.year, d.month): self.cur_month = (d.year, d.month) self.day_count = 0 if d != self.last_date: self.last_date = d self.day_count += 1 for symbol in list(self.openinfo.keys()): if symbol not in bd or symbol not in self.tradeID: continue price = bd[symbol]['lastPrice'] info = self.openinfo[symbol] if info['buysell'] == 1 and price <= info['openprice'] * (1 - self.stoploss): self.send_close(symbol) elif info['buysell'] == -1 and price >= info['openprice'] * (1 + self.stoploss): self.send_close(symbol) if self.day_count != self.rebalance_day: return mom = {} for symbol in self.universe: m = self.momentum(symbol) if m is not None: mom[symbol] = m if len(mom) < self.long_n + self.short_n: return ranked = sorted(mom.keys(), key=lambda s: mom[s], reverse=True) longs = ranked[:self.long_n] shorts = ranked[-self.short_n:] targets = {} for s in longs: targets[s] = 1 for s in shorts: targets[s] = -1 nav = ab['NAV'] leg_notional = nav / (self.long_n + self.short_n) for symbol in list(self.net_pos.keys()): cur = self.net_pos[symbol] want = targets.get(symbol, 0) if cur != 0 and (want == 0 or want * cur < 0): self.send_close(symbol) for symbol, side in targets.items(): if self.net_pos.get(symbol, 0) != 0: continue if symbol not in bd: continue price = bd[symbol]['lastPrice'] volume = math.floor(leg_notional / price * 10000) / 10000.0 if volume < 0.0001: continue self.send_open(symbol, side, volume) def on_orderfeed(self, of): if of.status == 'success': if of.openclose == 'open': self.tradeID[of.instrument] = of.tradeID elif of.openclose == 'close': self.tradeID.pop(of.instrument, None) def on_openPositionfeed(self, op, oo, uo): for symbol in op: self.net_pos[symbol] = op[symbol]['netVolume'] for symbol in list(self.net_pos.keys()): if symbol not in op: self.net_pos.pop(symbol) for tid in oo: rec = oo[tid] if rec['instrument'] in self.universe: self.openinfo[rec['instrument']] = { 'buysell': rec['buysell'], 'openprice': rec['openprice']} for symbol in list(self.openinfo.keys()): if symbol not in op: self.openinfo.pop(symbol) |
How to run it on ALGOGENE
You can backtest this momentum model in a few minutes:
- Create a free account at algogene.com and open the backtest environment.
- Create a Python strategy and paste the full script.
- Subscribe at least 6 liquid instruments. A diversified stock basket is a good first test. If a name has a short history, the code skips it until 31 daily closes exist.
- Set the data interval to 1-day bars. The day counter and the 30-day return sum are both defined on daily closes.
- Turn Allow Short Sell on. The bottom 3 names are short positions. If short selling is disabled, those sell orders are rejected and you only keep the long sleeve.
- Pick a multi-year window so the strategy can rebalance many times. One month is not enough to see the ranking rotate.
- Run the backtest and read the trade list, equity curve, and risk statistics on the result page.
Suggested first backtest settings:
- Universe: a liquid stock basket with more than 6 symbols
- Data interval: 1-day bar
- Lookback: 30 daily returns
- Book: long 3 / short 3, equal NAV weight
- Rebalance: 2nd trading day of each month
- Stop-loss: 10% from entry
- Allow short sell: True
A backtest is a research result, not a promise of live profit. Slippage, borrow fees for shorts, corporate actions, and a different universe will change the equity curve. Use the ALGOGENE report to compare variants before you risk capital.
Frequently asked questions
What is a momentum trading strategy?
A momentum strategy buys assets that have been going up and sells assets that have been going down, based on the observation that recent trends often continue for a period of time. This tutorial uses cross-sectional momentum: it ranks a universe against itself instead of using a fixed indicator threshold.
Why sum 30 daily returns instead of a 30-day percent change?
A single percent change from the close 30 days ago ignores the path in between. Summing daily returns treats each session equally and matches the code path closes[i] / closes[i-1] - 1. You can replace that sum with a total return C_t / C_{t-30} - 1 if you want to test the simpler definition.
Why rebalance on the second trading day?
The first session of a month can be thin or distorted by month-end flows. Waiting one trading day gives every symbol a fresh daily bar before the ranking is turned into orders. Change rebalance_day if you want to test day 1 or day 5.
Can I run this without short selling?
Yes. Disable shorts in the backtest settings, or set short_n = 0 and only open the long basket. Position size should then divide NAV by the number of legs you still trade, or the book will be under-invested.
Which markets can I test on ALGOGENE?
Use any instruments available in your subscribe list: stocks, ETFs, FX, or other supported symbols. Momentum behaves differently across asset classes, so run the same script on more than one universe and compare the backtest statistics.
Ideas to test next
The version above is a complete, runnable baseline. Serious research starts after the first backtest. Questions worth answering on ALGOGENE:
- Is 30 days better than 20, 60, or 120 for your universe?
- Should the basket be top 5 / bottom 5, or a fixed percentile such as the best and worst 20%?
- Does a volatility filter help, so a quiet trend outranks a noisy spike?
- Should the stop be 5%, 10%, or based on ATR instead of a fixed percent?
- What happens if you skip the short book and only buy winners?
- Does delaying execution by one day (to reduce look-ahead bias) change the result?
Each of those changes is a one-line edit to the parameters or the ranking sort. Cloud backtesting is the fastest way to see which edit actually matters.
