Pro tier upgrade: quant rearchitect + WebSocket level-breach monitor

- Restore XAU/USD (Pro plan), drop USD/CAD, raise rate limit to 55/min
- Reduce signal engine to 4 uncorrelated pillars (EMA, RSI, BB, Candlestick)
  with regime-adaptive weights (trending boosts EMA, ranging boosts RSI+BB)
- Add structure-based S/R (swing highs/lows) for trade setup targeting
- Fix confidence death spiral: all adjustments now additive, not multiplicative
- Dynamic R:R trade setup: SL behind swing level, TP at next structure level,
  reject if R:R < 1.5
- Fix news engine for all 4 pairs (was hardcoded to USDJPY)
- Add WebSocket monitor: streams live ticks, triggers M15 scan on level breach
  with 5-min cooldown per pair — catches breakouts between candle boundaries
- Add correlation filter (EUR/USD+GBP/USD, XAU/USD+USD/JPY exposure warnings)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harwinder
2026-05-11 16:33:23 -04:00
parent a15dc40050
commit dc9c76a716
8 changed files with 435 additions and 196 deletions
+14 -12
View File
@@ -39,13 +39,13 @@ class MarketSession(Enum):
USDJPY = "USDJPY=X" USDJPY = "USDJPY=X"
EURUSD = "EURUSD=X" EURUSD = "EURUSD=X"
GBPUSD = "GBPUSD=X" GBPUSD = "GBPUSD=X"
USDCAD = "USDCAD=X" XAUUSD = "XAU/USD"
PAIR_INFO = { PAIR_INFO = {
USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True}, USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True},
EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False}, EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False},
GBPUSD: {"display": "GBP/USD", "pip_value": 0.0001, "is_jpy_pair": False}, GBPUSD: {"display": "GBP/USD", "pip_value": 0.0001, "is_jpy_pair": False},
USDCAD: {"display": "USD/CAD", "pip_value": 0.0001, "is_jpy_pair": False}, XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False},
} }
# Defaults (kept for backward compat) # Defaults (kept for backward compat)
@@ -92,6 +92,7 @@ class TechnicalConfig:
volume_ma_period: int = 20 volume_ma_period: int = 20
pivot_lookback: int = 1 pivot_lookback: int = 1
divergence_lookback: int = 14 divergence_lookback: int = 14
swing_sr_lookback: int = 50
disabled_indicators: List[str] = field(default_factory=list) disabled_indicators: List[str] = field(default_factory=list)
@@ -102,12 +103,11 @@ class SignalConfig:
min_confidence: float = 55.0 min_confidence: float = 55.0
weights: Dict[str, float] = field(default_factory=lambda: { weights: Dict[str, float] = field(default_factory=lambda: {
"trend": 0.25, "ema_trend": 1.0,
"momentum": 0.20, "rsi": 1.5,
"volatility": 0.15, "bollinger": 1.5,
"price_action": 0.15, "candlestick": 1.5,
"multi_timeframe": 0.15, "divergence": 1.0,
"sentiment": 0.10,
}) })
mtf_agreement_threshold: int = 2 # of 3 timeframes mtf_agreement_threshold: int = 2 # of 3 timeframes
@@ -115,13 +115,14 @@ class SignalConfig:
default_risk_pct: float = 1.0 default_risk_pct: float = 1.0
default_rr_ratio: float = 2.0 default_rr_ratio: float = 2.0
atr_sl_multiplier: float = 1.5 # SL distance = 1.5 × ATR atr_sl_multiplier: float = 1.5
min_rr_ratio: float = 1.5
@dataclass @dataclass
class NewsConfig: class NewsConfig:
"""Economic calendar & sentiment.""" """Economic calendar & sentiment."""
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "JPY", "EUR", "GBP", "CAD"]) tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "JPY", "EUR", "GBP", "XAU"])
min_impact: str = "medium" min_impact: str = "medium"
pre_event_buffer_minutes: int = 30 pre_event_buffer_minutes: int = 30
post_event_buffer_minutes: int = 15 post_event_buffer_minutes: int = 15
@@ -168,11 +169,12 @@ class PlatformConfig:
refresh_interval: int = 300 refresh_interval: int = 300
scan_pairs: List[str] = field( scan_pairs: List[str] = field(
default_factory=lambda: [EURUSD, USDJPY, GBPUSD, USDCAD] default_factory=lambda: [EURUSD, USDJPY, GBPUSD, XAUUSD]
) )
scan_timeframes: List[Timeframe] = field( scan_timeframes: List[Timeframe] = field(
default_factory=lambda: [Timeframe.M15, Timeframe.H1] default_factory=lambda: [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
) )
candle_buffer_seconds: int = 10 candle_buffer_seconds: int = 10
ws_cooldown_seconds: int = 300
log_level: str = "INFO" log_level: str = "INFO"
+4 -2
View File
@@ -49,7 +49,7 @@ class ForexDataProvider:
Timeframe.D1: 86400, Timeframe.W1: 604800, Timeframe.D1: 86400, Timeframe.W1: 604800,
} }
RATE_LIMIT = 8 RATE_LIMIT = 55
RATE_WINDOW = 60.0 RATE_WINDOW = 60.0
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
@@ -67,6 +67,8 @@ class ForexDataProvider:
@staticmethod @staticmethod
def _to_td_symbol(symbol: str) -> str: def _to_td_symbol(symbol: str) -> str:
if "/" in symbol:
return symbol
clean = symbol.replace("=X", "") clean = symbol.replace("=X", "")
return f"{clean[:3]}/{clean[3:]}" return f"{clean[:3]}/{clean[3:]}"
@@ -132,7 +134,7 @@ class ForexDataProvider:
# ── Private ──────────────────────────────────────────────────── # ── Private ────────────────────────────────────────────────────
def _throttle(self, credits: int = 1) -> None: def _throttle(self, credits: int = 1) -> None:
"""Free tier: 8 credits / minute. Block until budget allows.""" """Pro tier: 55 credits / minute. Block until budget allows."""
while True: while True:
now = time.time() now = time.time()
while self._request_times and now - self._request_times[0] >= self.RATE_WINDOW: while self._request_times and now - self._request_times[0] >= self.RATE_WINDOW:
Regular → Executable
+71 -70
View File
@@ -87,16 +87,15 @@ class TechnicalEngine:
if "ema_trend" not in disabled: self._compute_ema_signals(df, result) if "ema_trend" not in disabled: self._compute_ema_signals(df, result)
if "rsi" not in disabled: self._compute_rsi(df, result) if "rsi" not in disabled: self._compute_rsi(df, result)
if "macd" not in disabled: self._compute_macd(df, result)
if "bollinger" not in disabled: self._compute_bollinger(df, result) if "bollinger" not in disabled: self._compute_bollinger(df, result)
if "stochastic" not in disabled: self._compute_stochastic(df, result)
if "adx" not in disabled: self._compute_adx(df, result) if "adx" not in disabled: self._compute_adx(df, result)
if "atr" not in disabled: self._compute_atr(df, result) if "atr" not in disabled: self._compute_atr(df, result)
if "ichimoku" not in disabled: self._compute_ichimoku(df, result)
self._compute_macd_internal(df, result)
if "divergence" not in disabled: self._detect_divergences(df, result) if "divergence" not in disabled: self._detect_divergences(df, result)
if "structure" not in disabled: self._compute_structure_sr(df, result)
if "candlestick" not in disabled: self._detect_candlestick_patterns(df, result) if "candlestick" not in disabled: self._detect_candlestick_patterns(df, result)
if "pivot" not in disabled: self._compute_support_resistance(df, result)
if "volume" not in disabled: self._compute_volume_analysis(df, result)
# ── Classify market regime ───────────────────────────────── # ── Classify market regime ─────────────────────────────────
self._classify_regime(df, result) self._classify_regime(df, result)
@@ -222,14 +221,14 @@ class TechnicalEngine:
result.indicators["rsi"] = IndicatorSignal( result.indicators["rsi"] = IndicatorSignal(
name="RSI", value=rsi_val, signal=signal, name="RSI", value=rsi_val, signal=signal,
confidence=conf, description=desc, weight=1.8, confidence=conf, description=desc, weight=1.5,
) )
# Attach RSI series for divergence detection (dynamic attribute, filtered by _prefix) # Attach RSI series for divergence detection (dynamic attribute, filtered by _prefix)
result._rsi_series = rsi # type: ignore[attr-defined] result._rsi_series = rsi # type: ignore[attr-defined]
def _compute_macd(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_macd_internal(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""MACD with histogram momentum analysis.""" """Compute MACD histogram internally for divergence detection (not a signal pillar)."""
c = self.config c = self.config
close = df["Close"] close = df["Close"]
@@ -239,62 +238,6 @@ class TechnicalEngine:
signal_line = macd_line.ewm(span=c.macd_signal, adjust=False).mean() signal_line = macd_line.ewm(span=c.macd_signal, adjust=False).mean()
histogram = macd_line - signal_line histogram = macd_line - signal_line
macd_val = macd_line.iloc[-1]
signal_val = signal_line.iloc[-1]
hist_val = histogram.iloc[-1]
# Check for crossover
crossover = False
crossover_type = ""
if len(macd_line) >= 2:
prev_macd = macd_line.iloc[-2]
prev_signal = signal_line.iloc[-2]
if prev_macd <= prev_signal and macd_val > signal_val:
crossover = True
crossover_type = "bullish"
elif prev_macd >= prev_signal and macd_val < signal_val:
crossover = True
crossover_type = "bearish"
# Histogram momentum (is it expanding or contracting?)
hist_momentum = ""
if len(histogram) >= 3:
h1, h2, h3 = histogram.iloc[-3], histogram.iloc[-2], histogram.iloc[-1]
if h3 > h2 > h1 and h3 > 0:
hist_momentum = "accelerating bullish"
elif h3 < h2 < h1 and h3 < 0:
hist_momentum = "accelerating bearish"
elif abs(h3) < abs(h2) and h3 * h2 > 0:
hist_momentum = "decelerating"
# Signal
if crossover and crossover_type == "bullish":
signal = SignalStrength.STRONG_BUY
conf = 75
desc = f"MACD bullish crossover | Hist: {hist_val:.6f}"
elif crossover and crossover_type == "bearish":
signal = SignalStrength.STRONG_SELL
conf = 75
desc = f"MACD bearish crossover | Hist: {hist_val:.6f}"
elif macd_val > signal_val and hist_val > 0:
signal = SignalStrength.BUY
conf = 55
desc = f"MACD above signal, positive histogram | {hist_momentum}"
elif macd_val < signal_val and hist_val < 0:
signal = SignalStrength.SELL
conf = 55
desc = f"MACD below signal, negative histogram | {hist_momentum}"
else:
signal = SignalStrength.NEUTRAL
conf = 35
desc = f"MACD mixed | Hist: {hist_val:.6f}"
result.indicators["macd"] = IndicatorSignal(
name="MACD", value=hist_val, signal=signal,
confidence=conf, description=desc, weight=1.2,
)
# Attach histogram for divergence detection
result._macd_histogram = histogram # type: ignore[attr-defined] result._macd_histogram = histogram # type: ignore[attr-defined]
def _compute_bollinger(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_bollinger(self, df: pd.DataFrame, result: TechnicalAnalysis):
@@ -672,11 +615,39 @@ class TechnicalEngine:
name="Divergence", value=len(divergences), name="Divergence", value=len(divergences),
signal=signal, confidence=70, signal=signal, confidence=70,
description=" | ".join(divergences), description=" | ".join(divergences),
weight=3.5, weight=1.0,
) )
# Note: divergences are NOT added to pattern_signals to avoid # Note: divergences are NOT added to pattern_signals to avoid
# duplicate reason collection (the indicator entry covers them). # duplicate reason collection (the indicator entry covers them).
def _compute_structure_sr(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""Detect swing high/low structure levels for S/R and trade setup."""
lookback = min(self.config.swing_sr_lookback, len(df) - 1)
if lookback < 10:
return
price = df["Close"].iloc[-1]
high_window = df["High"].iloc[-lookback:]
low_window = df["Low"].iloc[-lookback:]
swing_highs = self._find_swing_points(high_window, mode="high", window=3)
swing_lows = self._find_swing_points(low_window, mode="low", window=3)
highs_above = sorted([v for _, v in swing_highs if v > price])
highs_below = sorted([v for _, v in swing_highs if v <= price], reverse=True)
lows_below = sorted([v for _, v in swing_lows if v < price], reverse=True)
lows_above = sorted([v for _, v in swing_lows if v >= price])
for i, val in enumerate(highs_above[:3], 1):
result.key_levels[f"swing_high_{i}"] = float(val)
for i, val in enumerate(lows_below[:3], 1):
result.key_levels[f"swing_low_{i}"] = float(val)
if not highs_above and highs_below:
result.key_levels["swing_high_1"] = float(highs_below[0])
if not lows_below and lows_above:
result.key_levels["swing_low_1"] = float(lows_above[0])
def _detect_candlestick_patterns(self, df: pd.DataFrame, result: TechnicalAnalysis): def _detect_candlestick_patterns(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""Detect significant candlestick patterns on recent bars.""" """Detect significant candlestick patterns on recent bars."""
if len(df) < 5: if len(df) < 5:
@@ -738,11 +709,33 @@ class TechnicalEngine:
else: else:
signal = SignalStrength.NEUTRAL signal = SignalStrength.NEUTRAL
conf = 50
atr_val = result.key_levels.get("atr", 0)
latest = c
proximity = atr_val * 0.5 if atr_val > 0 else 0
if proximity > 0:
swing_lows = sorted(
[v for k, v in result.key_levels.items() if k.startswith("swing_low_")]
)
swing_highs = sorted(
[v for k, v in result.key_levels.items() if k.startswith("swing_high_")]
)
near_support = any(abs(latest - sl) <= proximity for sl in swing_lows)
near_resistance = any(abs(latest - sh) <= proximity for sh in swing_highs)
if bullish > bearish and near_support:
conf += 20
patterns.append("(at support)")
elif bearish > bullish and near_resistance:
conf += 20
patterns.append("(at resistance)")
result.indicators["candlestick"] = IndicatorSignal( result.indicators["candlestick"] = IndicatorSignal(
name="Candlestick", value=float(bullish - bearish), name="Candlestick", value=float(bullish - bearish),
signal=signal, confidence=50, signal=signal, confidence=conf,
description=" | ".join(patterns), description=" | ".join(patterns),
weight=1.2, weight=1.5,
) )
result.pattern_signals.extend(patterns) result.pattern_signals.extend(patterns)
@@ -854,10 +847,18 @@ class TechnicalEngine:
total_weight = 0.0 total_weight = 0.0
total_confidence = 0.0 total_confidence = 0.0
_trending = {
"ema_trend": 2.0, "rsi": 1.5, "bollinger": 0.8,
"candlestick": 1.5, "divergence": 1.0,
}
regime_weights = { regime_weights = {
"trending_up": {"divergence": 1.5, "rsi": 1.2, "stochastic": 1.1, "candlestick": 1.2, "ema_trend": 1.0, "macd": 1.0, "adx": 1.0, "ichimoku": 0.8, "bollinger": 0.7, "pivot": 0.8}, "trending_up": _trending,
"trending_down": {"divergence": 1.5, "rsi": 1.2, "stochastic": 1.1, "candlestick": 1.2, "ema_trend": 1.0, "macd": 1.0, "adx": 1.0, "ichimoku": 0.8, "bollinger": 0.7, "pivot": 0.8}, "trending_down": _trending,
"ranging": {"divergence": 1.3, "rsi": 1.4, "stochastic": 1.4, "bollinger": 1.5, "candlestick": 1.3, "pivot": 1.3, "ema_trend": 0.5, "macd": 0.6, "adx": 0.4, "ichimoku": 0.2}, "trending": _trending,
"ranging": {
"ema_trend": 0.5, "rsi": 2.0, "bollinger": 2.0,
"candlestick": 1.5, "divergence": 1.5,
},
} }
active_regime_weights = regime_weights.get(result.market_regime, {}) active_regime_weights = regime_weights.get(result.market_regime, {})
Regular → Executable
+12 -9
View File
@@ -76,9 +76,8 @@ def print_header(title: str = "USD/JPY SIGNAL INTELLIGENCE"):
# ── Live signal renderer ─────────────────────────────────────────── # ── Live signal renderer ───────────────────────────────────────────
_INDICATOR_PREFIXES = ( _INDICATOR_PREFIXES = (
"EMA Trend", "RSI", "MACD", "Bollinger Bands", "Stochastic", "EMA Trend", "RSI", "Bollinger Bands", "Divergence",
"ADX", "Ichimoku", "Divergence", "Candlestick", "Pivot Points", "Candlestick", "ADX", "ATR", "News", "Regime",
"News", "Regime",
) )
@@ -170,13 +169,17 @@ def print_signal_report(report: SignalReport, detail: bool = False):
levels = report.primary_analysis.key_levels levels = report.primary_analysis.key_levels
if levels: if levels:
shown = [lvl for lvl in ["r3", "r2", "r1", "pivot", "s1", "s2", "s3"] if lvl in levels] swing_keys = sorted(
if shown: [k for k in levels if k.startswith("swing_")],
print(f"\n {Colors.BOLD}Pivot Levels{Colors.RESET}") key=lambda k: levels[k], reverse=True,
for lvl in shown: )
val = levels[lvl] if swing_keys:
print(f"\n {Colors.BOLD}Structure Levels{Colors.RESET}")
for k in swing_keys:
val = levels[k]
color = Colors.RED if val > report.current_price else Colors.GREEN color = Colors.RED if val > report.current_price else Colors.GREEN
print(f" {lvl.upper():>6} {color}{val:.3f}{Colors.RESET}") label = k.replace("_", " ").title()
print(f" {label:>20} {color}{val:.3f}{Colors.RESET}")
def report_to_dict(report: SignalReport) -> dict: def report_to_dict(report: SignalReport) -> dict:
Regular → Executable
+19 -10
View File
@@ -104,6 +104,7 @@ class NewsEngine:
"AUD": ["aussie", "rba", "australian", "australia"], "AUD": ["aussie", "rba", "australian", "australia"],
"CAD": ["loonie", "boc", "canadian", "canada"], "CAD": ["loonie", "boc", "canadian", "canada"],
"NZD": ["kiwi", "rbnz", "new zealand"], "NZD": ["kiwi", "rbnz", "new zealand"],
"XAU": ["gold", "xau", "precious metal", "safe haven", "bullion"],
} }
# High-impact event types (these move markets significantly) # High-impact event types (these move markets significantly)
@@ -187,15 +188,21 @@ class NewsEngine:
return context return context
@staticmethod
def _parse_pair(pair_symbol: str) -> Tuple[str, str]:
if "/" in pair_symbol:
parts = pair_symbol.split("/")
return parts[0], parts[1]
clean = pair_symbol.replace("=X", "")
return clean[:3], clean[3:]
def get_event_risk_level(self, pair_symbol: str, context: FundamentalContext) -> Tuple[str, str]: def get_event_risk_level(self, pair_symbol: str, context: FundamentalContext) -> Tuple[str, str]:
""" """
Get event risk level for a specific pair. Get event risk level for a specific pair.
Returns (risk_level, description). Returns (risk_level, description).
risk_level: "clear", "caution", "danger" risk_level: "clear", "caution", "danger"
""" """
clean = pair_symbol.replace("=X", "") base, quote = self._parse_pair(pair_symbol)
base = clean[:3]
quote = clean[3:]
# Check upcoming high-impact events # Check upcoming high-impact events
for event in context.events_upcoming_1h: for event in context.events_upcoming_1h:
@@ -222,9 +229,7 @@ class NewsEngine:
Returns (score, description) where score is -1 to +1. Returns (score, description) where score is -1 to +1.
Positive = bullish for the pair, negative = bearish. Positive = bullish for the pair, negative = bearish.
""" """
clean = pair_symbol.replace("=X", "") base, quote = self._parse_pair(pair_symbol)
base = clean[:3]
quote = clean[3:]
base_sent = context.news_sentiment.get(base, 0.0) base_sent = context.news_sentiment.get(base, 0.0)
quote_sent = context.news_sentiment.get(quote, 0.0) quote_sent = context.news_sentiment.get(quote, 0.0)
@@ -413,7 +418,11 @@ class NewsEngine:
@staticmethod @staticmethod
def _get_affected_pairs(currency: str) -> List[str]: def _get_affected_pairs(currency: str) -> List[str]:
"""Map a currency event to USD/JPY (the only watched pair).""" _MAP = {
if currency in ("USD", "JPY"): "USD": ["EURUSD=X", "USDJPY=X", "GBPUSD=X", "XAU/USD"],
return ["USDJPY=X"] "EUR": ["EURUSD=X"],
return [] "JPY": ["USDJPY=X"],
"GBP": ["GBPUSD=X"],
"XAU": ["XAU/USD"],
}
return _MAP.get(currency, [])
+61 -34
View File
@@ -334,54 +334,44 @@ class SignalAggregator:
score = report.primary_score score = report.primary_score
confidence = report.primary_confidence confidence = report.primary_confidence
# MTF
if report.mtf_agreement: if report.mtf_agreement:
confidence = min(confidence + 15, 95) confidence += 10
all_scores = list(report.mtf_scores.values()) all_scores = list(report.mtf_scores.values())
if all_scores: if all_scores:
mtf_avg = sum(all_scores) / len(all_scores) mtf_avg = sum(all_scores) / len(all_scores)
score = score * 0.6 + mtf_avg * 0.4 score = score * 0.6 + mtf_avg * 0.4
else: else:
confidence *= 0.7 confidence -= 15
if len(report.mtf_scores) >= 2: if len(report.mtf_scores) >= 2:
scores = list(report.mtf_scores.values()) scores = list(report.mtf_scores.values())
has_bull = any(s > 10 for s in scores) has_bull = any(s > 10 for s in scores)
has_bear = any(s < -10 for s in scores) has_bear = any(s < -10 for s in scores)
if has_bull and has_bear: if has_bull and has_bear:
report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes") report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes")
confidence *= 0.6
# News sentiment
if abs(report.news_sentiment) > 0.2: if abs(report.news_sentiment) > 0.2:
if (report.news_sentiment > 0 and score > 0) or \ if (report.news_sentiment > 0 and score > 0) or \
(report.news_sentiment < 0 and score < 0): (report.news_sentiment < 0 and score < 0):
confidence = min(confidence + 5, 95) confidence += 5
elif (report.news_sentiment > 0 and score < -10) or \ elif (report.news_sentiment > 0 and score < -10) or \
(report.news_sentiment < 0 and score > 10): (report.news_sentiment < 0 and score > 10):
report.warnings.append("News sentiment CONFLICTS with technical signal") report.warnings.append("News sentiment CONFLICTS with technical signal")
confidence *= 0.85 confidence -= 10
# Event risk
if report.event_risk == "danger": if report.event_risk == "danger":
report.warnings.append(f"EVENT RISK: {report.event_description}") report.warnings.append(f"EVENT RISK: {report.event_description}")
confidence *= 0.3 confidence = min(confidence, 30)
report.action_recommendation = f"WAIT — {report.event_description}" report.action_recommendation = f"WAIT — {report.event_description}"
elif report.event_risk == "caution": elif report.event_risk == "caution":
report.warnings.append(f"Upcoming event: {report.event_description}") report.warnings.append(f"Upcoming event: {report.event_description}")
confidence *= 0.7 confidence -= 15
# Volatility regime
if report.volatility_regime == "extreme": if report.volatility_regime == "extreme":
report.warnings.append("EXTREME volatility — wider stops needed, reduced position size") report.warnings.append("EXTREME volatility — wider stops needed, reduced position size")
confidence *= 0.8 confidence -= 10
elif report.volatility_regime == "low": elif report.volatility_regime == "low":
report.warnings.append("Low volatility — potential for breakout, tight ranges") report.warnings.append("Low volatility — potential for breakout, tight ranges")
# EMA200 distance gate — empirically the strongest single discriminator
# across 2024 trend / 2023 chop / 2026 hostile windows. Sweet spot is
# 0.5–1.5% from EMA200: closer = false-breakout chop, farther = chase.
# Within band keeps confidence; outside band cuts it sharply so the
# trade falls below the B-grade threshold and is rejected.
primary = report.primary_analysis primary = report.primary_analysis
if primary is not None: if primary is not None:
ema200 = primary.key_levels.get("ema_trend") ema200 = primary.key_levels.get("ema_trend")
@@ -389,17 +379,18 @@ class SignalAggregator:
ema_dist_pct = abs(report.current_price - ema200) / ema200 * 100.0 ema_dist_pct = abs(report.current_price - ema200) / ema200 * 100.0
_lo = float(os.environ.get("FB2_EMA_LO", "0.5")) _lo = float(os.environ.get("FB2_EMA_LO", "0.5"))
_hi = float(os.environ.get("FB2_EMA_HI", "1.5")) _hi = float(os.environ.get("FB2_EMA_HI", "1.5"))
_mult = float(os.environ.get("FB2_EMA_MULT", "0.55"))
if ema_dist_pct < _lo: if ema_dist_pct < _lo:
report.warnings.append( report.warnings.append(
f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk, signal damped" f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk"
) )
confidence *= _mult confidence -= 20
elif ema_dist_pct > _hi: elif ema_dist_pct > _hi:
report.warnings.append( report.warnings.append(
f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended, signal damped" f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended"
) )
confidence *= _mult confidence -= 20
confidence = max(0.0, min(95.0, confidence))
# Final signal # Final signal
if score >= 40 and confidence >= 65: if score >= 40 and confidence >= 65:
@@ -452,21 +443,57 @@ class SignalAggregator:
return None return None
is_buy = report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY) is_buy = report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY)
rr = self.config.signals.default_rr_ratio levels = report.primary_analysis.key_levels
sl_mult = self.config.signals.atr_sl_multiplier min_rr = self.config.signals.min_rr_ratio
sl_distance = atr * sl_mult
tp1_distance = sl_distance * rr
tp2_distance = sl_distance * rr * 1.5
if is_buy: if is_buy:
entry, sl = price, price - sl_distance swing_lows = sorted(
tp1, tp2 = price + tp1_distance, price + tp2_distance [v for k, v in levels.items() if k.startswith("swing_low_") and v < price],
direction = "BUY" reverse=True,
)
if swing_lows:
sl_price = swing_lows[0] - atr * 0.5
else: else:
entry, sl = price, price + sl_distance sl_price = price - atr * 1.5
tp1, tp2 = price - tp1_distance, price - tp2_distance if price - sl_price < atr * 1.0:
sl_price = price - atr * 1.0
swing_highs = sorted(
v for k, v in levels.items() if k.startswith("swing_high_") and v > price + atr * 0.3
)
tp1 = swing_highs[0] if len(swing_highs) >= 1 else price + atr * 2.0
tp2 = swing_highs[1] if len(swing_highs) >= 2 else price + atr * 3.0
sl_distance = price - sl_price
direction = "BUY"
entry, sl = price, sl_price
else:
swing_highs = sorted(
v for k, v in levels.items() if k.startswith("swing_high_") and v > price
)
if swing_highs:
sl_price = swing_highs[0] + atr * 0.5
else:
sl_price = price + atr * 1.5
if sl_price - price < atr * 1.0:
sl_price = price + atr * 1.0
swing_lows = sorted(
[v for k, v in levels.items() if k.startswith("swing_low_") and v < price - atr * 0.3],
reverse=True,
)
tp1 = swing_lows[0] if len(swing_lows) >= 1 else price - atr * 2.0
tp2 = swing_lows[1] if len(swing_lows) >= 2 else price - atr * 3.0
sl_distance = sl_price - price
direction = "SELL" direction = "SELL"
entry, sl = price, sl_price
tp1_distance = abs(tp1 - entry)
rr = tp1_distance / sl_distance if sl_distance > 0 else 0
if rr < min_rr:
return None
risk_pips = sl_distance / pip_value risk_pips = sl_distance / pip_value
reward_pips = tp1_distance / pip_value reward_pips = tp1_distance / pip_value
@@ -479,7 +506,7 @@ class SignalAggregator:
stop_loss=sl, stop_loss=sl,
take_profit_1=tp1, take_profit_1=tp1,
take_profit_2=tp2, take_profit_2=tp2,
risk_reward=rr, risk_reward=round(rr, 2),
risk_pips=risk_pips, risk_pips=risk_pips,
reward_pips=reward_pips, reward_pips=reward_pips,
pip_value=pip_value, pip_value=pip_value,
+254 -59
View File
@@ -2,19 +2,27 @@
""" """
ForexBot2 — Telegram Bot ForexBot2 — Telegram Bot
======================== ========================
Async Telegram interface with candle-boundary auto-scanner. Async Telegram interface with candle-boundary auto-scanner and
real-time WebSocket level-breach monitor.
Manual scan: 4 pairs × 2 timeframes via inline buttons. Manual scan: 4 pairs × 4 timeframes via inline buttons.
Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set. Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set.
/autoscan toggles for additional chats or to stop. /autoscan toggles for additional chats or to stop.
M5 fires at :00/:05/:10/… + 10s buffer.
M15 fires at :00/:15/:30/:45 + 10s buffer. M15 fires at :00/:15/:30/:45 + 10s buffer.
H1 fires at :00 + 70s buffer (staggered to respect 8 credits/min). H1 fires at :00 + 10s buffer.
H4 fires at :00 every 4 hours + 10s buffer.
WebSocket: Streams live ticks from TwelveData for all 4 pairs.
When price crosses a key structure level (swing S/R, BB,
EMA200) cached from the last boundary scan, triggers an
immediate M15 scan and sends a Level Break Alert.
5-minute cooldown per pair prevents spam.
Credit budget (free tier): Plan: TwelveData Pro (55 credits/min, 1500 WS credits).
M15 boundary: 4 pairs × 1 credit = 4 credits Credit budget (worst case, 4-hour boundary):
H1 boundary: 4 pairs × 1 credit = 4 credits (staggered to next minute) Unique fetches: 4 pairs × ~6 TFs = ~24 calls (rest cached) → fits in 55/min.
Top of hour: 4 + 4 = 8 across two minutes → ≤ 8 / min WebSocket: 4 symbols = 4 credits (one-time on connect).
Daily total: 384 (M15) + 96 (H1) = 480 / day (limit 800) Level breach scans: ~3 REST calls each, ~10/hour worst case.
Usage: Usage:
python telegram_bot.py python telegram_bot.py
@@ -22,12 +30,16 @@ Usage:
""" """
import asyncio import asyncio
import json
import logging import logging
import os import os
import sys import sys
import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import Dict, List, Optional
import websockets
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode from telegram.constants import ParseMode
@@ -40,7 +52,7 @@ from telegram.ext import (
from config import ( from config import (
PlatformConfig, SignalStrength, Timeframe, PlatformConfig, SignalStrength, Timeframe,
EURUSD, USDJPY, GBPUSD, USDCAD, PAIR_INFO, EURUSD, USDJPY, GBPUSD, XAUUSD, PAIR_INFO,
) )
from data_provider import ForexDataProvider from data_provider import ForexDataProvider
from signal_aggregator import SignalAggregator, SignalReport from signal_aggregator import SignalAggregator, SignalReport
@@ -63,31 +75,60 @@ CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# ── Pairs & buttons ─────────────────────────────────────────────────── # ── Pairs & buttons ───────────────────────────────────────────────────
SCAN_PAIRS = [EURUSD, USDJPY, GBPUSD, USDCAD] SCAN_PAIRS = [EURUSD, USDJPY, GBPUSD, XAUUSD]
CORRELATION_GROUPS = {
"usd_negative": {"pairs": {EURUSD, GBPUSD}, "label": "USD-weakness"},
"risk_sentiment": {"pairs": {XAUUSD, USDJPY}, "label": "Risk-sentiment"},
}
_WS_TO_PAIR = {
"EUR/USD": EURUSD,
"GBP/USD": GBPUSD,
"USD/JPY": USDJPY,
"XAU/USD": XAUUSD,
}
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
_LEVEL_KEYS = (
"swing_high_1", "swing_high_2", "swing_high_3",
"swing_low_1", "swing_low_2", "swing_low_3",
"bb_upper", "bb_lower", "ema_trend",
)
SCAN_BUTTONS = [ SCAN_BUTTONS = [
{"cb": "scan_eurusd_m5", "symbol": EURUSD, "tf": Timeframe.M5, "label": "EUR/USD 5M"},
{"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR/USD 15M"}, {"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR/USD 15M"},
{"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR/USD 1H"}, {"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR/USD 1H"},
{"cb": "scan_eurusd_h4", "symbol": EURUSD, "tf": Timeframe.H4, "label": "EUR/USD 4H"},
{"cb": "scan_usdjpy_m5", "symbol": USDJPY, "tf": Timeframe.M5, "label": "USD/JPY 5M"},
{"cb": "scan_usdjpy_m15", "symbol": USDJPY, "tf": Timeframe.M15, "label": "USD/JPY 15M"}, {"cb": "scan_usdjpy_m15", "symbol": USDJPY, "tf": Timeframe.M15, "label": "USD/JPY 15M"},
{"cb": "scan_usdjpy_h1", "symbol": USDJPY, "tf": Timeframe.H1, "label": "USD/JPY 1H"}, {"cb": "scan_usdjpy_h1", "symbol": USDJPY, "tf": Timeframe.H1, "label": "USD/JPY 1H"},
{"cb": "scan_usdjpy_h4", "symbol": USDJPY, "tf": Timeframe.H4, "label": "USD/JPY 4H"},
{"cb": "scan_gbpusd_m5", "symbol": GBPUSD, "tf": Timeframe.M5, "label": "GBP/USD 5M"},
{"cb": "scan_gbpusd_m15", "symbol": GBPUSD, "tf": Timeframe.M15, "label": "GBP/USD 15M"}, {"cb": "scan_gbpusd_m15", "symbol": GBPUSD, "tf": Timeframe.M15, "label": "GBP/USD 15M"},
{"cb": "scan_gbpusd_h1", "symbol": GBPUSD, "tf": Timeframe.H1, "label": "GBP/USD 1H"}, {"cb": "scan_gbpusd_h1", "symbol": GBPUSD, "tf": Timeframe.H1, "label": "GBP/USD 1H"},
{"cb": "scan_usdcad_m15", "symbol": USDCAD, "tf": Timeframe.M15, "label": "USD/CAD 15M"}, {"cb": "scan_gbpusd_h4", "symbol": GBPUSD, "tf": Timeframe.H4, "label": "GBP/USD 4H"},
{"cb": "scan_usdcad_h1", "symbol": USDCAD, "tf": Timeframe.H1, "label": "USD/CAD 1H"}, {"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "XAU/USD 5M"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU/USD 15M"},
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "XAU/USD 1H"},
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "XAU/USD 4H"},
] ]
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS} SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
# Manual scans use richer confirmations (may spend extra cached credits)
TF_CONFIRMATIONS = { TF_CONFIRMATIONS = {
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
Timeframe.M15: [Timeframe.M5, Timeframe.H1], Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4], Timeframe.H1: [Timeframe.M15, Timeframe.H4],
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
} }
# Auto-scan uses only cross-TF (already cached, zero extra credits)
AUTOSCAN_CONFIRMATIONS = { AUTOSCAN_CONFIRMATIONS = {
Timeframe.M15: [Timeframe.H1], Timeframe.M5: [Timeframe.M15, Timeframe.H1],
Timeframe.H1: [Timeframe.M15], Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
} }
_executor = ThreadPoolExecutor(max_workers=2) _executor = ThreadPoolExecutor(max_workers=2)
@@ -209,6 +250,33 @@ def format_signal_message(report: SignalReport) -> str:
return "\n".join(lines) return "\n".join(lines)
# ── Correlation filter ───────────────────────────────────────────────
def _detect_correlated_exposure(reports: List[SignalReport]) -> List[str]:
warnings = []
for group_info in CORRELATION_GROUPS.values():
group_pairs = group_info["pairs"]
group_label = group_info["label"]
active = [
r for r in reports
if r.symbol in group_pairs
and r.final_signal not in (SignalStrength.NEUTRAL,)
]
if len(active) >= 2:
directions = set()
for r in active:
if r.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
directions.add("BUY")
else:
directions.add("SELL")
if len(directions) == 1:
names = ", ".join(r.display_name for r in active)
warnings.append(
f"⚠️ Same exposure ({group_label}): {names} both {directions.pop()}"
)
return warnings
# ── Auto-scan summary format ───────────────────────────────────────── # ── Auto-scan summary format ─────────────────────────────────────────
def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str: def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
@@ -235,6 +303,10 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
else: else:
lines.append("No actionable signals this cycle.") lines.append("No actionable signals this cycle.")
corr_warnings = _detect_correlated_exposure(reports)
for w in corr_warnings:
lines.append(w)
return "\n".join(lines) return "\n".join(lines)
@@ -242,10 +314,10 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
def scan_keyboard() -> InlineKeyboardMarkup: def scan_keyboard() -> InlineKeyboardMarkup:
rows = [] rows = []
for i in range(0, len(SCAN_BUTTONS), 2): for i in range(0, len(SCAN_BUTTONS), 4):
rows.append([ rows.append([
InlineKeyboardButton(f"🔍 {b['label']}", callback_data=b["cb"]) InlineKeyboardButton(b["label"], callback_data=b["cb"])
for b in SCAN_BUTTONS[i:i + 2] for b in SCAN_BUTTONS[i:i + 4]
]) ])
return InlineKeyboardMarkup(rows) return InlineKeyboardMarkup(rows)
@@ -315,6 +387,8 @@ async def _scan_timeframe(
if not reports: if not reports:
return return
_update_level_cache(reports)
summary = _format_scan_summary(reports, tf) summary = _format_scan_summary(reports, tf)
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")] actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B")]
@@ -328,60 +402,175 @@ async def _scan_timeframe(
logger.exception(f"Failed to send to chat {chat_id}") logger.exception(f"Failed to send to chat {chat_id}")
async def _m15_loop(bot) -> None: # ── WebSocket level-breach monitor ──────────────────────────────────
"""Sleep until each M15 candle close, then scan all pairs."""
_level_cache: Dict[str, Dict[str, float]] = {}
_last_prices: Dict[str, float] = {}
_scan_cooldowns: Dict[str, float] = {}
_cfg = PlatformConfig()
_WS_API_KEY = os.environ.get("TWELVE_DATA_API_KEY") or _cfg.twelve_data_api_key
_WS_URL = f"wss://ws.twelvedata.com/v1/quotes/price?apikey={_WS_API_KEY}"
_WS_COOLDOWN = _cfg.ws_cooldown_seconds
def _update_level_cache(reports: List[SignalReport]) -> None:
for r in reports:
if r.primary_analysis and r.primary_analysis.key_levels:
levels = {
k: v for k, v in r.primary_analysis.key_levels.items()
if k in _LEVEL_KEYS
}
if levels:
_level_cache[r.symbol] = levels
def _check_level_breach(symbol: str, price: float) -> Optional[str]:
levels = _level_cache.get(symbol)
if not levels:
return None
last = _last_prices.get(symbol)
if last is None:
return None
for name, level in levels.items():
if (last < level <= price) or (last > level >= price):
return name
return None
async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, level_name: str) -> None:
now = time.time()
if now - _scan_cooldowns.get(pair_symbol, 0) < _WS_COOLDOWN:
return
_scan_cooldowns[pair_symbol] = now
display = PAIR_INFO.get(pair_symbol, {}).get("display", ws_symbol)
label = level_name.replace("_", " ").title()
logger.info(f"Level breach: {display} crossed {label} at {price}")
try:
report = await run_signal(pair_symbol, Timeframe.M15)
_update_level_cache([report])
text = (
f"🔔 *Level Break — {display}*\n"
f"Price crossed *{label}* at `{price}`\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
+ format_signal_message(report)
)
except Exception as exc:
logger.exception(f"Breach scan failed for {display}")
text = f"🔔 *Level Break — {display}*\nPrice crossed {label} at `{price}`\n❌ Scan failed: {exc}"
for chat_id in list(_autoscan_chats):
try:
await bot.send_message(chat_id, text, parse_mode=ParseMode.MARKDOWN)
except Exception:
logger.exception(f"Failed to send breach alert to {chat_id}")
async def _ws_monitor(bot) -> None:
backoff = 1
while _autoscan_chats:
try:
async with websockets.connect(_WS_URL) as ws:
subscribe = {
"action": "subscribe",
"params": {"symbols": ",".join(_WS_SYMBOLS)},
}
await ws.send(json.dumps(subscribe))
logger.info(f"WebSocket connected, subscribed to {len(_WS_SYMBOLS)} symbols")
backoff = 1
async for raw in ws:
if not _autoscan_chats:
break
msg = json.loads(raw)
if msg.get("event") != "price":
continue
ws_symbol = msg.get("symbol")
pair_symbol = _WS_TO_PAIR.get(ws_symbol)
if not pair_symbol:
continue
price = float(msg["price"])
breached = _check_level_breach(pair_symbol, price)
_last_prices[pair_symbol] = price
if breached:
asyncio.create_task(
_handle_breach(bot, ws_symbol, pair_symbol, price, breached)
)
except asyncio.CancelledError:
break
except Exception:
logger.exception(f"WebSocket error, reconnecting in {backoff}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
logger.info("WebSocket monitor stopped")
async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
label = tf.value
try: try:
while _autoscan_chats: while _autoscan_chats:
wait = _seconds_until_boundary(15, buffer=10) wait = _seconds_until_boundary(interval_minutes, buffer=10)
logger.info(f"M15 auto-scan: next in {wait:.0f}s") logger.info(f"{label} auto-scan: next in {wait:.0f}s")
await asyncio.sleep(wait) await asyncio.sleep(wait)
if not _autoscan_chats: if not _autoscan_chats:
break break
logger.info("M15 auto-scan: firing") logger.info(f"{label} auto-scan: firing")
await _scan_timeframe( await _scan_timeframe(
bot, Timeframe.M15, AUTOSCAN_CONFIRMATIONS[Timeframe.M15] bot, tf, AUTOSCAN_CONFIRMATIONS[tf]
) )
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
logger.info("M15 auto-scan loop stopped") logger.info(f"{label} auto-scan loop stopped")
async def _h1_loop(bot) -> None: _AUTOSCAN_LOOPS = [
"""Sleep until each H1 candle close + 60s stagger, then scan all pairs.""" (Timeframe.M5, 5),
try: (Timeframe.M15, 15),
while _autoscan_chats: (Timeframe.H1, 60),
wait = _seconds_until_boundary(60, buffer=70) (Timeframe.H4, 240),
logger.info(f"H1 auto-scan: next in {wait:.0f}s") ]
await asyncio.sleep(wait)
if not _autoscan_chats:
break _ws_task: Optional[asyncio.Task] = None
logger.info("H1 auto-scan: firing")
await _scan_timeframe(
bot, Timeframe.H1, AUTOSCAN_CONFIRMATIONS[Timeframe.H1]
)
except asyncio.CancelledError:
pass
finally:
logger.info("H1 auto-scan loop stopped")
def _start_scheduler(bot) -> None: def _start_scheduler(bot) -> None:
"""Start the M15 and H1 scanning loops if not already running.""" global _ws_task
alive = [t for t in _autoscan_tasks if not t.done()] alive = [t for t in _autoscan_tasks if not t.done()]
if not alive: if not alive:
_autoscan_tasks.clear() _autoscan_tasks.clear()
_autoscan_tasks.append(asyncio.create_task(_m15_loop(bot))) for tf, interval in _AUTOSCAN_LOOPS:
_autoscan_tasks.append(asyncio.create_task(_h1_loop(bot))) _autoscan_tasks.append(
logger.info("Candle-boundary scheduler started") asyncio.create_task(_candle_loop(bot, tf, interval))
)
logger.info("Candle-boundary scheduler started (M5/M15/H1/H4)")
if _ws_task is None or _ws_task.done():
_ws_task = asyncio.create_task(_ws_monitor(bot))
logger.info("WebSocket level-breach monitor started")
def _stop_scheduler() -> None: def _stop_scheduler() -> None:
"""Cancel all scanning loops.""" global _ws_task
for t in _autoscan_tasks: for t in _autoscan_tasks:
t.cancel() t.cancel()
_autoscan_tasks.clear() _autoscan_tasks.clear()
logger.info("Candle-boundary scheduler stopped") if _ws_task and not _ws_task.done():
_ws_task.cancel()
_ws_task = None
logger.info("Candle-boundary scheduler and WebSocket monitor stopped")
# ── Handlers ────────────────────────────────────────────────────────── # ── Handlers ──────────────────────────────────────────────────────────
@@ -417,13 +606,16 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
_autoscan_chats.add(chat_id) _autoscan_chats.add(chat_id)
_start_scheduler(context.bot) _start_scheduler(context.bot)
next_m5 = _seconds_until_boundary(5, buffer=10)
next_m15 = _seconds_until_boundary(15, buffer=10) next_m15 = _seconds_until_boundary(15, buffer=10)
next_h1 = _seconds_until_boundary(60, buffer=70) next_h1 = _seconds_until_boundary(60, buffer=10)
next_h4 = _seconds_until_boundary(240, buffer=10)
await update.message.reply_text( await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n" "✅ *Auto-scan enabled*\n\n"
f"Scanning {len(SCAN_PAIRS)} pairs on M15 + H1 at candle close.\n" f"Scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4 at candle close.\n"
f"Next M15 scan in {next_m15 / 60:.0f} min\n" f"📡 Live price monitoring active — level-break alerts enabled.\n"
f"Next H1 scan in {next_h1 / 60:.0f} min\n\n" f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
"Use /autoscan again to stop.", "Use /autoscan again to stop.",
parse_mode=ParseMode.MARKDOWN, parse_mode=ParseMode.MARKDOWN,
) )
@@ -470,20 +662,23 @@ async def _post_init(app: Application) -> None:
_autoscan_chats.add(chat_id) _autoscan_chats.add(chat_id)
_start_scheduler(app.bot) _start_scheduler(app.bot)
next_m5 = _seconds_until_boundary(5, buffer=10)
next_m15 = _seconds_until_boundary(15, buffer=10) next_m15 = _seconds_until_boundary(15, buffer=10)
next_h1 = _seconds_until_boundary(60, buffer=70) next_h1 = _seconds_until_boundary(60, buffer=10)
next_h4 = _seconds_until_boundary(240, buffer=10)
logger.info( logger.info(
f"Auto-scan started for chat {chat_id} — " f"Auto-scan started for chat {chat_id} — "
f"M15 in {next_m15:.0f}s, H1 in {next_h1:.0f}s" f"M5 {next_m5:.0f}s, M15 {next_m15:.0f}s, H1 {next_h1:.0f}s, H4 {next_h4:.0f}s"
) )
try: try:
await app.bot.send_message( await app.bot.send_message(
chat_id, chat_id,
f"🤖 *ForexBot2 started*\n\n" f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning {len(SCAN_PAIRS)} pairs on M15 + H1.\n" f"Auto-scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4.\n"
f"Next M15 in {next_m15 / 60:.0f} min | " f"📡 Live price monitoring active — level-break alerts enabled.\n"
f"Next H1 in {next_h1 / 60:.0f} min\n\n" f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
f"/autoscan to stop | Manual buttons via /start", f"/autoscan to stop | Manual buttons via /start",
parse_mode=ParseMode.MARKDOWN, parse_mode=ParseMode.MARKDOWN,
) )
Regular → Executable
+1 -1
View File
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
API_KEY = "f563b22ffdfd4924955decbe1a8c8701" API_KEY = "f563b22ffdfd4924955decbe1a8c8701"
WS_URL = f"wss://ws.twelvedata.com/v1/quotes/price?apikey={API_KEY}" WS_URL = f"wss://ws.twelvedata.com/v1/quotes/price?apikey={API_KEY}"
SYMBOLS = ["EUR/USD", "GBP/USD", "USD/JPY"] SYMBOLS = ["EUR/USD", "GBP/USD", "USD/JPY", "XAU/USD"]
async def stream_ticks(): async def stream_ticks():