Skip to main content

Vectorized backtesting & forward-testing framework for intraday strategies

Project description

mtrader

Vectorized backtesting & forward/live-testing framework for intraday trading strategies.
38+ indicators, 7 exit types, position sizing, risk controls, walk-forward optimization, auto strategy discovery.

import mtrader as mt

result = mt.run_backtest(df, entry_conditions, buy_or_sell="buy",
                          indicators=["sma1", "rsi"], rolling_minutes=[20, 14])
result.to_html("report.html")

Quick start

import pandas as pd
import numpy as np
import mtrader as mt

# 1. Load & clean data
df = pd.read_csv("ticks.csv")
df = mt.clean_data(df, start_time="09:15", end_time="15:30")

# 2. Define strategy
entry = mt.cross_above("can1_sma1_p20", "close")
exit_cond = mt.cross_below("can1_rsi_p14", mt.condition("close", upper=70))

# 3. Run backtest
result = mt.run_backtest(
    df, entry, buy_or_sell="buy", exit_conditions=exit_cond,
    indicators=["sma1", "rsi"], rolling_minutes=[20, 14],
    target_delta_normalized=0.5, stoploss_delta_normalized=0.25,
)
print(result.metrics)
result.to_html("backtest.html")

Pipeline

raw CSV/DataFrame
    │
    ▼
clean_data()              — auto-detect types, normalize OHLCV, fill gaps, handle splits
    │
    ▼
add_indicators()          — 38+ indicators across multiple rolling windows
    │
    ▼
add_trailing_stop_column()   — trailing stop price (optional)
add_time_filter_column()     — restrict to trading hours (optional)
add_regime_filter_column()   — ADX-based trend/ranging filter (optional)
    │
    ▼
precalculate_exit_time_amount_profit()
    │                       — exit signals: conditional, target, stoploss, trailing
    │
    ▼
take_trade_on_condition*()
    │                       — capital simulation (with sizing_fn, risk controls, hold filters)
    │
    ▼
backtest_report()          — Sharpe, Sortino, Calmar, win rate, profit factor, drawdown
equity_curve()             — per-bar equity + drawdown
html_backtest_report()     — standalone HTML with SVG charts
trade_log()                — per-trade log with exit reason, hold bars

One-call backtest

from mtrader import run_backtest

result = run_backtest(
    df,
    entry_conditions=mt.cross_above("can1_sma1_p20", "close"),
    buy_or_sell="buy",
    exit_conditions=mt.cross_below("can1_ema1_p9", "can1_ema1_p21"),
    indicators=["sma1", "ema1", "rsi"],
    rolling_minutes=[20, 9, 14],
    target_delta_normalized=0.5,
    stoploss_delta_normalized=0.25,
    initial_capital=100000,
)

result.final_capital     # 112345.67
result.metrics           # {'Sharpe Ratio': 1.23, 'sortino_ratio': 1.45, 'win_rate_pct': 55.0, ...}
result.report            # backtest_report dict (extended)
result.equity            # equity_curve DataFrame
result.trades            # trade_log DataFrame (with exit_reason, hold_bars)
result.to_html("report.html")

Position Sizing

Control how much capital is deployed per trade — fixed fraction or dynamic callable.

Fixed fraction

result = run_backtest(df, entry, indicators=[], rolling_minutes=[],
                      initial_capital=100000, capital_per_trade_pct=0.25)
# Only 25% of capital at risk per trade. Remaining 75% stays as cash.

Dynamic sizing callable

Use sizing_fn for ATR-based, Kelly, or equity-curve sizing:

def atr_sizing(entry_idx, capital_before, df):
    atr_pct = df.loc[entry_idx, "can1_atr_p14"] / df.loc[entry_idx, "close"]
    return min(0.5, 0.02 / max(atr_pct, 0.001))  # risk 2% per ATR unit

result = run_backtest(df, entry, indicators=[], rolling_minutes=[],
                      initial_capital=100000, sizing_fn=atr_sizing)

Position sizing utilities

from mtrader import (
    fixed_quantity_size, fixed_capital_size,
    percent_equity_size, atr_risk_size,
)

qty = atr_risk_size(price, atr_values, equity=100000, risk_pct=0.01, atr_multiple=2)
# Returns quantity of shares to take: equity * risk_pct / (atr * atr_multiple)

Risk Controls

Apply max-trades-per-day, cooldown, and max-daily-loss directly in run_backtest:

result = run_backtest(
    df, entry, indicators=[], rolling_minutes=[],
    capital_per_trade_pct=0.5,
    max_trades_per_day=3,       # at most 3 entries per day
    cooldown_bars=5,            # wait 5 bars between trades
    max_daily_loss_pct=2.0,     # stop trading for the day if -2%
)

Post-hoc filtering also available:

from mtrader import apply_risk_controls
controlled = apply_risk_controls(trades, max_trades_per_day=3, cooldown_bars=5)
filtered = trades[controlled["allowed"]]

Holding Period Filters

Skip trades that exit too quickly or hold too long:

result = run_backtest(df, entry, indicators=[], rolling_minutes=[],
                      min_hold_bars=5,     # skip trades < 5 bars
                      max_hold_bars=50)    # skip trades > 50 bars

Exit Strategies

Target & stoploss

# Fixed price delta
result = run_backtest(df, entry, target_delta=200, stoploss_delta=100)

# Normalized (% of price / 10000)
result = run_backtest(df, entry, target_delta_normalized=1.0, stoploss_delta_normalized=0.5)

# Dynamic column-based (e.g., ATR multiples)
df["target_2atr"] = df["can1_atr_p14"] * 2.0
df["stoploss_1atr"] = df["can1_atr_p14"]
result = run_backtest(df, entry, target_delta_column="target_2atr",
                      stoploss_delta_column="stoploss_1atr")

Trailing stop

from mtrader import add_trailing_stop_column

df = add_trailing_stop_column(df, trail_pct=0.5, lookback=20)
df["trail_delta"] = df["close"] - df["trailing_stop_price"]
result = run_backtest(df, entry, stoploss_delta_column="trail_delta")

Condition-based exit

# Exit when RSI crosses above 70
exit_cond = mt.cross_above("can1_rsi_p14", mt.condition("close", upper=70))
result = run_backtest(df, entry, exit_conditions=exit_cond)

Filters

Time filter (trading hours)

from mtrader import add_time_filter_column

df = add_time_filter_column(df, start_time="09:45", end_time="14:30")
entry = [[
    condition("close", "can1_sma1_p20", lower=0),
    condition("time_filter", lower=1),     # only trade 09:45–14:30
]]
result = run_backtest(df, entry)

Regime filter (ADX trend detection)

from mtrader import add_regime_filter_column

df = add_regime_filter_column(df, adx_period=14, adx_threshold=25.0)
entry = [[
    condition("close", "can1_ema1_p20", lower=0),
    condition("regime_filter", lower=1),   # only trade trending markets
]]
result = run_backtest(df, entry)
# Invert for ranging markets: condition("regime_filter", upper=1)

Indicators

Moving averages

Code Description
sma Simple Moving Average
ema Exponential Moving Average
wma Weighted Moving Average
ssma Smoothed Simple Moving Average

Popular technical indicators

Code Description
rsi Relative Strength Index
atr Average True Range
macd MACD line, signal, histogram
stochk / stochd Stochastic %K / %D
bbp Bollinger Band %B
cci Commodity Channel Index
willr Williams %R
adx Average Directional Index
mfi Money Flow Index (volume-weighted)
obv On-Balance Volume
psar Parabolic SAR
supertrend SuperTrend (direction 1/-1)
ichimoku Ichimoku Cloud (5 lines)
ha Heikin Ashi candles
vwap / ewap / iwap Volume / Equal / Incremental WAP

Candlestick patterns

Code Description
inside_bar 1 when bar inside previous bar's range
engulfing 1 bullish, -1 bearish engulfing

Session-aware

Code Description
or_high / or_low Opening range expanding high/low
prev_day Previous day high/low/close
pivot Classic pivot points (P, S1, S2, R1, R2)
gap Gap up/down detection

Feature codes (base signals)

Code Signal
0, 1 close
2 av2 (H+L)/2
3 av3 (H+L+C)/3
4 av4 (O+H+L+C)/4
5 open
6 high
7 low
8..34 dif, ret, lret for 1/3/5/7/10/15/20/30/60 bars

Normalized indicators

Prefix for ratio/statistical forms: SMN_, EMN_, WMN_, SSMN_, SVN_, EVN_, WVN_, SSVN_, Z_, BRN_, TMN_.

Suffix controls denominator: ""/F = signal itself, P/0 = close, B = base signal, numeric code = that feature.

df = add_indicators(df, add=["Z_sma1", "SMN_ema1", "EMN_rsi"], rolling_minutes=[14])
# can1_Z_sma1_p14  — Z-score of SMA(14)
# can1_SMN_ema1_p14 — EMA(14) / SMA(14)

Distance indicators

df = add_indicators(df, add=["smadis1", "emadis1"], rolling_minutes=[14])
# can1_smadis1_p14 — close - SMA(14)
# can1_emadis1_p14 — close - EMA(14)

Column naming

can1_{indicator}_p{period}
can1_{normalization}_{indicator}_p{period}

Examples:

  • can1_sma1_p20 — SMA(20) of close
  • can1_rsi_p14 — RSI(14)
  • can1_macd — MACD line (no period)
  • can1_supertrend_dir_p10 — SuperTrend direction
  • can1_Z_sma1_p14 — Z-score of SMA(14)
  • can1_ichi_span_a — Ichimoku cloud span A

Conditions

Conditions compare two columns: (first - second) ∈ [lower, upper].

{
    "first_column_name": "can1_sma1_p20",
    "second_column_name": "close",
    "shift_down_first": 0,
    "shift_down_second": 0,
    "lower_range_of_difference": 0,       # first - second >= lower
    "upper_range_of_difference": np.inf,  # first - second <= upper
    "perform_normalization_of_diff": False,
}

Helper functions

from mtrader import condition, cross_above, cross_below

cond = condition("close", "can1_sma1_p20", lower=0)            # close > sma
crossover = cross_above("can1_macd", "can1_macdsignal")         # MACD crossover
crossunder = cross_below("can1_stochk_p14", "can1_stochd_p14")  # Stoch crossunder

AND / OR logic

AND within a group, OR across groups:

entry = [
    # Group 1: both must be true
    [
        condition("close", "can1_sma1_p20", lower=0),
        condition("can1_rsi_p14", lower=30, upper=70),
    ],
    # Group 2: OR with Group 1
    [
        condition("can1_macd", "can1_macdsignal", lower=0),
    ],
]

update_cond helper

Modify conditions programmatically:

from mtrader import update_cond

base_entry = [[condition("ema9", "ema21", lower=0)]]

# Update ALL conditions (default behavior)
updated = update_cond(base_entry, "ema20", "ema50", lower=-5)

# Or update only matching a specific first_column_name
updated = update_cond(base_entry, "ema20", "ema50",
                      match_first="ema9")  # only updates conditions with first="ema9"

Strategy object (serializable)

from mtrader import Strategy

strat = Strategy(
    name="SMA Crossover",
    indicators=["sma1", "ema1"],
    rolling_minutes=[20, 50],
    entry_conditions=cross_above("can1_sma1_p20", "can1_sma1_p50"),
    exit_conditions=cross_below("can1_sma1_p20", "can1_sma1_p50"),
    side="buy",
    target_delta_normalized=0.5,
    stoploss_delta_normalized=0.25,
    capital_per_trade_pct=0.5,
    max_trades_per_day=3,
    cooldown_bars=3,
)

result = strat.run(df)
strat.save("strat.json")
loaded = Strategy.load("strat.json")

live_engine = strat.to_live(df)  # convert to live engine with warmup

Exit Optimization

Find the best target/stoploss combination via grid search:

from mtrader import find_best_exit

best, results = find_best_exit(
    df, entry_conditions, buy_or_sell="buy",
    target_deltas=[50, 100, 150, 200],
    stoploss_deltas=[25, 50, 75, 100],
    metric="sharpe",
    verbose=True,
)
print(best)  # {'target_delta': 150, 'stoploss_delta': 75}

Entry condition optimization

Grid-search for the best entry threshold values:

from mtrader import find_best_entry_conditions

entry = [[
    {"first_column_name": "can1_rsi_p14", "second_column_name": "zero",
     "lower_range_of_difference": -np.inf, "upper_range_of_difference": 30, ...}
]]
best, results = find_best_entry_conditions(
    df, entry,
    condition_ranges={0: ([-np.inf, -np.inf], [20, 30])},  # try RSI < 20, RSI < 30
    metric="sharpe",
)
print(best)  # {'cond_0_lower': -inf, 'cond_0_upper': 20}

Walk-Forward Optimization

from mtrader import walk_forward_optimize, Strategy

results = walk_forward_optimize(
    df,
    strategy_factory=lambda params: Strategy(**params),
    param_grid={"rolling_minutes": [[10, 30], [20, 50]]},
    train_days=60,
    test_days=20,
    metric="sharpe",
)

Random parameter search

from mtrader import random_parameter_search

def factory(**kw):
    return Strategy(name="Opt", indicators=["ema1"], rolling_minutes=[20],
                    entry_conditions=[[condition("can1_ema1_p20", "close", lower=0)]],
                    **kw)

best, results_df = random_parameter_search(
    df, factory,
    param_space={"target_delta_normalized": [0.25, 0.5, 0.75, 1.0],
                 "stoploss_delta_normalized": [0.125, 0.25, 0.5]},
    n_iter=20, metric="sharpe",
)

Scenario Sweeper

Run the same strategy across multiple parameter combos and compare results:

from mtrader import run_scenarios

base = {
    "entry_conditions": entry,
    "indicators": [], "rolling_minutes": [],
    "initial_capital": 100000,
}
grid = {
    "target_delta_normalized": [0.25, 0.5, 1.0, 2.0],
    "stoploss_delta_normalized": [0.125, 0.25, 0.5],
}

scenarios = run_scenarios(df, base, grid, metric="sharpe", verbose=True)
# Returns sorted DataFrame: target, stoploss, final_capital, sharpe, sortino,
#                           calmar, win_rate, profit_factor, total_trades
best_params = scenarios.iloc[0]

Auto Strategy Discovery

Automatically generate, evaluate, and rank trading strategies from 7 indicator families:

from mtrader import discover_strategies

results, best_candidates = discover_strategies(
    df,
    train_days=5,               # training window per fold
    test_days=1,                # test window per fold
    strategy_types=["crossover", "threshold", "macd", "psar", "supertrend",
                    "vwap", "price_action"],
    exit_targets=[0.5, 1.0],    # try these target levels
    exit_stops=[0.25, 0.5],     # try these stoploss levels
    metric="sharpe",             # ranking metric
    top_n=10,                   # walk-forward validate top 10
    verbose=True,
)

# results: DataFrame with train/test scores per candidate
# best_candidates: list of StrategyCandidate objects for the top strategies

Quick-rank pre-defined strategies

Faster than discover_strategies — pre-computes all indicators once:

from mtrader import quick_rank_strategies, StrategyCandidate

strategies = [
    StrategyCandidate(name="SMA Crossover", indicators=["sma1"],
                      rolling_minutes=[20, 50],
                      entry_conditions=[[_cond("can1_sma1_p20", "can1_sma1_p50", lower=0)]]),
    StrategyCandidate(name="RSI Oversold", indicators=["rsi"],
                      rolling_minutes=[14],
                      entry_conditions=[[_cond("can1_rsi_p14", upper=30)]]),
]
ranked = quick_rank_strategies(df, strategies, metric="sharpe")

Trade log (enhanced)

The trade log now includes exit_reason and hold_bars columns:

result = run_backtest(df, entry, exit_conditions=exit_cond,
                      target_delta_normalized=1.0, stoploss_delta_normalized=0.5)
print(result.trades.columns)
# ['entry_index', 'entry_time', 'exit_index', 'exit_time', 'side',
#  'entry_price', 'exit_price', 'profit', 'return_pct', 'capital_at_exit',
#  'capital_before', 'capital_return_pct', 'exit_reason', 'hold_bars']

print(result.trades["exit_reason"].value_counts())
# target      14
# stoploss     7
# condition    3
# end          1

Exit reasons: "target" (hit profit target), "stoploss" (hit stop), "condition" (exit condition triggered), "end" (ran to end of data).


Performance reports

from mtrader import backtest_report, equity_curve, html_backtest_report

report = backtest_report(df, initial_capital=1000)
# total_trades, win_rate_pct, profit_factor, avg_win/loss_pct,
# sharpe_ratio, sortino_ratio, calmar_ratio, max_drawdown_pct,
# max_consecutive_wins/losses, ...

# Enhanced BacktestResult.metrics includes:
# Sharpe Ratio, Volatility, Max Drawdown, sortino_ratio,
# calmar_ratio, win_rate_pct, profit_factor, total_trades

eq = equity_curve(df)
# columns: datetime, equity, drawdown_pct, trade

html_backtest_report(result, output_path="report.html")
# standalone HTML with SVG charts, no external dependencies

Live trading

from mtrader import live_strategy_from_history, stream_live_signals

engine = live_strategy_from_history(
    df, indicators=["sma1", "ema1", "rsi"],
    periods=[20, 50, 14],
    entry_conditions=cross_above("can1_sma1_p20", "can1_sma1_p50"),
    side="buy",
)

for signal in stream_live_signals(engine, candle_feed):
    if signal["action"] == "BUY":
        place_order(signal)

Incremental indicators (SMA, EMA, RSI, ATR, VWAP) update in O(1) per bar.


Data cleaning

from mtrader import clean_data

cleaned = clean_data(
    raw_df,
    start_time="09:15", end_time="15:30",
    start_date="2024-01-01", end_date="2024-12-31",
    fill_gap=True, adjustsplit=True, multiplier=100,
)

Auto-detects: datetime columns, OHLCV columns, stock splits/reverse mergers, gap-fills to 1-min frequency.


Multi-timeframe indicators

from mtrader import add_higher_timeframe_indicators

# Compute 15-min EMA(5) and SMA(15) merged onto 1-min bars
df = add_higher_timeframe_indicators(
    df, rule="15min", add=["sma1", "ema1"],
    rolling_minutes=[5, 15], prefix="can15",
)
# Columns: can15_sma1_p5, can15_ema1_p15

# Entry: 1-min EMA > 15-min SMA AND 15-min EMA > 60-min SMA
entry = [[
    condition("can1_ema1_p20", "can15_sma1_p15", lower=0),
    condition("can15_ema1_p15", "can60_ema1_p5", lower=0),
]]

Signal Functions

crossover(A, B)

Detect crossovers between two arrays. Returns integer signals:

  • +N: A crossed above B N bars ago (A still above B)
  • 0: No recent crossover
  • -N: A crossed below B N bars ago (A still below B)
import numpy as np
from mtrader.strategy_finder import crossover, just_crossed_up, just_crossed_down

# Example 1: Basic crossover detection
fast_ema = np.array([10, 12, 14, 13, 15, 17, 16, 14, 12, 15])
slow_ema = np.array([13, 13, 13, 13, 13, 13, 13, 13, 13, 13])

cross = crossover(fast_ema, slow_ema, lookback=5)
# → [ 0  0  1  0  1  2  3  4 -1  1]
#   +1 = just crossed up (bar 2)
#   +2 = crossed up 1 bar ago (bar 4)
#   -1 = just crossed down (bar 8)

# Example 2: Just crossed (current bar only)
enter_now = just_crossed_up(fast_ema, slow_ema)
# → [0 0 1 0 1 0 0 0 0 1]

# Example 3: Entry within N bars of crossover
entry_window = (cross >= 1) & (cross <= 3)
# → Enter within 3 bars of bullish cross

pathsignal(raw, value1, value2)

Detect when a raw signal crosses between two levels. Auto-detects direction:

  • value1 < value2 (bullish): raw was <= value1, now > value2
  • value1 > value2 (bearish): raw was >= value1, now < value2
import numpy as np
from mtrader.strategy_finder import pathsignal, pathsignal_cross

# Example 1: RSI oversold to recovery (bullish)
rsi = np.array([25, 28, 35, 42, 48, 55, 52, 45, 30, 58])
entry = pathsignal(rsi, value1=30, value2=50, lookback=10)
# → [0 0 0 0 0 1 1 0 0 1]
#   RSI was <=30 at index 0-1, then crossed above 50 at index 5

# Example 2: RSI overbought to decline (bearish)
rsi2 = np.array([75, 72, 65, 58, 52, 45, 48, 55, 70, 42])
exit_sig = pathsignal(rsi2, value1=70, value2=50, lookback=10)
# → [0 0 0 0 0 1 1 0 0 1]
#   RSI was >=70 at index 0-1, then dropped below 50 at index 5

# Example 3: Exact cross bar only
entry_exact = pathsignal_cross(rsi, value1=30, value2=50, lookback=10)
# → [0 0 0 0 0 1 0 0 0 1]

Limit parameter

Only generate signal when raw is within a specific range:

# Signal only when RSI is between 45-65
rsi = np.array([25, 28, 35, 42, 55, 65, 75, 55, 45, 58])
entry = pathsignal(rsi, value1=30, value2=50, limit=(45, 65))
# → [0 0 0 0 1 1 0 0 0 0]
#   Index 4: RSI=55, in limit -> signal
#   Index 6: RSI=75, out of limit -> no signal

Reset parameter

Control when the "was in state" condition resets:

# Default with limit: reset when RSI goes outside limit
rsi = np.array([25, 28, 35, 42, 55, 45, 35, 28, 42, 55])
entry = pathsignal(rsi, value1=30, value2=50, limit=(40, 60))
# → [0 0 0 0 1 0 0 0 0 1]
#   Index 4: RSI=55, was<=30, in limit -> signal
#   Index 6: RSI=35, outside limit -> reset!
#   Index 9: RSI=55, was<=30 at index 7 -> signal again

# Explicit reset zones: reset if RSI < 20 or RSI > 80
rsi2 = np.array([25, 28, 35, 42, 55, 85, 45, 30, 42, 55])
entry2 = pathsignal(rsi2, value1=30, value2=50, reset=[(0, 20), (80, 100)])
# → [0 0 0 0 1 0 0 0 0 1]
#   Index 5: RSI=85, in reset zone -> state resets!

Combining crossover + pathsignal

import numpy as np
from mtrader.strategy_finder import crossover, pathsignal

fast = np.array([10, 12, 14, 13, 15, 17, 16, 14, 12, 15])
slow = np.array([13, 13, 13, 13, 13, 13, 13, 13, 13, 13])
rsi = np.array([25, 28, 35, 42, 48, 55, 52, 45, 30, 58])

# Entry: EMA cross up + RSI was oversold
ema_cross = crossover(fast, slow, lookback=5)
rsi_signal = pathsignal(rsi, value1=30, value2=50, lookback=10)

combined_entry = (ema_cross >= 1) & (rsi_signal == 1)
# → Both conditions must be true for entry

Strategy Templates

from mtrader.strategy_finder import (
    EMACrossover, SMACrossover, RSIReversion,
    MACDCrossover, DonchianBreakout, BollingerBreakout,
)

# All templates have default_param_space() and generate_signals()
template = EMACrossover()
ps = template.default_param_space()
params = {k: v[0] for k, v in ps.to_dict().items()}

# Generate signals for a single symbol
entries, exits = template.generate_signals(df, params)

# Generate signals for multiple symbols at once
entries, exits = template.generate_signals_batch(dfs, params, n_bars=500)

Multi-symbol portfolio

from mtrader import run_portfolio

data = {"AAPL": df_aapl, "TSLA": df_tsla, "GOOG": df_goog}
result = run_portfolio(data, strategy, initial_capital=300000)
# result.equity — combined portfolio equity curve
# result.trades — all trades with symbol column
# result.results — per-symbol BacktestResult objects

Batch signal generation (100+ symbols)

Generate signals for multiple symbols at once:

from mtrader.strategy_finder import (
    prepare_multi_symbol_data,
    build_multi_symbol_arrays,
    EMACrossover,
)

# Load all symbol DataFrames
dfs = {"SYM1": df1, "SYM2": df2, ..., "SYM100": df100}

# Align to same bar count
sliced = prepare_multi_symbol_data(dfs, n_bars=500)

# Generate signals for all symbols at once
template = EMACrossover()
entries, exits = template.generate_signals_batch(sliced, params, n_bars=500)
# entries.shape = (100, 500)
# exits.shape = (100, 500)

# Build kernel-ready arrays
closes, highs, lows, ent, ext, tps, sds, trd = build_multi_symbol_arrays(
    sliced, entries, exits, target_pcts, stop_deltas, trail_deltas
)

Complete Strategy Examples

0. Multi-symbol v8 Donchian Breakout (recommended)

The v8 Donchian strategy is the best-tested multi-symbol strategy in mtrader. It is a trend-following strategy that buys on a 20-day high breakout above a 100-day SMA and exits on a 3.8% target, a 4.0 * ATR trailing stop from entry, a 10-day low breakdown, or a 103-day max hold. It is designed for multi-symbol portfolio use with very low drawdown.

Quick start (single command):

import mtrader as mt

result = mt.run_v8_portfolio(
    data_dir=r"C:\trade_data\sharemarket\all_nse_pure",
    symbols=None,             # None = use all CSVs in data_dir
    timeframe="1D",
    lookback_days=1825,        # 5 years
    initial_capital=1000.0,
    trail_atr_mult=4.0,        # looser stop = better return
    verbose=True,
)
print(result["summary"])

Or use the strategy preset directly in a custom backtest:

from mtrader import (
    make_v8_donchian_breakout, run_portfolio, add_indicators, add_atr_trailing_stop_from_entry_column
)
strat = make_v8_donchian_breakout(
    entry_n=20, exit_n=10, sma_period=100, atr_period=10,
    target_pct=0.038, max_hold_bars=103,
)
# Load and prep data per symbol...
# result = run_portfolio(data_by_symbol, strat, initial_capital=1000)

Typical results on 30+ NSE stocks over 5 years (research 2026-07-03):

  • Total return: +5% to +12% per year (depends on stock basket)
  • Max drawdown from peak: 1% to 5%
  • Win rate: 60-80%
  • Profitable symbols: 70-95% of 30

The trailing stop in the v8 strategy is critical. The default trail_atr_mult=4.0 was found to be the best across two different 30-symbol universes; tighter values like 2.05 are more conservative but give lower returns. The add_atr_trailing_stop_from_entry_column helper is the recommended way to pre-compute the trailing stop on the dataframe before backtest.

1. Short selling (RSI overbought fade)

result = run_backtest(
    df, entry_conditions=[[condition("can1_rsi_p14", lower=70)]],
    buy_or_sell="sell",
    exit_conditions=[[condition("can1_rsi_p14", upper=40)]],
    target_delta_normalized=0.5, stoploss_delta_normalized=0.25,
)

2. MACD + Bollinger Band squeeze

entry = [[
    condition("can1_macd", "can1_macdsignal", lower=0),
    condition("can1_bbp_p20", lower=0.2, upper=0.8),
]]
result = run_backtest(df, entry,
    exit_conditions=[[condition("can1_macdsignal", "can1_macd", lower=0)]],
    capital_per_trade_pct=0.5, max_trades_per_day=3)

3. Multi-TF trend with trailing stop

df = add_higher_timeframe_indicators(df, "15min", add=["ema1"], ...)
df = add_trailing_stop_column(df, trail_pct=0.5, lookback=20)
df["trail_delta"] = df["close"] - df["trailing_stop_price"]
df = add_time_filter_column(df, start_time="09:45", end_time="15:00")
df = add_regime_filter_column(df, adx_threshold=20)

entry = [[
    condition("can1_ema1_p20", "can15_ema1_p15", lower=0),
    condition("time_filter", lower=1),
    condition("regime_filter", lower=1),
]]
result = run_backtest(df, entry, stoploss_delta_column="trail_delta",
                      capital_per_trade_pct=0.3, max_trades_per_day=2)

4. Ichimoku cloud breakout

entry = [[
    condition("close", "can1_ichi_span_a", lower=0),
    condition("close", "can1_ichi_span_b", lower=0),
]]
result = run_backtest(df, entry,
    exit_conditions=[[condition("can1_ichi_kijun", "close", lower=0)]])

5. Opening range breakout with ATR exits

df = add_indicators(df, add=["or_high", "atr"], rolling_minutes=[15, 14])
df["target_2atr"] = df["can1_atr_p14"] * 2.0
df["stoploss_1atr"] = df["can1_atr_p14"]

entry = [[condition("close", "can1_or_high_p15", lower=0)]]
result = run_backtest(df, entry, target_delta_column="target_2atr",
                      stoploss_delta_column="stoploss_1atr")

6. SuperTrend + PSAR dual confirmation

entry = [[
    condition("can1_supertrend_dir_p10", lower=1),
    condition("close", "can1_psar", lower=0),
]]
result = run_backtest(df, entry,
    exit_conditions=[[condition("can1_psar", "close", lower=0)]])

7. Long-short combined

r_long  = run_backtest(df, long_entry,  buy_or_sell="buy")
r_short = run_backtest(df, short_entry, buy_or_sell="sell")
combined_pnl = r_long.final_capital + r_short.final_capital - 2 * initial_capital

Modules

Module Key exports
data_cleaner clean_data, detect_data_types_with_formats, fill_missing_rows
indicators All standalone indicator functions (ema, rsi, macd, psar, ichimoku, ...)
indicator_engine add_indicators, add_indicators_on_group, FEATURE_CODE
exit_strategy precalculate_exit_time_amount_profit
trading take_trade_on_condition*, update_cond
optimize_exit find_best_exit
backtest run_backtest, BacktestResult, condition, cross_above/below, walk_forward_splits, parameter_grid, trade_log, run_scenarios, find_best_entry_conditions
advanced Strategy, CostModel, sizing functions, run_portfolio, walk_forward_optimize, random_parameter_search, resample_ohlcv, add_higher_timeframe_indicators,
add_trailing_stop_column, add_atr_trailing_stop_from_entry_column, add_time_filter_column, add_regime_filter_column, apply_risk_controls, make_v8_donchian_breakout, run_v8_portfolio
strategy_discovery discover_strategies, quick_rank_strategies, StrategyCandidate
live LiveIndicatorEngine, LiveStrategyEngine, stream_live_signals, live_strategy_from_history
report backtest_report, equity_curve, html_backtest_report
monotonic_stack monotonic_stack_for_value1_gt/lt_value2
utils timenum

Testing

python -m pytest src/tests/ -v

163+ tests cover: data cleaning, all indicators, exit precalculation, trade simulation (NumPy + CuPy), exit optimization, performance reports, 20 strategy scenarios, edge cases, position sizing, risk controls, trailing stop, time/regime filters, entry optimization, scenario sweeper, exit reason tagging, and 6 complex strategy pipelines.


Dependencies

  • Required: numpy>=1.21, pandas>=1.3, inspecty>=0.1
  • Optional: numba>=0.58 (monotonic stack), cupy-cuda12x (GPU trading)

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mtrader-0.9.5.tar.gz (124.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mtrader-0.9.5-py3-none-any.whl (105.3 kB view details)

Uploaded Python 3

File details

Details for the file mtrader-0.9.5.tar.gz.

File metadata

  • Download URL: mtrader-0.9.5.tar.gz
  • Upload date:
  • Size: 124.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.6

File hashes

Hashes for mtrader-0.9.5.tar.gz
Algorithm Hash digest
SHA256 0ddb3d5a05209c1a9e7377aac2e992c7330ce2cc5aee8872a22ca2e55b1d850d
MD5 650dd509add054c31f71006025c2463a
BLAKE2b-256 4c1e82033eeb6b2d76b3d1d1ad85957a50836d0e05121050d2747dad2f6c73dd

See more details on using hashes here.

File details

Details for the file mtrader-0.9.5-py3-none-any.whl.

File metadata

  • Download URL: mtrader-0.9.5-py3-none-any.whl
  • Upload date:
  • Size: 105.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.6

File hashes

Hashes for mtrader-0.9.5-py3-none-any.whl
Algorithm Hash digest
SHA256 04b2a6fafd7c421977b9b1abf20dc635ea3b731ca6c9e7a4e6ed3889c6247985
MD5 37a5b08852ed7a3e6fda43ed6f19a9ee
BLAKE2b-256 56569114fb06a712b300c5da5363e7d35984e18513058b8da7a5fb768227a4af

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page