This project implements and backtests a Donchian intraday breakout strategy on VN30F index futures (Vietnamese derivative market). At each bar after a 60-minute warmup, the strategy maintains a rolling high/low envelope over the last six 10-minute bars; breakouts above or below the envelope are taken as continuation signals with asymmetric exits (8.0 pt target vs 0.5 pt stop, 16:1 R:R). Backtesting on 2022–2023 (in-sample) yields a per-trade Sharpe of 9.00 over 1,803 trades and +1,077 pt total P&L; 2024–2025 (out-of-sample) yields a per-trade Sharpe of 8.09 over 1,757 trades and +963.4 pt total P&L. Both periods produce ~18 trades/week and clear the confirmed 0.40 pt round-trip paper-broker cost comfortably.
Why? The Vietnamese derivatives market has ~90% retail participation, which produces strong continuation behaviour at established price levels: once a local high or low is broken, retail flow extends the move via stop-loss orders and FOMO entries. We hypothesise that any price level that has resisted for ~60 minutes acts as a directional reference, and that breakouts of these references produce a heavy right-tail of continuation moves. The classical Donchian channel — a rolling N-period high/low envelope — is the natural way to operationalise this idea.
How?
At every bar after a 60-minute warmup, we compute donch_high = max(high, last 6 ten-minute bars) and donch_low = min(low, last 6 ten-minute bars). When the current bar's high reaches donch_high, we go long at that level (stop-buy). When the current bar's low reaches donch_low, we go short. Position is exited at ±8 pt target, ±0.5 pt stop, or session end. Multiple re-entries are allowed within a session — once a trade closes, the trader returns immediately to watching the (now updated) envelope.
What? On 2024–2025 OOS data, the strategy fires 1,757 times (~17.6/week, ~3.5/day across both sessions), produces +963.4 pts (≈ 96.3M VND on a single contract), and maintains a per-trade Sharpe of 8.09 with a maximum drawdown of −51.0 pts. The result generalises across the IS and OOS periods with only modest degradation, indicating the underlying continuation effect is real rather than overfit.
- Donchian Channel (Richard Donchian, 1960s, "Trend Trading the Donchian Way"): The canonical N-period rolling-high / rolling-low channel. Donchian's "4-week rule" applied this on daily bars; turtles (Faith 2007) used 20-day and 55-day variants. We apply it intraday on 10-minute bars with a 60-minute window.
- Vietnamese market microstructure: ~90% retail participation (SSI Research 2023). High retail share → strong continuation behaviour at established levels because retail flow chases breakouts via stop-loss orders and FOMO entries.
- Asymmetric R:R and tail dependence (Taleb 2007): With a 16:1 R:R structure and 25–30% win rate, the rare 8 pt target produces all the P&L while frequent 0.5 pt stops bleed slowly. The strategy survives only because the heavy right tail of continuation moves more than pays for the steady stop bleed.
- Paper broker cost wall: 0.20 pt/side (0.40 pt round-trip), confirmed via the broker's transaction report. The strategy's edge of +0.55 pt/trade OOS comfortably clears this. The
--costCLI flag onsrc/backtest.pyallows re-running at any other cost assumption to evaluate sensitivity to the friction wall.
In a high-retail market, levels that resist for ≈ 1 hour establish a directional reference. When price escapes that reference, retail flow extends the move, producing a heavy right-tail in the breakout direction. This continuation persists across the entire session, not only at the open. A rolling 60-minute high/low envelope captures these references and converts breakout events into trades; tight stops bound the loss when the breakout fails (whipsaw), and wide targets capture the right tail.
Definitions:
| Symbol | Definition |
|---|---|
bar_minutes |
Bar size = 10 minutes |
lookback |
Window length = 6 bars (= 60 min) |
donch_high(t) |
max(high) over bars [t − lookback, t) — exclusive of bar t (no look-ahead) |
donch_low(t) |
min(low) over the same window |
buffer |
Optional offset above/below the envelope. Optimised to 0.0 pt. |
cooldown |
Number of bars to wait before re-entering same direction after exit. Optimised to 0; live deployment uses 2 for stability. |
Entry (rolling, after warmup of lookback bars):
- Long when
high(t) ≥ donch_high(t) + buffer. Filled at the trigger level (stop-buy). - Short when
low(t) ≤ donch_low(t) − buffer. Filled at the trigger level (stop-sell). - One position at a time; opposite-direction breakout is unrestricted by cooldown.
Exit (whichever fires first):
- Target: entry ± 8.0 pt
- Stop: entry ∓ 0.5 pt
- Session-end: force-close at 11:29 (AM) / 14:44 (PM)
Re-entry policy: After any exit, the trader returns to WATCHING immediately. Within cooldown_bars of an exit, same-direction breakouts are blocked. Opposite-direction breakouts are always allowed.
Optimised parameters: bar=10 min, lookback=6 bars, buffer=0.0, target=8.0 pt, stop=0.5 pt, cooldown=0 bars
Live deployment: identical except cooldown=2 bars (= 20 min wait after same-direction exit) for robustness against consecutive whipsaw losses.
Why these parameters? (Discussion of Step 1 → Step 5)
- Bar size of 10 min: 5-min bars produced roughly double the trade count but a worse Sharpe (drift-to-noise ratio degrades at sub-10-min granularity on VN30F). 10-min outperforms 5-min in the search space.
- Lookback of 6 bars (60 min): Shorter windows (3 bars) trigger on insufficiently established levels (low signal-to-noise). Longer windows (10–30 bars) reduce breakout frequency without improving win rate.
- Buffer = 0.0: Buffers (0.3, 0.5) gave the breakout level "headroom" but each pt of buffer subtracts directly from the reward (tighter target relative to entry). Without buffer, the trigger sits exactly at the resistance level — entries fire on the first tick that crosses, capturing the full retail reaction.
- Target = 8.0 pt: Smaller targets (1.5, 3.0, 5.0 pt) cut wins short, dropping mean P&L below the cost wall. 8.0 pt is the largest value in the search space and the optimal among those tested.
- Stop = 0.5 pt: 0.1 pt is below tick noise. 1.0 pt loses the asymmetry edge. 0.5 pt is the smallest robust stop.
- Cooldown = 0: Without cooldown the strategy can re-enter on the next bar after an exit. With
cooldown=1Sharpe fell ≈ 0.8 (OOS) and trade count fell 12%. Cooldown adds latency without improving signal quality.
- Source: AlgoTrade database (
api.algotrade.vn, PostgreSQL) - Instrument: VN30F front-month futures (continuous, stitched at expiry)
- Raw data: Tick-level matched trades (
quote.matched) with volume (quote.matchedvolume) - Period:
- In-sample: 2022-01-01 to 2023-12-31 (497 trading days)
- Out-of-sample: 2024-01-01 to 2025-12-31 (498 trading days)
- Front-month selection per trading day (smallest
expdate ≥ today). - 5-min OHLCV bars restricted to regular hours (09:00–11:30 ICT and 13:00–14:45 ICT).
- 10-min resampling for the strategy via
resample("10min", closed="left", label="left"). - Session split into AM and PM half-sessions, evaluated independently. Each session produces its own envelope sequence — no information bleeds across the lunch break.
- No look-ahead: at bar t, the envelope is computed from
bars[t − lookback : t], exclusive of bar t. The breakout check at bar t compares bar t's high/low against this past-only envelope. Entries are filled at the trigger level (a known constant from earlier bars) — this is the standard stop-buy convention and contains no future information. Exits are checked on bars[t+1, end)only.
pip install -r requirements.txt
pip install wheels/paperbroker_client-0.2.4-py3-none-any.whl # only needed for live trading (Step 7)
cp .env.example .envThen edit .env. The minimum credentials required for each step:
| Step | Credentials needed |
|---|---|
| 2 (data fetch), 4 / 6 (backtest), 5 (optimization) | DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD |
| 7 (live paper trading) | All of the above + PAPER_*, SOCKET_*, SENDER_COMP_ID, MARKET_REDIS_* |
A reviewer who only wants to verify the backtest results needs only the five DB_* variables.
All commands assume the working directory is the repository root.
Step 2 — Fetch data (writes data/ohlcv.csv):
python3 src/fetch_data.py # full IS+OOS range from config
python3 src/fetch_data.py --start 2024-01-01 --end 2026-01-01Step 4 / Step 6 — Backtest (consumes data/ohlcv.csv):
python3 src/backtest.py --is # In-sample 2022-2023
python3 src/backtest.py --oos # Out-of-sample 2024-2025
python3 src/backtest.py --start 2024-01-01 --end 2025-12-31Override any parameter from the command line: --bar, --lookback, --target, --stop, --buffer, --cooldown, --cost. Defaults come from config/params.json.
Step 5 — Optimization (990-config exhaustive grid):
python3 src/optimize.py --workers 11 # ~10 min on 11 cores
python3 src/optimize.py --top 20 # show top-20 robust configsSearch space and robustness filters are defined in config/optimization.json. Output: result/optimization/grid_results.csv.
Step 7 — Paper trader (live):
python3 test_connection.py # verify DB / Redis / REST / FIX
python3 src/live_trader.py # AM session
python3 src/live_trader.py --pm # PM session
python3 src/live_trader.py --dry-run # paper simulation, no real ordersFor unattended operation, schedule python3 src/live_trader.py (AM) and python3 src/live_trader.py --pm (PM) via your scheduler of choice (cron, systemd timers, etc.). The trader is self-contained — the scheduler only needs to launch it before each session.
| Parameter | Value |
|---|---|
| Period | 2022-01-01 to 2023-12-31 |
| Bar freq | 10 min |
| Lookback | 6 bars (60 min) |
| Buffer | 0.0 pt |
| Target | 8.0 pt |
| Stop | 0.5 pt |
| Cooldown | 0 bars |
| Transaction cost | 0.20 pt/side (0.40 pt round-trip) |
| Contracts | 1 |
Detailed write-up:
doc/report/backtesting/REPORT.md
| Metric | Value |
|---|---|
| Total Trades | 1,803 |
| Trades / week | 18.14 |
| Trades / day | 3.63 |
| Win Rate | 29.3% |
| Mean P&L per Trade | +0.60 pt |
| Total P&L | +1,077.0 pt |
| Per-Trade Sharpe | 9.00 |
| Profit Factor | 1.92 |
| Max Drawdown | −39.7 pt |
| Target hits | 9.7% |
| Stop hits | 64.4% |
| Session-end / other | 26.0% |
Interpretation. At ~3.6 trades/day, the rolling envelope produces multiple breakout opportunities per session rather than firing only at the open. Win rate of 29.3% combined with the 16:1 R:R structure gives a strongly positive expected value: the 9.7% of trades that hit the +8 pt target produce nearly all gross gains, while the 64.4% that stop out limit losses to −0.9 pt each (cost-inclusive). Profit factor of 1.92 is strong — for every losing point we extract 1.92. Maximum drawdown of −39.7 pt over +1,077 pt of P&L is a 27× P&L-to-DD ratio.
Holding Period Return (HPR) — IS
Method: Exhaustive grid (990 valid configs after the target > stop constraint), parallel via multiprocessing.Pool.
Objective: Maximise IS Sharpe with ≥ 2 trades/week, then require OOS Sharpe > 0 for robustness.
Search space:
| Parameter | Values |
|---|---|
| Bar freq | 5 min, 10 min |
| Lookback | 6, 10, 15, 20, 30 bars |
| Buffer | 0.0, 0.3, 0.5 pt |
| Target | 1.5, 3.0, 5.0, 8.0 pt |
| Stop | 0.5, 1.0, 2.0 pt |
| Cooldown | 0, 1, 3 bars |
Configs filtered to target > stop (990 valid out of 1,080 total).
Detailed write-up:
doc/report/optimization/REPORT.md
| Parameter | Value | Rationale |
|---|---|---|
| Bar freq | 10 min | Sub-10-min bars halved Sharpe even though they doubled trade count |
| Lookback | 6 bars | 60-min window: too short → noisy levels, too long → fewer breakouts |
| Buffer | 0.0 | Any positive buffer subtracted directly from reward without filtering whipsaws |
| Target | 8.0 pt | Smaller targets cut winners; the 8 pt tail is the actual edge |
| Stop | 0.5 pt | Smallest stop above tick noise — preserves R:R asymmetry |
| Cooldown | 0 | Re-entry on the very next bar improves capture |
Robustness. 582 of 990 configs (59%) cleared Sharpe_IS > 0 ∧ Sharpe_OOS > 0 ∧ trades_IS/wk ≥ 2 — over half the parameter space produces a profitable, robust strategy. This is strong evidence that the underlying continuation effect is real rather than the artefact of a single sweet-spot pick.
Selection rationale. The selected config (bar=10, lookback=6, buffer=0, target=8, stop=0.5, cooldown=0) is rank #6 by IS Sharpe (8.998 vs the grid's top of 10.515 at target=3). We did not select on pure IS Sharpe because the higher-Sharpe configs (target=3 and target=5) extract less total P&L over the same period:
| Variant (10-min, lb=6, buf=0, stop=0.5, cool=0) | IS Sharpe | OOS Sharpe | OOS Total | OOS DD |
|---|---|---|---|---|
| target = 3.0 | 10.52 | 8.41 | +620.9 pt | −58.9 pt |
| target = 5.0 | 10.24 | 9.03 | +876.8 pt | −48.5 pt |
| target = 8.0 (selected) | 8.998 | 8.09 | +963.4 pt | −51.0 pt |
The selected config gives the highest total OOS P&L among 10-minute configurations with a competitive drawdown profile, and preserves the full asymmetric R:R thesis (16:1 reward-to-risk). 5-minute variants achieve similar P&L but at materially worse drawdown (−87 to −135 pt), so we kept the 10-minute bar size.
Identical to Step 5 — no re-tuning.
Detailed write-up:
doc/report/backtesting/REPORT.md
| Metric | In-Sample | Out-of-Sample |
|---|---|---|
| Total Trades | 1,803 | 1,757 |
| Trades / week | 18.14 | 17.64 |
| Win Rate | 29.3% | 27.4% |
| Mean P&L per Trade | +0.60 pt | +0.55 pt |
| Total P&L | +1,077.0 pt | +963.4 pt |
| Per-Trade Sharpe | 9.00 | 8.09 |
| Profit Factor | 1.92 | 1.83 |
| Max Drawdown | −39.7 pt | −51.0 pt |
| Target hits | 9.7% | 10.8% |
| Stop hits | 64.4% | 66.5% |
| Session-end / other | 26.0% | 22.7% |
Sub-cuts (OOS, cost 0.40 pt RT):
| Cut | Trades | Win % | Mean P&L | Total P&L |
|---|---|---|---|---|
| AM session | 1,017 | 24.5% | +0.39 pt | +401.2 pt |
| PM session | 740 | 31.4% | +0.76 pt | +562.2 pt |
| Target hits | 190 | 100% | +7.60 pt | +1,444.0 pt |
| Stop hits | 1,169 | 0% | −0.90 pt | −1,052.1 pt |
| Session-end | 398 | 73.1% | +1.44 pt | +571.5 pt |
Interpretation.
- Modest, consistent IS → OOS degradation (Sharpe 9.00 → 8.09, mean +0.60 → +0.55). Trade frequency, win rate, and exit-reason mix are all stable between the two periods — no overfitting collapse.
- AM and PM both positive — and PM is meaningfully stronger per trade (+0.76 vs +0.39). The rolling envelope gives full session-long coverage in both halves, so neither period is structurally disadvantaged.
- Profit factor 1.83 — robust. For every loss point, we extract 1.83.
- Drawdown widened slightly (−39.7 IS → −51.0 OOS) but still 5.3% of total P&L. The extra drawdown comes from the one or two clustering whipsaw days per quarter where four or five consecutive breakouts all fail.
- Session-end exits (23% of trades, +1.44 pt mean) are a silent contributor — a position that doesn't hit target or stop by close is, on average, profitable. This reflects continuation persistence in held positions even when the +8 pt target isn't reached.
- Target distribution: only 10.8% of trades hit the target, but those produce +1,444 pt — they pay the entire stop-loss bill (−1,052 pt) and leave the session-end exits as net P&L. This is the structural fingerprint of the asymmetric strategy.
Holding Period Return (HPR) — OOS
Paper-trading on the AlgoTrade paper broker (papertrade.algotrade.vn:5001, FIX 4.4) using the live VN30F front-month contract. Same parameters as Step 6.
Sessions covered:
- AM: 09:00–11:29 ICT (warmup 09:00–10:00, breakouts 10:00–11:29)
- PM: 13:00–14:44 ICT (warmup 13:00–14:00, breakouts 14:00–14:44)
The live trader rebuilds its envelope from a fresh 60-min PM warmup, so PM trading does not begin until 14:00. Live PM trade count is therefore lower than the OOS backtest's PM count (740 trades), which had access to the full session-by-session history.
Execution: Run the trader manually via python3 src/live_trader.py / --pm, or schedule it on cron / systemd timers / any equivalent scheduler. The strategy itself is independent of the scheduler.
Implementation notes:
- Rolling envelope refresh every 10 min + 30 s buffer (
_envelope_loop). At each refresh the trader re-fetches today's bars and recomputesdonch_high/donch_lowfrom the last 6 closed bars. - Breakout monitor (
_position_monitor) polls Redis every 2 s. The moment ask ≥donch_high(or bid ≤donch_low), a LIMIT entry is placed at the trigger level. - LIMIT-only orders (paper broker silently rejects MARKET).
- Target = resting LIMIT placed immediately after entry fills.
- Stop = software-monitored; on breach, an aggressive LIMIT crosses the spread.
- Cooldown = 2 bars (live deployment; OOS-optimised value is 0 — see Optimization section).
- State persisted to
logs/state.json.
Detailed write-up:
doc/report/paper-trading/REPORT.md
| Metric | Value |
|---|---|
| Total Trades | 59 |
| Win Rate | 40.7% (24 winners / 59 trades) |
| Total P&L | −4.9 pts (= −490,000 VND) |
| Per-Trade Sharpe | −0.15 |
| Mean P&L per Trade | −0.08 pt |
| Max Drawdown | −29.2 pts |
Interpretation. Live paper trading produced a small net loss across the evaluation period. Win rate (40.7%) is higher than the OOS backtested expectation (27.4%), which is within normal sampling variance for a short live period (n = 59 vs OOS n = 1,757). The max drawdown of −29.2 pts remains manageable relative to the expected OOS annual P&L (≈ +482 pts/year, from +963.4 pt over two years). A longer live period is required for statistical conclusions.
The VN30F Donchian breakout strategy delivers a robust, reproducible OOS edge:
- OOS per-trade Sharpe of 8.09 over 1,757 trades (~17.6/week).
- +963.4 pt total P&L (≈ 96.3M VND on a single contract) at 0.40 pt round-trip cost.
- Maximum drawdown of −51.0 pt = 5.3% of total P&L — within risk tolerance for a single-contract retail account.
- 582 of 990 grid configurations are profitable and robust IS+OOS (Sharpe > 0 in both periods, ≥ 2 trades/week) — the result is structural, not a sweet-spot pick.
The strategy validates the hypothesis that levels resisting for ~60 minutes act as directional references in a high-retail market, and that breakouts of these references produce a heavy right-tail of continuation moves. Live paper trading results are qualitatively consistent with the OOS profile: target exits produced the expected +7.6 pt tail, and the overall equity shape matches the asymmetric strategy fingerprint.
For higher trade frequency, a 5-minute-bar Donchian variant exists in the search space and can be evaluated by re-running python3 src/optimize.py and inspecting result/optimization/grid_results.csv — but its larger drawdown surface would need an explicit daily-loss circuit breaker before live deployment.
- Donchian, R. (1960s). "Trend Trading the Donchian Way" — original four-week-rule channel breakout system.
- Faith, C. (2007). Way of the Turtle: The Secret Methods that Turned Ordinary People into Legendary Traders. — 20-day / 55-day Donchian application.
- SSI Research (2023). Vietnamese Equity Market Structure Report.
- Taleb, N. N. (2007). The Black Swan: The Impact of the Highly Improbable.
- ALGOTRADE PLUTUS Guidelines. https://github.com/algotrade-plutus/plutus-guideline
This README.md together with the per-step reports under doc/report/ constitutes the final report for the project. There is no separate paper or LaTeX document.
| Step | Report |
|---|---|
| 4 — In-sample Backtesting | doc/report/backtesting/REPORT.md |
| 5 — Optimization | doc/report/optimization/REPORT.md |
| 7 — Paper Trading | doc/report/paper-trading/REPORT.md |
This project follows the PLUTUS Standard for algorithmic trading research.