TrendFollowingBot¶
A pragmatic trend-following strategy that pairs EMA trend detection with two simple, complementary entry patterns:
- a crossback into trend (price crosses the EMA and the very next bar opens on the trend side), and
- an ATR-scaled breakout away from the EMA (current open is beyond the EMA by k × ATR).
Exits (initial stop, trail/TP) and sizing are delegated to the shared bot base via your chosen ExitStrategy (e.g., TrailingATRExit, FixedRatioExit), so this class focuses purely on when to enter. It reads signals from the previous closed bar and places orders at the current bar open to avoid look-ahead. :contentReference[oaicite:0]{index=0}
Overview¶
- Goal: Participate in sustained moves while avoiding most chop by requiring either a confirmed cross of the trend filter (EMA) or enough volatility-adjusted distance from it.
- Trend filter: Exponential moving average of length
trend_ema_span(default 50). - Volatility gate: ATR from the prior closed bar;
breakout_atr_mult(default 1.5) scales how far from the EMA price must be to count as a breakout. - Signal timing: Compute on **bar *t-1; place the order at the **open of bar *t (no look-ahead).
- Exits & sizing: Inherited from the base strategy (risk %, SL/TP policy, trailing logic).
- Data requirements: The dataframe must include an EMA column named
ema_{trend_ema_span}(e.g.,ema_50) and an ATR series used by the base bot.
Algorithm & Entry Logic¶
Let:
ema_prev= EMA value on the previous (closed) barprev_close= prior bar’s closeentry_open= current bar open (where your order will execute)atr_prev= prior bar’s ATR (from the base helper)
1) Crossback into trend
- Long:
prev_close ≤ ema_prevandentry_open > ema_prev→buy - Short:
prev_close ≥ ema_prevandentry_open < ema_prev→sell
This insists the trend filter is actually crossed and that the very next open is on the trend side—reducing whipsaw from intrabar pokes.
2) EMA-±(k × ATR) breakout
Compute a volatility buffer: threshold = breakout_atr_mult × atr_prev.
- Long:
entry_open > ema_prev + threshold→buy - Short:
entry_open < ema_prev − threshold→sell
If neither rule fires, the bot stays flat for that bar.
Key Parameters¶
| Param | Default | What it does |
|---|---|---|
trend_ema_span |
50 | Length of the EMA trend filter (expects a column named ema_50, etc.). |
breakout_atr_mult |
1.5 | Volatility buffer (in ATRs) required for the EMA-distance breakout entries. |
Tuning tips: Smaller
breakout_atr_multcaptures more breakouts (higher turnover, more noise). Larger values trade less but target stronger impulses. Shortertrend_ema_spanreacts faster (more trades), longer spans are steadier.
Notes on Exits & Risk¶
This bot only decides side/entry. Stops, trailing, take-profits, and position sizing are handled by the shared base (BaseStrategyBot) through the configured ExitStrategy—so you can mix and match exits (e.g., ATR-trailing) without rewriting entry logic. :contentReference[oaicite:1]{index=1}
Assests Included in Portfolio¶
| Assest name | Notebook symbol (example) | Comment |
|---|---|---|
| Gold | GC=F |
COMEX Gold |
| Silver | SI=F |
COMEX Silver |
| U.S. Bonds | ZB=F |
30-Year Treasury Bond |
| Crude Oil | CL=F |
NYMEX WTI |
| Soybeans | ZS=F |
CBOT Soybeans |
| Deutsche Mark | 6E=F |
Euro FX as successor to DEM |
| British Pound | 6B=F |
British Pound FX |
| Live Cattle | LE=F |
CME Live Cattle |
Setup¶
# jump to repo root (fallback: parent if in notebooks/)
ROOT = !git rev-parse --show-toplevel 2>/dev/null
%cd {ROOT[0] if ROOT else '..'}
/home/dennis/Algo-Trading-Stack
!./setup/fetch_sample_portfolio_futures_data.sh
========== Last 10 years ========== ⏭️ Gold: already exists, skipping. ⏭️ Silver: already exists, skipping. ⏭️ Crude_Oil: already exists, skipping. ⏭️ Soybeans: already exists, skipping. ⏭️ Sugar: already exists, skipping. ⏭️ US_Treasury_Bonds: already exists, skipping. ⏭️ Euro: already exists, skipping. ⏭️ British_Pound: already exists, skipping. ⏭️ Live_Cattle: already exists, skipping. ========== Last 20 years ========== ⏭️ Gold: already exists, skipping. ⏭️ Silver: already exists, skipping. ⏭️ Crude_Oil: already exists, skipping. ⏭️ Soybeans: already exists, skipping. ⏭️ Sugar: already exists, skipping. ⏭️ US_Treasury_Bonds: already exists, skipping. ⏭️ Euro: already exists, skipping. ⏭️ British_Pound: already exists, skipping. ⏭️ Live_Cattle: already exists, skipping. ========== 2000 to 2015 ========== ⏭️ Gold: already exists, skipping. ⏭️ Silver: already exists, skipping. ⏭️ Crude_Oil: already exists, skipping. ⏭️ Soybeans: already exists, skipping. ⏭️ Sugar: already exists, skipping. ⏭️ US_Treasury_Bonds: already exists, skipping. ⏭️ Euro: already exists, skipping. ⏭️ British_Pound: already exists, skipping. ⏭️ Live_Cattle: already exists, skipping. ✅ All downloads complete.
# Enable autoreload (useful while iterating), and hook Qt into Jupyter
%load_ext autoreload
%autoreload 2
%gui qt
Project root & imports¶
Set the project root if your notebook isn't at the repo root. By default, we assume the notebook lives in the root (where classes/ and bots/ exist).
import sys, os, pathlib
PROJECT_ROOT = os.path.abspath('.')
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
print('PROJECT_ROOT =', PROJECT_ROOT)
os.chdir(PROJECT_ROOT)
print("Current working directory:", os.getcwd())
PROJECT_ROOT = /home/dennis/Algo-Trading-Stack Current working directory: /home/dennis/Algo-Trading-Stack
from PyQt5 import QtWidgets
import gc
from classes.Backtester_Engine import BacktesterEngine
from classes.Trading_Environment import TradingEnvironment
from classes.ui_main_window import launch_gui
# Bots
from bots.coin_flip_bot.coin_flip_bot import CoinFlipBot
from bots.trend_following_bot.trend_following_bot import TrendFollowingBot
# Exits
from bots.exit_strategies import TrailingATRExit, FixedRatioExit
Build the exit strategy¶
exit_strategy = TrailingATRExit(atr_multiple=3.0)
Build the bot¶
bot = TrendFollowingBot(
exit_strategy=exit_strategy,
base_risk_percent=0.01,
enforce_sessions=False,
flatten_before_maintenance=True,
enable_online_learning=False
)
Initialize engine and environment¶
config_path = "backtest_configs/backtest_config_10_yrs.yaml"
api = BacktesterEngine(config_path=config_path)
api.connect()
env = TradingEnvironment()
env.set_api(api)
env.set_bot(bot)
# Initial indicator compute happens inside TradingEnvironment on connect.
print('Assets:', env.get_asset_list())
Assets: ['6B=F', 'CL=F', '6E=F', 'GC=F', 'LE=F', 'SI=F', 'ZS=F', 'ZB=F']
Launch GUI and Run Backtest¶
This starts the backtest control panel and charting UI. You can open charts, start/pause/restart, and view statistics.
If the window doesn't appear from within Jupyter, ensure you ran %gui qt above, or run this notebook locally (VS Code, JupyterLab).
launch_gui(env, api)
Backtesting Results¶
Show Statistics¶
# Minimal: pull stats from the running/backtested engine and show them inline
import pandas as pd
from IPython.display import display
stats = api.get_stats_snapshot() # live snapshot; safe to call anytime
# Portfolio (one row)
display(pd.DataFrame([{
"Initial Cash": stats["portfolio"].get("initial_cash", 0.0),
"Final Equity": stats["portfolio"].get("total_equity", 0.0),
"Used Margin": stats["portfolio"].get("used_margin", 0.0),
"Max Drawdown %": 100.0 * stats["portfolio"].get("max_drawdown", 0.0),
}]))
# Per-asset table
display(pd.DataFrame.from_dict(stats["per_asset"], orient="index").reset_index().rename(columns={"index":"Symbol"}))
| Initial Cash | Final Equity | Used Margin | Max Drawdown % | |
|---|---|---|---|---|
| 0 | 1000000.0 | 1.545871e+06 | 0.0 | 15.238082 |
| Symbol | trades | wins | losses | long_trades | short_trades | win_rate | avg_win | avg_loss | profit_factor | expectancy | commission_total | fee_total | max_drawdown | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 6B=F | 83 | 38 | 45 | 38 | 45 | 0.457831 | 11652.302632 | -7216.388890 | 1.363524 | 1422.289156 | 2100.0 | 249.0 | 0.069206 |
| 1 | CL=F | 72 | 26 | 46 | 40 | 32 | 0.361111 | 13742.307693 | -6131.956521 | 1.266707 | 1044.861112 | 472.0 | 216.0 | 0.108080 |
| 2 | 6E=F | 82 | 31 | 51 | 36 | 46 | 0.378049 | 11842.943546 | -7097.058825 | 1.014315 | 63.185974 | 1368.0 | 246.0 | 0.118169 |
| 3 | GC=F | 100 | 39 | 61 | 55 | 45 | 0.390000 | 15513.333341 | -6805.901640 | 1.457318 | 1898.600003 | 820.0 | 300.0 | 0.059000 |
| 4 | LE=F | 88 | 43 | 45 | 54 | 34 | 0.488636 | 11444.186049 | -8417.555555 | 1.299137 | 1287.613638 | 1836.0 | 264.0 | 0.110060 |
| 5 | SI=F | 138 | 49 | 89 | 73 | 65 | 0.355072 | 11692.857146 | -6729.775281 | 0.956591 | -188.405796 | 1116.0 | 414.0 | 0.194345 |
| 6 | ZS=F | 81 | 28 | 53 | 40 | 41 | 0.345679 | 17283.035713 | -7013.207546 | 1.301924 | 1385.493827 | 1400.0 | 243.0 | 0.127162 |
| 7 | ZB=F | 76 | 28 | 48 | 38 | 38 | 0.368421 | 11052.455355 | -7070.963540 | 0.911794 | -393.914473 | 776.0 | 228.0 | 0.107688 |
Show Equity Curve¶
# Assuming `s` is the equity Series you already built
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
# Times + equity (portfolio). Safe to call anytime; uses the engine's live history.
times, equity = api.get_equity_series() # None -> portfolio; pass a symbol for per-asset
n = min(len(times), len(equity))
if n == 0:
print("No equity data available yet.")
else:
s = pd.Series(equity[:n], index=pd.to_datetime(times[:n])).dropna()
# (Optional) smooth gaps like weekends/holidays:
s = s.resample("h").last().ffill()
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(s.index, s.values)
ax.set_title("Portfolio Equity")
ax.set_xlabel("Time"); ax.set_ylabel("Equity ($)")
ax.grid(True)
# Turn off scientific notation/offset and format with commas
ax.ticklabel_format(axis='y', style='plain', useOffset=False)
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, pos: f'${x:,.0f}'))
fig.autofmt_xdate()
plt.show()