This document is the standalone presentation for the final VN30 research model. It reports the locked implementation and its existing artifacts; it does not rerun or reinterpret OOS parameters.
Research status: historical simulation, not investment advice. All figures are in Vietnamese đồng (VND). The strategy begins each evaluation with VND 1 billion.
Open or download the presentation PDF.
| Metric | In-sample | Out-of-sample |
|---|---|---|
| Operational window | 5 Jan 2022–31 Dec 2024 | 7 Feb 2025–17 Jun 2026 |
| Ending equity | 1,211,217,423 | 1,158,740,765 |
| Terminal marked-to-market HPR | 21.12% | 15.87% |
| Maximum drawdown | 14.91% | 12.12% |
| Realized net P&L | 276,511,091 | 195,536,374 |
| Ending unrealized inventory P&L | -65,293,668 | -36,795,610 |
| Total execution fills | 1,249 | 531 |
The result is profitable in both windows, but the economic character changes. In IS, the grid outperformed both benchmarks and materially reduced drawdown. In OOS, it remained profitable with lower drawdown, but captured much less of the strong directional rally than either the VN30 index or the selection-matched equal-weight basket.
The strategy tests the following hypothesis:
A stock that is trending upward over 60 sessions, remains statistically mean-reverting, and has a high posterior probability of occupying a non-negative, high-variance regime should produce repeated pullbacks and recoveries. A cash-first grid can monetize those oscillations without paying for a permanent core position.
This separates the system into two responsibilities:
- The selector searches for upward drift plus oscillation, not merely high volatility.
- The execution engine waits below the market, buys stateful tranches, and exits only after T+2.5 settlement through a soft target and ATR-scaled trailing stop.
The hard P_opt >= 0.50 gate is the high-conviction component. The 1.25 incumbency multiplier is intended to reduce needless turnover. The breakeven escape is an inventory-management mechanism, not a loss guarantee.
| Dataset | Coverage used | Resolution | Validation |
|---|---|---|---|
| Expanded VN30 equity universe | 4 Jan 2022–17 Jun 2026 | Daily OHLC, floor, ceiling, matched volume | 32 symbols, 1,107 trading sessions, unique ticker/date keys |
| Execution data | Same equity window | 1-minute OHLCV and best bid/ask fields | A ticker-month minute file exists for every daily ticker-month |
| VN30 benchmark | Matching IS/OOS sessions | Daily index OHLCV | Actual VN30 price-index series, normalized to VND 1 billion |
The expanded universe contains ACB, BID, BSR, CTG, FPT, GAS, GVR, HDB, HPG, LPB, MBB, MCH, MSN, MWG, PNJ, SAB, SHB, SSB, SSI, STB, TCB, TCX, VCB, VHM, VIB, VIC, VJC, VND, VNM, VPB, VPL, and VRE.
The external benchmark snapshot was retrieved from the Entrade VN30 daily OHLC endpoint and saved as vn30_index_daily.csv. This is a price index, not a total-return index.
| Stage | Nominal specification | Effective simulated dates | Use |
|---|---|---|---|
| IS | 1 Jan 2022–31 Dec 2024 | 5 Jan 2022–31 Dec 2024 | Parameter search and detailed evaluation |
| Embargo | 21 trading sessions | 2 Jan–6 Feb 2025 | No trading and no overlapping target window |
| OOS | 1 Jan 2025–17 Jun 2026 | 7 Feb 2025–17 Jun 2026 | Blind evaluation with frozen parameters |
The first effective IS record is 5 January because the causal signal requires a prior close. The OOS backtest begins on 7 February because the 21-session embargo is applied after 31 December 2024.
The canonical loader is data.py: DailyStore.load() normalizes the daily snapshot, MinuteStore.read_day() loads only the requested ticker/session, and validate_daily_minute_overlap() checks that every daily ticker-month has a minute file. The strategy entry point expects this layout under one data root:
<data-root>/
├── expanded_daily_32_20220101_20260617.csv.gz
└── minute_bars/<TICKER>/minute_bars_<TICKER>_<YYYY_MM>.csv.gz
The minute-tree fingerprint is deterministic: sort the ticker-months present in the daily snapshot, then hash each relative filename, a null byte, and that file's binary SHA-256 digest. The verifier implements this exact algorithm. Extra minute files outside the daily snapshot do not affect the fingerprint.
Run the full input check with:
make reproduce_step_01For a different local data location, use make reproduce_step_01 DATA_ROOT=/path/to/data.
The selector runs every 20 trading sessions using only the trailing 60 sessions ending on the signal date.
Let
The stock survives only if:
The generalized Hurst estimate uses lags
The discrete OU regression is:
Both constraints must pass:
For every survivor, the HMM observes the standardized vector:
The implementation fits a three-state Gaussian HMM with full covariance, 250 maximum iterations, tolerance
The grid-optimal state is the highest return-variance state whose return mean is non-negative:
The hard probability gate is:
Names are ranked by RankScore and the first five are selected. If fewer than five pass, every missing 20% slot remains cash.
| Research step | Authoritative implementation | Reproducibility control |
|---|---|---|
| OLS, Hurst, OU and HMM feature | stationary_hmm_feature() in signals.py |
Exactly 60 trailing observations; no forward rows |
| Non-negative/high-variance state | grid_optimal_state() in signals.py |
Stable state-index tie-break when variances match |
| Probability gate and incumbency | high_conviction_hmm_candidates() in signals.py |
Seed 1729; deterministic ticker tie-break |
| Twenty-session schedule | periodic_rebalance_actions() in backtest.py |
Signal is the previous session; action is the next session |
| Portfolio handoff | Backtester.monthly_decision() in backtest.py |
Maximum five names; unused slots stay cash |
Every HMM is initialized with random_state=1729. Input features are standardized using only their own 60-session window. A selected ticker's entire causal record—including slope, Hurst, half-life, state means and variances, selector_state_log.csv.
To reproduce just the final selector evidence after completing the backtest and presentation build:
make show_final_selectionsExpected final-stage selections are MCH, PNJ for IS and VPL for OOS. The byte-level verifier additionally compares the full selector-state and ticker-selection logs against the canonical files.
There is no initial base inventory. For each newly selected ticker, the anchor
| Level | Slot-capital weight | Locked distance from anchor |
|---|---|---|
| L1 | 5% | 1% |
| L2 | 10% | 2% |
| L3 | 15% | 3% |
| L4 | 30% | 4% |
| L5 | 40% | 5% |
Each tranche is stateful: a level cannot buy again while its associated inventory remains open. Orders are rounded to valid HOSE tick sizes and 100-share lots. A fill is limited to 10% of the observed minute volume.
Bought shares enter the T+2.5 queue and cannot sell before the 13:00 settlement release. After settlement, a tranche activates its trailing exit at:
Once a minute high crosses the trigger, the stop trails the high by:
expressed as a percentage of price. When the minute path pulls through the stop, the tranche sells subject to volume participation and the sell is followed by a rearmed trap:
Friction is 0.1% on buys and 0.2% on sells: 0.1% brokerage plus 0.1% sell tax.
- A ticker dropped at rebalance becomes orphaned: its unfilled buys are canceled, but its soft-target exits remain active.
- If an overnight down-gap exceeds 7.5%, the data-free anomaly handler voids the inventory, refunds exact fiat cost, and blacklists the name for 30 sessions.
- If an orphan is still open after 40 sessions, session 41 replaces its target with
$d_{\mathrm{tp}}=0.35%$ . Its new soft trigger is$P_{\mathrm{fill}}(1+0.85\times0.0035)$ . There is no forced market sale.
The anomaly refund is deliberately capital-neutral and therefore idealized. It prevents unadjusted splits/rights events from appearing as trading losses, but it is not a live-broker cash flow and should be replaced by authoritative corporate-action data before production use.
The success panels use 30-minute candles aggregated from the 1-minute feed. The longer failure panels use daily candles, also rebuilt exclusively from those minute bars, to keep the entire orphan life visible. Triangles are exact timestamps and prices from the fill ledger. Dashed lines are anchors, dotted lines are initial L1–L5 limits, and the failure panels show orphan and escape-repricing dates.
The execution path is implemented in engine.py. The key methods are start_stateful_anchor() and _build_stateful_grid() for deployment, process_soft_target_grid() for minute fills and trailing exits, request_breakeven_escape() for session-41 repricing, and refund_anomaly_inventory() for the capital-neutral anomaly rule. calendar.py supplies the observed-session T+2.5 release timestamp.
Deterministic execution conventions are part of the model, not presentation assumptions:
- Minute bars are processed chronologically and the fill cap is 10% of matched minute volume.
- A soft target first observed in a minute cannot execute until a later minute, avoiding unknown high/low ordering inside one OHLC bar.
- A gap below an active trailing stop fills at the observed minute open; otherwise it fills at the standing stop.
- Prices use
round_to_tick(), quantities use 100-shareround_lot(), and fees are charged directly by_buy()and_sell(). - Bought inventory uses the observed trading calendar to unlock at 13:00 on T+2.
The authoritative execution outputs are in_sample/fills.csv and out_of_sample/fills.csv. Rebuild the four audited charts from those ledgers and the original minute feed with the presentation command in Section 10. The expected ledger checkpoints are 1,249 total IS fills and 531 total OOS fills; the verifier compares the full ledgers byte-for-byte.
The optimizer maximized terminal marked-to-market HPR, so trapped inventory was included rather than ignored:
| Parameter | Search |
|---|---|
d_buy |
1.0% to 4.5%, step 0.5% |
d_tp |
2.5% to 4.5%, step 0.5% |
| Combinations | 40 |
| Selector/HMM | Fixed for every combination |
| Winner | d_buy = 1.0%, d_tp = 4.5% |
| Winner IS HPR | 21.1217% |
IS HPR sensitivity:
d_buy / d_tp |
2.5% | 3.0% | 3.5% | 4.0% | 4.5% |
|---|---|---|---|---|---|
| 1.0% | 18.27% | 20.58% | 19.92% | 19.99% | 21.12% |
| 1.5% | 13.83% | 14.77% | 16.05% | 16.68% | 16.70% |
| 2.0% | 8.71% | 11.07% | 11.87% | 12.16% | 12.08% |
| 2.5% | 6.69% | 7.72% | 8.34% | 8.48% | 9.25% |
| 3.0% | 6.20% | 6.15% | 7.11% | 7.77% | 7.75% |
| 3.5% | 4.91% | 4.82% | 5.28% | 5.65% | 5.93% |
| 4.0% | 3.50% | 4.65% | 5.08% | 5.61% | 5.71% |
| 4.5% | 3.44% | 3.78% | 4.53% | 4.86% | 4.90% |
The winning d_buy and d_tp lie on opposite boundaries of the tested domain. That is a sensitivity warning: the locked result is valid for this experiment, but the optimum is not an interior plateau.
The full frozen configuration is locked_config.json. Its SHA-256 is 26637593284242b627544785e32cf83f240133eaf3b50d29f576f914f6263db1; the same hash was checked before OOS execution.
high_conviction_hmm_grid.py defines BUY_STEPS, TP_STEPS, _evaluate_pair(), and _optimize(). It evaluates the Cartesian product of eight buy distances and five take-profit distances exclusively over 5 January 2022–31 December 2024. The winning row is selected by descending terminal MTM HPR, then ascending d_buy, then ascending d_tp; this deterministic tie-break is important when scores are equal.
The complete search is written to is_grid_search.csv. Recheck the winner with:
make show_optimization_winnerExpected output is 40 rows and a winner of d_buy=0.01, d_tp=0.045, and IS HPR 0.21121742304930025. Once selected, config_sha256() hashes the sorted canonical JSON representation of BacktestConfig. The runner checks that hash immediately before OOS and stores it again in the OOS metrics.
| Accounting item | IS |
|---|---|
| Starting equity | 1,000,000,000 |
| Completed-grid gross P&L | 305,155,000 |
| Fees and taxes | -28,643,909 |
| Realized net P&L | 276,511,091 |
| Ending unrealized inventory P&L | -65,293,668 |
| Ending equity | 1,211,217,423 |
| Maximum drawdown | 14.91% |
| Grid-only fills / all fills | 595 / 1,249 |
| Trailing exit fills | 654 |
| Breakeven escape activations / sell fills | 22 / 105 |
| Capital-neutral anomaly events | 7 |
The last IS selection was formed from the 20 December 2024 signal and acted on 23 December:
| Rank | Ticker | Log-price slope (m) | Hurst | OU half-life | (P_{opt}) | Incumbent | Rank score |
|---|---|---|---|---|---|---|---|
| 1 | MCH | 0.002609 | 0.111966 | 10.78 | 1.000000 | No | 0.002609 |
| 2 | PNJ | 0.000035 | 0.411844 | 5.04 | 0.999118 | No | 0.000035 |
Only two of five slots qualified; the other three remained cash.
Backtester.run(IS_EFFECTIVE_START, IS_END) executes the locked winner over the complete IS window after optimization. compute_metrics() produces the daily-equity metrics, while _tear_sheet() in high_conviction_hmm_grid.py reconciles execution P&L and ending marked-to-market inventory.
For an exact result, the following identities must hold within floating-point tolerance:
The canonical machine-readable checkpoints are tear_sheet.json, daily_equity.csv, and monthly_decisions.csv. The verifier checks these identities, terminal equity 1,211,217,423.0493002, HPR 0.21121742304930025, and the complete files.
| Accounting item | OOS |
|---|---|
| Starting equity | 1,000,000,000 |
| Completed-grid gross P&L | 211,375,000 |
| Fees and taxes | -15,838,626 |
| Realized net P&L | 195,536,374 |
| Ending unrealized inventory P&L | -36,795,610 |
| Ending equity | 1,158,740,765 |
| Maximum drawdown | 12.12% |
| Grid-only fills / all fills | 271 / 531 |
| Trailing exit fills | 260 |
| Breakeven escape activations / sell fills | 8 / 46 |
| Capital-neutral anomaly events | 2 |
The last OOS selection was formed from the 22 May 2026 signal and acted on 25 May:
| Rank | Ticker | Log-price slope (m) | Hurst | OU half-life | (P_{opt}) | Incumbent | Rank score |
|---|---|---|---|---|---|---|---|
| 1 | VPL | 0.003054 | 0.084722 | 11.34 | 0.501864 | No | 0.001532 |
Only one of five slots qualified; 80% of deployable capital remained unassigned at that final rebalance.
OOS is not a second call to the optimizer. The runner first verifies that the in-memory configuration still hashes to 26637593284242b627544785e32cf83f240133eaf3b50d29f576f914f6263db1, then calls Backtester.run(OOS_START, OOS_END) once. The 21 observed sessions between IS and OOS are counted from the daily snapshot and asserted before any search begins.
The canonical OOS checkpoints are tear_sheet.json, daily_equity.csv, and monthly_decisions.csv. Exact terminal equity is 1,158,740,764.6341465, HPR is 0.15874076463414655, and total fills are 531. The same accounting checks used for IS are applied to OOS.
| Window | Portfolio | Ending equity | Return | Maximum drawdown |
|---|---|---|---|---|
| IS | High-Conviction HMM Grid | 1,211,217,423 | 21.12% | 14.91% |
| IS | Selection-matched equal-weight hold | 990,422,999 | -0.96% | 26.16% |
| IS | VN30 price index | 869,819,729 | -13.02% | 41.89% |
| OOS | High-Conviction HMM Grid | 1,158,740,765 | 15.87% | 12.12% |
| OOS | Selection-matched equal-weight hold | 1,635,284,143 | 63.53% | 13.04% |
| OOS | VN30 price index | 1,459,703,162 | 45.97% | 16.96% |
The equal-weight comparator buys the strategy-selected names equally across five fixed 20% slots at the action-session open, holds them until the next selection cycle, and leaves missing slots in cash. It applies the same 0.1% buy fee, 0.1% sell fee, 0.1% sell tax, valid lots, and causal rebalance dates. It deliberately contains no grid timing, soft exits, orphan retention, or HMM execution overlay; therefore it isolates whether the grid adds value beyond simply holding the selected basket.
The OOS conclusion is nuanced: the grid preserved capital well, but cash waiting below market and early trailing exits imposed a large opportunity cost during the 2025–2026 momentum advance. It should be presented as a defensive, path-dependent trading system—not as a strategy that consistently outperforms a rising index.
The comparator itself is implemented by selection_matched_buy_hold() in benchmark.py. build_high_conviction_presentation.py joins it to the strategy's daily equity and the frozen VN30 index by trading date, writes the underlying CSVs, and renders both combined and standalone PNG/SVG charts.
Use the checked-in index snapshot to avoid a changing network response:
make reproduce_step_04Numeric benchmark results are authoritative in benchmark_metrics.csv and the joined equity series is in benchmark_equity_daily.csv. PNG bytes may vary with fonts or rendering backend; compare the CSVs for numerical reproducibility and use the pinned Matplotlib version for the closest visual reproduction.
“Cash” means no ticker passed every selector gate. These are action dates; every selection was calculated from the preceding signal date.
In-sample selections (38 cycles)
| Action date | Selected tickers |
|---|---|
| 2022-01-05 | Cash |
| 2022-02-09 | Cash |
| 2022-03-09 | Cash |
| 2022-04-06 | BSR, GAS |
| 2022-05-09 | FPT |
| 2022-06-06 | Cash |
| 2022-07-04 | Cash |
| 2022-08-01 | BID |
| 2022-08-29 | BID |
| 2022-09-28 | MSN, MCH |
| 2022-10-26 | VNM, SAB |
| 2022-11-23 | VNM |
| 2022-12-21 | CTG, VNM |
| 2023-01-19 | SSI, VPB, VCB, MBB, HDB |
| 2023-02-23 | HDB, ACB, FPT, MBB, TCB |
| 2023-03-23 | VCB, STB, CTG |
| 2023-04-20 | SSI |
| 2023-05-23 | SSI, TCB |
| 2023-06-20 | SHB |
| 2023-07-18 | VPB, VNM |
| 2023-08-15 | SSI, VND, BID, STB |
| 2023-09-14 | MWG, VPB, GAS |
| 2023-10-12 | SSI, VND, BSR, STB |
| 2023-11-09 | HDB, VJC |
| 2023-12-07 | LPB |
| 2024-01-05 | LPB, SSI, FPT, VIB, SHB |
| 2024-02-02 | MWG, HPG |
| 2024-03-08 | VND, BSR |
| 2024-04-05 | VND, CTG, VCB |
| 2024-05-09 | MCH, TCB, GVR, VCB, VIB |
| 2024-06-06 | Cash |
| 2024-07-04 | FPT, MSN, SHB |
| 2024-08-01 | BSR, HDB, VPB, VNM |
| 2024-08-29 | MWG, PNJ, BSR, VHM |
| 2024-09-30 | ACB, FPT, VJC |
| 2024-10-28 | VHM, TCB, VJC |
| 2024-11-25 | HPG, TCB |
| 2024-12-23 | MCH, PNJ |
Out-of-sample selections (17 cycles)
| Action date | Selected tickers |
|---|---|
| 2025-02-07 | PNJ |
| 2025-03-07 | ACB |
| 2025-04-04 | Cash |
| 2025-05-08 | SHB |
| 2025-06-05 | Cash |
| 2025-07-03 | GVR, VND, MBB, SSI |
| 2025-07-31 | MBB, GAS, MWG, BID, LPB |
| 2025-08-28 | STB, MWG |
| 2025-09-29 | VJC, STB, VND, HDB, CTG |
| 2025-10-27 | LPB |
| 2025-11-24 | Cash |
| 2025-12-22 | VPL, GAS, VNM, FPT |
| 2026-01-21 | VPL, TCX, SAB, BSR, SSB |
| 2026-02-25 | VNM, MSN, VHM, HPG |
| 2026-03-25 | PNJ, GAS, MBB |
| 2026-04-22 | LPB, STB |
| 2026-05-25 | VPL |
The machine-readable history is selection_by_cycle.csv, and the last-cycle quantitative evidence is final_stage_selector_evidence.csv.
The backtest writes the causal signal date, next-session action date, selected tuple, and ranked survivor count to ticker_selection_log.csv. The presentation script formats that ledger but does not recalculate selections. Therefore ticker_selection_log.csv, not the Markdown table, is the source of truth.
Expected invariants are 38 IS cycles, 17 OOS cycles, final IS action date 23 December 2024, and final OOS action date 25 May 2026. Byte comparison of both the selection and selector-state logs is included in the verifier.
The project uses a Makefile as its task runner. It keeps installation flags, environment variables, paths, and Python entry points out of the README. Display every available task with:
make reproduce_helpThe canonical run used Python 3.12.13. requirements-repro.txt pins the numerical libraries. Step 01 creates .venv-repro, installs those packages, runs all tests, and verifies the daily and minute-data fingerprints:
make reproduce_step_01The authoritative entry point is high_conviction_hmm_grid.py. It deliberately refuses to overwrite an existing output directory. Use a new path and one worker for the most portable deterministic reproduction:
make reproduce_step_02The pipeline order is fixed:
- Validate the 21-session embargo.
- Evaluate all 40 parameter pairs on IS only.
- Select and hash the winning configuration.
- Rerun the full IS evaluation with the winner.
- Verify the configuration hash and run OOS once.
- Export metrics, daily equity, fills, decisions, selections, and selector evidence.
The verifier checks package versions, daily and minute input fingerprints, the optimizer winner, configuration hash, accounting identities, exact IS/OOS endpoints, and byte identity of the key output ledgers:
make reproduce_step_03A successful run ends with "status": "PASS". Use --semantic-only only when platform-specific serialization prevents byte equality; it still enforces the exact input hashes, configuration, optimizer winner, accounting, terminal equity, HPR, and fill counts. --skip-minute-hash is a faster diagnostic mode, not an exact reproducibility check.
After the verifier passes, build the benchmark tables and all eight standalone presentation images from the reproduced ledgers:
make reproduce_step_04Run the complete sequence with one command:
make reproduce_allPaths remain configurable without exposing the underlying commands. For example:
make reproduce_all \
DATA_ROOT=/path/to/vn30-data \
REPRO_OUTPUT=artifacts/my_high_conviction_reproductionREPRO_OUTPUT must not already exist; the task runner never deletes or overwrites a prior research run.
| Stage | Authoritative artifact | What to verify |
|---|---|---|
| Input/config lock | locked_config.json |
Search space, winner, embargo count, configuration hash |
| IS optimization | is_grid_search.csv |
All 40 parameter combinations and terminal MTM objective |
| Selector | selector_state_log.csv |
Every causal feature, gate, probability, and rank |
| Selection handoff | ticker_selection_log.csv |
Signal/action dates and selected names |
| IS evaluation | in_sample/tear_sheet.json |
Accounting and reported IS headline metrics |
| OOS evaluation | out_of_sample/tear_sheet.json |
Accounting and reported OOS headline metrics |
| Minute execution | fills.csv — IS, fills.csv — OOS |
Exact timestamp, ticker, side, quantity, price, purpose, fee |
| Benchmarks | benchmark_metrics.csv |
Strategy, equal-weight, and VN30 returns/drawdowns |
| Grid reconstruction | grid_levels_and_initial_fills.csv |
Anchor, L1–L5 prices, weights, and filled quantities |
| Example audit | execution_examples_summary.csv |
Focused profitable and failed inventory episodes |
| Presentation slide | GRID_TRADING_STRATEGY.pdf and PNG preview |
Original slide and README-renderable 2400×1350 preview |
- The 32-name expanded universe is not a point-in-time VN30 constituent database; composition and survivorship effects may remain.
- The VN30 comparison is a price-index comparison and excludes dividends.
- Minute OHLCV does not reveal the within-minute event path. The simulator uses volume caps and next-observable conditions, but cannot reproduce queue priority.
- The 7.5% anomaly refund is an OHLC-derived proxy for corporate actions and is economically neutral by construction.
- The winning grid parameters are on search boundaries, increasing selection-risk concerns.
- OOS is one market regime, not proof of future robustness.








