Market Exposure Timing vs Randomness A personal project to see if machine learning models can beat random market entries using basic price data.
I wanted to see if machine learning models trained on price, volume, and implied volatility (VIX) could figure out when a basic momentum signal is actually worth trading. I used a technique called meta-labeling and tested the results against 10,000 random Monte Carlo simulations to make sure any success wasn't just luck.
-
Layer 1 (The Trigger): A basic 5-day momentum signal generated in
src/indicators.py. -
Layer 2 (The Filter): An XGBoost classifier (
src/train_trade_filter.py) that acts as a second opinion, deciding whether to take the trade based on broader market conditions. -
Testing: I used
src/monte_carlo_audit.pyto check for statistical significance. - Assets tested: Nifty 50, GOLDBEES (NSE), and USD/INR.
-
Validation method: 5-fold TimeSeriesSplit, and I only evaluated the model on days where a trade was actually triggered (
$Signal \neq 0$ ).
| Component | What I Used | Why |
|---|---|---|
| Data | Polars | Fast execution and joins |
| Model | XGBoost | Standard binary classification |
| Labeling | Triple Barrier | Uses volatility to set dynamic profit/loss targets |
| Memory | FracDiff | Fractional differentiation ( |
| Metrics | Calmar / Sharpe | Adjusted for 5-bps trade friction |
- I fetched raw OHLCV data using
src/fetch_data.py. - The assets are Nifty, GOLDBEES, USD/INR, and India VIX (a volatility gauge).
- The total row counts varied slightly because different markets have different holidays.
I split the data work across two notebooks:
- Syncing Assets: I aligned Nifty, Gold, USD/INR, and VIX to the same timeline.
- Calendar Alignment: I handled the mismatched market holidays (like MCX vs. NSE).
- Date Intersection: I made sure all cross-asset features were only calculated on days where all markets were open (around 3,440+ days).
- Lookback Buffer: I used 2012-2013 data just to warm up the indicators, and started the actual training data from 2014-01-01.
-
Fractional Differentiation: I used this (
src/frac_diff.py) to make the data stationary for the ML model while keeping about 80% of its historical memory. -
Labeling Target: I generated targets using the Triple Barrier method with a 5-day window (
$T$ ) and different volatility multipliers ($k$ ) for each asset. -
Directional Returns: I adjusted the returns based on the trade signal (
Return * Signal) so the model knew if a positive outcome was from a long or short trade. -
Stress Features: I created features like
Usdinr_Stress_Filterto capture how different assets interacted during market stress.
The primary momentum signal is constructed as:
-
Signal = +1 if
$Ret_{5d} > Vol_{20d}$ (Long conviction) -
Signal = -1 if
$Ret_{5d} < -Vol_{20d}$ (Short conviction) - Signal = 0 otherwise (Flat — no trade)
Important constraint: The XGBoost filter is only trained and tested on rows where
Total Features: 13 Technical + 12 Interaction + 5 Macro/VIX.
- Buffer Data: 2012–2013 (For rolling indicators)
- Train (In-Sample): 2014–2023 (~2200 rows)
- Test (Out-of-Sample): 2024–2025 (~450 rows)
- I split this strictly by time to avoid data leakage.
I chose XGBoost because it usually works best for tabular financial data. Deep learning models like LSTMs need way more data than I had available and tend to overfit small datasets.
Best Parameters Found:
learning_rate: 0.1, max_depth: 4, n_estimators: 5000, subsample: 1.0
Results:
| Metric | Train | Test |
|---|---|---|
| Accuracy | 86.54% | 53.42% |
| ROC AUC | — | 0.5251 |
| Overfit Gap | 33.1% |
Analysis: The model was heavily overfitting. An 86% training accuracy meant it was memorizing the past data rather than learning real patterns.
Results after tuning:
| Metric | Train | Test |
|---|---|---|
| Accuracy | 59.24% | 51.66% |
| ROC AUC | — | 0.5020 |
| Overfit Gap | 7.6% |
Diagnosis: I fixed the overfitting (gap dropped from 33% to 7.6%), but my initial regularization was too strict (reg_alpha=1.0, reg_lambda=5.0), which killed the model's ability to find any signals at all. I had to dial it back to lighter values (reg_alpha=0.1, reg_lambda=0.5) to strike a balance.
Improvements:
-
Sign-Adjusted Returns: I changed the target to
Directional_Return = TB_Return * Signalso the model understood if it was winning on a long or short trade. -
Filtering: I strictly trained only when
$Signal \neq 0$ . - Cross-Validation: I started using 5-fold TimeSeries Cross-Validation to get a more realistic estimate of the probabilities.
Results (Model v3):
| Asset | ROC AUC | AUPRC |
|---|---|---|
| Nifty | 0.5918 | 0.4431 |
| Gold | 0.5935 | 0.4602 |
| USD/INR | 0.5489 | 0.4833 |
Conclusion: Fixing the directional logic helped uncover some edge that was hidden before.
I ran 10,000 simulations where trades were picked at random dates (but matching the frequency of my model) to see if my model was actually doing anything special.
Results (Early Model):
| Metric | Model | Random Average |
|---|---|---|
| Total Return | 18.73% | 14.90% |
| Win Rate | 60.5% | ~51% |
| P-Value | 0.376 | — |
Interpretation:
- My model beat the random average by about 3.8%.
- A p-value of 0.38 means this result wasn't statistically significant yet.
- However, a 60.5% win rate compared to a 51% random baseline was a good sign. (Note: This was on training data. The final test results are in Section 15).
I found that different assets rely on very different features to predict success. Here's what worked for each:
Nifty relies mostly on relative strength against Gold and general volatility.
| Feature | Importance (Gain) | Correlation (r) |
|---|---|---|
Gold_Nifty_RS_5d |
6.44 | +0.128 |
ROC_10 |
6.38 | -0.077 |
VIX_ATR_Ratio |
5.21 | -0.128 |
Gold was heavily driven by volatility and fear in the market.
| Feature | Importance (Gain) | Correlation (r) |
|---|---|---|
VIX_Momentum_Efficiency |
13.61 | +0.007 |
RSI |
11.06 | +0.031 |
RS_Momentum_Decoupling |
2.76 | -0.108 |
The currency model looked mostly for signs of stress in the broader Indian equity market.
| Feature | Importance (Gain) | Correlation (r) |
|---|---|---|
Vol Efficiency |
2.83 | +0.159 |
Nifty_Vol_Ratio |
2.71 | +0.153 |
Usdinr_Stress_Filter |
4.11 | +0.048 |
| Approach | Why It Failed |
|---|---|
| USD/INR Zero-Fee Model | It looked great on paper, but once I added a realistic 5-bps trading fee, the edge disappeared completely. |
| Hidden Markov Models | I tried using an HMM to detect "crisis" states, but it didn't work any better than just looking at simple VIX spikes. |
| Long-Only Labels | Initially, I ignored short trades. This confused the model during pullbacks. |
| Basic RSI/MACD | On a 5-day horizon, standard indicators didn't have enough predictive power on their own. |
| Finding | Impact |
|---|---|
| Directional Labels | Factoring in the trade direction (long/short) helped isolate the edge. |
| Cross-Asset Features | Comparing Nifty vs. Gold improved the correlations nicely. |
| Testing with Friction | Adding a 5-bps cost helped me realize Gold was the only asset worth trading. |
| Fractional Differencing | Keeping 80% of the historical memory ( |
(Code: src/feature_engineering.ipynb)
Instead of just labeling days as "up" or "down", I used a rolling 5-day window (
-
Upper Barrier: Price hits profit target of
$k$ * daily volatility (Label = +1) -
Lower Barrier: Price hits stop-loss of
$k$ * daily volatility (Label = -1) - Time Barrier: 5 days pass without hitting either (Label = 0)
If the base signal is a short trade, hitting the lower barrier is a win. I adjusted for this using:
Directional_Return = TB_Return * Signal
Meta_Label = 1 if Directional_Return > 0 else 0I found that a 3-day window (
-
NIFTY:
$k=1.5$ -
GOLD:
$k=1.75$ (Needs wider stops due to volatility) -
USDINR:
$k=1.5$
(Code: src/feature_engineering.ipynb)
To make price data work with ML models, you usually have to difference it (e.g., use daily returns). The problem is this wipes out all the long-term price memory. I used fractional differencing to find a middle ground.
I manually picked the differencing value (
| Asset | Chosen |
Correlation Kept | ADF p-value | Why I picked it |
|---|---|---|---|---|
| Nifty 50 | 0.45 | 0.818 | 0.079 | Good balance of memory and stationarity |
| Gold | 0.50 | 0.772 | 0.096 | Kept it from decaying too much |
| USD/INR | 0.30 | 0.908 | 0.034 | Easily became stationary while keeping 91% memory |
Here are some of the main custom features I built:
Vol Efficiency:Ret_5d / Vol_20d. Shows how strong a trend is compared to recent volatility.FD_Close: The fractionally differenced price.
Relative Strength (RS):Gold_Ret_5d - Nifty_Ret_5d. Shows where money is flowing.Usdinr_Stress_Filter:Risk_Off * Nifty_Vol_Ratio. Looks for times when equity markets are stressed and money is fleeing to safety.Cross_Vol_Ratio:Nifty_Vol_20d / Gold_Vol_20d.
VIX_Relative:VIX / SMA(VIX, 20). Checks if current fear is above the monthly average.VIX_ATR_Ratio: Checks if implied volatility (VIX) is getting disconnected from actual realized volatility (ATR).VIX_Momentum_Efficiency: Identifies price trends that are continuing despite rising fear in the market.
(On 2014–2023 Training Data)
I tested different probability thresholds to find the best balance of returns and drawdowns, assuming a 5 basis point trading fee.
| Asset | Chosen Threshold | Net Sharpe | Net Max Drawdown | Outcome |
|---|---|---|---|---|
| GOLDBEES | 0.52 | 1.42 | 18% | Looks good |
| Nifty 50 | 0.49 | 0.63 | 7% | Marginal |
| USD/INR | 0.50 | -2.41 | 3% | Failed due to fees |
For Nifty, setting the threshold to 0.49 instead of 0.48 dropped the maximum drawdown significantly (from 23% to 7%), which felt like a much safer bet.
I realized relying just on technicals caused the model to vary too much. Adding macro features (like VIX and cross-asset comparisons) helped ground the model.
For instance, the SHAP values showed that Gold relies heavily on Vol_Efficiency and VIX_Momentum_Efficiency. The model learned that Gold momentum works best when the rest of the market is stressed.
I ran the final test on unseen data from 2024 to 2025, using a 5 bps fee.
| Asset | Target Threshold | Account Sharpe | Max Drawdown | Verdict |
|---|---|---|---|---|
| GOLDBEES | 0.52 | 1.48 | 8% | Statistically Significant |
| Nifty 50 | 0.49 | -0.48 | 16% | No Edge Found |
| USD/INR | 0.50 | -0.80 | 5% | No Edge Found |
Ultimately, Gold was the only asset worth keeping in the strategy. USD/INR and Nifty just didn't hold up after costs.
14. Failed Experiment: Hidden Markov Models
I tried building a Hidden Markov Model (HMM) to explicitly detect "crisis" market states. It ended up just being overly complicated and didn't perform any better than my simpler VIX indicators, so I scrapped it.
To make sure the Gold results weren't just a fluke, I ran three different statistical tests against the Monte Carlo random simulations.
| Test | Gold P-value | Nifty P-value | USD/INR P-value |
|---|---|---|---|
| Monte Carlo (10k runs) | 0.0104 | 0.4019 | 0.1237 |
| Binomial (Win Rate) | 0.0092 | 0.4721 | 0.3725 |
| Kupiec | 0.0126 | 0.8886 | 0.6841 |
(Note: The Sharpe ratio used for the Monte Carlo comparison was slightly different than the account Sharpe, as it only annualized based on trade days).
Conclusion: Gold passed all the statistical tests, confirming the model actually learned a real edge for that asset.
My XGBoost model successfully improved a basic momentum strategy for the Gold ETF (GOLDBEES) on unseen data. It proved that it could filter out bad trades better than random chance (with p-values < 0.05).
Total Return: +17.92% (out-of-sample)
Account Sharpe: 1.48
Max Drawdown: 7.99%
Win Rate: 66.1% (39 out of 59 trades)
Baseline Sharpe: -0.73 (If traded without the ML filter)
The raw momentum signal was losing money (Sharpe -0.73). The ML filter saved it, bumping the Sharpe up to +1.48. This proved my core idea worked: use simple momentum for the trigger, and machine learning for the filter.
The model couldn't find a reliable edge for Nifty 50 or USD/INR.
- Nifty is highly efficient and heavily traded, so simple price data isn't enough to beat it.
- USD/INR is managed by the RBI, meaning its price moves are often dictated by central bank intervention rather than natural momentum.
I think it's a good sign that the model failed here—it shows it wasn't just overfitting to everything. It only found an edge where it structurally made sense (a commodity ETF).
- Sample Size: I only had 59 trades for Gold in the test period. It's a small sample size, which limits how confident I can be.
- Train/Test Split: I picked my thresholds (0.52 for Gold) using the training data, and while it held up in the test data, having a third validation set would have been more rigorous.
- Stationarity vs Memory: My fractional differencing values left the data slightly non-stationary (p-values around 0.07 - 0.09). I chose to keep more memory rather than strict stationarity, but it's a trade-off.
- Fees: I assumed a flat 5 bps fee, but real-world slippage can be worse depending on liquidity.
- Single Test Window: I only tested this on the 2024-2025 market environment. A robust strategy needs to be tested across multiple different market crashes and regimes.
| File | Purpose |
|---|---|
src/fetch_data.py |
Downloads data from Yahoo Finance |
src/data_cleaning.ipynb |
Syncs the dates across all assets |
src/feature_engineering.ipynb |
Builds the technical features and labels |
src/indicators.py |
The math for the technical indicators |
src/frac_diff.py |
The fractional differencing code |
src/triple_barrier.py |
The labeling code |
src/train_trade_filter.py |
Trains the XGBoost model |
src/backtest_engine.py |
Calculates returns and Sharpe ratios |
src/monte_carlo_audit.py |
Runs the random simulations |
src/threshold_optimizer.py |
Finds the best probability threshold |
src/shap_audit.py |
Checks feature importance |