8.8 KiB
Strategy Development Guide
Strategies on Dexorder are PandasStrategy subclasses that receive a live stream of OHLCV bars and call self.buy() / self.sell() / self.flatten() to place orders.
See ../api-reference.md for the DataAPI and ChartingAPI used in research scripts. For indicator calculations, see ../pandas-ta-reference.md.
PandasStrategy API
from dexorder.nautilus.pandas_strategy import PandasStrategy, PandasStrategyConfig
class MyStrategy(PandasStrategy):
def evaluate(self, dfs: dict[str, pd.DataFrame]) -> None:
"""Called after every new bar across all feeds.
Args:
dfs: dict mapping feed_key → pd.DataFrame
Columns: timestamp (ns), open, high, low, close, volume,
buy_vol, sell_vol, open_interest
Rows accumulate over time — last row = latest bar.
"""
df = dfs.get("BTC/USDT.BINANCE:300")
if df is None or len(df) < 20:
return # not enough data yet
close = df["close"]
# ... compute signals ...
if buy_signal:
self.buy(quantity=0.1)
elif sell_signal:
self.sell(quantity=0.1)
Feed key format
"{SYMBOL.EXCHANGE}:{period_seconds}" — e.g. "BTC/USDT.BINANCE:900" for 15-minute bars.
Access all feeds via self.config.feed_keys (tuple of strings).
Order methods
self.buy(quantity: float, feed_key: str = None)
self.sell(quantity: float, feed_key: str = None)
self.flatten(feed_key: str = None) # close all open positions
If feed_key is omitted, the first feed in feed_keys is used. quantity is in base currency units (e.g. 0.1 BTC).
Available data
Strategies may only use data in the dfs feeds: crypto OHLCV + buy/sell volume split + open interest. The following are not available:
- TradFi data (equities, forex, bonds, options, macro indicators)
- Alternative data (news, social sentiment, on-chain metrics, economic calendars)
Using pandas_ta
Use import pandas_ta as ta for all indicator calculations. Never write manual rolling() or ewm() implementations.
import pandas_ta as ta
rsi = ta.rsi(df["close"], length=14)
macd_df = ta.macd(df["close"], fast=12, slow=26, signal=9)
hist = macd_df.iloc[:, 2] # histogram column
ema = ta.ema(df["close"], length=20)
atr = ta.atr(df["high"], df["low"], df["close"], length=14)
See ../pandas-ta-reference.md for the full indicator catalog and multi-output column extraction patterns.
Using Custom Indicators
Prefer referencing a custom indicator that already exists in the indicator category rather than duplicating the logic inline. Custom indicators appear on the user's chart, making the signal transparent.
import pandas_ta as ta
def evaluate(self, dfs):
df = dfs.get("BTC/USDT.BINANCE:3600")
if df is None or len(df) < 20:
return
vw_rsi = ta.custom_vw_rsi(df["close"], df["volume"], length=14)
if vw_rsi is None or vw_rsi.isna().all():
return
if vw_rsi.iloc[-1] < 30:
self.buy(0.01)
elif vw_rsi.iloc[-1] > 70:
self.sell(0.01)
Custom indicator names follow the pattern ta.custom_{sanitized_name}. See ../indicators/indicator-development.md for naming rules and how to create custom indicators.
Strategy Metadata
When writing a strategy with PythonWrite(category="strategy", ...), always provide:
| Field | Required | Description |
|---|---|---|
description |
yes | One-sentence summary |
details |
yes | Full markdown: algorithm, entry/exit logic, parameters, data feeds, position sizing. Enough detail to reproduce the code from scratch. |
PythonWrite(
category="strategy",
name="RSI Mean Reversion",
description="Buy oversold, sell overbought based on RSI(14) on BTC/USDT 5m bars.",
details="""## RSI Mean Reversion
...""",
code="""...""",
metadata={
"data_feeds": [
{"symbol": "BTC/USDT.BINANCE", "period_seconds": 300, "description": "BTC/USDT 5m"}
],
"parameters": {
"rsi_length": {"default": 14, "description": "RSI lookback period"},
"oversold": {"default": 30, "description": "Buy threshold"},
"overbought": {"default": 70, "description": "Sell threshold"},
"trade_qty": {"default": 0.01, "description": "Trade quantity in BTC"}
}
}
)
Backtest Workflow
- Check existing indicators first:
PythonList(category="indicator")— reuse signals already on the chart. - Write the strategy:
PythonWrite(...)— runs against synthetic data automatically. - Run a backtest targeting 100,000–200,000 bars (max 5 years):
BacktestStrategy( strategy_name="RSI Mean Reversion", feeds=[{"symbol": "BTC/USDT.BINANCE", "period_seconds": 900}], from_time="2023-01-01", to_time="2024-12-31", initial_capital=10000 ) - Interpret results:
summary.total_return— total fractional return (0.15 = +15%)summary.sharpe_ratio— annualized Sharpe (>1.0 good, >2.0 excellent)summary.max_drawdown— maximum peak-to-trough losssummary.win_rate— fraction of profitable tradesstatistics.profit_factor— gross profit / gross loss (>1.5 good)
- Iterate with
PythonEdit, re-run backtest. - Activate (paper first):
ActivateStrategy(..., paper=True)
Bar resolution and backtest window
Choose the resolution appropriate to the strategy's signal frequency, then set the date range to hit 100k–200k bars:
| Resolution | ~100k bars | ~200k bars |
|---|---|---|
| 5m | 1 year | 2 years |
| 15m | 2.9 years | 5 years |
| 1h | cap at 5 yr (≈44k bars) | — |
| 4h | cap at 5 yr (≈11k bars) | — |
Strategy Patterns
Trend following
Follow sustained price movements using moving average crossovers, breakout of price channels, or trend-direction filters:
ema_fast = ta.ema(df["close"], length=20)
ema_slow = ta.ema(df["close"], length=50)
bullish = ema_fast.iloc[-1] > ema_slow.iloc[-1]
crossover = ema_fast.iloc[-2] <= ema_slow.iloc[-2]
if bullish and crossover:
self.buy(qty)
Mean reversion
Profit from price returning to an average after extremes:
rsi = ta.rsi(df["close"], length=14)
if rsi.iloc[-1] < 30:
self.buy(qty)
elif rsi.iloc[-1] > 70:
self.sell(qty)
Multi-timeframe confluence
Use a higher-timeframe trend filter with a lower-timeframe entry signal:
df_4h = dfs.get("BTC/USDT.BINANCE:14400")
df_15m = dfs.get("BTC/USDT.BINANCE:900")
if df_4h is None or df_15m is None:
return
ema_4h = ta.ema(df_4h["close"], length=20)
bullish_trend = df_4h["close"].iloc[-1] > ema_4h.iloc[-1]
macd_df = ta.macd(df_15m["close"])
hist = macd_df.iloc[:, 2]
if bullish_trend and hist.iloc[-1] > 0 and hist.iloc[-2] <= 0:
self.buy(qty, feed_key="BTC/USDT.BINANCE:900")
Important Rules
evaluate()must be fast, lightweight, and deterministic — no model inference, file I/O, network calls, or randomness. It runs on every bar during backtests over potentially hundreds of thousands of bars.- No LLM calls inside strategies — strategies must be fully reproducible.
- Guard for insufficient data — always check
len(df) >= min_requiredbefore computing indicators with a lookback period. - Use
.get()for feeds — multi-feed strategies may have feeds missing during warm-up. - Size conservatively — a typical trade quantity is
0.001–0.01 * initial_capital / price. - No
importfromdexorderinsideevaluate()— the strategy file is exec'd in a sandbox; PandasStrategy and pandas_ta are pre-loaded.
Performance Metrics Reference
| Metric | Good | Excellent |
|---|---|---|
| Sharpe ratio | > 1.0 | > 2.0 |
| Profit factor | > 1.5 | > 2.0 |
| Max drawdown | < 20% | < 10% |
| Win rate | context-dependent | — |
A strategy with a lower win rate can still be profitable if winners are larger than losers (profit factor > 1). Focus on Sharpe and max drawdown as primary quality metrics.
Avoiding overfitting
- Do not optimize parameters on the same data used for validation
- Use a held-out out-of-sample period to verify results
- Prefer fewer parameters — simpler strategies generalize better
- Walk-forward analysis: re-fit on a rolling window, evaluate on the next
See Also
../pandas-ta-reference.md— Indicator catalog and usage examples../indicators/indicator-development.md— Creating custom indicators../api-reference.md— DataAPI reference (for research scripts)../usage-examples.md— Research script patterns