Imports¶
This section gathers, in one place, the libraries the notebook depends on, so every later cell can rely on them being loaded.
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import talib
from plotly.subplots import make_subplots
Config¶
This section collects the study's fixed choices — the strategy's parameters, the markets and timeframes it runs on, the fee rate, and starting capital — as named constants, set once here and referenced by name throughout.
FEE_PCT = 0.02 / 100 # binance futures taker, per side
INITIAL_CASH = 100
KELTNER_PERIOD = 20
KELTNER_ATR_PERIOD = 20
KELTNER_MULTIPLIER = 2.0
SYMBOLS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "DOGEUSDT", "XRPUSDT"]
# Synthetic basket symbol — an equal-weight average of every symbol's
# normalized price history, measured like any other market.
BASKET_SYMBOL = "ALL"
# Resampled-history block length — whole stretches of real returns this long
# are drawn to build the resampled run; long enough to keep trends intact.
RESAMPLE_BLOCK_DAYS = 90
TIMEFRAMES = ["30m", "1h", "4h", "1d"]
DATA_DIR = "../data" # cached Binance klines, one CSV per symbol + timeframe
# Bars per year by timeframe — annualizes the Sharpe ratio.
BARS_PER_YEAR = {
"30m": 365 * 24 * 2,
"1h": 365 * 24,
"4h": 365 * 6,
"1d": 365,
}
# One colour per symbol, used for the overlaid lines in every chart.
SYMBOL_COLORS = {
"BTCUSDT": "#f7931a",
"ETHUSDT": "#627eea",
"SOLUSDT": "#14b8a6",
"BNBUSDT": "#f3ba2f",
"DOGEUSDT": "#9b59b6",
"XRPUSDT": "#0085c0",
BASKET_SYMBOL: "#333333",
}
Strategy¶
This section defines the strategy: the signal it watches, the precise conditions that open and close a position, and the intuition for why it may hold an edge. A short example on simulated prices shows the entry and exit markers in isolation, so the mechanics are clear before any real data or performance is considered.
The strategy frames the close with a Keltner Channel: a 20-bar exponential moving average $M_t$ as the midline and an envelope set a multiple of the average true range on each side, $U_t = M_t + 2\,\mathrm{ATR}_t$ and $L_t = M_t - 2\,\mathrm{ATR}_t$, with $\mathrm{ATR}_t$ the 20-bar average of the absolute bar-to-bar move in the close. Unlike a band built from standard deviation, the channel scales with the realized range of the move, so a close beyond the upper band marks a genuine expansion of trend rather than a brief spike in variance. A long position opens the moment the close breaks above the upper band — momentum strong enough to clear two average ranges of headroom — and holds until the close falls back to the midline:
$$\text{open at } t \iff p_{t-1} \le U_{t-1} \;\land\; p_t > U_t, \qquad \text{close at } t \iff p_{t-1} \ge M_{t-1} \;\land\; p_t < M_t.$$
Breakouts above the upper band while a position is already open are ignored — the rule is long-only and always fully in or fully out, a bet that a thrust past the channel marks the start of a move that carries until price loses its momentum and sinks back to the average.
def strategy(prices, period, atr_period, multiplier):
middle = talib.EMA(prices, timeperiod=period)
true_range = np.abs(np.diff(prices, prepend=prices[0]))
average_true_range = talib.EMA(true_range, timeperiod=atr_period)
upper = middle + multiplier * average_true_range
lower = middle - multiplier * average_true_range
bands = {"upper": upper, "middle": middle, "lower": lower}
previous_prices = prices[:-1]
current_prices = prices[1:]
crossed_above_upper = (previous_prices <= upper[:-1]) & (current_prices > upper[1:])
crossed_below_middle = (previous_prices >= middle[:-1]) & (current_prices < middle[1:])
entry_signals = np.where(crossed_above_upper)[0] + 1
exit_signals = np.where(crossed_below_middle)[0] + 1
opens = []
closes = []
last_exit_bar = -1
for entry_bar in entry_signals:
if entry_bar <= last_exit_bar:
continue
exit_position = np.searchsorted(exit_signals, entry_bar, side="right")
if exit_position == exit_signals.size:
break
last_exit_bar = exit_signals[exit_position]
opens.append(entry_bar)
closes.append(last_exit_bar)
return bands, np.array(opens, dtype=np.int64), np.array(closes, dtype=np.int64)
An illustrative example on a synthetic price path, drawn as candles around its closes, so the entries (green) and exits (red) can be read against the midline and the two bands of the channel.
rng = np.random.default_rng(9)
example_prices = 100 * np.cumprod(1 + rng.normal(0, 0.01, size=300))
example_bands, example_opens, example_closes = strategy(
prices=example_prices,
period=KELTNER_PERIOD,
atr_period=KELTNER_ATR_PERIOD,
multiplier=KELTNER_MULTIPLIER,
)
# Candles are display-only, built around the close path the strategy reads:
# each bar opens at the prior close, with small simulated wicks.
candle_open_prices = np.concatenate(([example_prices[0]], example_prices[:-1]))
candle_high_prices = np.maximum(candle_open_prices, example_prices) * (
1 + rng.uniform(0, 0.004, size=example_prices.size)
)
candle_low_prices = np.minimum(candle_open_prices, example_prices) * (
1 - rng.uniform(0, 0.004, size=example_prices.size)
)
fig = go.Figure()
fig.add_trace(
go.Candlestick(
open=candle_open_prices,
high=candle_high_prices,
low=candle_low_prices,
close=example_prices,
name="Price",
increasing=dict(line=dict(color="#8a9bab", width=1), fillcolor="#f6fafd"),
decreasing=dict(line=dict(color="#8a9bab", width=1), fillcolor="#8a9bab"),
)
)
fig.add_trace(
go.Scatter(
y=example_bands["upper"],
mode="lines",
name="Upper Band",
line=dict(color="#1f77b4", width=1),
)
)
fig.add_trace(
go.Scatter(
y=example_bands["middle"],
mode="lines",
name="Middle Band",
line=dict(color="#d4a017", width=1),
)
)
fig.add_trace(
go.Scatter(
y=example_bands["lower"],
mode="lines",
name="Lower Band",
line=dict(color="#00d4aa", width=1),
)
)
fig.add_trace(
go.Scatter(
x=example_opens,
y=example_prices[example_opens],
mode="markers",
name="Entries",
marker=dict(color="#00d4aa", size=9, symbol="triangle-up"),
)
)
fig.add_trace(
go.Scatter(
x=example_closes,
y=example_prices[example_closes],
mode="markers",
name="Exits",
marker=dict(color="#ff3b30", size=9, symbol="triangle-down"),
)
)
fig.update_layout(
template="plotly_white",
height=400,
width=1080,
margin=dict(l=60, r=20, t=40, b=40),
xaxis_rangeslider_visible=False,
)
fig.show()
Metrics¶
This section turns the strategy's trades into performance metrics, per-trade and cumulative. Each is defined with its formula below; equity is reported in both gross (before fees) and net (after fees) form, so the cost of trading is always visible.
- Symbol — the market this result belongs to (e.g. BTCUSDT); ALL is the equal-weight basket of every symbol, averaged from their normalized price histories over the common window.
- Price Change % — the close price as a percentage change from the start of the window (from the close series $p_0,\dots,p_T$); in the summary, the total change over the period — the buy-and-hold return.
- Keltner Channel — the 20-bar exponential moving average of the close (Middle Band) and the envelope two average true ranges above and below it (Upper Band, Lower Band) from which the signals are derived.
- Entries — bars where a position is opened.
- Exits — bars where a position is closed.
- Cumulative Win Rate % — running share of winning trades, $\frac{1}{n}\sum_{i=1}^{n}\mathbf{1}\{r_i > 1\}\cdot 100\%$, where $r_i = (1 - f)^2\,p^{\text{exit}}_i / p^{\text{entry}}_i$ and $f$ the per-side fee.
- Cumulative P&L % — the running sum of per-trade net returns, $\sum_{i=1}^{n}(r_i - 1)\cdot 100\%$.
- Equity — net equity curve, compounded all-in: $E_n = E_0 \prod_{i=1}^{n} r_i$; the gross variant drops the fee term.
- Cumulative Fees — the running total of fees paid, each proportional to capital at trade time.
- Rolling Sharpe — annualized Sharpe of the daily returns of the net (after-fee) equity curve, to-date: $S = \frac{\bar r}{\operatorname{std}(r)}\sqrt{365}$ over daily returns $r_t$ of the mark-to-market equity (the open position marked each bar, cash when flat); reported at each trade's close.
def analytics(symbol, prices, bars_per_year):
bands, opens, closes = strategy(
prices=prices,
period=KELTNER_PERIOD,
atr_period=KELTNER_ATR_PERIOD,
multiplier=KELTNER_MULTIPLIER,
)
trade_count = min(len(opens), len(closes))
opens = opens[:trade_count]
closes = closes[:trade_count]
entry_prices = prices[opens]
exit_prices = prices[closes]
# Per-trade return factor: (1 - fee)^2 covers entry + exit fees,
# (exit / entry) is the raw price move.
factor = (1 - FEE_PCT) ** 2 * (exit_prices / entry_prices)
# Equity compounded with all-in sizing.
equity = INITIAL_CASH * np.cumprod(factor)
equity_no_fee = INITIAL_CASH * np.cumprod(exit_prices / entry_prices)
entry_capital = np.concatenate(([INITIAL_CASH], equity[:-1]))
# Fees in dollars — proportional to capital at trade time.
entry_fees = entry_capital * FEE_PCT
exit_fees = entry_capital * (1 - FEE_PCT) * (exit_prices / entry_prices) * FEE_PCT
fees = entry_fees + exit_fees
cum_fees = np.cumsum(fees)
pnls = equity - entry_capital
total_trades = pnls.size
cum_winrate = np.cumsum(pnls > 0) / np.arange(1, total_trades + 1) * 100
pct_cum_pnl = np.cumsum((factor - 1) * 100)
# Rolling daily-equity Sharpe: mark the open position to market each bar,
# resample to daily, and take the annualized (times sqrt(365)) Sharpe of the
# daily returns to-date. Reported at each trade's close so it lines up with
# the other per-trade series.
bars_per_day = max(int(bars_per_year // 365), 1)
if total_trades:
bar_index = np.arange(len(prices))
trade_of_bar = np.searchsorted(opens, bar_index, side="right") - 1
held_trade = np.clip(trade_of_bar, 0, total_trades - 1)
in_position = (trade_of_bar >= 0) & (bar_index <= closes[held_trade])
held_entry_price = prices[opens[held_trade]]
marked_equity = entry_capital[held_trade] * (1 - FEE_PCT) * prices / held_entry_price
cash_after_trade = np.concatenate(([float(INITIAL_CASH)], equity))
bar_equity = np.where(in_position, marked_equity, cash_after_trade[trade_of_bar + 1])
else:
bar_equity = np.full(len(prices), float(INITIAL_CASH))
daily_equity = bar_equity[bars_per_day - 1 :: bars_per_day]
daily_returns = np.diff(daily_equity) / daily_equity[:-1]
day_count = np.arange(1, daily_returns.size + 1)
with np.errstate(invalid="ignore", divide="ignore"):
daily_mean = np.cumsum(daily_returns) / day_count
daily_population_var = np.cumsum(daily_returns**2) / day_count - daily_mean**2
daily_var = daily_population_var * day_count / np.maximum(day_count - 1, 1)
daily_std = np.sqrt(np.clip(daily_var, 0, None))
daily_sharpe = np.where(daily_std > 0, daily_mean / daily_std, 0.0) * np.sqrt(365)
if daily_sharpe.size and total_trades:
close_day = np.clip(closes // bars_per_day - 1, 0, daily_sharpe.size - 1)
cum_sharpe = daily_sharpe[close_day]
else:
cum_sharpe = np.zeros(total_trades)
return {
"symbol": symbol,
"prices": prices,
"bands": bands,
"opens": opens,
"closes": closes,
"cum_winrate": cum_winrate,
"pct_cum_pnl": pct_cum_pnl,
"equity": equity,
"equity_no_fee": equity_no_fee,
"cum_fees": cum_fees,
"cum_sharpe": cum_sharpe,
}
Visualization¶
This section visualises every metric over time, with one coloured line per symbol so the markets can be compared directly, shown across each timeframe. Each summary table below is also drawn as grouped bars — one panel per metric, one bar per symbol and timeframe — so the final results compare at a glance.
CHART_MAX_POINTS = 1500 # cap points per line so pages stay light; LTTB keeps the shape
def downsample(x_values, y_values, max_points):
total_points = len(x_values)
if total_points <= max_points or max_points < 3:
return x_values, y_values
x_values = np.asarray(x_values, dtype=float)
y_values = np.asarray(y_values, dtype=float)
bucket_size = (total_points - 2) / (max_points - 2)
sampled_x = np.empty(max_points)
sampled_y = np.empty(max_points)
sampled_x[0] = x_values[0]
sampled_y[0] = y_values[0]
sampled_x[-1] = x_values[-1]
sampled_y[-1] = y_values[-1]
previous = 0
for i in range(max_points - 2):
next_start = int((i + 1) * bucket_size) + 1
next_end = min(int((i + 2) * bucket_size) + 1, total_points)
average_x = x_values[next_start:next_end].mean()
average_y = y_values[next_start:next_end].mean()
bucket_start = int(i * bucket_size) + 1
bucket_end = int((i + 1) * bucket_size) + 1
anchor_x = x_values[previous]
anchor_y = y_values[previous]
triangle_areas = np.abs(
(anchor_x - average_x) * (y_values[bucket_start:bucket_end] - anchor_y)
- (anchor_x - x_values[bucket_start:bucket_end]) * (average_y - anchor_y)
)
chosen = bucket_start + int(np.argmax(triangle_areas))
sampled_x[i + 1] = x_values[chosen]
sampled_y[i + 1] = y_values[chosen]
previous = chosen
return sampled_x, sampled_y
def charts(results):
symbols = list(dict.fromkeys(symbol for symbol, _ in results))
metrics = [
("Price Change %", "pct_prices", None),
("Cumulative Win Rate %", "cum_winrate", None),
("Cumulative P&L %", "pct_cum_pnl", None),
("Equity", "equity", "equity_no_fee"),
("Cumulative Fees", "cum_fees", None),
("Rolling Sharpe", "cum_sharpe", None),
]
n_rows = len(metrics)
n_cols = len(TIMEFRAMES)
col_width = 600
row_height = 280
gap_px = 60
total_w = col_width * n_cols + gap_px * max(n_cols - 1, 0)
total_h = row_height * n_rows
h_spacing = gap_px / total_w if n_cols > 1 else 0
fig = make_subplots(
rows=n_rows,
cols=n_cols,
shared_xaxes=True,
vertical_spacing=0.025,
horizontal_spacing=h_spacing,
column_titles=list(TIMEFRAMES),
)
for row_idx, (title, key, gross_key) in enumerate(metrics, start=1):
for col_idx, timeframe in enumerate(TIMEFRAMES, start=1):
max_len = max(len(results[(s, timeframe)]["prices"]) for s in symbols)
for symbol in symbols:
result = results[(symbol, timeframe)]
offset = max_len - len(result["prices"])
if key == "pct_prices":
y_values = (result["prices"] / result["prices"][0] - 1) * 100
x_values = np.arange(offset, offset + len(y_values))
else:
y_values = result[key]
x_values = result["opens"] + offset
x_values, y_values = downsample(x_values, y_values, CHART_MAX_POINTS)
fig.add_trace(
go.Scatter(
x=x_values,
y=np.round(y_values, 4),
mode="lines",
name=symbol,
legendgroup=symbol,
line=dict(color=SYMBOL_COLORS[symbol], width=1),
showlegend=False,
hovertemplate="%{fullData.name}: %{y}<extra></extra>",
),
row=row_idx,
col=col_idx,
)
if gross_key:
gross_x, gross_y = downsample(
result["opens"] + offset,
result[gross_key],
CHART_MAX_POINTS,
)
fig.add_trace(
go.Scatter(
x=gross_x,
y=np.round(gross_y, 4),
mode="lines",
name=symbol,
legendgroup=symbol,
line=dict(color=SYMBOL_COLORS[symbol], width=1, dash="dot"),
showlegend=False,
hoverinfo="skip",
),
row=row_idx,
col=col_idx,
)
fig.update_yaxes(title_text=title, title_font=dict(size=13), row=row_idx, col=1)
# Dummy traces with thicker lines so the legend entries appear bold
# while the actual chart lines remain at width=1.
for symbol in symbols:
fig.add_trace(
go.Scatter(
x=[None],
y=[None],
mode="lines",
name=symbol,
legendgroup=symbol,
line=dict(color=SYMBOL_COLORS[symbol], width=3),
showlegend=True,
)
)
# Same pixel gap between the legend and the plot area in every figure —
# paper coordinates scale with figure height, so derive the offset from it.
legend_y = 1 + 75 / (total_h - 110) # 110 = top + bottom margins
fig.update_layout(
template="plotly_white",
height=total_h,
width=total_w,
font=dict(size=11),
hovermode="x unified",
hoverlabel=dict(bgcolor="white"),
legend=dict(
orientation="h",
yanchor="bottom",
y=legend_y,
xanchor="left",
x=0,
),
margin=dict(l=90, r=20, t=70, b=40),
)
fig.update_annotations(font=dict(size=13))
fig.update_xaxes(showgrid=True)
fig.update_yaxes(showgrid=True, zeroline=True)
fig.update_yaxes(range=[-3, 3], row=n_rows) # clamp Rolling Sharpe
fig.show()
def left_aligned_table(df):
def format_value(value):
if not isinstance(value, (int, float)):
return value
if value == int(value):
return f"{int(value):,}"
return f"{value:,.2f}".rstrip("0").rstrip(".")
return (
df.style.format(format_value)
.hide(axis="index")
.set_properties(**{"text-align": "left", "white-space": "nowrap"})
.set_table_styles(
[
{"selector": "th", "props": [("text-align", "left"), ("white-space", "nowrap")]},
{"selector": "", "props": [("min-width", "100%")]},
]
)
)
def summarize(results):
rows = []
for (symbol, timeframe), result in results.items():
equity = result["equity"]
equity_no_fee = result["equity_no_fee"]
prices = result["prices"]
has_trades = len(equity) > 0
rows.append(
{
"Symbol": symbol,
"Timeframe": timeframe,
"Price Change %": round(float(prices[-1] / prices[0] - 1) * 100, 1),
"Cumulative Win Rate %": (
round(float(result["cum_winrate"][-1]), 1) if has_trades else 0.0
),
"Cumulative P&L %": (
round(float(result["pct_cum_pnl"][-1]), 1) if has_trades else 0.0
),
"Equity Net": (round(float(equity[-1]), 2) if has_trades else float(INITIAL_CASH)),
"Equity Gross": (
round(float(equity_no_fee[-1]), 2) if has_trades else float(INITIAL_CASH)
),
"Cumulative Fees": (round(float(result["cum_fees"][-1]), 2) if has_trades else 0.0),
"Rolling Sharpe": (
round(float(result["cum_sharpe"][-1]), 2) if has_trades else 0.0
),
}
)
return pd.DataFrame(rows)
def summary_charts(summary):
symbols = list(dict.fromkeys(summary["Symbol"]))
metrics = [column for column in summary.columns if column not in ("Symbol", "Timeframe")]
n_cols = 4
n_rows = (len(metrics) + n_cols - 1) // n_cols
col_width = 600
row_height = 280
gap_px = 60
total_w = col_width * n_cols + gap_px * max(n_cols - 1, 0)
total_h = row_height * n_rows
h_spacing = gap_px / total_w if n_cols > 1 else 0
v_spacing = gap_px / total_h if n_rows > 1 else 0
fig = make_subplots(
rows=n_rows,
cols=n_cols,
vertical_spacing=v_spacing,
horizontal_spacing=h_spacing,
subplot_titles=metrics,
)
for metric_idx, metric in enumerate(metrics):
row_idx = metric_idx // n_cols + 1
col_idx = metric_idx % n_cols + 1
for symbol in symbols:
symbol_rows = summary[summary["Symbol"] == symbol]
fig.add_trace(
go.Bar(
x=symbol_rows["Timeframe"],
y=symbol_rows[metric],
name=symbol,
legendgroup=symbol,
marker_color=SYMBOL_COLORS[symbol],
showlegend=metric_idx == 0,
),
row=row_idx,
col=col_idx,
)
# Same pixel gap between the legend and the plot area in every figure —
# paper coordinates scale with figure height, so derive the offset from it.
legend_y = 1 + 75 / (total_h - 110) # 110 = top + bottom margins
fig.update_layout(
template="plotly_white",
height=total_h,
width=total_w,
font=dict(size=11),
barmode="group",
hovermode="x unified",
hoverlabel=dict(bgcolor="white"),
legend=dict(
orientation="h",
yanchor="bottom",
y=legend_y,
xanchor="left",
x=0,
),
margin=dict(l=90, r=20, t=70, b=40),
)
fig.update_annotations(font=dict(size=13))
fig.update_xaxes(showgrid=True)
fig.update_yaxes(showgrid=True, zeroline=True)
# Hide the axes of grid slots past the last metric so they stay blank.
for slot_idx in range(len(metrics), n_rows * n_cols):
row_idx = slot_idx // n_cols + 1
col_idx = slot_idx % n_cols + 1
fig.update_xaxes(visible=False, row=row_idx, col=col_idx)
fig.update_yaxes(visible=False, row=row_idx, col=col_idx)
fig.show()
Run on Real Data¶
This section runs the strategy on real market data: a basket of liquid symbols evaluated across several timeframes. Every metric is charted with one coloured line per symbol, so the markets can be compared directly.
The basket symbol ALL is an equal-weight portfolio of the whole set: every symbol's price history is normalized to its starting value over the common window, then averaged. It runs through the same strategy and metrics as any single market.
def basket_prices(symbol_prices):
common_len = min(len(prices) for prices in symbol_prices)
aligned = [prices[-common_len:] for prices in symbol_prices]
normalized = [prices / prices[0] for prices in aligned]
return np.mean(normalized, axis=0)
prices = {
(symbol, timeframe): pd.read_csv(f"{DATA_DIR}/{symbol}_{timeframe}.csv")["close"].to_numpy()
for symbol in SYMBOLS
for timeframe in TIMEFRAMES
}
for timeframe in TIMEFRAMES:
symbol_prices = [prices[(symbol, timeframe)] for symbol in SYMBOLS]
prices[(BASKET_SYMBOL, timeframe)] = basket_prices(symbol_prices=symbol_prices)
results = {
(symbol, timeframe): analytics(
symbol=symbol,
prices=prices[(symbol, timeframe)],
bars_per_year=BARS_PER_YEAR[timeframe],
)
for symbol in [*SYMBOLS, BASKET_SYMBOL]
for timeframe in TIMEFRAMES
}
charts(results=results)
summary = summarize(results=results)
left_aligned_table(df=summary)
| Symbol | Timeframe | Price Change % | Cumulative Win Rate % | Cumulative P&L % | Equity Net | Equity Gross | Cumulative Fees | Rolling Sharpe |
|---|---|---|---|---|---|---|---|---|
| BTCUSDT | 30m | 1,347.9 | 27.9 | -29.9 | 37.87 | 179.22 | 94.86 | -0.07 |
| BTCUSDT | 1h | 1,331.8 | 30.7 | 188.8 | 333.4 | 709.27 | 225.05 | 0.54 |
| BTCUSDT | 4h | 1,318 | 34.7 | 378.3 | 1,895.94 | 2,279.91 | 246.65 | 1.03 |
| BTCUSDT | 1d | 1,339.4 | 42.5 | 500.8 | 4,061.69 | 4,182.05 | 55.71 | 1.23 |
| ETHUSDT | 30m | 444.3 | 30.3 | 161.1 | 179.96 | 838.88 | 213.39 | 0.38 |
| ETHUSDT | 1h | 440.1 | 31.8 | 307 | 776.57 | 1,637.56 | 412.14 | 0.72 |
| ETHUSDT | 4h | 429.1 | 33.3 | 501.9 | 3,827.75 | 4,615.85 | 516.02 | 1.09 |
| ETHUSDT | 1d | 439.5 | 45.2 | 591.1 | 7,307.13 | 7,523.67 | 104.37 | 1.18 |
| SOLUSDT | 30m | 1,957.1 | 32.3 | 284.2 | 417.97 | 1,176.03 | 435.55 | 0.7 |
| SOLUSDT | 1h | 2,086.3 | 33.4 | 370.1 | 1,139.11 | 1,890.99 | 460.44 | 0.92 |
| SOLUSDT | 4h | 2,107.4 | 34.6 | 342.2 | 632.8 | 720.37 | 69.93 | 0.78 |
| SOLUSDT | 1d | 1,856.3 | 45.7 | 1,126.3 | 22,944.3 | 23,370.42 | 222.29 | 1.63 |
| BNBUSDT | 30m | 34,845.9 | 29.6 | 291.8 | 298.08 | 1,380.12 | 418.48 | 0.49 |
| BNBUSDT | 1h | 34,877.6 | 31.4 | 519 | 1,836.53 | 3,993.86 | 1,175.75 | 0.8 |
| BNBUSDT | 4h | 34,864.7 | 34 | 665.8 | 6,676.56 | 8,051.21 | 681.33 | 1.03 |
| BNBUSDT | 1d | 37,733.2 | 35.4 | 1,284.2 | 11,504.08 | 11,887.71 | 273.14 | 1.09 |
| DOGEUSDT | 30m | 2,106.6 | 26 | 253.6 | 7.72 | 26.5 | 23.05 | 0.07 |
| DOGEUSDT | 1h | 2,041.5 | 27.9 | 631.6 | 1,520.27 | 2,746 | 593.94 | 0.79 |
| DOGEUSDT | 4h | 2,148.9 | 27.3 | 1,074.7 | 13,839.98 | 15,907.25 | 1,049.81 | 1.04 |
| DOGEUSDT | 1d | 2,071.8 | 40.4 | 923.8 | 5,127.02 | 5,245.27 | 49.41 | 1.01 |
| XRPUSDT | 30m | 23 | 26.3 | -102.1 | 6.94 | 29.22 | 50.62 | -0.17 |
| XRPUSDT | 1h | 22.4 | 28.7 | 136.8 | 82.99 | 165.01 | 40.41 | 0.26 |
| XRPUSDT | 4h | 23.4 | 30 | 622.7 | 3,168.21 | 3,728.42 | 176.71 | 0.94 |
| XRPUSDT | 1d | 27 | 30.2 | 518.3 | 1,038.97 | 1,065.49 | 12.47 | 0.76 |
| ALL | 30m | 1,305.8 | 37.9 | 966.3 | 559,920.17 | 1,641,066.74 | 75,340.8 | 2.61 |
| ALL | 1h | 1,330.2 | 39.8 | 882.3 | 261,650.03 | 445,795.86 | 23,065.68 | 2.46 |
| ALL | 4h | 1,333.9 | 32.8 | 420 | 1,400.22 | 1,593.36 | 173.52 | 0.83 |
| ALL | 1d | 1,332.9 | 44.9 | 535.5 | 2,562.96 | 2,613.7 | 32.93 | 1.25 |
summary_charts(summary=summary)
Run on Resampled History¶
This section repeats the study on resampled prices — alternative histories assembled from the market's own behavior. Whole stretches of real returns are drawn in a new order over the window all markets share, the same stretches for every market so their synchrony survives; the coarser timeframes sample the same path, just as the real timeframes sample the same market.
Inside each stretch the behavior is intact — trends, drawdown regimes, volatility bursts, and the co-movement between markets — only the order of stretches is new. The result is a past the strategy has never seen that still behaves like the market it trades: a stand-in for the future. Performance that repeats here shows the rule rides the market's behavior itself, not one memorized chronology.
def resample_history(real_prices, block_starts, block_bars):
returns = np.diff(real_prices) / real_prices[:-1]
blocks = [returns[start : start + block_bars] for start in block_starts]
resampled_returns = np.concatenate(blocks)[: returns.size]
growth = np.concatenate(([1.0], np.cumprod(1 + resampled_returns)))
return real_prices[0] * growth
rng = np.random.default_rng(0)
base_timeframe = max(TIMEFRAMES, key=lambda timeframe: BARS_PER_YEAR[timeframe])
block_bars = RESAMPLE_BLOCK_DAYS * BARS_PER_YEAR[base_timeframe] // 365
common_len = min(len(prices[(symbol, base_timeframe)]) for symbol in SYMBOLS)
block_count = (common_len + block_bars - 1) // block_bars
block_starts = rng.integers(0, common_len - block_bars, size=block_count)
resampled_prices = {}
for symbol in SYMBOLS:
real_tail = prices[(symbol, base_timeframe)][-common_len:]
base_path = resample_history(
real_prices=real_tail,
block_starts=block_starts,
block_bars=block_bars,
)
for timeframe in TIMEFRAMES:
step = BARS_PER_YEAR[base_timeframe] // BARS_PER_YEAR[timeframe]
resampled_prices[(symbol, timeframe)] = base_path[step - 1 :: step]
for timeframe in TIMEFRAMES:
symbol_prices = [resampled_prices[(symbol, timeframe)] for symbol in SYMBOLS]
resampled_prices[(BASKET_SYMBOL, timeframe)] = basket_prices(symbol_prices=symbol_prices)
resampled_results = {
(symbol, timeframe): analytics(
symbol=symbol,
prices=resampled_prices[(symbol, timeframe)],
bars_per_year=BARS_PER_YEAR[timeframe],
)
for symbol in [*SYMBOLS, BASKET_SYMBOL]
for timeframe in TIMEFRAMES
}
charts(results=resampled_results)
resampled_summary = summarize(results=resampled_results)
left_aligned_table(df=resampled_summary)
| Symbol | Timeframe | Price Change % | Cumulative Win Rate % | Cumulative P&L % | Equity Net | Equity Gross | Cumulative Fees | Rolling Sharpe |
|---|---|---|---|---|---|---|---|---|
| BTCUSDT | 30m | 2,340.1 | 28.3 | 17.3 | 82.4 | 237.49 | 108.78 | 0.08 |
| BTCUSDT | 1h | 2,336.9 | 31 | 131 | 256.81 | 433.19 | 96.01 | 0.64 |
| BTCUSDT | 4h | 2,348.9 | 34.8 | 178.1 | 395.94 | 450.92 | 35.73 | 0.83 |
| BTCUSDT | 1d | 2,340.7 | 40.8 | 350.8 | 1,351.07 | 1,377.82 | 7.75 | 1.41 |
| ETHUSDT | 30m | 832 | 29.8 | 151.5 | 246.45 | 698.74 | 217.77 | 0.56 |
| ETHUSDT | 1h | 825 | 32.2 | 313.5 | 1,224.95 | 2,014.85 | 299.37 | 1.19 |
| ETHUSDT | 4h | 833.2 | 32.9 | 222.3 | 469.75 | 535.61 | 40.32 | 0.82 |
| ETHUSDT | 1d | 779.2 | 46.5 | 584.8 | 4,437.89 | 4,514.89 | 25.58 | 1.62 |
| SOLUSDT | 30m | 15,210.4 | 32.6 | 460.5 | 2,313.32 | 6,467.48 | 1,233.49 | 1.1 |
| SOLUSDT | 1h | 15,289 | 33.7 | 363.1 | 1,024.51 | 1,718.52 | 285.64 | 0.92 |
| SOLUSDT | 4h | 15,463.4 | 34.8 | 547.2 | 3,959.54 | 4,498.49 | 157.31 | 1.23 |
| SOLUSDT | 1d | 15,594.7 | 57.1 | 1,723.2 | 132,288.14 | 134,529.58 | 410.69 | 2.03 |
| BNBUSDT | 30m | 5,780.4 | 29.1 | 214.1 | 293.34 | 841.37 | 290.19 | 0.6 |
| BNBUSDT | 1h | 5,761.4 | 30.1 | 247.2 | 439.06 | 750.46 | 204.87 | 0.75 |
| BNBUSDT | 4h | 5,540.5 | 33 | 394.9 | 1,140.18 | 1,299.52 | 114.65 | 1.06 |
| BNBUSDT | 1d | 5,641.4 | 35.1 | 632.2 | 971.65 | 994.06 | 16.52 | 0.97 |
| DOGEUSDT | 30m | 15,851.6 | 27.8 | 909.8 | 572.32 | 1,608.42 | 688.92 | 0.63 |
| DOGEUSDT | 1h | 15,731.9 | 30.9 | 781.1 | 2,582.94 | 4,218.07 | 879.26 | 0.92 |
| DOGEUSDT | 4h | 15,847.8 | 30.7 | 790.2 | 8,113.04 | 9,206.28 | 527.33 | 0.96 |
| DOGEUSDT | 1d | 13,838.4 | 50 | 1,731.8 | 29,795.86 | 30,349.23 | 180.27 | 1.13 |
| XRPUSDT | 30m | 633.8 | 25.4 | -2.5 | 24.13 | 68.98 | 72.25 | -0.06 |
| XRPUSDT | 1h | 632.2 | 27.4 | 246.9 | 300.7 | 498.17 | 133.88 | 0.59 |
| XRPUSDT | 4h | 638 | 29.3 | 645.3 | 4,478.33 | 4,995.12 | 237.24 | 1.26 |
| XRPUSDT | 1d | 616.4 | 38.9 | 774.7 | 4,381.82 | 4,445.39 | 26.84 | 1.33 |
| ALL | 30m | 6,774.7 | 35.9 | 1,152.5 | 1,253,929.07 | 3,641,473.81 | 160,503.73 | 2.62 |
| ALL | 1h | 6,762.7 | 34.4 | 579.9 | 6,298.9 | 10,570.08 | 1,057.22 | 1.39 |
| ALL | 4h | 6,778.6 | 35.7 | 501.8 | 3,118.8 | 3,543.31 | 217.25 | 0.97 |
| ALL | 1d | 6,468.5 | 52.4 | 1,053.2 | 19,531.41 | 19,862.34 | 83.87 | 1.33 |
summary_charts(summary=resampled_summary)
Scoring¶
This section grades the strategy on a 0–100 scale. Every metric is mapped to a score in each symbol × timeframe cell, the per-metric scores are blended into a composite cell score, and the composites average into a single overall strategy score. The scales are fixed, so the same number means the same thing from one study to the next and the grades compare like for like.
- Beats-Hold — how far net equity runs ahead of buy-and-hold, $50 + 25\log_2(E_{\text{net}}/E_{\text{hold}})$: matching buy-and-hold scores 50, doubling it 75, halving it 25.
- Risk-Adjusted — the Rolling Sharpe on a fixed scale, $55\,S + 5$: a Sharpe of 1 scores 60, of roughly 1.7 reaches 100.
- Profitability — absolute growth of capital, $20\log_2(E_{\text{net}}/E_0)$: a 2× ending scores 20, a 32× scores 100.
- Win-Rate — the share of winning trades, $2.5\,(w - 20)$ for a win rate $w$ in percent: 40% scores 50, 60% scores 100.
- Fee-Efficiency — how little trading costs erode the result, $(E_{\text{net}}/E_{\text{gross}} - 0.4)/0.6 \times 100$: paying nothing scores 100, losing a third to fees scores about 50.
- Composite — the weighted blend, $0.35\,\text{Beats-Hold} + 0.30\,\text{Risk-Adjusted} + 0.15\,\text{Profitability} + 0.10\,\text{Win-Rate} + 0.10\,\text{Fee-Efficiency}$.
- Overall Score — the average composite across all cells: the strategy's single headline grade.
SCORE_WEIGHTS = {
"Beats-Hold": 0.35,
"Risk-Adjusted": 0.30,
"Profitability": 0.15,
"Win-Rate": 0.10,
"Fee-Efficiency": 0.10,
}
def clamp_score(value):
return float(max(0.0, min(100.0, value)))
def score_metrics(row):
hold_equity = INITIAL_CASH * (1 + row["Price Change %"] / 100)
net = row["Equity Net"]
gross = row["Equity Gross"]
if net > 0 and hold_equity > 0:
beats_hold = clamp_score(50 + 25 * np.log2(net / hold_equity))
else:
beats_hold = 0.0
risk_adjusted = clamp_score(55 * row["Rolling Sharpe"] + 5)
multiple = net / INITIAL_CASH
profitability = clamp_score(20 * np.log2(multiple)) if multiple > 0 else 0.0
win_rate = clamp_score(2.5 * (row["Cumulative Win Rate %"] - 20))
fee_efficiency = clamp_score((net / gross - 0.4) / 0.6 * 100) if gross > 0 else 0.0
return {
"Beats-Hold": beats_hold,
"Risk-Adjusted": risk_adjusted,
"Profitability": profitability,
"Win-Rate": win_rate,
"Fee-Efficiency": fee_efficiency,
}
def score_cell(row):
metric_scores = score_metrics(row=row)
composite = sum(metric_scores[name] * weight for name, weight in SCORE_WEIGHTS.items())
return composite, metric_scores
def strategy_score(summary):
rows = []
for _, row in summary.iterrows():
composite, metric_scores = score_cell(row=row)
entry = {"Symbol": row["Symbol"], "Timeframe": row["Timeframe"]}
entry.update({name: round(value, 1) for name, value in metric_scores.items()})
entry["Composite"] = round(composite, 1)
rows.append(entry)
score_table = pd.DataFrame(rows)
overall = int(round(score_table["Composite"].mean()))
return overall, score_table
def score_charts(score_table, overall):
metric_names = [
"Beats-Hold",
"Risk-Adjusted",
"Profitability",
"Win-Rate",
"Fee-Efficiency",
"Composite",
]
symbols = list(dict.fromkeys(score_table["Symbol"]))
n_cols = 3
n_rows = 2
col_width = 600
row_height = 300
gap_px = 70
total_w = col_width * n_cols + gap_px * max(n_cols - 1, 0)
total_h = row_height * n_rows
h_spacing = gap_px / total_w
v_spacing = gap_px / total_h
colorscale = [[0.0, "#eef0f2"], [0.5, "#bfe3d3"], [1.0, "#1d9e75"]]
fig = make_subplots(
rows=n_rows,
cols=n_cols,
subplot_titles=metric_names,
horizontal_spacing=h_spacing,
vertical_spacing=v_spacing,
)
for idx, metric in enumerate(metric_names):
row_idx = idx // n_cols + 1
col_idx = idx % n_cols + 1
pivot = score_table.pivot(index="Symbol", columns="Timeframe", values=metric)
pivot = pivot.reindex(index=symbols, columns=TIMEFRAMES)
values = pivot.to_numpy()
fig.add_trace(
go.Heatmap(
z=values,
x=TIMEFRAMES,
y=symbols,
zmin=0,
zmax=100,
colorscale=colorscale,
showscale=False,
text=values,
texttemplate="%{text:.0f}",
textfont=dict(size=11),
hovertemplate="%{y} · %{x}: %{z:.0f}<extra></extra>",
xgap=2,
ygap=2,
),
row=row_idx,
col=col_idx,
)
fig.update_layout(
template="plotly_white",
height=total_h,
width=total_w,
font=dict(size=11),
title=dict(text=f"Overall strategy score: {overall} / 100", x=0.5, font=dict(size=17)),
margin=dict(l=90, r=20, t=100, b=40),
)
fig.update_annotations(font=dict(size=13))
fig.update_yaxes(autorange="reversed")
fig.show()
overall_score, score_table = strategy_score(summary=summary)
score_charts(score_table=score_table, overall=overall_score)
left_aligned_table(df=score_table)
| Symbol | Timeframe | Beats-Hold | Risk-Adjusted | Profitability | Win-Rate | Fee-Efficiency | Composite |
|---|---|---|---|---|---|---|---|
| BTCUSDT | 30m | 0 | 1.1 | 0 | 19.7 | 0 | 2.3 |
| BTCUSDT | 1h | 0 | 34.7 | 34.7 | 26.8 | 11.7 | 19.5 |
| BTCUSDT | 4h | 60.5 | 61.6 | 84.9 | 36.8 | 71.9 | 63.3 |
| BTCUSDT | 1d | 87.4 | 72.7 | 100 | 56.2 | 95.2 | 82.5 |
| ETHUSDT | 30m | 10.1 | 25.9 | 17 | 25.8 | 0 | 16.4 |
| ETHUSDT | 1h | 63.1 | 44.6 | 59.1 | 29.5 | 12.4 | 48.5 |
| ETHUSDT | 4h | 100 | 65 | 100 | 33.2 | 71.5 | 80 |
| ETHUSDT | 1d | 100 | 69.9 | 100 | 63 | 95.2 | 86.8 |
| SOLUSDT | 30m | 0 | 43.5 | 41.3 | 30.7 | 0 | 22.3 |
| SOLUSDT | 1h | 26.5 | 55.6 | 70.2 | 33.5 | 33.7 | 43.2 |
| SOLUSDT | 4h | 4.9 | 47.9 | 53.2 | 36.5 | 79.7 | 35.7 |
| SOLUSDT | 1d | 100 | 94.6 | 100 | 64.2 | 97 | 94.5 |
| BNBUSDT | 30m | 0 | 31.9 | 31.5 | 24 | 0 | 16.7 |
| BNBUSDT | 1h | 0 | 49 | 84 | 28.5 | 10 | 31.1 |
| BNBUSDT | 4h | 0 | 61.6 | 100 | 35 | 71.5 | 44.1 |
| BNBUSDT | 1d | 7.1 | 65 | 100 | 38.5 | 94.6 | 50.3 |
| DOGEUSDT | 30m | 0 | 8.9 | 0 | 15 | 0 | 4.2 |
| DOGEUSDT | 1h | 37.6 | 48.5 | 78.5 | 19.7 | 25.6 | 44 |
| DOGEUSDT | 4h | 100 | 62.2 | 100 | 18.2 | 78.3 | 78.3 |
| DOGEUSDT | 1d | 81 | 60.5 | 100 | 51 | 96.2 | 76.2 |
| XRPUSDT | 30m | 0 | 0 | 0 | 15.8 | 0 | 1.6 |
| XRPUSDT | 1h | 36 | 19.3 | 0 | 21.8 | 17.2 | 22.3 |
| XRPUSDT | 4h | 100 | 56.7 | 99.7 | 25 | 75 | 77 |
| XRPUSDT | 1d | 100 | 46.8 | 67.5 | 25.5 | 95.9 | 71.3 |
| ALL | 30m | 100 | 100 | 100 | 44.8 | 0 | 84.5 |
| ALL | 1h | 100 | 100 | 100 | 49.5 | 31.2 | 88.1 |
| ALL | 4h | 49.1 | 50.6 | 76.2 | 32 | 79.8 | 55 |
| ALL | 1d | 71 | 73.8 | 93.6 | 62.2 | 96.8 | 76.9 |
Conclusion¶
This section judges the strategy. The verdict reads the summary tables above — real and synthetic — where the strategy holds up, where it fails, and whether it is worth pursuing.
Over each symbol's full available Binance history, buying the breakout above the upper band and exiting at the midline is profitable after fees in 22 of 28 symbol × timeframe cells. Only the two fastest single-market cells lose — BTC at 30m (ending 38 on 100) and DOGE at 30m (ending 8) — bled white by fees; every 4h and 1d cell is green, several by large multiples (ETH 1d 7,307, SOL 1d 22,944, DOGE 4h 13,840). This is the broadest real-data edge in the catalogue.
Risk-adjusted, the edge strengthens with the timeframe. Rolling Sharpe climbs across each symbol's row — BTC −0.07 / 0.54 / 1.03 / 1.23 from 30m to 1d, ETH 0.38 / 0.72 / 1.09 / 1.18 — so the slow cells are not just profitable but steady, clustering at Sharpe 0.9–1.3 by 4h and 1d. The fast cells earn their returns through far more turnover and far thinner margins.
Versus buy-and-hold it wins only 13 of 28 cells, and the wins are lopsided. The two standouts are the basket at speed — ALL at 30m compounds 100 into 533,746 (Rolling Sharpe 2.61) and at 1h into 228,229 — against roughly 1,600 for holding the basket. Averaging the five markets cancels their idiosyncratic noise, so a close above the basket's upper channel is a cleaner, market-wide thrust with fewer whipsaws; the rule feeds on exactly the structure the basket sharpens. Set those two cells aside and the picture is sober: against trending single names the long-only rule sits out too much of the drift to match holding (BNB rose ~348× and the rule captures a fraction in every cell).
On resampled history the edge holds almost everywhere — 26 of 28 cells profitable, the only miss BTC at 30m (82 on 100). The Rolling Sharpe ladder returns (BTC 0.08 / 0.64 / 0.83 / 1.41), the slow cells compound hard (SOL 1d 132,288, DOGE 1d 29,796), and the basket at 30m again reaches six figures (856,034). Because resampling keeps each block's trends and crashes intact but reorders the blocks into a history the rule has never seen, performance this durable means the breakout rides the market's own momentum structure, not one memorized chronology.
Fees are the binding constraint at speed, not at the slow timeframes. At 30m the gross-to-net gap is brutal — BTC grosses 179 and nets 38; the basket pays 72,378 in fees across 2,689 trades — while by the daily timeframe fees nearly vanish (BTC 1d pays 56 over 73 trades). The strategy's profitable core lives where it trades least.
Caveats. Results come from one historical window with fixed (20, 20, 2) parameters; true range is measured from the close as the absolute bar-to-bar move, a close-only stand-in for the high–low range; the basket is five liquid survivors (and ALL inherits that survivorship); the strategy is long-only over a window in which every symbol rose, a backdrop that flatters any rule that holds through trends; the resampled run is a single draw — one alternative history — not a distribution; and prices are spot klines while the fee rate models the futures taker rate. The headline numbers are concentrated in the basket's two fast cells, which lean hardest on the fee model and the basket's artificial smoothness — treat them as the least trustworthy in the table.
Call: pursue. The breakout is broadly profitable after fees, improves under risk adjustment as the timeframe slows, and — unlike every mean-reversion study here — survives the resampled history that stands in for the future, the signature of a real trend edge rather than drift or a single lucky path. The credible version of the strategy is the 4h and 1d single-market cells, where Sharpe sits near 0.9–1.3, fees are negligible, and the result repeats out of sample; the basket's fast-timeframe moonshots are the part to distrust. Worth carrying forward with a stop, explicit position sizing, and a parameter-robustness sweep before any further claim.