Skip to content

Commit f8e215a

Browse files
committed
feat: add Fear Index strategy with mathematical specification
- add Fear_Index strategy implementation - register Fear_Index in strategy registry - document Fear Index equations, thresholds, and risk model - add standardized mathematical strategy spec template
1 parent 3c1c534 commit f8e215a

3 files changed

Lines changed: 261 additions & 1 deletion

File tree

docs/STRATEGIES.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,90 @@ A mean reversion strategy using RSI with trend filter. Generates signals when RS
6060
- Stop Loss: 1.5x ATR below/above entry price
6161
- Take Profit: RSI-based target levels
6262

63+
### 4. Fear Index (Fear_Index)
64+
65+
A composite risk-regime strategy designed to quantify market fear from multiple factors and convert it into risk-on/risk-off signals.
66+
67+
**Inputs (preferred):**
68+
- `VIX` (volatility proxy)
69+
- `FX_VOL` (FX implied/realized volatility proxy)
70+
- `DXY` (USD risk proxy)
71+
- `NEWS_SENTIMENT` (normalized sentiment score)
72+
- `POLICY_RISK` (event/policy risk score)
73+
74+
If some inputs are missing, the strategy uses internal price-based proxies.
75+
76+
**Mathematical Model:**
77+
78+
For each feature \(x_i(t)\), compute rolling z-score:
79+
80+
\[
81+
z_i(t) = \frac{x_i(t) - \mu_i(t,L)}{\sigma_i(t,L)}
82+
\]
83+
84+
where \(L\) is the lookback window.
85+
86+
Composite fear score:
87+
88+
\[
89+
F_{raw}(t)=0.30z_{VIX}(t)+0.25z_{FXVOL}(t)+0.15z_{DXY}(t)+0.20z_{NEWS}(t)+0.10z_{POLICY}(t)
90+
\]
91+
92+
Smoothed score:
93+
94+
\[
95+
F(t)=SMA(F_{raw}(t), s)
96+
\]
97+
98+
with smoothing window \(s\).
99+
100+
**Signal Logic:**
101+
- Buy (`+1`): \(F(t)\) crosses below `risk_on_threshold`
102+
- Sell (`-1`): \(F(t)\) crosses above `risk_off_threshold`
103+
- Hold (`0`): otherwise
104+
105+
**Risk Management:**
106+
- Stop Loss: `stop_atr_mult × ATR`
107+
- Take Profit: `take_atr_mult × ATR`
108+
- Volatility: rolling std of returns used by risk engine for sizing
109+
110+
**Performance Goal:**
111+
- Optimize for robust risk-adjusted returns (Sharpe, drawdown, profit factor), not fixed win-rate guarantees.
112+
113+
## Mathematical Strategy Spec Template
114+
115+
Use this template for every strategy specification in this document:
116+
117+
1. **Objective / Regime**
118+
Define when the strategy should be active and what inefficiency it targets.
119+
120+
2. **Feature Set**
121+
List exact input variables and data sources.
122+
123+
3. **Core Equations**
124+
- Indicator equations
125+
- Signal equation
126+
- Thresholds and transitions
127+
128+
4. **Execution Rules**
129+
- Entry conditions
130+
- Exit conditions
131+
- Cooldown / position constraints
132+
133+
5. **Risk Model**
134+
- Stop and target equations
135+
- Position sizing equation
136+
- Portfolio exposure constraints
137+
138+
6. **Validation Protocol**
139+
- In-sample and out-of-sample split
140+
- Walk-forward validation
141+
- Cost/slippage assumptions
142+
- Acceptance metrics (Sharpe, DD, PF, stability)
143+
144+
7. **Failure Modes**
145+
Known scenarios where the strategy should be disabled or down-weighted.
146+
63147
## Strategy Development
64148

65149
### Creating a Custom Strategy

forexsmartbot/strategies/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .momentum_breakout import MomentumBreakout
1010
from .scalping_ma import ScalpingMA
1111
from .news_trading import NewsTrading
12+
from .fear_index import FearIndexStrategy
1213

1314
# New ML Strategies
1415
# Use broad exception handling to catch DLL loading errors on Windows
@@ -80,6 +81,7 @@
8081
# High Risk Strategies
8182
'Momentum_Breakout': MomentumBreakout,
8283
'News_Trading': NewsTrading,
84+
'Fear_Index': FearIndexStrategy,
8385
'ML_Adaptive_SuperTrend': MLAdaptiveSuperTrend,
8486
'Adaptive_Trend_Flow': AdaptiveTrendFlow,
8587
}
@@ -102,7 +104,7 @@
102104
RISK_LEVELS = {
103105
'Low_Risk': ['Mean_Reversion', 'SMA_Crossover'],
104106
'Medium_Risk': ['Scalping_MA', 'RSI_Reversion', 'BreakoutATR'],
105-
'High_Risk': ['Momentum_Breakout', 'News_Trading', 'ML_Adaptive_SuperTrend', 'Adaptive_Trend_Flow',
107+
'High_Risk': ['Momentum_Breakout', 'News_Trading', 'Fear_Index', 'ML_Adaptive_SuperTrend', 'Adaptive_Trend_Flow',
106108
'LSTM_Strategy', 'SVM_Strategy', 'Ensemble_ML_Strategy', 'Transformer_Strategy',
107109
'RL_Strategy', 'Multi_Timeframe']
108110
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Fear Index strategy.
2+
3+
Composite macro-risk strategy that builds a normalized fear score from
4+
volatility, momentum, and optional macro/news feature columns.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Any, Dict, Optional
10+
11+
import numpy as np
12+
import pandas as pd
13+
14+
from ..core.interfaces import IStrategy
15+
16+
17+
class FearIndexStrategy(IStrategy):
18+
"""Regime-aware strategy using a composite fear score."""
19+
20+
def __init__(
21+
self,
22+
lookback: int = 30,
23+
signal_smooth: int = 5,
24+
risk_on_threshold: float = -0.5,
25+
risk_off_threshold: float = 0.5,
26+
atr_period: int = 14,
27+
stop_atr_mult: float = 1.5,
28+
take_atr_mult: float = 2.5,
29+
):
30+
self._lookback = int(lookback)
31+
self._signal_smooth = int(signal_smooth)
32+
self._risk_on_threshold = float(risk_on_threshold)
33+
self._risk_off_threshold = float(risk_off_threshold)
34+
self._atr_period = int(atr_period)
35+
self._stop_atr_mult = float(stop_atr_mult)
36+
self._take_atr_mult = float(take_atr_mult)
37+
self._name = "Fear Index"
38+
39+
@property
40+
def name(self) -> str:
41+
return self._name
42+
43+
@property
44+
def params(self) -> Dict[str, Any]:
45+
return {
46+
"lookback": self._lookback,
47+
"signal_smooth": self._signal_smooth,
48+
"risk_on_threshold": self._risk_on_threshold,
49+
"risk_off_threshold": self._risk_off_threshold,
50+
"atr_period": self._atr_period,
51+
"stop_atr_mult": self._stop_atr_mult,
52+
"take_atr_mult": self._take_atr_mult,
53+
}
54+
55+
def set_params(self, **kwargs) -> None:
56+
if "lookback" in kwargs:
57+
self._lookback = max(5, int(kwargs["lookback"]))
58+
if "signal_smooth" in kwargs:
59+
self._signal_smooth = max(1, int(kwargs["signal_smooth"]))
60+
if "risk_on_threshold" in kwargs:
61+
self._risk_on_threshold = float(kwargs["risk_on_threshold"])
62+
if "risk_off_threshold" in kwargs:
63+
self._risk_off_threshold = float(kwargs["risk_off_threshold"])
64+
if "atr_period" in kwargs:
65+
self._atr_period = max(2, int(kwargs["atr_period"]))
66+
if "stop_atr_mult" in kwargs:
67+
self._stop_atr_mult = max(0.1, float(kwargs["stop_atr_mult"]))
68+
if "take_atr_mult" in kwargs:
69+
self._take_atr_mult = max(0.1, float(kwargs["take_atr_mult"]))
70+
71+
def _zscore(self, series: pd.Series, window: int) -> pd.Series:
72+
mean = series.rolling(window).mean()
73+
std = series.rolling(window).std(ddof=0).replace(0, np.nan)
74+
return (series - mean) / std
75+
76+
def indicators(self, df: pd.DataFrame) -> pd.DataFrame:
77+
out = df.copy()
78+
if len(out) < max(self._lookback, self._atr_period) + 2:
79+
return out
80+
81+
# Required price features
82+
out["ret_1"] = out["Close"].pct_change()
83+
out["ret_5"] = out["Close"].pct_change(5)
84+
out["vol_rolling"] = out["ret_1"].rolling(self._lookback).std(ddof=0)
85+
out["mom_abs"] = out["ret_5"].abs()
86+
87+
# ATR
88+
tr = np.maximum(
89+
out["High"] - out["Low"],
90+
np.maximum(
91+
(out["High"] - out["Close"].shift(1)).abs(),
92+
(out["Low"] - out["Close"].shift(1)).abs(),
93+
),
94+
)
95+
out["ATR"] = tr.rolling(self._atr_period).mean()
96+
97+
# Optional macro/news columns if present
98+
# If not present, fallback to NaN then filled with price-derived proxies.
99+
out["feature_vix"] = out["VIX"] if "VIX" in out.columns else np.nan
100+
out["feature_fx_vol"] = out["FX_VOL"] if "FX_VOL" in out.columns else out["vol_rolling"]
101+
out["feature_dxy"] = out["DXY"] if "DXY" in out.columns else np.nan
102+
out["feature_news"] = out["NEWS_SENTIMENT"] if "NEWS_SENTIMENT" in out.columns else np.nan
103+
out["feature_policy"] = out["POLICY_RISK"] if "POLICY_RISK" in out.columns else np.nan
104+
105+
# Fill missing optional features from local proxies
106+
out["feature_vix"] = out["feature_vix"].fillna(out["vol_rolling"] * 100.0)
107+
out["feature_dxy"] = out["feature_dxy"].fillna(out["ret_5"] * -100.0)
108+
out["feature_news"] = out["feature_news"].fillna(-out["ret_1"] * 50.0)
109+
out["feature_policy"] = out["feature_policy"].fillna(out["mom_abs"] * 100.0)
110+
111+
# Z-normalized components
112+
z_vix = self._zscore(out["feature_vix"], self._lookback)
113+
z_fx_vol = self._zscore(out["feature_fx_vol"], self._lookback)
114+
z_dxy = self._zscore(out["feature_dxy"], self._lookback)
115+
z_news = self._zscore(out["feature_news"], self._lookback)
116+
z_policy = self._zscore(out["feature_policy"], self._lookback)
117+
118+
# Weighted fear score (higher = more risk-off)
119+
out["FearScore_raw"] = (
120+
0.30 * z_vix
121+
+ 0.25 * z_fx_vol
122+
+ 0.15 * z_dxy
123+
+ 0.20 * z_news
124+
+ 0.10 * z_policy
125+
)
126+
out["FearScore"] = out["FearScore_raw"].rolling(self._signal_smooth).mean()
127+
128+
return out
129+
130+
def signal(self, df: pd.DataFrame) -> int:
131+
if len(df) < max(self._lookback, self._signal_smooth) + 2:
132+
return 0
133+
if "FearScore" not in df.columns:
134+
return 0
135+
136+
score = df["FearScore"].iloc[-1]
137+
prev = df["FearScore"].iloc[-2]
138+
if pd.isna(score) or pd.isna(prev):
139+
return 0
140+
141+
# Cross-based regime transitions
142+
if prev >= self._risk_on_threshold and score < self._risk_on_threshold:
143+
return 1
144+
if prev <= self._risk_off_threshold and score > self._risk_off_threshold:
145+
return -1
146+
return 0
147+
148+
def volatility(self, df: pd.DataFrame) -> Optional[float]:
149+
if len(df) < self._lookback + 1:
150+
return None
151+
ret = df["Close"].pct_change().rolling(self._lookback).std(ddof=0).iloc[-1]
152+
if pd.isna(ret):
153+
return None
154+
return float(ret)
155+
156+
def stop_loss(self, df: pd.DataFrame, entry_price: float, side: int) -> Optional[float]:
157+
if "ATR" not in df.columns or len(df) == 0:
158+
return None
159+
atr = float(df["ATR"].iloc[-1])
160+
if pd.isna(atr) or atr <= 0:
161+
return None
162+
if side > 0:
163+
return entry_price - self._stop_atr_mult * atr
164+
return entry_price + self._stop_atr_mult * atr
165+
166+
def take_profit(self, df: pd.DataFrame, entry_price: float, side: int) -> Optional[float]:
167+
if "ATR" not in df.columns or len(df) == 0:
168+
return None
169+
atr = float(df["ATR"].iloc[-1])
170+
if pd.isna(atr) or atr <= 0:
171+
return None
172+
if side > 0:
173+
return entry_price + self._take_atr_mult * atr
174+
return entry_price - self._take_atr_mult * atr

0 commit comments

Comments
 (0)