deploy: 2026-03-20 07:49

This commit is contained in:
ufo6849
2026-03-20 07:49:42 +09:00
commit d14a8bab04
73 changed files with 76534 additions and 0 deletions

0
indicators/__init__.py Normal file
View File

173
indicators/confluence.py Normal file
View File

@@ -0,0 +1,173 @@
"""Confluence checker for ICT signals.
Evaluates whether enough ICT conditions align (minimum 3 out of 6)
to produce a valid trade signal.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List
import pandas as pd
from loguru import logger
from config import settings
from indicators.ict_engine import ICTSignals
from indicators.multi_timeframe import (
MarketBias,
MTFAnalysis,
TradeDirection,
TradeZone,
)
@dataclass
class ConditionResult:
"""Result for a single confluence condition."""
name: str
met: bool
detail: str = ""
@dataclass
class ConfluenceResult:
"""Aggregated confluence evaluation."""
score: int
conditions: List[ConditionResult] = field(default_factory=list)
is_valid: bool = False
direction: TradeDirection = TradeDirection.NONE
def summary(self) -> str:
met = [c.name for c in self.conditions if c.met]
return f"Score {self.score}/6 ({', '.join(met)})"
class ConfluenceChecker:
"""Check if enough ICT conditions align for a trade entry.
Six conditions are evaluated:
1. Market Structure (HTF bias alignment)
2. Liquidity Sweep (sweep then reversal)
3. Order Block (price in OB zone)
4. Fair Value Gap (price in FVG)
5. BOS (break of structure confirmation)
6. CHOCH (change of character confirmation)
"""
MIN_CONFLUENCE = settings.MIN_CONFLUENCE_SCORE
def check(
self,
mtf: MTFAnalysis,
current_price: float,
htf_signals: ICTSignals | None = None,
mtf_signals: ICTSignals | None = None,
ltf_signals: ICTSignals | None = None,
) -> ConfluenceResult:
"""Evaluate all six conditions and return the result."""
conditions: List[ConditionResult] = []
bias = mtf.htf_bias
target = 1 if bias == MarketBias.BULLISH else (-1 if bias == MarketBias.BEARISH else 0)
# 1. Market Structure
ms_met = bias != MarketBias.NEUTRAL
conditions.append(
ConditionResult("Market Structure", ms_met, f"HTF bias: {bias.value}")
)
# 2. Liquidity Sweep
liq_met = False
try:
if mtf_signals and not mtf_signals.liquidity.empty:
liq_col = mtf_signals.liquidity.get("Liquidity", pd.Series(dtype=float))
swept = liq_col.dropna()
if len(swept) > 0:
val = swept.iloc[-1]
if pd.notna(val) and int(val) == target:
liq_met = True
except (ValueError, TypeError):
pass
conditions.append(
ConditionResult("Liquidity Sweep", liq_met)
)
# 3. Order Block (price in or near aligned OB)
ob_met = False
for zone in mtf.mtf_zones:
if zone.zone_type == "OB" and zone.direction == target:
if zone.contains_price(current_price):
ob_met = True
break
# Also check proximity (within 0.5% of zone)
zone_size = zone.top - zone.bottom
margin = max(zone_size * 0.5, current_price * 0.005)
if (zone.bottom - margin) <= current_price <= (zone.top + margin):
ob_met = True
break
conditions.append(
ConditionResult(
"Order Block",
ob_met,
"Price in/near aligned OB" if ob_met else "",
)
)
# 4. Fair Value Gap (price in or near aligned FVG)
fvg_met = False
for zone in mtf.mtf_zones:
if zone.zone_type == "FVG" and zone.direction == target:
if zone.contains_price(current_price):
fvg_met = True
break
zone_size = zone.top - zone.bottom
margin = max(zone_size * 0.5, current_price * 0.005)
if (zone.bottom - margin) <= current_price <= (zone.top + margin):
fvg_met = True
break
conditions.append(
ConditionResult(
"Fair Value Gap",
fvg_met,
"Price in/near aligned FVG" if fvg_met else "",
)
)
# 5. BOS confirmation
bos_met = False
if ltf_signals:
bos_met = ltf_signals.latest_bos == target
conditions.append(
ConditionResult("BOS", bos_met)
)
# 6. CHOCH confirmation
choch_met = False
if ltf_signals:
choch_met = ltf_signals.latest_choch == target
elif mtf_signals:
choch_met = mtf_signals.latest_choch == target
conditions.append(
ConditionResult("CHOCH", choch_met)
)
score = sum(1 for c in conditions if c.met)
direction = (
TradeDirection.LONG
if target == 1
else TradeDirection.SHORT
if target == -1
else TradeDirection.NONE
)
is_valid = score >= self.MIN_CONFLUENCE and direction != TradeDirection.NONE
result = ConfluenceResult(
score=score,
conditions=conditions,
is_valid=is_valid,
direction=direction,
)
logger.info("Confluence check: {}", result.summary())
return result

164
indicators/ict_engine.py Normal file
View File

@@ -0,0 +1,164 @@
"""ICT Smart Money Concepts indicator engine.
Wraps the smartmoneyconcepts library to provide a unified analysis
interface producing structured ICTSignals results.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import pandas as pd
from loguru import logger
try:
from smartmoneyconcepts import smc
except ImportError:
smc = None
logger.warning(
"smartmoneyconcepts not installed. "
"Run: pip install smartmoneyconcepts"
)
from config import settings
@dataclass
class ICTSignals:
"""Container for all ICT analysis results on a single timeframe."""
swing_highs_lows: pd.DataFrame # HighLow, Level
fvg: pd.DataFrame # FVG(1/-1), Top, Bottom, MitigatedIndex
bos_choch: pd.DataFrame # BOS(1/-1), CHoCH(1/-1), Level, BrokenIndex
order_blocks: pd.DataFrame # OB(1/-1), Top, Bottom, OBVolume, Percentage
liquidity: pd.DataFrame # Liquidity(1/-1), Level, End, SweptIndex
prev_high_low: pd.DataFrame # PreviousHigh, PreviousLow
retracements: pd.DataFrame # Direction, CurrentRetracement, DeepestRetracement
@property
def latest_bos(self) -> Optional[int]:
"""Return the most recent BOS direction (1=bullish, -1=bearish) or None."""
col = "BOS"
if col not in self.bos_choch.columns:
return None
try:
valid = self.bos_choch[col].dropna()
if len(valid) == 0:
return None
val = valid.iloc[-1]
return int(val) if pd.notna(val) else None
except (ValueError, TypeError):
return None
@property
def latest_choch(self) -> Optional[int]:
"""Return the most recent CHOCH direction or None."""
col = "CHOCH"
if col not in self.bos_choch.columns:
return None
try:
valid = self.bos_choch[col].dropna()
if len(valid) == 0:
return None
val = valid.iloc[-1]
return int(val) if pd.notna(val) else None
except (ValueError, TypeError):
return None
@property
def active_order_blocks(self) -> pd.DataFrame:
"""Return order blocks that have not been mitigated (OB == 1 or -1).
MitigatedIndex: NaN or 0 means not mitigated in the SMC library.
"""
if "OB" not in self.order_blocks.columns:
return pd.DataFrame()
ob_col = self.order_blocks["OB"]
mask = ob_col.notna() & (ob_col.isin([1, -1]))
mitigated = self.order_blocks.get("MitigatedIndex", pd.Series(dtype=float))
if not mitigated.empty:
# 0 means "not mitigated" in SMC lib, NaN also means not mitigated
mask = mask & (mitigated.isna() | (mitigated == 0))
return self.order_blocks[mask].tail(10)
@property
def active_fvg(self) -> pd.DataFrame:
"""Return FVGs that have not been mitigated."""
if "FVG" not in self.fvg.columns:
return pd.DataFrame()
fvg_col = self.fvg["FVG"]
mask = fvg_col.notna() & (fvg_col.isin([1, -1]))
mitigated = self.fvg.get("MitigatedIndex", pd.Series(dtype=float))
if not mitigated.empty:
mask = mask & (mitigated.isna() | (mitigated == 0))
return self.fvg[mask].tail(10)
class ICTEngine:
"""ICT Smart Money Concepts analysis engine.
Processes OHLC DataFrames through the smc library to detect
Order Blocks, Fair Value Gaps, BOS/CHOCH, Liquidity, etc.
"""
def __init__(self, swing_length: int | None = None):
self.swing_length = swing_length or settings.SWING_LENGTH
if smc is None:
raise ImportError("smartmoneyconcepts package is required")
def analyze(self, ohlc: pd.DataFrame) -> ICTSignals:
"""Run full ICT analysis on an OHLC DataFrame.
Args:
ohlc: DataFrame with columns [open, high, low, close, volume].
Returns:
ICTSignals with all indicator results.
"""
if ohlc.empty or len(ohlc) < self.swing_length:
raise ValueError(
f"Need at least {self.swing_length} candles, got {len(ohlc)}"
)
logger.debug("Running ICT analysis on {} candles (swing={})", len(ohlc), self.swing_length)
swing = smc.swing_highs_lows(ohlc, self.swing_length)
fvg = smc.fvg(ohlc, join_consecutive=settings.FVG_JOIN_CONSECUTIVE)
bos_choch = smc.bos_choch(ohlc, swing, close_break=True)
order_blocks = smc.ob(ohlc, swing, close_mitigation=settings.OB_CLOSE_MITIGATION)
liquidity = smc.liquidity(
ohlc, swing, range_percent=settings.LIQUIDITY_RANGE_PERCENT
)
# previous_high_low and retracements may not be available in all versions
try:
prev_high_low = smc.previous_high_low(ohlc, time_frame="1D")
except Exception:
prev_high_low = pd.DataFrame(index=ohlc.index)
try:
retracements = smc.retracements(ohlc, swing)
except Exception:
retracements = pd.DataFrame(index=ohlc.index)
return ICTSignals(
swing_highs_lows=swing,
fvg=fvg,
bos_choch=bos_choch,
order_blocks=order_blocks,
liquidity=liquidity,
prev_high_low=prev_high_low,
retracements=retracements,
)
def detect_swing_highs(self, ohlc: pd.DataFrame) -> pd.Series:
"""Return a boolean series marking swing highs."""
swing = smc.swing_highs_lows(ohlc, self.swing_length)
return swing.get("HighLow", pd.Series(dtype=float)) == 1
def detect_swing_lows(self, ohlc: pd.DataFrame) -> pd.Series:
"""Return a boolean series marking swing lows."""
swing = smc.swing_highs_lows(ohlc, self.swing_length)
return swing.get("HighLow", pd.Series(dtype=float)) == -1

View File

@@ -0,0 +1,314 @@
"""Multi-timeframe ICT analysis.
Combines Higher, Middle, and Lower timeframe signals to determine
market bias, key zones, and precise entry points.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional
import pandas as pd
from loguru import logger
from config import settings
from core.data_feed import DataFeed
from indicators.ict_engine import ICTEngine, ICTSignals
class MarketBias(str, Enum):
BULLISH = "BULLISH"
BEARISH = "BEARISH"
NEUTRAL = "NEUTRAL"
class TradeDirection(str, Enum):
LONG = "LONG"
SHORT = "SHORT"
NONE = "NONE"
@dataclass
class TradeZone:
"""A price zone of interest (OB or FVG)."""
zone_type: str # "OB" or "FVG"
direction: int # 1 = bullish, -1 = bearish
top: float
bottom: float
timeframe: str
strength: float = 1.0
@property
def mid(self) -> float:
return (self.top + self.bottom) / 2
def contains_price(self, price: float) -> bool:
return self.bottom <= price <= self.top
@dataclass
class EntryPoint:
"""Precise entry details derived from LTF analysis."""
direction: TradeDirection
price: float
stop_loss: float
take_profit: float
zone: TradeZone
timeframe: str
@dataclass
class MTFAnalysis:
"""Result of multi-timeframe analysis."""
htf_bias: MarketBias
mtf_zones: List[TradeZone] = field(default_factory=list)
ltf_entry: Optional[EntryPoint] = None
confluence_score: int = 0
class MultiTimeframeAnalyzer:
"""Analyze market structure across Higher, Middle, and Lower timeframes."""
TIMEFRAMES = {
"htf": settings.HTF_TIMEFRAME,
"mtf": settings.MTF_TIMEFRAME,
"ltf": settings.LTF_TIMEFRAME,
}
def __init__(self, ict_engine: ICTEngine):
self.engine = ict_engine
async def analyze_all(
self, data_feed: DataFeed, symbol: str
) -> MTFAnalysis:
"""Run ICT analysis on all three timeframes and synthesise results."""
# Fetch data for all timeframes
tf_data = await data_feed.fetch_multi_timeframe(
symbol, list(self.TIMEFRAMES.values())
)
htf_df = tf_data[self.TIMEFRAMES["htf"]]
mtf_df = tf_data[self.TIMEFRAMES["mtf"]]
ltf_df = tf_data[self.TIMEFRAMES["ltf"]]
# Analyze each timeframe
htf_signals = self.engine.analyze(htf_df)
mtf_signals = self.engine.analyze(mtf_df)
ltf_signals = self.engine.analyze(ltf_df)
# Determine bias from HTF
bias = self.get_htf_bias(htf_signals)
# Find key zones from MTF
zones = self.find_mtf_zones(mtf_signals, self.TIMEFRAMES["mtf"])
# Find precise entry from LTF
current_price = float(ltf_df["close"].iloc[-1])
entry = self.find_ltf_entry(ltf_signals, bias, zones, current_price)
# Calculate confluence
score = self._calculate_confluence(htf_signals, mtf_signals, ltf_signals, bias)
result = MTFAnalysis(
htf_bias=bias,
mtf_zones=zones,
ltf_entry=entry,
confluence_score=score,
)
logger.info(
"MTF Analysis for {}: bias={}, zones={}, score={}",
symbol, bias.value, len(zones), score,
)
return result
def get_htf_bias(self, htf_signals: ICTSignals) -> MarketBias:
"""Determine overall market direction from the Higher Timeframe.
Uses BOS/CHOCH and swing structure to decide bias.
"""
latest_bos = htf_signals.latest_bos
latest_choch = htf_signals.latest_choch
# CHOCH takes precedence (trend reversal)
if latest_choch == 1:
return MarketBias.BULLISH
if latest_choch == -1:
return MarketBias.BEARISH
# BOS confirms existing trend
if latest_bos == 1:
return MarketBias.BULLISH
if latest_bos == -1:
return MarketBias.BEARISH
return MarketBias.NEUTRAL
def find_mtf_zones(
self, mtf_signals: ICTSignals, timeframe: str
) -> List[TradeZone]:
"""Extract active Order Block and FVG zones from the Middle Timeframe."""
zones: List[TradeZone] = []
# Order Blocks
obs = mtf_signals.active_order_blocks
if not obs.empty and "Top" in obs.columns and "Bottom" in obs.columns:
for _, row in obs.iterrows():
ob_val = row.get("OB", 0)
top_val = row.get("Top")
bottom_val = row.get("Bottom")
if pd.isna(ob_val) or pd.isna(top_val) or pd.isna(bottom_val):
continue
zones.append(
TradeZone(
zone_type="OB",
direction=int(ob_val),
top=float(top_val),
bottom=float(bottom_val),
timeframe=timeframe,
strength=float(row.get("OBVolume", 1.0)) if pd.notna(row.get("OBVolume")) else 1.0,
)
)
# Fair Value Gaps
fvgs = mtf_signals.active_fvg
if not fvgs.empty and "Top" in fvgs.columns and "Bottom" in fvgs.columns:
for _, row in fvgs.iterrows():
fvg_val = row.get("FVG", 0)
top_val = row.get("Top")
bottom_val = row.get("Bottom")
if pd.isna(fvg_val) or pd.isna(top_val) or pd.isna(bottom_val):
continue
zones.append(
TradeZone(
zone_type="FVG",
direction=int(fvg_val),
top=float(top_val),
bottom=float(bottom_val),
timeframe=timeframe,
)
)
return zones
def find_ltf_entry(
self,
ltf_signals: ICTSignals,
bias: MarketBias,
zones: List[TradeZone],
current_price: float,
) -> Optional[EntryPoint]:
"""Search for a precise entry on the Lower Timeframe.
Only looks for entries aligned with the HTF bias within
identified MTF zones.
"""
if bias == MarketBias.NEUTRAL:
return None
target_dir = 1 if bias == MarketBias.BULLISH else -1
# Check if price is inside any zone aligned with the bias
for zone in zones:
if zone.direction != target_dir:
continue
if not zone.contains_price(current_price):
continue
# Confirm with LTF BOS in the same direction
ltf_bos = ltf_signals.latest_bos
if ltf_bos != target_dir:
continue
direction = (
TradeDirection.LONG
if bias == MarketBias.BULLISH
else TradeDirection.SHORT
)
# SL beyond the zone, TP at 2:1 ratio
if direction == TradeDirection.LONG:
stop_loss = zone.bottom * 0.998 # small buffer below zone
risk = current_price - stop_loss
take_profit = current_price + risk * 2
else:
stop_loss = zone.top * 1.002
risk = stop_loss - current_price
take_profit = current_price - risk * 2
return EntryPoint(
direction=direction,
price=current_price,
stop_loss=stop_loss,
take_profit=take_profit,
zone=zone,
timeframe=settings.LTF_TIMEFRAME,
)
return None
def _calculate_confluence(
self,
htf: ICTSignals,
mtf: ICTSignals,
ltf: ICTSignals,
bias: MarketBias,
) -> int:
"""Count how many ICT conditions align (0-6)."""
score = 0
target = 1 if bias == MarketBias.BULLISH else (-1 if bias == MarketBias.BEARISH else 0)
if target == 0:
return 0
# 1. Market structure (HTF bias exists)
if bias != MarketBias.NEUTRAL:
score += 1
# 2. Liquidity sweep detected on MTF
try:
if not mtf.liquidity.empty:
liq = mtf.liquidity.get("Liquidity", pd.Series(dtype=float))
swept = liq.dropna()
if len(swept) > 0:
val = swept.iloc[-1]
if pd.notna(val) and int(val) == target:
score += 1
except (ValueError, TypeError):
pass
# 3. Order Block present on MTF
try:
if not mtf.active_order_blocks.empty:
ob_dirs = mtf.active_order_blocks.get("OB", pd.Series(dtype=float))
valid_obs = ob_dirs.dropna()
if (valid_obs == target).any():
score += 1
except (ValueError, TypeError):
pass
# 4. FVG present on MTF
try:
if not mtf.active_fvg.empty:
fvg_dirs = mtf.active_fvg.get("FVG", pd.Series(dtype=float))
valid_fvgs = fvg_dirs.dropna()
if (valid_fvgs == target).any():
score += 1
except (ValueError, TypeError):
pass
# 5. BOS on LTF
if ltf.latest_bos is not None and ltf.latest_bos == target:
score += 1
# 6. CHOCH on MTF or LTF
mtf_choch = mtf.latest_choch
ltf_choch = ltf.latest_choch
if (mtf_choch is not None and mtf_choch == target) or \
(ltf_choch is not None and ltf_choch == target):
score += 1
return score