The strategy trades an instrument on an intraday chart (here, SPY on a 30-minute chart). When a green candle closes with an upper wick around 50% of its range and almost no lower wick — or the mirror, a red candle with a lower wick around 50% of its range and almost no upper wick — it enters in the direction of the dominant wick, targeting a retest of its extreme and stopping out at the candle's opposite extreme.
At most one trade is held at a time, at least 3 hours of session remains after the last entry, and any open position is closed at the day's close price regardless of whether target or stop has been reached. Developed and evaluated on roughly five years of 30-minute and 1-minute SPY bars from Interactive Brokers, following the 9-step Development Process and the Plutus Reproducibility Standard.
Full pipeline result: the original, un-tuned hypothesis (dominant wick 40–60%, opposite wick ≤20%) is weak in-sample (Sharpe 0.19, 51.4% win rate, +1.12% total return, mean R -0.02 ± 0.05). The optimization finds a higher-scoring candidate (dominant wick 45–55%, opposite wick ≤10%) that scores well in-sample (Sharpe 0.98, 57.4% win rate, +3.11% total return, mean R +0.12 ± 0.09) and falls back out-of-sample (Sharpe 0.36, 51.7% win rate, +0.63% total return, mean R +0.01 ± 0.13).
Observation and Hypothesis: Price frequently revisits levels it has already touched — call this overlap, a byproduct of market noise. This strategy targets candles (or parts of candles) most likely to be overlapped again: wicks rather than solid candle bodies. The wick needs to be long enough for a reasonable risk-reward ratio, but not so long that the candle starts to resemble a classic reversal pattern (inverted hammer or hanging man), which would raise the odds of getting stopped out — roughly 50% of the candle's range looks like the right target. The wick also has to sit on the same side as the candle's body: upper wick with a green body, lower wick with a red body. Read together, the wick signals indecision while the body's color signals a push in one direction. The hypothesis is that the push wins, and the dominant wick's extreme gets revisited (overlapped) by a later candle before the opposite wick's extreme gets revisited. The opposite wick has to stay short, since a long one (plus an already-long dominant wick) would undercut the candle's "greenness" or "redness," leaving the next move closer to a coin flip. Finally, this is tested on intraday candles rather than daily ones because intraday price moves continuously, without the gaps a daily chart has between sessions.
I start with a dominant wick that is between 40% and 60% of the range of the candle and an opposite wick that is at most 20% of the range of the candle. These values will be optimized on the in-sample data.
- Source: Interactive Brokers (
reqHistoricalDataviaib_async), SPY on SMART routing. - Bars: 30-minute (signal generation) and 1-minute (intrabar fill-order resolution — determining whether a trade's stop or target was touched first).
- Period: roughly five years, 2021-08-09 → 2026-08-06 (regular trading hours only). In-sample is the first three years (2021-08-09 → 2024-08-06), out-of-sample the most recent two (2024-08-07 → 2026-08-06).
# Requires IB Gateway or TWS running, logged in, and API access enabled
uv run --with ib_async python scripts/fetch_ibkr_data.pyTakes 3.5–4+ hours. The data is saved to data/spy_30min.csv and data/spy_1min.csv. scripts/fetch_ibkr_data.py uses fixed start/end dates to ensure reproducibility.
For a candle with open
Long setup (green candle,
Entry at
Short setup (red candle,
Entry at
- Entry: wick-ratio pattern above, on a closed 30-minute candle; enter at that candle's close.
- Exit: target = the signal candle's extreme in the trade direction; stop = its opposite extreme. Whichever is touched first, resolved using 1-minute bars walked forward from entry.
- Timing: an entry must leave at least three hours of session ahead of it, so the trade has room to reach target or stop (for a regular 4pm ET close, 1pm — the close of the 12:30–1:00 bar — is the last possible entry; for an early 1pm ET close, 10am — the close of the 9:30–10:00 bar — is the last possible entry). Any open position is force-closed at the day's close price, so no positions are held overnight.
- Concurrency: at most one open position at a time. A second (or third) trade the same day is fine once the prior one has exited, as long as it still leaves the three-hour entry runway.
-
Sizing: 100% of current equity, compounded (every trade uses the whole account, win or lose). Shares per trade:
shares = floor(equity / entry_price). Initial capital is $1,000,000 (INITIAL_CAPITALinsteps/_shared.py) — see Capital Sensitivity below for how results vary with capital. - Costs: commission $0.005/share, $1.00 minimum, charged on both the entry and exit fill. A fixed $0.01/share slippage (small, since SPY is liquid) applies only to stop fills — not to target fills (a limit order) or end-of-day forced closes.
Sharpe ratio (daily-aggregated, annualized over 252 days), Sortino ratio, win rate, total return, CAGR, maximum drawdown, and mean R (expectancy per unit of risk, stop-out is -1R before costs). Risk-free rate of 0% is used in the Sharpe and Sortino ratios to isolate the strategy's own performance. The distribution of exits by reason (target / stop / forced end-of-day) is also tracked.
The pipeline is packaged as half_wick. The pattern detector is half_wick.intraday.patterns. The backtest engine, half_wick.intraday.backtest, is the shared mechanism behind the in_sample, optimization, and out_of_sample steps.
uv sync # create the env from the committed lockfileDependencies are pinned by uv.lock; .python-version pins the interpreter to 3.11. This project declares no secrets — the IBKR data pull runs once by hand, outside the reproducible pipeline (see Data Preparation above), so there's no .env.
This repo ships a .plutus/manifest.yaml declaring the environment, data requirements, steps, and expected metrics. Reproduce results in an isolated Docker container with plutus-verify v0.5.1:
uv venv .plutus-venv && source .plutus-venv/bin/activate
uv pip install "plutus-verify[runner] @ https://github.com/algotrade-plutus/plutus-verify/releases/download/v0.5.1/plutus_verify-0.5.1-py3-none-any.whl"
plutus check .Exit code 0 = reproduced, 1 = partial, 2 = failed.
Baseline run of the strategy as originally specified (dominant-wick range 40–60%, opposite ≤20%) before optimization.
uv run python -m half_wick.steps.in_sample| Metric | Value |
|---|---|
| Sharpe Ratio | 0.190 |
| Sortino Ratio | 0.278 |
| Win Rate | 51.4% |
| Total Return | +1.12% |
| CAGR | +0.37% |
| Max Drawdown | −3.11% |
| Mean R | −0.019 ± 0.049 |
| Trades | 418 |
| Target Exits | 215 |
| Stop Exits | 203 |
| EOD Exits | 0 |
The search covers three parameters — the dominant-wick range's lower bound, upper bound, and the opposite-wick cap — searched via Optuna's BruteForceSampler on the Sharpe ratio. Range bounds run from 40% to 60% in 5% steps, with each range at least 10% wide (40–50, 40–55, 40–60, 45–55, 45–60, 50–60), crossed with opposite-wick caps of 0, 5, 10, 15 and 20% — 30 combinations in all. Sizing and the mechanical rules (one trade at a time, EOD close, at least 3 hours of session after last entry) stay fixed, not searched. Any combination producing fewer than 100 in-sample trades is penalized regardless of its Sharpe, to keep the strategy usable — a rule that fires a handful of times a year can't be paper-traded.
uv run python -m half_wick.steps.optimization| Metric | Value |
|---|---|
| Combinations Evaluated | 30 (11 penalized due to firing less than 100 times across the 3-year in-sample window) |
| Best Dominant-wick Range | 45%–55% |
| Best Opposite-wick Cap | ≤10% |
| Sharpe Ratio | 0.98 |
| Sortino Ratio | 1.57 |
| Win Rate | 57.4% |
| Total Return | +3.11% |
| CAGR | +1.03% |
| Max Drawdown | −1.89% |
| Mean R | +0.120 ± 0.094 |
| Trades | 115 |
| Target Exits | 66 |
| Stop Exits | 49 |
| EOD Exits | 0 |
Winning parameters are also written to parameter/optimized_params.json.
This chart and Section 6's can be redrawn by
uv run python scripts/make_figures.pyafter the pipeline.
uv run python -m half_wick.steps.out_of_sample| Metric | Value |
|---|---|
| Sharpe Ratio | 0.36 |
| Sortino Ratio | 0.57 |
| Win Rate | 51.7% |
| Total Return | +0.63% |
| CAGR | +0.31% |
| Max Drawdown | −1.32% |
| Mean R | +0.008 ± 0.133 |
| Trades | 58 |
| Target Exits | 30 |
| Stop Exits | 28 |
| EOD Exits | 0 |
| Run | Sharpe | Total Return | Mean R | Period Ending Balance | Period Ending Balance Using 3.5%/yr Benchmark |
|---|---|---|---|---|---|
| In-sample (original hypothesis) | 0.19 | +1.12% | −0.019 ± 0.049 | $1,011,196 | $1,108,264 (+10.83%) |
| Optimization (best in-sample) | 0.98 | +3.11% | +0.120 ± 0.094 | $1,031,085 | $1,108,264 (+10.83%) |
| Out-of-sample (optimized) | 0.36 | +0.63% | +0.008 ± 0.133 | $1,006,268 | $1,070,786 (+7.08%) |
Ending balances are computed over the row's own actual period (3 years for the two in-sample rows, 2 years for out-of-sample), with the benchmark compounding at 3.5%/yr over that same span.
This section is a standalone side analysis — it is not part of the Plutus pipeline. The table below can be regenerated with
uv run python scripts/capital_sensitivity.pyafter running the pipeline.
Disregarding commission and slippage, the strategy would perform identically at any capital level — capital would be irrelevant. This backtest charges commission at $0.005/share with a $1.00 minimum per fill, approximating IBKR Pro's Fixed commission schedule. (Slippage of $0.01/share is also simulated, but only on stop exits, and its effect on capital is much smaller than the $1 minimum commission's.) Buying 200 shares of SPY pays the same $1 minimum commission as buying 2 shares — below 200 shares, the $1 minimum is the binding cost regardless of trade size — so commission eats a larger fraction of a smaller account's returns. A small account therefore needs a stronger edge just to break even.
The table below shows this strategy's metrics with the optimized parameters at different starting capital levels, over the full 5-year period (in-sample and out-of-sample combined). Total return crosses zero somewhere around $8,700. Metrics stabilize by roughly $500,000.
| Initial capital | Sharpe | Sortino | CAGR | Total return over 5 years | Max DD |
|---|---|---|---|---|---|
| $5,000,000 | 0.7592 | 1.2000 | 0.74% | 3.76% | -1.89% |
| $2,000,000 | 0.7592 | 1.2000 | 0.74% | 3.76% | -1.89% |
| $1,000,000 | 0.7593 | 1.2001 | 0.74% | 3.76% | -1.89% |
| $500,000 | 0.7593 | 1.2001 | 0.74% | 3.75% | -1.89% |
| $100,000 | 0.7537 | 1.1899 | 0.74% | 3.72% | -1.89% |
| $25,000 | 0.5513 | 0.8394 | 0.53% | 2.67% | -1.96% |
| $8,700 (total-return crossover zone) | 0.0057 | 0.0079 | 0.00% | 0.00% | -2.16% |
| $5,000 | -0.6491 | -0.8292 | -0.61% | -3.00% | -4.18% |
[1] ALGOTRADE, The 9-Step Development Process. Online: https://www.algotrade.vn/knowledge/9-step-process
[2] ALGOTRADE, The Plutus Reproducibility Standard. Online: https://github.com/algotrade-plutus/plutus-guideline
[3] ALGOTRADE, 43. Return Rate. Online: https://hub.algotrade.vn/knowledge-hub/evaluation-of-algorithmic-performance/
[4] ALGOTRADE, Sortino Ratio. Online: https://hub.algotrade.vn/knowledge-hub/sortino-ratio/
[5] ALGOTRADE, 44. Maximum Drawdown in Algorithmic Trading. Online: https://hub.algotrade.vn/knowledge-hub/maximum-drawdown-in-algorithmic-trading/



