This commit is contained in:
tinh
2026-03-21 15:11:33 +07:00
parent 0948bda053
commit dd3c4eed79
11 changed files with 779 additions and 0 deletions
@@ -0,0 +1,257 @@
from __future__ import annotations
import pandas as pd
import pandas_ta as ta
class ADXATRFilter:
"""
ADX + ATR filter engine (MVP v1)
Mục tiêu:
- tính ADX để phân loại regime
- tính ATR để đo volatility
- lọc thị trường quá yên hoặc quá nóng
- tạo khoảng cách stop / spacing cơ bản cho risk engine
Output chính:
- adx
- atr
- atr_pct
- regime
- allow_mean_reversion
- allow_trend_follow
- volatility_ok
- stop_distance
- spacing_distance
"""
def __init__(
self,
adx_period: int = 14,
atr_period: int = 14,
range_threshold: float = 20.0,
trend_threshold: float = 25.0,
atr_min_pct: float = 0.0003,
atr_max_pct: float = 0.01,
stop_atr_multiplier: float = 1.5,
spacing_atr_multiplier: float = 1.0,
):
"""
Args:
adx_period: chu kỳ ADX
atr_period: chu kỳ ATR
range_threshold:
ADX dưới ngưỡng này xem là range/sideway
trend_threshold:
ADX trên ngưỡng này xem là trend
atr_min_pct:
ATR / close quá thấp thì bỏ qua
atr_max_pct:
ATR / close quá cao thì bỏ qua
stop_atr_multiplier:
hệ số ATR cho stop width
spacing_atr_multiplier:
hệ số ATR cho DCA spacing
"""
self.adx_period = adx_period
self.atr_period = atr_period
self.range_threshold = range_threshold
self.trend_threshold = trend_threshold
self.atr_min_pct = atr_min_pct
self.atr_max_pct = atr_max_pct
self.stop_atr_multiplier = stop_atr_multiplier
self.spacing_atr_multiplier = spacing_atr_multiplier
def _validate_input(self, df: pd.DataFrame) -> None:
required = {"high", "low", "close"}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {sorted(missing)}")
if len(df) < max(self.adx_period, self.atr_period) + 5:
raise ValueError(
f"Not enough rows for ADX/ATR calculation. Got {len(df)} rows."
)
def compute_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
adx_df = ta.adx(
high=df["high"],
low=df["low"],
close=df["close"],
length=self.adx_period,
)
if adx_df is None or adx_df.empty:
raise ValueError("Failed to compute ADX with pandas_ta.")
# pandas_ta thường trả tên kiểu ADX_14, DMP_14, DMN_14
adx_col = f"ADX_{self.adx_period}"
dmp_col = f"DMP_{self.adx_period}"
dmn_col = f"DMN_{self.adx_period}"
if adx_col not in adx_df.columns:
# fallback an toàn nếu pandas_ta đổi format
candidates = [c for c in adx_df.columns if c.startswith("ADX_")]
if not candidates:
raise ValueError("ADX column not found in pandas_ta output.")
adx_col = candidates[0]
if dmp_col not in adx_df.columns:
candidates = [c for c in adx_df.columns if c.startswith("DMP_")]
dmp_col = candidates[0] if candidates else None
if dmn_col not in adx_df.columns:
candidates = [c for c in adx_df.columns if c.startswith("DMN_")]
dmn_col = candidates[0] if candidates else None
df["adx"] = adx_df[adx_col]
df["plus_di"] = adx_df[dmp_col] if dmp_col else pd.NA
df["minus_di"] = adx_df[dmn_col] if dmn_col else pd.NA
df["atr"] = ta.atr(
high=df["high"],
low=df["low"],
close=df["close"],
length=self.atr_period,
)
df["atr_pct"] = df["atr"] / df["close"]
return df
def classify_regime(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["regime"] = "neutral"
df.loc[df["adx"] < self.range_threshold, "regime"] = "range"
df.loc[df["adx"] > self.trend_threshold, "regime"] = "trend"
# vùng giữa 20-25 là transition
transition_mask = (
(df["adx"] >= self.range_threshold)
& (df["adx"] <= self.trend_threshold)
)
df.loc[transition_mask, "regime"] = "transition"
df["allow_mean_reversion"] = df["regime"] == "range"
df["allow_trend_follow"] = df["regime"] == "trend"
return df
def classify_directional_strength(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["directional_bias"] = 0
if "plus_di" in df.columns and "minus_di" in df.columns:
df.loc[df["plus_di"] > df["minus_di"], "directional_bias"] = 1
df.loc[df["plus_di"] < df["minus_di"], "directional_bias"] = -1
return df
def apply_volatility_filter(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["volatility_too_low"] = df["atr_pct"] < self.atr_min_pct
df["volatility_too_high"] = df["atr_pct"] > self.atr_max_pct
df["volatility_ok"] = ~(df["volatility_too_low"] | df["volatility_too_high"])
return df
def build_risk_distances(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["stop_distance"] = df["atr"] * self.stop_atr_multiplier
df["spacing_distance"] = df["atr"] * self.spacing_atr_multiplier
return df
def build_trade_permissions(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["allow_trade"] = df["volatility_ok"] & df["regime"].isin(["range", "trend"])
# tín hiệu hỗ trợ chiến lược sau này
df["allow_trend_long"] = (
df["allow_trade"]
& df["allow_trend_follow"]
& (df["directional_bias"] == 1)
)
df["allow_trend_short"] = (
df["allow_trade"]
& df["allow_trend_follow"]
& (df["directional_bias"] == -1)
)
df["allow_range_long"] = df["allow_trade"] & df["allow_mean_reversion"]
df["allow_range_short"] = df["allow_trade"] & df["allow_mean_reversion"]
return df
def run(self, df: pd.DataFrame) -> pd.DataFrame:
self._validate_input(df)
df = df.copy().sort_index()
df = self.compute_indicators(df)
df = self.classify_regime(df)
df = self.classify_directional_strength(df)
df = self.apply_volatility_filter(df)
df = self.build_risk_distances(df)
df = self.build_trade_permissions(df)
return df
if __name__ == "__main__":
symbol = "EURUSDm"
timeframe = "M15"
path = f"data/processed/{symbol}/{timeframe}/{symbol}_{timeframe}_clean.parquet"
try:
df = pd.read_parquet(path)
engine = ADXATRFilter(
adx_period=14,
atr_period=14,
range_threshold=20,
trend_threshold=25,
atr_min_pct=0.0002,
atr_max_pct=0.005,
stop_atr_multiplier=1.5,
spacing_atr_multiplier=1.0,
)
result = engine.run(df)
cols = [
"close",
"adx",
"plus_di",
"minus_di",
"atr",
"atr_pct",
"regime",
"directional_bias",
"volatility_ok",
"allow_trade",
"allow_trend_long",
"allow_trend_short",
"allow_range_long",
"allow_range_short",
"stop_distance",
"spacing_distance",
]
print(f"--- ADX/ATR filter test for {symbol} {timeframe} ---")
print(result[cols].tail(10))
print("\nRegime counts:")
print(result["regime"].value_counts(dropna=False))
except Exception as e:
print(f"ADX/ATR filter test error: {e}")
@@ -0,0 +1,121 @@
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
try:
from src.strategies.smart_money_adx_atr_rsi_strategy import (
SmartMoneyADXATRRSIStrategy,
)
except ModuleNotFoundError:
# Hỗ trợ chạy trực tiếp file: python src/strategies/run_backtest.py
project_root = Path(__file__).resolve().parents[2]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from src.strategies.smart_money_adx_atr_rsi_strategy import (
SmartMoneyADXATRRSIStrategy,
)
def load_parquet(symbol: str, timeframe: str, suffix: str = "clean") -> pd.DataFrame:
path = Path(
f"data/processed/{symbol}/{timeframe}/{symbol}_{timeframe}_{suffix}.parquet"
)
if not path.exists():
raise FileNotFoundError(f"Missing file: {path}")
df = pd.read_parquet(path)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
df = df.set_index("timestamp")
df = df.sort_index()
return df
def summarize_signals(result: pd.DataFrame) -> None:
signals = result[result["strategy_signal"] != 0].copy()
print("\n===== BACKTEST SUMMARY =====")
print(f"Total rows: {len(result):,}")
print(f"Total signals: {len(signals):,}")
if "entry_mode" in result.columns:
print("\nEntry mode counts:")
print(result["entry_mode"].value_counts(dropna=False))
if signals.empty:
print("\nNo signals found.")
return
cols = [
"close",
"regime",
"liquidity_context",
"strategy_signal",
"entry_mode",
"confidence",
"stop_distance",
"spacing_distance",
]
cols = [c for c in cols if c in signals.columns]
print("\nLast 10 signals:")
print(signals[cols].tail(10))
if "strategy_signal" in signals.columns:
print("\nSignal distribution:")
print(signals["strategy_signal"].value_counts(dropna=False))
def main() -> None:
symbol = "EURUSDm"
print(f"Loading data for {symbol} ...")
df_m15 = load_parquet(symbol, "M15")
df_h1 = load_parquet(symbol, "H1")
df_h4 = load_parquet(symbol, "H4")
print("Running strategy ...")
strategy = SmartMoneyADXATRRSIStrategy(
require_rsi_for_range=False,
require_rsi_for_trend=True,
)
result = strategy.run(df_m15, df_h1, df_h4)
print("\n===== RESULT TAIL =====")
preview_cols = [
"open",
"high",
"low",
"close",
"regime",
"liquidity_context",
"rsi_m15",
"rsi_h1",
"rsi_h4",
"strategy_signal",
"entry_mode",
"confidence",
"stop_distance",
"spacing_distance",
]
preview_cols = [c for c in preview_cols if c in result.columns]
print(result[preview_cols].tail(10))
summarize_signals(result)
output_path = Path(f"reports/backtest/{symbol}_strategy_output.parquet")
output_path.parent.mkdir(parents=True, exist_ok=True)
result.to_parquet(output_path)
print(f"\nSaved result to: {output_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,92 @@
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
try:
from src.strategies.smart_money_adx_atr_rsi_strategy import (
SmartMoneyADXATRRSIStrategy,
)
except ModuleNotFoundError:
# Hỗ trợ chạy trực tiếp file: python src/strategies/run_live.py
project_root = Path(__file__).resolve().parents[2]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from src.strategies.smart_money_adx_atr_rsi_strategy import (
SmartMoneyADXATRRSIStrategy,
)
def load_latest_data(symbol: str, timeframe: str, suffix: str = "clean") -> pd.DataFrame:
path = Path(
f"data/processed/{symbol}/{timeframe}/{symbol}_{timeframe}_{suffix}.parquet"
)
if not path.exists():
raise FileNotFoundError(f"Missing file: {path}")
df = pd.read_parquet(path)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
df = df.set_index("timestamp")
return df.sort_index()
def main() -> None:
symbol = "EURUSDm"
print(f"Loading latest market data for {symbol} ...")
df_m15 = load_latest_data(symbol, "M15")
df_h1 = load_latest_data(symbol, "H1")
df_h4 = load_latest_data(symbol, "H4")
strategy = SmartMoneyADXATRRSIStrategy(
require_rsi_for_range=False,
require_rsi_for_trend=True,
)
result = strategy.run(df_m15, df_h1, df_h4)
latest = result.tail(1).copy()
cols = [
"close",
"regime",
"liquidity_context",
"rsi_m15",
"rsi_h1",
"rsi_h4",
"trigger_buy",
"trigger_sell",
"strategy_signal",
"entry_mode",
"confidence",
"stop_distance",
"spacing_distance",
]
cols = [c for c in cols if c in latest.columns]
print("\n===== LIVE SIGNAL CHECK =====")
print(latest[cols])
signal = int(latest["strategy_signal"].iloc[0])
if signal == 1:
print("\n[BUY] Live setup detected.")
elif signal == -1:
print("\n[SELL] Live setup detected.")
else:
print("\n[HOLD] No live setup.")
output_path = Path(f"reports/live/{symbol}_latest_signal.parquet")
output_path.parent.mkdir(parents=True, exist_ok=True)
latest.to_parquet(output_path)
print(f"Saved latest live snapshot to: {output_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,309 @@
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
try:
from src.signal.adx_atr_filter import ADXATRFilter
from src.signal.liquidity_engine import LiquidityEngine
from src.signal.price_action_engine import PriceActionEngine
from src.signal.rsi_mtf import RSIMultiTimeframe
except ModuleNotFoundError:
# Hỗ trợ chạy trực tiếp file: python src/strategies/smart_money_adx_atr_rsi_strategy.py
project_root = Path(__file__).resolve().parents[2]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from src.signal.adx_atr_filter import ADXATRFilter
from src.signal.liquidity_engine import LiquidityEngine
from src.signal.price_action_engine import PriceActionEngine
from src.signal.rsi_mtf import RSIMultiTimeframe
class SmartMoneyADXATRRSIStrategy:
"""
Smart Money strategy v1
Logic ưu tiên:
1. Liquidity
2. Price Action
3. ADX / ATR regime filter
4. RSI MTF trigger phụ
Hai mode chính:
- Range / mean reversion
- Trend / continuation sau pullback hoặc liquidity grab
Output chính:
- strategy_signal: 1 buy, -1 sell, 0 no trade
- entry_mode: range_reversal / trend_pullback / none
"""
def __init__(
self,
liquidity_engine: LiquidityEngine | None = None,
price_action_engine: PriceActionEngine | None = None,
adx_atr_filter: ADXATRFilter | None = None,
rsi_mtf_engine: RSIMultiTimeframe | None = None,
require_rsi_for_range: bool = False,
require_rsi_for_trend: bool = True,
):
self.liquidity_engine = liquidity_engine or LiquidityEngine()
self.price_action_engine = price_action_engine or PriceActionEngine()
self.adx_atr_filter = adx_atr_filter or ADXATRFilter()
self.rsi_mtf_engine = rsi_mtf_engine or RSIMultiTimeframe()
self.require_rsi_for_range = require_rsi_for_range
self.require_rsi_for_trend = require_rsi_for_trend
def _validate_inputs(
self,
df_m15: pd.DataFrame,
df_h1: pd.DataFrame,
df_h4: pd.DataFrame,
) -> None:
if df_m15.empty:
raise ValueError("df_m15 is empty")
if df_h1.empty:
raise ValueError("df_h1 is empty")
if df_h4.empty:
raise ValueError("df_h4 is empty")
required_cols = {"open", "high", "low", "close"}
for name, df in {"m15": df_m15, "h1": df_h1, "h4": df_h4}.items():
missing = required_cols - set(df.columns)
if missing:
raise ValueError(f"{name} dataframe missing columns: {sorted(missing)}")
def _merge_rsi_context(
self,
base_df: pd.DataFrame,
df_m15: pd.DataFrame,
df_h1: pd.DataFrame,
df_h4: pd.DataFrame,
) -> pd.DataFrame:
rsi_df = self.rsi_mtf_engine.run(df_m15, df_h1, df_h4)
keep_cols = [
"rsi_m15",
"rsi_h1",
"rsi_h4",
"trend",
"bias",
"trigger_buy",
"trigger_sell",
"signal",
]
keep_cols = [c for c in keep_cols if c in rsi_df.columns]
df = base_df.join(rsi_df[keep_cols], how="left")
return df
def _build_range_setups(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
range_long_base = (
df["allow_range_long"]
& df["reclaim_after_sell_sweep"]
& df["pa_bullish_confirm"]
)
range_short_base = (
df["allow_range_short"]
& df["reclaim_after_buy_sweep"]
& df["pa_bearish_confirm"]
)
if self.require_rsi_for_range:
df["range_long_setup"] = range_long_base & df["trigger_buy"].fillna(False)
df["range_short_setup"] = range_short_base & df["trigger_sell"].fillna(False)
else:
df["range_long_setup"] = range_long_base
df["range_short_setup"] = range_short_base
return df
def _build_trend_setups(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
trend_long_base = (
df["allow_trend_long"]
& (
df["reclaim_after_sell_sweep"]
| df["sweep_sell_side"]
| df["close_back_above_swing_low"]
)
& df["pa_bullish_confirm"]
& (df["trend"] == 1)
& (df["bias"] == 1)
)
trend_short_base = (
df["allow_trend_short"]
& (
df["reclaim_after_buy_sweep"]
| df["sweep_buy_side"]
| df["close_back_below_swing_high"]
)
& df["pa_bearish_confirm"]
& (df["trend"] == -1)
& (df["bias"] == -1)
)
if self.require_rsi_for_trend:
df["trend_long_setup"] = trend_long_base & df["trigger_buy"].fillna(False)
df["trend_short_setup"] = trend_short_base & df["trigger_sell"].fillna(False)
else:
df["trend_long_setup"] = trend_long_base
df["trend_short_setup"] = trend_short_base
return df
def _build_final_signal(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["strategy_signal"] = 0
df["entry_mode"] = "none"
range_long = df["range_long_setup"]
range_short = df["range_short_setup"]
trend_long = df["trend_long_setup"]
trend_short = df["trend_short_setup"]
df.loc[range_long, "strategy_signal"] = 1
df.loc[range_short, "strategy_signal"] = -1
df.loc[trend_long, "strategy_signal"] = 1
df.loc[trend_short, "strategy_signal"] = -1
df.loc[range_long, "entry_mode"] = "range_reversal"
df.loc[range_short, "entry_mode"] = "range_reversal"
df.loc[trend_long, "entry_mode"] = "trend_pullback"
df.loc[trend_short, "entry_mode"] = "trend_pullback"
# confidence score đơn giản cho v1
df["confidence"] = 0.0
df.loc[df["reclaim_after_sell_sweep"], "confidence"] += 0.25
df.loc[df["reclaim_after_buy_sweep"], "confidence"] += 0.25
df.loc[df["pa_bullish_confirm"], "confidence"] += 0.25
df.loc[df["pa_bearish_confirm"], "confidence"] += 0.25
df.loc[df["allow_trend_follow"], "confidence"] += 0.15
df.loc[df["allow_mean_reversion"], "confidence"] += 0.10
df.loc[df["trigger_buy"].fillna(False), "confidence"] += 0.10
df.loc[df["trigger_sell"].fillna(False), "confidence"] += 0.10
df["confidence"] = df["confidence"].clip(upper=1.0)
return df
def run(
self,
df_m15: pd.DataFrame,
df_h1: pd.DataFrame,
df_h4: pd.DataFrame,
) -> pd.DataFrame:
self._validate_inputs(df_m15, df_h1, df_h4)
# 1. Liquidity trên M15
df = self.liquidity_engine.run(df_m15)
# 2. Price Action trên M15
df = self.price_action_engine.run(df)
# 3. ADX / ATR filter trên M15
df = self.adx_atr_filter.run(df)
# 4. RSI MTF context
df = self._merge_rsi_context(df, df_m15, df_h1, df_h4)
# 5. Build setups
df = self._build_range_setups(df)
df = self._build_trend_setups(df)
# 6. Final signal
df = self._build_final_signal(df)
return df
if __name__ == "__main__":
symbol = "EURUSDm"
path_m15 = f"data/processed/{symbol}/M15/{symbol}_M15_clean.parquet"
path_h1 = f"data/processed/{symbol}/H1/{symbol}_H1_clean.parquet"
path_h4 = f"data/processed/{symbol}/H4/{symbol}_H4_clean.parquet"
try:
df_m15 = pd.read_parquet(path_m15)
df_h1 = pd.read_parquet(path_h1)
df_h4 = pd.read_parquet(path_h4)
strategy = SmartMoneyADXATRRSIStrategy(
require_rsi_for_range=False,
require_rsi_for_trend=True,
)
result = strategy.run(df_m15, df_h1, df_h4)
cols = [
"open",
"high",
"low",
"close",
"regime",
"liquidity_context",
"pa_bullish_confirm",
"pa_bearish_confirm",
"rsi_m15",
"rsi_h1",
"rsi_h4",
"trigger_buy",
"trigger_sell",
"range_long_setup",
"range_short_setup",
"trend_long_setup",
"trend_short_setup",
"strategy_signal",
"entry_mode",
"confidence",
"stop_distance",
"spacing_distance",
]
available_cols = [c for c in cols if c in result.columns]
print(f"--- Smart Money Strategy test for {symbol} ---")
print(result[available_cols].tail(10))
signals = result[result["strategy_signal"] != 0]
print(f"\nTotal signals found: {len(signals)}")
if not signals.empty:
print("\nLast 10 signals:")
print(
signals[
[
c for c in [
"close",
"regime",
"liquidity_context",
"strategy_signal",
"entry_mode",
"confidence",
"stop_distance",
"spacing_distance",
] if c in signals.columns
]
].tail(10)
)
print("\nEntry mode counts:")
print(result["entry_mode"].value_counts(dropna=False))
except Exception as e:
print(f"Smart money strategy test error: {e}")