Signals and backtests¶
Indicators describe the market; signals and backtests turn that description into decisions. This page covers the last three analysis methods. Two of them, from the Signals class, mark where one column of a frame crosses another, which is how most simple trading rules are written. The third, from StrategyBacktests, runs a trading strategy over an instrument's past candles with the backtesting package and reports how it would have done.
The table below lists the three methods.
| Kind | Member | Description |
|---|---|---|
| method | is_cross_over |
Marks the rows where one column rises above another |
| method | is_cross_under |
Marks the rows where one column falls below another |
| method | run_backtest |
Runs a backtesting strategy over the instrument's candles and returns its statistics |
Signals¶
The two signal methods are different from every other analysis method in one way: they do not fetch candles. They work on a frame you already have, usually the result of an indicator method, so you choose the range once and then look for crossings in it. Neither method changes the frame you pass in; each returns a copy with a fresh index and one new column of booleans.
The two tests compare the columns on the previous row and on this row. The table below states them exactly.
| Method | On the previous row | On this row | Column added |
|---|---|---|---|
is_cross_over |
first column at or below the second | first column above the second | cross_over |
is_cross_under |
first column at or above the second | first column below the second | cross_under |
The first row is never marked, because it has no previous row. This is the textbook test, and it replaced the old project's version, which compared the previous first value with the current second value and could disagree when the second column moved sharply. On 400 days of INFY closes against a 20-candle simple moving average, the corrected methods marked 16 crossovers and 17 crossunders on 2026-09-14.
is_cross_over¶
method is_cross_over(data, first_column, second_column)
This method marks each row where the first column rises above the second. A typical use is the close crossing above a moving average, or a fast average crossing above a slow one.
Parameters¶
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
data |
pandas.DataFrame |
Yes | The frame holding both columns. It is not changed. | |
first_column |
str |
Yes | The name of the column that crosses | |
second_column |
str |
Yes | The name of the column that is crossed |
Example¶
The example below was not run for this page, because it needs a live UBI. It finds the days in the last year on which RELIANCE closed above its 20-day simple moving average after closing at or below it the day before.
from tradingmachine.assets import equities
reliance = equities.Equity(exchange="nse", symbol="RELIANCE")
frame = reliance.simple_moving_average(window=20, days=365)
crossings = reliance.is_cross_over(frame, "close", "sma_20")
print(crossings[crossings["cross_over"]][["datetime", "close", "sma_20"]])
Returns¶
A pandas.DataFrame copy of data with a fresh index and an added bool cross_over column.
Raises¶
| Exception | When |
|---|---|
KeyError |
data has no column named first_column or second_column |
is_cross_under¶
method is_cross_under(data, first_column, second_column)
This method marks each row where the first column falls below the second. It is the mirror image of is_cross_over.
Parameters¶
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
data |
pandas.DataFrame |
Yes | The frame holding both columns. It is not changed. | |
first_column |
str |
Yes | The name of the column that crosses | |
second_column |
str |
Yes | The name of the column that is crossed |
Example¶
The example below, which was not run for this page, marks the days RELIANCE's MACD line fell below its signal line. The column names come from moving_average_convergence_divergence with its default periods of 12, 26 and 9.
frame = reliance.moving_average_convergence_divergence(days=365)
crossings = reliance.is_cross_under(frame, "macd_12_26_9", "macd_12_26_9_signal")
print(crossings[crossings["cross_under"]][["datetime", "close"]])
Returns¶
A pandas.DataFrame copy of data with a fresh index and an added bool cross_under column.
Raises¶
| Exception | When |
|---|---|
KeyError |
data has no column named first_column or second_column |
Backtests¶
A backtest replays a trading strategy over past candles as though it had been running then, and reports the trades it would have made and what they would have earned. run_backtest does this with the backtesting package, version 0.6.5, which the library declares as a dependency. You write the strategy as a subclass of backtesting.Strategy; the method supplies the candles and runs it.
The sequence diagram below shows what happens inside one call.
sequenceDiagram
autonumber
participant Y as Your code
participant R as run_backtest
participant U as UBI
participant B as backtesting.Backtest
Y->>R: strategy class, cash, commission, range
R->>U: GET /api/instruments/prices
U-->>R: candles, or none
alt no candles
R-->>Y: None
else candles
R->>R: index by datetime, keep open high low close volume
R->>R: rename to Open High Low Close Volume
R->>B: Backtest(candles, strategy, cash, commission, margin, ...)
B->>B: run(), which calls next() once per candle
B-->>R: statistics
opt plot_filename given
R->>B: plot(results, filename, open_browser=False)
end
R-->>Y: pandas Series of statistics
end
The method selects the five candle columns by name and renames them to the capitalised names backtesting requires, so the extra columns UBI's candles carry, such as oi and price_factor, are dropped. The candles' datetime becomes the index, still in India time with its time zone, which backtesting accepts.
run_backtest¶
method run_backtest(strategy, cash=10000, commission=0.0, margin=1.0, trade_on_close=False, hedging=False, exclusive_orders=False, plot_filename=None, interval="day", from_date=None, to_date=None, days=None, adjusted=True)GET /api/instruments/prices
This method runs a strategy over the instrument's candles in a range and returns the backtest's statistics. It sends no orders; it only reads candles. When plot_filename is given, it also writes the interactive backtesting chart to that HTML file without opening it.
Parameters¶
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
strategy |
type[backtesting.Strategy] |
Yes | The strategy class to run, not an instance of it | |
cash |
float |
No | 10000 |
The starting cash |
commission |
float |
No | 0.0 |
The commission on each trade, as a fraction of its value |
margin |
float |
No | 1.0 |
The margin required, as a fraction, where 1.0 means no leverage |
trade_on_close |
bool |
No | False |
True to fill market orders at the current candle's close rather than the next candle's open |
hedging |
bool |
No | False |
True to allow long and short trades at the same time |
exclusive_orders |
bool |
No | False |
True to close the open trade whenever a new order is placed |
plot_filename |
str |
No | None |
The path of an HTML file to write the plot to, or None for no plot |
interval, from_date, to_date, days, adjusted |
No | as in prices |
The candle range, as described in Analysis |
Example¶
The example below defines the classic moving average crossover strategy, which buys when a 10-candle average crosses above a 30-candle one and sells on the reverse, and runs it over two years of RELIANCE with 100,000 rupees. It was not run for this page, because it needs a live UBI. The same strategy over 730 days of INFY reported 9 trades when the method was checked on 2026-09-14, and the plotted version wrote an 82,745-byte HTML file.
import backtesting
from backtesting import lib
import pandas as pd
from tradingmachine.assets import equities
class SmaCross(backtesting.Strategy):
fast = 10
slow = 30
def init(self):
close = pd.Series(self.data.Close)
self.fast_average = self.I(lambda: close.rolling(self.fast).mean())
self.slow_average = self.I(lambda: close.rolling(self.slow).mean())
def next(self):
if lib.crossover(self.fast_average, self.slow_average):
self.position.close()
self.buy()
elif lib.crossover(self.slow_average, self.fast_average):
self.position.close()
self.sell()
reliance = equities.Equity(exchange="nse", symbol="RELIANCE")
statistics = reliance.run_backtest(
SmaCross,
cash=100000,
commission=0.001,
days=730,
plot_filename="sma_cross.html",
)
print(statistics[["Return [%]", "# Trades", "Win Rate [%]", "Max. Drawdown [%]"]])
The strategy's methods are backtesting's own interface, init and next, so they follow that package's names. The statistics labels in the last line are backtesting's too; its documentation lists them all.
Returns¶
A pandas.Series of the backtest's statistics, as backtesting.Backtest.run returns it, or None when UBI has no candles for the range. The trades themselves are in the Series under _trades.
Raises¶
| Exception | When |
|---|---|
UnifiedBrokerInterfaceError |
UBI refused the candle request or could not be reached |
Under the hood
The old project always called backtest.plot(), which wrote an HTML file named after the strategy into the current directory and opened a browser tab on every run. The plot is now optional and never opens anything by itself. When it is drawn, results=statistics is passed so that the chart shows the run just made rather than running the strategy a second time.
A backtest is only as good as its candles. Run it on an instrument that has them, which today means shares, equity indices, exchange traded funds and commodity derivatives, as Which instruments have candles explains. Shares and funds are adjusted for splits and bonuses by default, which is what a backtest over several years needs.