Enter Long When WMA20 Crosses Above WMA50, Exit When It Crosses Back Down¶
Imports¶
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import talib
from binance.client import Client
from plotly.subplots import make_subplots
Config¶
SYMBOLS = ["DOGEUSDT", "BNBUSDT"]
START_DATE = "2026-01-01"
INTERVAL = "30m"
FEE_PCT = 0.02 / 100 # binance futures taker
INITIAL_CASH = 100
BARS_PER_YEAR = 365 * 24 * 2 # 30-minute bars
WMA_FAST_PERIOD = 20
WMA_SLOW_PERIOD = 50
N_SYNTH = 5
SYNTH_BLOCK_SIZE = 72 # bars per bootstrap block (~36h on 30m candles)
Helpers¶
Reusable functions used throughout the analysis.
Strategy¶
Enter long when the fast moving average crosses above the slow one; close the position when it crosses back down.
The weighted moving average over $n$ bars weights recent bars more heavily:
$$\mathrm{WMA}_n(t) = \frac{\sum_{i=0}^{n-1}(n-i)\, p_{t-i}}{\sum_{i=1}^{n} i}.$$
A long entry fires the moment the fast WMA crosses above the slow:
$$\text{open at } t \iff \mathrm{WMA}_{20}(t-1) \le \mathrm{WMA}_{50}(t-1) \;\land\; \mathrm{WMA}_{20}(t) > \mathrm{WMA}_{50}(t).$$
def strategy(prices):
wma = {
"fast": talib.WMA(prices, timeperiod=WMA_FAST_PERIOD),
"slow": talib.WMA(prices, timeperiod=WMA_SLOW_PERIOD),
}
# Cross detection via edge of the boolean signal.
above = (wma["fast"] > wma["slow"]).astype(np.int8)
change = np.diff(above)
opens = np.where(change == 1)[0] + 1
closes = np.where(change == -1)[0] + 1
return wma, opens, closes
Analytics¶
Per-trade and cumulative performance: equity, P&L, win rate, fees, and Sharpe ratio.
Each trade's net return factor accounts for fees on entry and exit:
$$r_i = (1 - f)^2 \cdot \frac{p^{\text{exit}}_i}{p^{\text{entry}}_i},$$
where $f$ is the fee rate per side. Equity compounds across trades:
$$E_n = E_0 \prod_{i=1}^{n} r_i.$$
The annualized Sharpe ratio uses trade-level net returns $x_i = r_i - 1$ and rescales by the observed trade frequency $T_{\text{year}}$:
$$S = \frac{\bar x}{\mathrm{std}(x)}\,\sqrt{T_{\text{year}}}.$$
def analytics(symbol):
prices = np.array([float(b[4]) for b in bars[symbol]])
wma, opens, closes = strategy(prices=prices)
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
pnl_pct = (factor - 1) * 100
total_trades = pnls.size
cum_winrate = np.cumsum(pnls > 0) / np.arange(1, total_trades + 1) * 100
pct_cum_pnl = np.cumsum(pnl_pct)
pnl_pct_no_fee = (exit_prices / entry_prices - 1) * 100
pct_cum_pnl_no_fee = np.cumsum(pnl_pct_no_fee)
# Sharpe: per-trade fractional returns annualized by the observed
# trade frequency. sharpe_per_trade = mean / std,
# annual = sharpe_per_trade * sqrt(trades_per_year).
returns = factor - 1
returns_no_fee = exit_prices / entry_prices - 1
sharpe = 0.0
sharpe_no_fee = 0.0
if total_trades > 1 and len(prices) > 0:
trades_per_year = total_trades * BARS_PER_YEAR / len(prices)
sd = float(np.std(returns, ddof=1))
sd_no_fee = float(np.std(returns_no_fee, ddof=1))
if sd > 0:
sharpe = float(np.mean(returns)) / sd * np.sqrt(trades_per_year)
if sd_no_fee > 0:
sharpe_no_fee = float(np.mean(returns_no_fee)) / sd_no_fee * np.sqrt(trades_per_year)
# Cumulative (running) Sharpe — Sharpe-to-date after each trade.
with np.errstate(invalid="ignore", divide="ignore"):
cum_mean = np.cumsum(returns) / np.arange(1, total_trades + 1)
cum_var = (np.cumsum(returns**2) / np.arange(1, total_trades + 1)) - cum_mean**2
cum_std = np.sqrt(np.clip(cum_var, 0, None))
cum_sharpe_per_trade = np.where(cum_std > 0, cum_mean / cum_std, 0.0)
cum_sharpe = cum_sharpe_per_trade * np.sqrt(
np.arange(1, total_trades + 1) * BARS_PER_YEAR / max(len(prices), 1)
)
return {
"symbol": symbol,
"prices": prices,
"wma": wma,
"opens": opens,
"closes": closes,
"pnl_pct": pnl_pct,
"cum_winrate": cum_winrate,
"pct_cum_pnl": pct_cum_pnl,
"pct_cum_pnl_no_fee": pct_cum_pnl_no_fee,
"equity": equity,
"equity_no_fee": equity_no_fee,
"fees": fees,
"cum_fees": cum_fees,
"sharpe": sharpe,
"sharpe_no_fee": sharpe_no_fee,
"cum_sharpe": cum_sharpe,
}
Charts¶
Eight per-symbol views: price, per-trade P&L, cumulative win rate, cumulative P&L, equity, fees, cumulative fees, and rolling Sharpe.
def charts(results):
symbols_list = list(results.keys())
n_cols = len(symbols_list)
metric_titles = [
"pct_prices",
"pnl_pct",
"cum_winrate",
"pct_cum_pnl, pct_cum_pnl_no_fee",
"equity, equity_no_fee",
"fees",
"cum_fees",
"cum_sharpe",
]
# column-major fill: each metric row groups symbols side-by-side.
subplot_titles = [f"{sym} — {title}" for title in metric_titles for sym in symbols_list]
col_width = 500
row_height = 170
gap_px = 60
total_w = col_width * n_cols + gap_px * max(n_cols - 1, 0)
total_h = row_height * 8
h_spacing = gap_px / total_w if n_cols > 1 else 0 # constant pixel gap
fig = make_subplots(
rows=8,
cols=n_cols,
shared_xaxes=True,
vertical_spacing=0.02,
horizontal_spacing=h_spacing,
subplot_titles=subplot_titles,
)
for col_idx, symbol in enumerate(symbols_list, start=1):
a = results[symbol]
prices = a["prices"]
wma = a["wma"]
opens = a["opens"]
closes = a["closes"]
pnl_pct = a["pnl_pct"]
cum_winrate = a["cum_winrate"]
pct_cum_pnl = a["pct_cum_pnl"]
pct_cum_pnl_no_fee = a["pct_cum_pnl_no_fee"]
equity = a["equity"]
equity_no_fee = a["equity_no_fee"]
fees = a["fees"]
cum_fees = a["cum_fees"]
cum_sharpe = a["cum_sharpe"]
pct_prices = (prices / prices[0] - 1) * 100
bar_width = max(len(prices) / 200, 5)
win_colors = np.where(pnl_pct > 0, "#00d4aa", "#ff3b30")
# row 1: pct_prices + WMA + entry/exit markers
fig.add_trace(
go.Scatter(
y=pct_prices, mode="lines", name="pct_prices", line=dict(color="#888", width=1)
),
row=1,
col=col_idx,
)
# row 2: pnl_pct
fig.add_trace(
go.Bar(
x=opens,
y=pnl_pct,
name="pnl_pct",
marker=dict(color=win_colors, line=dict(width=0)),
width=bar_width,
),
row=2,
col=col_idx,
)
# row 3: cum_winrate
fig.add_trace(
go.Scatter(
x=opens,
y=cum_winrate,
mode="lines",
name="cum_winrate",
line=dict(color="#f1c40f", width=1),
),
row=3,
col=col_idx,
)
fig.add_hline(y=50, line=dict(color="#bbb", dash="dash"), row=3, col=col_idx)
# row 4: pct_cum_pnl vs no-fee
fig.add_trace(
go.Scatter(
x=opens,
y=pct_cum_pnl_no_fee,
mode="lines",
name="pct_cum_pnl_no_fee",
line=dict(color="#7fb069", width=1, dash="dot"),
),
row=4,
col=col_idx,
)
fig.add_trace(
go.Scatter(
x=opens,
y=pct_cum_pnl,
mode="lines",
name="pct_cum_pnl",
line=dict(color="#00d4aa", width=1),
fill="tozeroy",
fillcolor="rgba(0,212,170,0.10)",
),
row=4,
col=col_idx,
)
fig.add_hline(y=0, line=dict(color="#bbb", dash="dash"), row=4, col=col_idx)
# row 5: equity vs equity_no_fee
fig.add_trace(
go.Scatter(
x=opens,
y=equity_no_fee,
mode="lines",
name="equity_no_fee",
line=dict(color="#7fb069", width=1, dash="dot"),
),
row=5,
col=col_idx,
)
fig.add_trace(
go.Scatter(
x=opens,
y=equity,
mode="lines",
name="equity",
line=dict(color="#1f77b4", width=1),
fill="tozeroy",
fillcolor="rgba(31,119,180,0.10)",
),
row=5,
col=col_idx,
)
fig.add_hline(y=INITIAL_CASH, line=dict(color="#bbb", dash="dash"), row=5, col=col_idx)
# row 6: fees per trade
fig.add_trace(
go.Bar(
x=opens,
y=fees,
name="fees",
marker=dict(color="#d35400", line=dict(width=0)),
width=bar_width,
),
row=6,
col=col_idx,
)
# row 7: cum_fees
fig.add_trace(
go.Scatter(
x=opens,
y=cum_fees,
mode="lines",
name="cum_fees",
line=dict(color="#d35400", width=1),
fill="tozeroy",
fillcolor="rgba(211,84,0,0.15)",
),
row=7,
col=col_idx,
)
# row 8: cumulative annualized Sharpe ratio
fig.add_trace(
go.Scatter(
x=opens,
y=cum_sharpe,
mode="lines",
name="cum_sharpe",
line=dict(color="#bb86fc", width=1),
fill="tozeroy",
fillcolor="rgba(187,134,252,0.12)",
),
row=8,
col=col_idx,
)
fig.add_hline(y=0, line=dict(color="#bbb", dash="dash"), row=8, col=col_idx)
fig.add_hline(y=1, line=dict(color="#ccc", dash="dot"), row=8, col=col_idx)
fig.update_layout(
template="plotly_white",
height=total_h,
width=total_w,
showlegend=False,
hovermode="x unified",
margin=dict(l=60, r=20, t=40, b=40),
bargap=0,
)
fig.update_xaxes(showgrid=True)
fig.update_yaxes(showgrid=True, zeroline=True)
fig.update_yaxes(range=[-3, 3], row=8) # clamp cum_sharpe view
fig.update_yaxes(showline=True, linewidth=1, row=2) # left border for pnl_pct
fig.update_yaxes(showline=True, linewidth=1, row=6) # left border for fees
for annotation in fig.layout.annotations:
annotation.update(font=dict(size=12))
fig.show()
Synth Prices¶
A synthetic price path built by block-bootstrapping the real log-returns. Each path keeps the original's return distribution and short-term autocorrelation but rearranges the order — an "alternate history" of the same market.
Log-returns:
$$\ell_t = \log p_t - \log p_{t-1}.$$
The path is reconstructed from a permutation of consecutive return blocks of length $k$:
$$\tilde p_t = p_0 \cdot \exp\!\Bigl(\textstyle\sum_{s=1}^{t}\tilde\ell_s\Bigr).$$
Block length controls how much serial structure survives. Single-bar bootstrap ($k = 1$) preserves only the distribution of returns; longer blocks keep more local trend and volatility clustering at the cost of variety across draws.
def synth_prices(prices, block, seed):
rng = np.random.default_rng(seed)
log_returns = np.diff(np.log(prices))
block_count = len(log_returns) // block + 1
block_starts = rng.integers(0, len(log_returns) - block + 1, size=block_count)
sampled = np.concatenate([log_returns[s : s + block] for s in block_starts])[: len(log_returns)]
return prices[0] * np.exp(np.concatenate(([0.0], np.cumsum(sampled))))
Expectations Helpers¶
Maximum drawdown — the deepest peak-to-trough decline of the equity curve, as a fraction:
$$\mathrm{DD}_{\max} = \max_{t}\!\left(1 - \frac{E_t}{\max_{s \le t} E_s}\right).$$
The share of synthetic runs the real result outperforms, for each metric:
$$\widehat{P}(\text{real} > \text{synth}) = \frac{1}{N}\sum_{j=1}^{N} \mathbf{1}\!\left\{m^{\text{synth}}_j < m^{\text{real}}\right\}.$$
def max_drawdown(equity):
peak = np.maximum.accumulate(equity)
return float(1 - (equity / peak).min())
def left_aligned_table(df):
return df.style.set_properties(**{"text-align": "left"}).set_table_styles(
[{"selector": "th", "props": [("text-align", "left")]}],
)
def run_metrics(a):
equity = a["equity"]
return {
"sharpe": float(a["sharpe"]),
"final_equity": float(equity[-1]),
"total_return%": float((equity[-1] / equity[0] - 1) * 100) if len(equity) else 0.0,
"winrate%": float(a["cum_winrate"][-1]) if len(a["cum_winrate"]) else 0.0,
"trades": int(len(a["pnl_pct"])),
"max_dd%": max_drawdown(equity=equity) * 100 if len(equity) else 0.0,
"fees_paid": float(a["cum_fees"][-1]) if len(a["cum_fees"]) else 0.0,
}
def summarize(symbol, real_metrics, synth_metric_list):
synth_df = pd.DataFrame(synth_metric_list)
rows = {
"metric": [],
"real": [],
"synth_median": [],
"synth_p5": [],
"synth_p95": [],
"P(real > synth)": [],
}
for metric_name in real_metrics:
synth_values = synth_df[metric_name]
rows["metric"].append(metric_name)
rows["real"].append(real_metrics[metric_name])
rows["synth_median"].append(float(synth_values.median()))
rows["synth_p5"].append(float(synth_values.quantile(0.05)))
rows["synth_p95"].append(float(synth_values.quantile(0.95)))
rows["P(real > synth)"].append(float((synth_values < real_metrics[metric_name]).mean()))
df = pd.DataFrame(rows).set_index("metric").round(3)
df.columns.name = symbol
return left_aligned_table(df=df)
Load Data¶
Historical OHLCV from Binance, by symbol.
client = Client(ping=False)
bars = {
symbol: client.get_historical_klines(
symbol=symbol,
interval=INTERVAL,
start_str=START_DATE,
end_str="now UTC",
)
for symbol in SYMBOLS
}
Run¶
The strategy applied to actual historical prices.
charts(results={symbol: analytics(symbol=symbol) for symbol in SYMBOLS})
Synthetic Prices¶
Five synthetic alternative price paths per symbol, each generated by block-bootstrap of the real log-returns.
synthetics = {
symbol: [
synth_prices(
prices=np.array([float(b[4]) for b in bars[symbol]]),
block=SYNTH_BLOCK_SIZE,
seed=seed,
)
for seed in range(N_SYNTH)
]
for symbol in SYMBOLS
}
Strategy on Synthetic¶
The same metrics and charts, computed on the real path and each synthetic alternative side-by-side.
results = {}
for symbol in SYMBOLS:
results[symbol] = analytics(symbol=symbol)
for index, synth in enumerate(synthetics[symbol]):
label = f"{symbol}_SYNTH{index}"
bars[label] = [[0, 0, 0, 0, float(price), 0, 0, 0, 0, 0, 0, 0] for price in synth]
results[label] = analytics(symbol=label)
charts(results=results)
Expectations¶
Each symbol's synthetic runs form an empirical distribution of possible outcomes. Comparing the real result against that distribution shows whether the strategy's edge is robust or path-dependent.
For each metric the table reports the real value, the synthetic median, the 5th and 95th percentiles, and the share of synthetic runs the real result beats.
# Group results back into {symbol: {"real": ..., "synth": [...]}}.
grouped = {}
for label, a in results.items():
if "_SYNTH" in label:
base_symbol = label.split("_SYNTH")[0]
grouped.setdefault(base_symbol, {"real": None, "synth": []})["synth"].append(
run_metrics(a=a)
)
else:
grouped.setdefault(label, {"real": None, "synth": []})["real"] = run_metrics(a=a)
for symbol, group in grouped.items():
if group["real"] is None or not group["synth"]:
continue
profitable_count = sum(1 for m in group["synth"] if m["final_equity"] > INITIAL_CASH)
profitable_share = profitable_count / len(group["synth"])
print(
f"=== {symbol} | P(synth profit > 0) = "
f"{profitable_share:.0%} of {len(group['synth'])} runs ==="
)
display(
summarize(
symbol=symbol,
real_metrics=group["real"],
synth_metric_list=group["synth"],
)
)
=== DOGEUSDT | P(synth profit > 0) = 0% of 5 runs ===
| DOGEUSDT | real | synth_median | synth_p5 | synth_p95 | P(real > synth) |
|---|---|---|---|---|---|
| metric | |||||
| sharpe | -0.491000 | -1.172000 | -6.472000 | -0.218000 | 0.800000 |
| final_equity | 87.378000 | 68.861000 | 45.162000 | 91.950000 | 0.600000 |
| total_return% | -22.180000 | -29.110000 | -54.417000 | -6.791000 | 0.600000 |
| winrate% | 35.417000 | 25.243000 | 23.666000 | 35.531000 | 0.800000 |
| trades | 96.000000 | 102.000000 | 87.800000 | 102.800000 | 0.400000 |
| max_dd% | 31.828000 | 31.199000 | 25.307000 | 55.697000 | 0.600000 |
| fees_paid | 3.611000 | 3.132000 | 2.634000 | 4.074000 | 0.800000 |
=== BNBUSDT | P(synth profit > 0) = 20% of 5 runs ===
| BNBUSDT | real | synth_median | synth_p5 | synth_p95 | P(real > synth) |
|---|---|---|---|---|---|
| metric | |||||
| sharpe | 0.049000 | -0.756000 | -1.827000 | 0.773000 | 0.800000 |
| final_equity | 98.865000 | 89.852000 | 75.028000 | 111.726000 | 0.800000 |
| total_return% | -2.193000 | -9.723000 | -25.616000 | 12.251000 | 0.800000 |
| winrate% | 32.673000 | 31.579000 | 21.942000 | 33.267000 | 0.600000 |
| trades | 101.000000 | 103.000000 | 89.600000 | 111.800000 | 0.400000 |
| max_dd% | 15.605000 | 19.863000 | 12.666000 | 28.155000 | 0.400000 |
| fees_paid | 3.895000 | 3.776000 | 3.390000 | 4.611000 | 0.600000 |