Contact
  • Enter Long When WMA20 Crosses Above WMA50, Exit When It Crosses Back Down
  • Imports
  • Config
  • Helpers
  • Strategy
  • Analytics
  • Charts
  • Synth Prices
  • Expectations Helpers
  • Load Data
  • Run
  • Synthetic Prices
  • Strategy on Synthetic
  • Expectations

Enter Long When WMA20 Crosses Above WMA50, Exit When It Crosses Back Down¶

Imports¶

In [1]:
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¶

In [2]:
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).$$

In [3]:
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}}}.$$

In [4]:
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.

In [5]:
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.

In [6]:
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\}.$$

In [7]:
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.

In [8]:
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.

In [9]:
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.

In [10]:
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.

In [11]:
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.

In [12]:
# 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
Home Contact
© Sort Finance. All rights reserved. For research and educational purposes only. Not financial advice.