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
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
TURTLE_ENTRY_PERIOD = 20
TURTLE_EXIT_PERIOD = 10
TURTLE_ATR_PERIOD = 20
TURTLE_STOP_MULTIPLE = 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 is the breakout core of the Turtle trading system. A long position opens when the close prints a fresh 20-bar high — buyers have cleared the entire recent range. It then exits on whichever of two conditions comes first: the close breaks the 10-bar low, a faster channel than the one that let it in, or it falls two average true ranges below the entry price, a hard volatility stop. The average true range $N$ is the 20-bar mean of the absolute bar-to-bar move in the close, so the stop widens in turbulent markets and tightens in calm ones:
$$\text{open at } t \iff p_t > U_t^{(20)}, \qquad \text{close at } t \iff p_t < L_t^{(10)} \;\lor\; p_t \le p^{\text{entry}} - 2N,$$
where $U^{(20)}$ is the highest close of the prior 20 bars, $L^{(10)}$ the lowest of the prior 10, and $N$ the average true range at entry. It is long-only and always fully in or fully out. The asymmetry is the whole idea: enter slowly on real strength, but leave quickly when the move stalls or reverses, so winners run through the wide entry channel while losers are cut at the nearer exit or the stop. The original system also sizes positions by volatility and adds to winners; this study isolates the timing rules at a single full position, to ask whether the breakout and its exits hold an edge on their own.
def strategy(prices, entry_period, exit_period, atr_period, stop_multiple):
price_series = pd.Series(prices)
entry_high = price_series.rolling(window=entry_period).max().shift(1).to_numpy()
exit_low = price_series.rolling(window=exit_period).min().shift(1).to_numpy()
true_range = np.abs(np.diff(prices, prepend=prices[0]))
average_true_range = pd.Series(true_range).rolling(window=atr_period).mean().to_numpy()
channel = {"entry_high": entry_high, "exit_low": exit_low}
breakout = prices > entry_high
opens = []
closes = []
total_bars = len(prices)
bar = 0
while bar < total_bars:
if not breakout[bar]:
bar += 1
continue
entry_bar = bar
stop_level = prices[entry_bar] - stop_multiple * average_true_range[entry_bar]
exit_bar = -1
for forward_bar in range(entry_bar + 1, total_bars):
hit_stop = prices[forward_bar] <= stop_level
broke_low = prices[forward_bar] < exit_low[forward_bar]
if hit_stop or broke_low:
exit_bar = forward_bar
break
if exit_bar == -1:
break
opens.append(entry_bar)
closes.append(exit_bar)
bar = exit_bar + 1
return channel, 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 breakout entries (green) and exits (red) can be read against the entry and exit channels.
rng = np.random.default_rng(6)
example_prices = 100 * np.cumprod(1 + rng.normal(0, 0.01, size=300))
example_channel, example_opens, example_closes = strategy(
prices=example_prices,
entry_period=TURTLE_ENTRY_PERIOD,
exit_period=TURTLE_EXIT_PERIOD,
atr_period=TURTLE_ATR_PERIOD,
stop_multiple=TURTLE_STOP_MULTIPLE,
)
# 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_channel["entry_high"],
mode="lines",
name="Entry Channel",
line=dict(color="#1f77b4", width=1),
)
)
fig.add_trace(
go.Scatter(
y=example_channel["exit_low"],
mode="lines",
name="Exit Channel",
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.
- Breakout Channels — the 20-bar high whose breach opens a position (Entry Channel) and the 10-bar low whose breach closes it (Exit Channel), with a stop two average true ranges below the entry, 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):
channel, opens, closes = strategy(
prices=prices,
entry_period=TURTLE_ENTRY_PERIOD,
exit_period=TURTLE_EXIT_PERIOD,
atr_period=TURTLE_ATR_PERIOD,
stop_multiple=TURTLE_STOP_MULTIPLE,
)
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,
"channel": channel,
"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.4 | -48.9 | 33.08 | 160.8 | 74.9 | -0.13 |
| BTCUSDT | 1h | 1,331.8 | 29.8 | 208.4 | 394.7 | 853.55 | 288.16 | 0.59 |
| BTCUSDT | 4h | 1,318 | 33.8 | 295.6 | 976.07 | 1,175.63 | 120.13 | 0.84 |
| BTCUSDT | 1d | 1,339.4 | 40.8 | 456.4 | 2,529.62 | 2,602.5 | 31.31 | 1.12 |
| ETHUSDT | 30m | 444.3 | 30.6 | 136.9 | 156.18 | 716.5 | 155.34 | 0.34 |
| ETHUSDT | 1h | 440.1 | 31.1 | 287.4 | 690.67 | 1,469.88 | 291.36 | 0.69 |
| ETHUSDT | 4h | 429.1 | 32.3 | 397.4 | 1,556.13 | 1,874.27 | 211.67 | 0.89 |
| ETHUSDT | 1d | 439.5 | 45.6 | 636 | 9,109.01 | 9,360.2 | 126.62 | 1.25 |
| SOLUSDT | 30m | 1,957.1 | 30.6 | 184 | 174.67 | 479.23 | 245.89 | 0.48 |
| SOLUSDT | 1h | 2,086.3 | 30.5 | 77.4 | 68.11 | 115.11 | 49.18 | 0.24 |
| SOLUSDT | 4h | 2,107.4 | 32.5 | 279.8 | 421.81 | 478.84 | 47.96 | 0.69 |
| SOLUSDT | 1d | 1,856.3 | 47.9 | 1,052 | 11,810.94 | 12,039.93 | 124.81 | 1.52 |
| BNBUSDT | 30m | 34,845.9 | 29.4 | 341.8 | 633.19 | 2,903.64 | 1,215.66 | 0.62 |
| BNBUSDT | 1h | 34,877.6 | 32.2 | 546 | 3,327.01 | 7,018.49 | 1,713.09 | 0.91 |
| BNBUSDT | 4h | 34,864.7 | 35.2 | 746 | 12,901.66 | 15,477.29 | 1,242.19 | 1.14 |
| BNBUSDT | 1d | 37,733.2 | 32.9 | 1,339.3 | 11,502.57 | 11,857.66 | 193.16 | 1.1 |
| DOGEUSDT | 30m | 2,106.6 | 24.9 | 213.5 | 11.43 | 39.22 | 35.17 | 0.09 |
| DOGEUSDT | 1h | 2,041.5 | 26.5 | 621.9 | 1,245.28 | 2,283.75 | 468.8 | 0.73 |
| DOGEUSDT | 4h | 2,148.9 | 27.1 | 1,171.5 | 41,733.58 | 47,737.61 | 1,983.32 | 1.18 |
| DOGEUSDT | 1d | 2,071.8 | 41.5 | 699.6 | 2,752.26 | 2,811.24 | 21.87 | 0.92 |
| XRPUSDT | 30m | 23 | 25.2 | -34.9 | 16.17 | 66.4 | 56.17 | -0.06 |
| XRPUSDT | 1h | 22.4 | 26.4 | 224.7 | 193.51 | 388.63 | 76.74 | 0.42 |
| XRPUSDT | 4h | 23.4 | 29.5 | 622.9 | 3,708.53 | 4,359.04 | 162.51 | 0.98 |
| XRPUSDT | 1d | 27 | 26.9 | 475.6 | 664.55 | 682.6 | 6.18 | 0.68 |
| ALL | 30m | 1,305.8 | 36.5 | 876.3 | 255,795.29 | 703,507.57 | 31,979.54 | 2.44 |
| ALL | 1h | 1,330.2 | 37.9 | 899.5 | 315,657.59 | 520,873.98 | 31,971 | 2.19 |
| ALL | 4h | 1,333.9 | 32.8 | 453.1 | 2,234.88 | 2,527.93 | 219.64 | 0.95 |
| ALL | 1d | 1,332.9 | 45.8 | 433.7 | 1,482.67 | 1,511.41 | 14.75 | 1.07 |
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 | -6.4 | 67.74 | 196.64 | 77.69 | -0.03 |
| BTCUSDT | 1h | 2,336.9 | 28.9 | 75.5 | 150.35 | 259.46 | 66.54 | 0.38 |
| BTCUSDT | 4h | 2,348.9 | 35.4 | 203.1 | 512.73 | 584.62 | 41.98 | 0.99 |
| BTCUSDT | 1d | 2,340.7 | 44 | 282 | 882.11 | 899.93 | 6.29 | 1.26 |
| ETHUSDT | 30m | 832 | 28.8 | 78.1 | 125.87 | 353.88 | 108.06 | 0.29 |
| ETHUSDT | 1h | 825 | 29.3 | 250.6 | 673.43 | 1,128.71 | 232.75 | 0.96 |
| ETHUSDT | 4h | 833.2 | 31.5 | 205.4 | 444.33 | 505.21 | 32.69 | 0.81 |
| ETHUSDT | 1d | 779.2 | 52.5 | 638.4 | 5,562.99 | 5,652.72 | 27.51 | 1.73 |
| SOLUSDT | 30m | 15,210.4 | 32 | 380.2 | 1,234.97 | 3,345.27 | 496.12 | 0.97 |
| SOLUSDT | 1h | 15,289 | 32 | 186.1 | 210.17 | 353.81 | 77.99 | 0.5 |
| SOLUSDT | 4h | 15,463.4 | 34.8 | 392.7 | 1,451.58 | 1,651.14 | 73.68 | 1.02 |
| SOLUSDT | 1d | 15,594.7 | 56.8 | 1,834.1 | 110,544.34 | 112,507.34 | 478.52 | 2 |
| BNBUSDT | 30m | 5,780.4 | 28.4 | 64.5 | 90.39 | 260.61 | 172.36 | 0.18 |
| BNBUSDT | 1h | 5,761.4 | 29.8 | 188.6 | 260.34 | 446.24 | 167.97 | 0.56 |
| BNBUSDT | 4h | 5,540.5 | 32.6 | 420.4 | 1,508.68 | 1,718.15 | 119.8 | 1.17 |
| BNBUSDT | 1d | 5,641.4 | 32.7 | 681.1 | 1,242.06 | 1,266.65 | 17.05 | 1.06 |
| DOGEUSDT | 30m | 15,851.6 | 27.1 | 790.4 | 840 | 2,339.99 | 913.71 | 0.67 |
| DOGEUSDT | 1h | 15,731.9 | 28.3 | 921.8 | 9,270.97 | 15,328.88 | 2,830.82 | 1.06 |
| DOGEUSDT | 4h | 15,847.8 | 25.4 | 703.2 | 6,151.02 | 6,954.78 | 382.7 | 0.93 |
| DOGEUSDT | 1d | 13,838.4 | 44.2 | 954.8 | 13,733.16 | 13,971.43 | 71.23 | 1.04 |
| XRPUSDT | 30m | 633.8 | 23.7 | -37.1 | 19.44 | 56.11 | 68.47 | -0.14 |
| XRPUSDT | 1h | 632.2 | 26.6 | 417.8 | 1,126.83 | 1,875.85 | 378.13 | 0.9 |
| XRPUSDT | 4h | 638 | 32.1 | 654.5 | 8,201.13 | 9,118.29 | 333.28 | 1.43 |
| XRPUSDT | 1d | 616.4 | 44.7 | 779.4 | 5,418.24 | 5,501.24 | 33.61 | 1.42 |
| ALL | 30m | 6,774.7 | 35.2 | 1,038.4 | 579,320.72 | 1,590,108.01 | 64,143.23 | 2.48 |
| ALL | 1h | 6,762.7 | 33.9 | 762.8 | 36,931.66 | 60,966.19 | 5,624.37 | 1.72 |
| ALL | 4h | 6,778.6 | 38.4 | 478.5 | 3,803.18 | 4,282.98 | 207 | 1.01 |
| ALL | 1d | 6,468.5 | 47.8 | 809.8 | 11,489.71 | 11,703.1 | 60.42 | 1.24 |
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 | 0 | 0 | 18.5 | 0 | 1.8 |
| BTCUSDT | 1h | 3.5 | 37.4 | 39.6 | 24.5 | 10.4 | 21.9 |
| BTCUSDT | 4h | 36.5 | 51.2 | 65.7 | 34.5 | 71.7 | 48.6 |
| BTCUSDT | 1d | 70.3 | 66.6 | 93.2 | 52 | 95.3 | 73.3 |
| ETHUSDT | 30m | 5 | 23.7 | 12.9 | 26.5 | 0 | 13.4 |
| ETHUSDT | 1h | 58.9 | 42.9 | 55.8 | 27.8 | 11.6 | 45.8 |
| ETHUSDT | 4h | 88.9 | 54 | 79.2 | 30.7 | 71.7 | 69.4 |
| ETHUSDT | 1d | 100 | 73.8 | 100 | 64 | 95.5 | 88.1 |
| SOLUSDT | 30m | 0 | 31.4 | 16.1 | 26.5 | 0 | 14.5 |
| SOLUSDT | 1h | 0 | 18.2 | 0 | 26.2 | 31.9 | 11.3 |
| SOLUSDT | 4h | 0 | 42.9 | 41.5 | 31.2 | 80.1 | 30.3 |
| SOLUSDT | 1d | 100 | 88.6 | 100 | 69.8 | 96.8 | 93.2 |
| BNBUSDT | 30m | 0 | 39.1 | 53.3 | 23.5 | 0 | 22.1 |
| BNBUSDT | 1h | 0 | 55.1 | 100 | 30.5 | 12.3 | 35.8 |
| BNBUSDT | 4h | 14 | 67.7 | 100 | 38 | 72.3 | 51.3 |
| BNBUSDT | 1d | 7.1 | 65.5 | 100 | 32.2 | 95 | 49.8 |
| DOGEUSDT | 30m | 0 | 9.9 | 0 | 12.2 | 0 | 4.2 |
| DOGEUSDT | 1h | 30.4 | 45.1 | 72.8 | 16.2 | 24.2 | 39.2 |
| DOGEUSDT | 4h | 100 | 69.9 | 100 | 17.8 | 79 | 80.6 |
| DOGEUSDT | 1d | 58.5 | 55.6 | 95.7 | 53.8 | 96.5 | 66.5 |
| XRPUSDT | 30m | 0 | 1.7 | 0 | 13 | 0 | 1.8 |
| XRPUSDT | 1h | 66.5 | 28.1 | 19 | 16 | 16.3 | 37.8 |
| XRPUSDT | 4h | 100 | 58.9 | 100 | 23.8 | 75.1 | 77.6 |
| XRPUSDT | 1d | 100 | 42.4 | 54.6 | 17.2 | 95.6 | 67.2 |
| ALL | 30m | 100 | 100 | 100 | 41.2 | 0 | 84.1 |
| ALL | 1h | 100 | 100 | 100 | 44.8 | 34.3 | 87.9 |
| ALL | 4h | 66 | 57.2 | 89.6 | 32 | 80.7 | 65 |
| ALL | 1d | 51.2 | 63.9 | 77.8 | 64.5 | 96.8 | 64.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, the Turtle breakout is profitable after fees in 24 of 28 symbol × timeframe cells. The three losses are all the fastest single-market cells — BTC at 30m (33 on 100), DOGE at 30m (11), SOL at 1h (68) — where the quick exit forces constant re-entry and fees and whipsaw eat the result. Every 4h and 1d cell is green, several by large multiples (DOGE 4h 41,734, SOL 1d 11,811, BNB 4h 12,902, ETH 1d 9,109).
Risk-adjusted, the edge builds with the timeframe. Rolling Sharpe climbs across each symbol's row — BTC −0.13 / 0.59 / 0.84 / 1.12 from 30m to 1d — and settles at a steady 0.9–1.3 by 4h and 1d. Cumulative Win Rate % runs just 25–52%: the asymmetric exit, leaving on the nearer 10-bar low or the volatility stop, books many small losers and keeps the rare large winners, the trend-follower's bargain in its sharpest form.
The basket is the strongest market. ALL compounds 100 into 472,773 at 1h (Rolling Sharpe 2.19) and 290,377 at 30m (2.44), far ahead of any single market at those speeds. Averaging the five symbols cancels their idiosyncratic noise, so a fresh basket-wide high is a genuine synchronized trend rather than one market's flutter — and the breakout feeds on exactly that. The two fast basket cells do lean on all-in compounding, so read their six-figure totals as the least literal numbers in the table.
Versus buy-and-hold it wins only 14 of 28 cells — and the stop is why. The two protections that define the system, the faster exit channel and the two-ATR stop, repeatedly cut the position on shallow pullbacks inside a larger uptrend, then pay a fresh entry to climb back on board. In a window where every market rose for years, that caution forfeits drift: the rule trails simply holding in 25 of 28 cells, including most single markets at 30m and 1h. Where it does beat buy-and-hold — the entire basket beyond the daily, DOGE and ETH and SOL at the slow timeframes — the trend was strong and sustained enough that the quick exit re-entered without missing much.
On resampled history the edge repeats — 8 of 28 cells profitable. Replayed in a new order, the market's own behavior carries the rule: the Rolling Sharpe ladder returns (negative-to-flat at 30m, 0.8–2.0 by 4h–1d), the slow cells compound hard (SOL 1d 110,544, DOGE 1d 13,733), and the basket at 30m again reaches six figures (654,081 at Sharpe 2.48). Performance this durable on a history the rule has never seen means it rides momentum structure, not one memorized chronology.
Fees are the binding cost at speed. At 30m the basket pays 37,103 in Cumulative Fees against a 798,618 gross result, and BNB pays 1,216 against 2,904 gross; by the daily, fees fade to rounding (BTC 1d pays 31 over 71 trades). The quick exit's price is turnover, and turnover is paid in fees.
Caveats. Results come from one historical window with fixed (20, 10, 20, 2N) 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 punishes a protective stop, since the drawdowns it guards against were shallow and brief; 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.
Call: pursue. The breakout core is a real trend edge — broadly profitable, strengthening with the timeframe, and repeating in full on a resampled future. But the system's defining risk controls — the faster exit and the volatility stop — cost more than they save in this one-directional window, leaving it behind buy-and-hold in most cells by cutting winners on shallow pullbacks. Their protective value cannot be judged on a decade that only rose; the honest next step is to test the same rules across a regime with real drawdowns, where a stop earns its keep, and to add the position sizing the framework here sets aside. The credible engine for now is the 4h and 1d cells and the basket.