diff --git a/engine.py b/engine.py index 3d63999..74c41b5 100755 --- a/engine.py +++ b/engine.py @@ -488,7 +488,7 @@ class TechnicalEngine: plus_di = 100 * (plus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() / atr) minus_di = 100 * (minus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() / atr) - dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan) + dx = 100 * (plus_di - minus_di).abs() / np.maximum(plus_di + minus_di, 1e-10) adx = dx.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() adx_val = adx.iloc[-1] if not adx.empty and not pd.isna(adx.iloc[-1]) else 0.0 @@ -598,8 +598,12 @@ class TechnicalEngine: if len(close) < n + 5: return - roc_val = ((close.iloc[-1] - close.iloc[-n - 1]) / close.iloc[-n - 1]) * 100 - roc_prev = ((close.iloc[-2] - close.iloc[-n - 2]) / close.iloc[-n - 2]) * 100 + denom1 = close.iloc[-n - 1] + denom2 = close.iloc[-n - 2] + if denom1 == 0 or denom2 == 0: + return + roc_val = ((close.iloc[-1] - denom1) / denom1) * 100 + roc_prev = ((close.iloc[-2] - denom2) / denom2) * 100 if pd.isna(roc_val) or pd.isna(roc_prev): return @@ -1112,12 +1116,14 @@ class TechnicalEngine: for i in range(window, len(vals) - window): if np.isnan(vals[i]): continue + left_vals = [vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])] + right_vals = [vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])] + if not left_vals or not right_vals: + continue if mode == "low": - if all(vals[i] <= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \ - all(vals[i] <= vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])): + if all(vals[i] <= v for v in left_vals) and all(vals[i] <= v for v in right_vals): points.append((i, vals[i])) else: - if all(vals[i] >= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \ - all(vals[i] >= vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])): + if all(vals[i] >= v for v in left_vals) and all(vals[i] >= v for v in right_vals): points.append((i, vals[i])) return points diff --git a/news_engine.py b/news_engine.py index 9460937..f8622c0 100755 --- a/news_engine.py +++ b/news_engine.py @@ -497,11 +497,12 @@ class NewsEngine: found = [] for currency, keywords in self.CURRENCY_KEYWORDS.items(): - if currency.lower() in headline_lower: + if re.search(r'\b' + re.escape(currency.lower()) + r'\b', headline_lower): found.append(currency) continue for kw in keywords: - if kw in headline_lower: + kw_stripped = kw.strip() + if kw_stripped and re.search(r'\b' + re.escape(kw_stripped) + r'\b', headline_lower): found.append(currency) break diff --git a/signal_aggregator.py b/signal_aggregator.py index ad8971e..c971eb2 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -252,11 +252,11 @@ class SignalAggregator: ) report.trade_setup = None else: - report.bullish_reasons.append( - f"Regime: {regime_tf.value} ADX={adx_value:.1f} confirms trending market" - ) if report.final_score > 0 else report.bearish_reasons.append( - f"Regime: {regime_tf.value} ADX={adx_value:.1f} confirms trending market" - ) + reason = f"Regime: {regime_tf.value} ADX={adx_value:.1f} confirms trending market" + if report.final_score > 0: + report.bullish_reasons.append(reason) + else: + report.bearish_reasons.append(reason) @staticmethod def _latest_adx(df: pd.DataFrame, period: int = 14) -> Optional[float]: @@ -915,6 +915,8 @@ class SignalAggregator: ) return None + if pip_value <= 0: + return None min_sl_pips = 8.0 if pip_value < 0.01 else 80.0 min_sl_price = min_sl_pips * pip_value @@ -932,9 +934,11 @@ class SignalAggregator: eur = self.config.eur_strategy min_rr = eur.min_rr_ratio mature_move = abs(report.final_score) > eur.mature_move_threshold - tp_mult = eur.tp_atr_multiplier if mature_move else eur.tp_atr_multiplier * 1.5 - tp_swing_cap = 2.0 if mature_move else 2.5 + quality_scale = eur.tp_quality_scale.get(report.signal_quality, 1.0) + tp_mult = (eur.tp_atr_multiplier if mature_move else eur.tp_atr_multiplier * 1.5) * quality_scale + tp_swing_cap = (2.0 if mature_move else 2.5) * quality_scale sl_atr_floor = eur.sl_atr_floor + sl_swing_buffer = eur.sl_atr_multiplier else: min_rr = self.config.signals.min_rr_ratio mature_move = abs(report.final_score) > 30 @@ -942,6 +946,7 @@ class SignalAggregator: tp_swing_cap = 2.0 if mature_move else 2.5 sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier if report.display_name == "XAU/USD" else 1.5) + sl_swing_buffer = 1.0 if is_buy: swing_lows = sorted( @@ -949,9 +954,9 @@ class SignalAggregator: reverse=True, ) if swing_lows: - sl_price = swing_lows[0] - atr * 1.0 + sl_price = swing_lows[0] - atr * sl_swing_buffer else: - sl_price = price - atr * 2.0 + sl_price = price - atr * (1.0 + sl_swing_buffer) min_sl = max(atr * sl_atr_floor, min_sl_price) if price - sl_price < min_sl: sl_price = price - min_sl @@ -979,9 +984,9 @@ class SignalAggregator: v for k, v in levels.items() if k.startswith("swing_high_") and v > price ) if swing_highs: - sl_price = swing_highs[0] + atr * 1.0 + sl_price = swing_highs[0] + atr * sl_swing_buffer else: - sl_price = price + atr * 2.0 + sl_price = price + atr * (1.0 + sl_swing_buffer) min_sl = max(atr * sl_atr_floor, min_sl_price) if sl_price - price < min_sl: sl_price = price + min_sl diff --git a/test_all.py b/test_all.py index bc6faed..7c5ce8f 100644 --- a/test_all.py +++ b/test_all.py @@ -1677,14 +1677,15 @@ def _(): assert 6 not in cfg.allowed_weekdays # No Sunday assert cfg.min_atr_threshold == 0.0003 assert cfg.sl_atr_floor == 1.2 - assert cfg.min_rr_ratio == 0.5 + assert cfg.min_rr_ratio == 1.0 assert cfg.mature_move_threshold == 35.0 assert cfg.tp_quality_scale["A+"] == 1.0 assert cfg.tp_quality_scale["A"] == 1.0 assert cfg.tp_quality_scale["C"] == 1.0 - assert "ranging" in cfg.blocked_regimes + assert cfg.blocked_regimes == [] assert "A" in cfg.min_quality_grades - assert "C" not in cfg.min_quality_grades + assert "A" in cfg.min_quality_grades + assert "B" not in cfg.min_quality_grades @test("PlatformConfig has eur_strategy field") @@ -1776,7 +1777,7 @@ def _(): config.eur_strategy.blocked_regimes = [] config.eur_strategy.min_quality_grades = [] assert config.eur_strategy.sl_atr_floor == 1.2 - assert config.eur_strategy.min_rr_ratio == 0.5 + assert config.eur_strategy.min_rr_ratio == 1.0 agg = SignalAggregator(config) df = make_ohlcv(n=300, start_price=1.0800, trend=0.0003, seed=77) dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} @@ -1785,6 +1786,971 @@ def _(): assert report.trade_setup.risk_reward >= 0.5 - 0.01 +# ══════════════════════════════════════════════════════════════════════ +# MODULE 10: Bug Fix Regressions & Comprehensive Edge Cases +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 10: Bug Fix Regressions & Edge Cases ═══") + +# ── 10a. Swing Point Bug Fix (engine.py) ───────────────────────────── + +@test("Swing points: all-NaN neighbors produces no false positives") +def _(): + series = pd.Series([np.nan, np.nan, np.nan, 5.0, np.nan, np.nan, np.nan]) + lows = TechnicalEngine._find_swing_points(series, mode="low", window=3) + highs = TechnicalEngine._find_swing_points(series, mode="high", window=3) + assert lows == [], f"Expected no lows with all-NaN neighbors, got {lows}" + assert highs == [], f"Expected no highs with all-NaN neighbors, got {highs}" + + +@test("Swing points: partial NaN neighbors still detect valid swings") +def _(): + series = pd.Series([10.0, 8.0, np.nan, 4.0, np.nan, 8.0, 10.0]) + lows = TechnicalEngine._find_swing_points(series, mode="low", window=2) + low_vals = [v for _, v in lows] + assert 4.0 in low_vals, f"Expected 4.0 as swing low, got {low_vals}" + + +@test("Swing points: empty series returns empty list") +def _(): + series = pd.Series([], dtype=float) + assert TechnicalEngine._find_swing_points(series, mode="low") == [] + assert TechnicalEngine._find_swing_points(series, mode="high") == [] + + +@test("Swing points: series shorter than 2*window returns empty") +def _(): + series = pd.Series([1.0, 2.0, 3.0]) + assert TechnicalEngine._find_swing_points(series, mode="low", window=3) == [] + + +@test("Swing points: monotonic increasing has no interior lows") +def _(): + series = pd.Series([float(i) for i in range(20)]) + lows = TechnicalEngine._find_swing_points(series, mode="low", window=2) + assert len(lows) == 0, f"Monotonic increase should have no interior lows, got {lows}" + + +@test("Swing points: monotonic decreasing has no interior highs") +def _(): + series = pd.Series([float(20 - i) for i in range(20)]) + highs = TechnicalEngine._find_swing_points(series, mode="high", window=2) + assert len(highs) == 0, f"Monotonic decrease should have no interior highs, got {highs}" + + +@test("Swing points: V-shape detects low at bottom") +def _(): + series = pd.Series([10.0, 8.0, 6.0, 4.0, 6.0, 8.0, 10.0]) + lows = TechnicalEngine._find_swing_points(series, mode="low", window=2) + low_vals = [v for _, v in lows] + assert 4.0 in low_vals, f"V-shape should detect 4.0 as low, got {low_vals}" + + +@test("Swing points: inverted V-shape detects high at top") +def _(): + series = pd.Series([4.0, 6.0, 8.0, 10.0, 8.0, 6.0, 4.0]) + highs = TechnicalEngine._find_swing_points(series, mode="high", window=2) + high_vals = [v for _, v in highs] + assert 10.0 in high_vals, f"Inverted V should detect 10.0 as high, got {high_vals}" + + +# ── 10b. ROC Bug Fix (engine.py) ───────────────────────────────────── + +@test("ROC: zero close at lookback position does not produce inf") +def _(): + df = make_ohlcv(n=100, seed=42) + df.iloc[-11, df.columns.get_loc("Close")] = 0.0 + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + if "roc" in result.indicators: + assert np.isfinite(result.indicators["roc"].value), "ROC should not be inf" + + +@test("ROC: normal computation produces finite values") +def _(): + df = make_ohlcv(n=100, seed=55) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + if "roc" in result.indicators: + assert np.isfinite(result.indicators["roc"].value) + assert 0 <= result.indicators["roc"].confidence <= 100 + + +# ── 10c. ADX Epsilon Fix (engine.py) ──────────────────────────────── + +@test("ADX: flat data produces finite ADX value") +def _(): + n = 100 + dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") + df = pd.DataFrame({ + "Open": [100.0]*n, "High": [100.01]*n, "Low": [99.99]*n, + "Close": [100.0]*n, "Volume": [1000.0]*n, + }, index=dates) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + if "adx" in result.indicators: + assert np.isfinite(result.indicators["adx"].value), "ADX should be finite on flat data" + + +@test("ADX: strong uptrend shows high ADX") +def _(): + df = make_ohlcv(n=300, trend=0.001, seed=10) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert "adx" in result.indicators + assert result.indicators["adx"].value > 0 + + +# ── 10d. RSI Boundaries ───────────────────────────────────────────── + +@test("RSI: monotonic up approaches 100") +def _(): + n = 100 + dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") + close = np.linspace(100, 150, n) + df = pd.DataFrame({ + "Open": close - 0.1, "High": close + 0.5, "Low": close - 0.5, + "Close": close, "Volume": [1000.0]*n, + }, index=dates) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert "rsi" in result.indicators + assert result.indicators["rsi"].value > 80, f"RSI should be high on uptrend, got {result.indicators['rsi'].value}" + + +@test("RSI: monotonic down approaches 0") +def _(): + n = 100 + dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") + close = np.linspace(150, 100, n) + df = pd.DataFrame({ + "Open": close + 0.1, "High": close + 0.5, "Low": close - 0.5, + "Close": close, "Volume": [1000.0]*n, + }, index=dates) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert "rsi" in result.indicators + assert result.indicators["rsi"].value < 20, f"RSI should be low on downtrend, got {result.indicators['rsi'].value}" + + +@test("RSI: flat price produces RSI near 50") +def _(): + n = 100 + dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") + np.random.seed(99) + close = 100.0 + np.random.randn(n) * 0.001 + df = pd.DataFrame({ + "Open": close, "High": close + 0.01, "Low": close - 0.01, + "Close": close, "Volume": [1000.0]*n, + }, index=dates) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + if "rsi" in result.indicators: + rsi = result.indicators["rsi"].value + assert 30 <= rsi <= 70, f"RSI on flat should be near 50, got {rsi}" + + +# ── 10e. MACD Edge Cases ──────────────────────────────────────────── + +@test("MACD: very short data (< 35 bars) skips cleanly") +def _(): + df = make_ohlcv(n=30, seed=42) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + # Should not crash; macd may or may not be present + + +@test("MACD: strong uptrend produces non-SELL signal") +def _(): + df = make_ohlcv(n=300, trend=0.001, seed=20) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + if "macd" in result.indicators: + assert result.indicators["macd"].signal not in (SignalStrength.SELL, SignalStrength.STRONG_SELL), \ + f"MACD on uptrend should not be bearish, got {result.indicators['macd'].signal}" + + +# ── 10f. Bollinger Bands Edge Cases ───────────────────────────────── + +@test("Bollinger: flat price collapses bands, position near 0.5") +def _(): + n = 100 + dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") + df = pd.DataFrame({ + "Open": [100.0]*n, "High": [100.01]*n, "Low": [99.99]*n, + "Close": [100.0]*n, "Volume": [1000.0]*n, + }, index=dates) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + if "bollinger" in result.indicators: + pos = result.indicators["bollinger"].value + assert 0.0 <= pos <= 1.0, f"BB position should be in [0,1], got {pos}" + + +# ── 10g. Keltner Channel Edge Cases ──────────────────────────────── + +@test("Keltner: BB disabled does not crash squeeze detection") +def _(): + df = make_ohlcv(n=200, seed=42) + config = PlatformConfig() + config.technical.disabled_indicators = ["bollinger"] + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert "keltner" in result.indicators, "Keltner should still work with BB disabled" + + +# ── 10h. Candlestick Pattern Edge Cases ───────────────────────────── + +@test("Candlestick: zero-range bar (H==L) does not crash") +def _(): + df = make_ohlcv(n=100, seed=42) + df.iloc[-1, df.columns.get_loc("High")] = df.iloc[-1]["Close"] + df.iloc[-1, df.columns.get_loc("Low")] = df.iloc[-1]["Close"] + df.iloc[-1, df.columns.get_loc("Open")] = df.iloc[-1]["Close"] + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert result is not None + + +@test("Candlestick: very short data (< 5 bars) does not crash") +def _(): + df = make_ohlcv(n=4, seed=42) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert result is not None + + +# ── 10i. Regime Classification ────────────────────────────────────── + +@test("Regime: strong uptrend produces trending_up or trending") +def _(): + df = make_ohlcv(n=300, trend=0.002, seed=15) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert result.market_regime in ("trending_up", "trending", "transitioning"), \ + f"Strong uptrend should be trending, got {result.market_regime}" + + +@test("Regime: strong downtrend produces trending_down or trending") +def _(): + df = make_ohlcv(n=300, trend=-0.002, seed=16) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert result.market_regime in ("trending_down", "trending", "transitioning"), \ + f"Strong downtrend should be trending, got {result.market_regime}" + + +@test("Regime: flat/choppy data produces ranging or transitioning") +def _(): + n = 300 + dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") + np.random.seed(17) + close = 100.0 + np.random.randn(n) * 0.001 + df = pd.DataFrame({ + "Open": close, "High": close + 0.002, "Low": close - 0.002, + "Close": close, "Volume": [1000.0]*n, + }, index=dates) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert result.market_regime in ("ranging", "transitioning"), \ + f"Flat data should be ranging, got {result.market_regime}" + + +# ── 10j. Composite Score ──────────────────────────────────────────── + +@test("Composite score always clamped to [-100, 100]") +def _(): + for seed in [1, 2, 3, 10, 20, 30]: + df = make_ohlcv(n=300, trend=0.005, seed=seed) + config = PlatformConfig() + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert -100 <= result.composite_score <= 100, \ + f"Score {result.composite_score} out of bounds (seed={seed})" + + +@test("Composite: regime weights affect score magnitude") +def _(): + df = make_ohlcv(n=300, trend=0.001, seed=42) + config1 = PlatformConfig() + config2 = PlatformConfig() + config2.technical.regime_weights_override = { + "trending_up": {"ema_trend": 0.1, "rsi": 0.1, "macd": 0.1, + "bollinger": 0.1, "keltner": 0.1, "roc": 0.1, + "candlestick": 0.1, "divergence": 0.1, "adx": 0.1}, + "trending_down": {"ema_trend": 0.1, "rsi": 0.1, "macd": 0.1, + "bollinger": 0.1, "keltner": 0.1, "roc": 0.1, + "candlestick": 0.1, "divergence": 0.1, "adx": 0.1}, + "ranging": {"ema_trend": 0.1, "rsi": 0.1, "macd": 0.1, + "bollinger": 0.1, "keltner": 0.1, "roc": 0.1, + "candlestick": 0.1, "divergence": 0.1, "adx": 0.1}, + "transitioning": {"ema_trend": 0.1, "rsi": 0.1, "macd": 0.1, + "bollinger": 0.1, "keltner": 0.1, "roc": 0.1, + "candlestick": 0.1, "divergence": 0.1, "adx": 0.1}, + } + engine1 = TechnicalEngine(config1.technical) + engine2 = TechnicalEngine(config2.technical) + r1 = engine1.analyze(df, "EUR/USD", Timeframe.H1) + r2 = engine2.analyze(df, "EUR/USD", Timeframe.H1) + assert r1.composite_score != r2.composite_score or r1.market_regime != r2.market_regime, \ + "Different regime weights should produce different results" + + +# ── 10k. Divergence Edge Cases ────────────────────────────────────── + +@test("Divergence: short lookback finds no divergence") +def _(): + df = make_ohlcv(n=100, seed=42) + config = PlatformConfig() + config.technical.divergence_lookback = 5 + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + # Should not crash, divergence may or may not appear + + +@test("Divergence: disabled divergence not in indicators") +def _(): + df = make_ohlcv(n=300, seed=42) + config = PlatformConfig() + config.technical.disabled_indicators = ["divergence"] + engine = TechnicalEngine(config.technical) + result = engine.analyze(df, "EUR/USD", Timeframe.H1) + assert "divergence" not in result.indicators + + +# ══════════════════════════════════════════════════════════════════════ +# MODULE 11: News Engine Edge Cases +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 11: News Engine Edge Cases ═══") + +from news_engine import NewsEngine + +@test("Currency extraction: USDA does not false-match USD") +def _(): + ne = NewsEngine(PlatformConfig().news) + result = ne._extract_currencies("USDA releases crop report") + assert "USD" not in result, f"USDA should not match USD, got {result}" + + +@test("Currency extraction: custodian does not false-match USD") +def _(): + ne = NewsEngine(PlatformConfig().news) + result = ne._extract_currencies("custodian reports quarterly earnings") + assert "USD" not in result, f"custodian should not match USD, got {result}" + + +@test("Currency extraction: euro matches EUR correctly") +def _(): + ne = NewsEngine(PlatformConfig().news) + result = ne._extract_currencies("euro strengthens against yen") + assert "EUR" in result, f"euro should match EUR, got {result}" + + +@test("Currency extraction: Eurozone matches EUR") +def _(): + ne = NewsEngine(PlatformConfig().news) + result = ne._extract_currencies("Eurozone GDP beats forecast") + assert "EUR" in result, f"Eurozone should match EUR, got {result}" + + +@test("Currency extraction: empty headline returns empty list") +def _(): + ne = NewsEngine(PlatformConfig().news) + result = ne._extract_currencies("") + assert result == [], f"Empty headline should return empty list, got {result}" + + +@test("Currency extraction: headline with multiple currencies") +def _(): + ne = NewsEngine(PlatformConfig().news) + result = ne._extract_currencies("EUR/USD pair rises as dollar weakens") + assert "EUR" in result, f"Should match EUR, got {result}" + assert "USD" in result, f"Should match USD, got {result}" + + +@test("Sentiment: both bullish and bearish keywords gives bounded score") +def _(): + ne = NewsEngine(PlatformConfig().news) + score = ne._analyze_headline_sentiment("rate hike fears amid bullish recovery expectations") + assert -1.0 <= score <= 1.0, f"Score should be bounded, got {score}" + + +@test("Sentiment: empty headline returns 0.0") +def _(): + ne = NewsEngine(PlatformConfig().news) + assert ne._analyze_headline_sentiment("") == 0.0 + + +@test("Sentiment: very long headline does not crash") +def _(): + ne = NewsEngine(PlatformConfig().news) + long_headline = "market " * 500 + "bullish recovery" + score = ne._analyze_headline_sentiment(long_headline) + assert isinstance(score, float) + + +# ══════════════════════════════════════════════════════════════════════ +# MODULE 12: Signal Aggregator Edge Cases +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 12: Signal Aggregator Edge Cases ═══") + +# ── 12a. Quality Grade Boundaries ──────────────────────────────────── + +@test("Quality grade: A+ requires score>=40, confidence>=65, MTF agreement") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + df = make_ohlcv(n=300, trend=0.003, seed=100) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.signal_quality == "A+": + assert abs(report.final_score) >= 40 + assert report.final_confidence >= 65 + assert report.mtf_agreement is True + + +@test("Quality grade: A requires score>=33, confidence>=55, MTF") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + df = make_ohlcv(n=300, trend=0.002, seed=200) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.signal_quality == "A": + assert abs(report.final_score) >= 33 + assert report.final_confidence >= 55 + assert report.mtf_agreement is True + + +@test("Quality grade: B requires score>=28, confidence>=50") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + df = make_ohlcv(n=300, seed=300) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.signal_quality == "B": + assert abs(report.final_score) >= 28 + assert report.final_confidence >= 50 + + +@test("Quality grade: C requires score>=25, confidence>=45") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + df = make_ohlcv(n=300, seed=400) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.signal_quality == "C": + assert abs(report.final_score) >= 25 + assert report.final_confidence >= 45 + + +@test("Quality grade: D requires score>=15") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + df = make_ohlcv(n=300, seed=500) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.signal_quality == "D": + assert abs(report.final_score) >= 15 + + +@test("Quality grade: F has score<15 or zero confidence") +def _(): + n = 300 + dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") + np.random.seed(600) + close = 100.0 + np.random.randn(n) * 0.0001 + df = pd.DataFrame({ + "Open": close, "High": close + 0.001, "Low": close - 0.001, + "Close": close, "Volume": [1000.0]*n, + }, index=dates) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.signal_quality == "F": + assert abs(report.final_score) < 15 or report.final_confidence < 45 + + +@test("Quality grade: all grades belong to valid set") +def _(): + valid_grades = {"A+", "A", "B", "C", "D", "F"} + for seed in range(42, 62): + df = make_ohlcv(n=300, seed=seed, trend=0.001 * (seed % 5 - 2)) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + assert report.signal_quality in valid_grades, \ + f"Unexpected grade '{report.signal_quality}' (seed={seed})" + + +# ── 12b. EUR Filter Pipeline ──────────────────────────────────────── + +@test("EUR filter: blocked regime sets quality F") +def _(): + from pair_signals.eurusd import make_config as eur_make_config + config = eur_make_config(Timeframe.H1) + config.eur_strategy.blocked_regimes = ["ranging"] + agg = SignalAggregator(config) + df = make_ohlcv(n=300, seed=42) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) + if report.market_regime == "ranging": + assert report.signal_quality == "F", \ + f"Ranging should be blocked, got {report.signal_quality}" + + +@test("EUR filter: quality not in min_quality_grades sets F") +def _(): + from pair_signals.eurusd import make_config as eur_make_config + config = eur_make_config(Timeframe.H1) + config.eur_strategy.min_quality_grades = ["A+"] + agg = SignalAggregator(config) + df = make_ohlcv(n=300, seed=42, trend=0.001) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) + if report.signal_quality not in ("A+", "F"): + assert False, f"Non-A+ grade should be blocked, got {report.signal_quality}" + + +@test("EUR filter: score below min sets F") +def _(): + from pair_signals.eurusd import make_config as eur_make_config + config = eur_make_config(Timeframe.H1) + config.eur_strategy.min_entry_score = 99.0 + agg = SignalAggregator(config) + df = make_ohlcv(n=300, seed=42, trend=0.001) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) + assert report.signal_quality == "F", \ + f"Score 99 threshold should block all, got {report.signal_quality}" + + +@test("EUR filter: confidence below min sets F") +def _(): + from pair_signals.eurusd import make_config as eur_make_config + config = eur_make_config(Timeframe.H1) + config.eur_strategy.min_entry_confidence = 99.0 + agg = SignalAggregator(config) + df = make_ohlcv(n=300, seed=42, trend=0.001) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) + assert report.signal_quality == "F", \ + f"Confidence 99 threshold should block all, got {report.signal_quality}" + + +@test("EUR filter: RSI extended buy (RSI>70) blocked") +def _(): + from pair_signals.eurusd import make_config as eur_make_config + config = eur_make_config(Timeframe.H1) + config.eur_strategy.block_rsi_extended = True + config.eur_strategy.min_entry_score = 0.0 + config.eur_strategy.min_entry_confidence = 0.0 + config.eur_strategy.min_quality_grades = ["A+", "A", "B", "C", "D"] + agg = SignalAggregator(config) + df = make_ohlcv(n=300, trend=0.005, seed=42) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) + if report.primary_analysis and "rsi" in report.primary_analysis.indicators: + rsi_v = report.primary_analysis.indicators["rsi"].value + if report.final_score > 0 and rsi_v > 70: + assert report.signal_quality == "F", \ + f"Buy with RSI {rsi_v:.0f} should be blocked" + + +# ── 12c. Trade Setup Edge Cases ───────────────────────────────────── + +@test("Trade setup: ATR zero returns None") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + df = make_ohlcv(n=300, trend=0.002, seed=42) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.primary_analysis: + report.primary_analysis.key_levels["atr"] = 0.0 + setup = agg._generate_trade_setup(report, pip_value=config.pip_value) + assert setup is None, "ATR=0 should produce no trade setup" + + +@test("Trade setup: pip_value zero returns None") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + df = make_ohlcv(n=300, trend=0.002, seed=42) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.primary_analysis and report.final_signal != SignalStrength.NEUTRAL: + setup = agg._generate_trade_setup(report, pip_value=0.0) + assert setup is None, "pip_value=0 should produce no trade setup" + + +@test("Trade setup: BUY SL always below entry") +def _(): + for seed in [10, 20, 30, 40, 50]: + df = make_ohlcv(n=300, trend=0.002, seed=seed) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.trade_setup and report.trade_setup.direction == "BUY": + assert report.trade_setup.stop_loss < report.trade_setup.entry_price, \ + f"BUY SL should be below entry (seed={seed})" + + +@test("Trade setup: SELL SL always above entry") +def _(): + for seed in [10, 20, 30, 40, 50]: + df = make_ohlcv(n=300, trend=-0.002, seed=seed) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.trade_setup and report.trade_setup.direction == "SELL": + assert report.trade_setup.stop_loss > report.trade_setup.entry_price, \ + f"SELL SL should be above entry (seed={seed})" + + +@test("Trade setup: risk_reward is always positive when present") +def _(): + for seed in range(10, 30): + df = make_ohlcv(n=300, trend=0.001 * (seed % 5 - 2), seed=seed) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.trade_setup: + assert report.trade_setup.risk_reward > 0, \ + f"RR should be positive, got {report.trade_setup.risk_reward} (seed={seed})" + + +@test("Trade setup: EUR uses EUR-specific parameters") +def _(): + from pair_signals.eurusd import make_config as eur_make_config + config = eur_make_config(Timeframe.H1) + agg = SignalAggregator(config) + df = make_ohlcv(n=300, trend=0.002, start_price=1.08, seed=77) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) + if report.trade_setup: + assert report.trade_setup.risk_reward >= config.eur_strategy.min_rr_ratio - 0.01 + + +# ── 12d. MTF Agreement ────────────────────────────────────────────── + +@test("MTF: all NEUTRAL returns False") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") + report.mtf_signals = { + "15min": SignalStrength.NEUTRAL, + "1h": SignalStrength.NEUTRAL, + "4h": SignalStrength.NEUTRAL, + } + assert agg._check_mtf_agreement(report) is False + + +@test("MTF: 2 bearish + 1 bullish returns True (bearish agreement)") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") + report.mtf_signals = { + "15min": SignalStrength.SELL, + "1h": SignalStrength.STRONG_SELL, + "4h": SignalStrength.BUY, + } + assert agg._check_mtf_agreement(report) is True + + +@test("MTF: all same direction returns True") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") + report.mtf_signals = { + "15min": SignalStrength.BUY, + "1h": SignalStrength.BUY, + "4h": SignalStrength.STRONG_BUY, + } + assert agg._check_mtf_agreement(report) is True + + +@test("MTF: empty signals returns False") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") + report.mtf_signals = {} + assert agg._check_mtf_agreement(report) is False + + +# ── 12e. Final Signal Computation ─────────────────────────────────── + +@test("Final signal: STRONG_BUY requires score>=35, confidence>=55") +def _(): + for seed in range(42, 62): + df = make_ohlcv(n=300, trend=0.001 * (seed % 4), seed=seed) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.final_signal == SignalStrength.STRONG_BUY: + assert report.final_score >= 35, f"STRONG_BUY needs score>=35, got {report.final_score}" + assert report.final_confidence >= 55, f"STRONG_BUY needs conf>=55, got {report.final_confidence}" + + +@test("Final signal: STRONG_SELL requires score<=-35, confidence>=55") +def _(): + for seed in range(42, 62): + df = make_ohlcv(n=300, trend=-0.001 * (seed % 4), seed=seed) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if report.final_signal == SignalStrength.STRONG_SELL: + assert report.final_score <= -35, f"STRONG_SELL needs score<=-35, got {report.final_score}" + assert report.final_confidence >= 55, f"STRONG_SELL needs conf>=55, got {report.final_confidence}" + + +@test("Final signal: NEUTRAL when score is low regardless of confidence") +def _(): + n = 300 + dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") + np.random.seed(42) + close = 100.0 + np.random.randn(n) * 0.0001 + df = pd.DataFrame({ + "Open": close, "High": close + 0.001, "Low": close - 0.001, + "Close": close, "Volume": [1000.0]*n, + }, index=dates) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + if abs(report.final_score) < 25: + assert report.final_signal == SignalStrength.NEUTRAL + + +# ── 12f. Penalty System ───────────────────────────────────────────── + +@test("Penalty: confidence never exceeds 95 or goes below 0") +def _(): + for seed in range(42, 72): + df = make_ohlcv(n=300, trend=0.001 * (seed % 6 - 3), seed=seed) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + assert 0.0 <= report.final_confidence <= 95.0, \ + f"Confidence out of [0,95] range: {report.final_confidence} (seed={seed})" + + +# ── 12g. Weekend Filter ───────────────────────────────────────────── + +@test("Weekend filter: Friday 22:01 UTC is weekend") +def _(): + from signal_aggregator import _is_forex_weekend + dt = datetime(2026, 5, 15, 22, 1, tzinfo=timezone.utc) # Friday + assert dt.weekday() == 4 + assert _is_forex_weekend(dt) is True + + +@test("Weekend filter: Friday 21:59 UTC is NOT weekend") +def _(): + from signal_aggregator import _is_forex_weekend + dt = datetime(2026, 5, 15, 21, 59, tzinfo=timezone.utc) # Friday + assert dt.weekday() == 4 + assert _is_forex_weekend(dt) is False + + +@test("Weekend filter: Saturday noon is weekend") +def _(): + from signal_aggregator import _is_forex_weekend + dt = datetime(2026, 5, 16, 12, 0, tzinfo=timezone.utc) # Saturday + assert dt.weekday() == 5 + assert _is_forex_weekend(dt) is True + + +@test("Weekend filter: Sunday 21:59 UTC is weekend") +def _(): + from signal_aggregator import _is_forex_weekend + dt = datetime(2026, 5, 17, 21, 59, tzinfo=timezone.utc) # Sunday + assert dt.weekday() == 6 + assert _is_forex_weekend(dt) is True + + +@test("Weekend filter: Sunday 22:00 UTC is NOT weekend") +def _(): + from signal_aggregator import _is_forex_weekend + dt = datetime(2026, 5, 17, 22, 0, tzinfo=timezone.utc) # Sunday + assert dt.weekday() == 6 + assert _is_forex_weekend(dt) is False + + +@test("Weekend filter: Monday is not weekend") +def _(): + from signal_aggregator import _is_forex_weekend + dt = datetime(2026, 5, 18, 10, 0, tzinfo=timezone.utc) # Monday + assert dt.weekday() == 0 + assert _is_forex_weekend(dt) is False + + +# ══════════════════════════════════════════════════════════════════════ +# MODULE 13: Integration Edge Cases +# ══════════════════════════════════════════════════════════════════════ + +print("\n═══ Module 13: Integration Edge Cases ═══") + + +@test("Integration: all indicators disabled produces valid report") +def _(): + config = PlatformConfig() + config.technical.disabled_indicators = [ + "ema_trend", "rsi", "bollinger", "macd", "adx", + "keltner", "roc", "candlestick", "divergence", + ] + agg = SignalAggregator(config) + df = make_ohlcv(n=300, seed=42) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + assert report is not None + assert report.signal_quality in ("A+", "A", "B", "C", "D", "F", "") + + +@test("Integration: data with 50% NaN still produces valid report") +def _(): + df = make_ohlcv(n=300, seed=42) + mask = np.random.RandomState(99).choice([True, False], size=len(df), p=[0.5, 0.5]) + df.loc[mask, "Close"] = np.nan + df.loc[mask, "High"] = np.nan + df.loc[mask, "Low"] = np.nan + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + assert report is not None + assert report.signal_quality in ("A+", "A", "B", "C", "D", "F", "") + + +@test("Integration: XAU and EUR produce independent results") +def _(): + from pair_signals.eurusd import make_config as eur_make_config + df = make_ohlcv(n=300, seed=42, trend=0.001) + + xau_config = PlatformConfig() + xau_agg = SignalAggregator(xau_config) + xau_dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + xau_report = xau_agg.analyze_from_dataframes(symbol=XAUUSD, dfs=xau_dfs, use_news=False) + + eur_config = eur_make_config(Timeframe.H1) + eur_agg = SignalAggregator(eur_config) + eur_dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + eur_report = eur_agg.analyze_from_dataframes(symbol=EURUSD, dfs=eur_dfs, use_news=False) + + assert xau_report.display_name == "XAU/USD" + assert eur_report.display_name == "EUR/USD" + + +@test("Integration: missing confirmation TFs still produces valid report") +def _(): + config = PlatformConfig() + agg = SignalAggregator(config) + df = make_ohlcv(n=300, seed=42) + dfs = {Timeframe.H1: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + assert report is not None + assert report.mtf_agreement is False + + +@test("Integration: extreme volatility data does not crash") +def _(): + n = 300 + dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h") + np.random.seed(42) + close = 100.0 + np.cumsum(np.random.randn(n) * 10.0) + close = np.maximum(close, 1.0) + df = pd.DataFrame({ + "Open": close * 0.99, "High": close * 1.05, + "Low": close * 0.95, "Close": close, + "Volume": [5000.0]*n, + }, index=dates) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + assert report is not None + assert -100 <= report.final_score <= 100 + + +@test("Integration: very short data (50 bars) produces valid report") +def _(): + df = make_ohlcv(n=50, seed=42) + config = PlatformConfig() + agg = SignalAggregator(config) + dfs = {Timeframe.H1: df} + report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + assert report is not None + + +@test("Integration: multiple seeds produce deterministic results") +def _(): + df = make_ohlcv(n=300, seed=42) + config = PlatformConfig() + agg1 = SignalAggregator(config) + agg2 = SignalAggregator(config) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + r1 = agg1.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + r2 = agg2.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False) + assert r1.final_score == r2.final_score + assert r1.final_signal == r2.final_signal + assert r1.signal_quality == r2.signal_quality + + +@test("Integration: session filter blocks outside hours correctly") +def _(): + from pair_signals.eurusd import make_config as eur_make_config + config = eur_make_config(Timeframe.H1) + config.eur_strategy.session_hours = [8, 9, 10, 11, 12, 13, 14, 15, 16] + agg = SignalAggregator(config) + n = 300 + end_time = pd.Timestamp("2026-05-18 03:00:00", tz="UTC") + dates = pd.date_range(end=end_time, periods=n, freq="h") + np.random.seed(42) + close = 1.08 + np.cumsum(np.random.randn(n) * 0.001 + 0.0002) + df = pd.DataFrame({ + "Open": close - 0.0001, "High": close + 0.0005, + "Low": close - 0.0005, "Close": close, "Volume": [1000.0]*n, + }, index=dates) + dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} + report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) + bar_hour = dates[-1].hour + if bar_hour not in config.eur_strategy.session_hours and report.signal_quality != "": + assert report.signal_quality == "F", \ + f"Hour {bar_hour} should be blocked, got {report.signal_quality}" + + # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════