#!/usr/bin/env python3 """ GENESIS — Apollo Cycle (Strategy C: MA Crossover Trend Following) Called by apollo_tool.py when Hermes decides to run a trend analysis. Strategy logic: - Fast EMA(9) crosses above Slow EMA(21) on M5 → BUY (Golden Cross) - Fast EMA(9) crosses below Slow EMA(21) on M5 → SELL (Death Cross) - H1 SMA(50) trend alignment required (only trade in direction of H1 trend) - ATR-based dynamic SL/TP (adapts to volatility) - ADX confirms trend strength - News + session + spread filters (same as Ares) run_analysis(symbol) → signal dict — NEVER places a trade itself. """ import os, json, time, logging, math from datetime import datetime, timezone from pathlib import Path import requests import yaml CONFIG_PATH = Path(__file__).parent / "apollo_config.yaml" if not CONFIG_PATH.exists(): CONFIG_PATH = Path(__file__).parents[2] / "configs" / "apollo_config.yaml" with open(CONFIG_PATH, encoding="utf-8") as f: CFG = yaml.safe_load(f) TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") TG_CHAT_ID = str(os.getenv("TELEGRAM_CHAT_ID", CFG["telegram"]["chat_id"])) CACHE_FILE = Path(CFG["cache"]["path"]) # Resolve safe journal path (fallback to local logs/ if system dir not writable) default_journal = CFG["journal"]["path"] try: Path(default_journal).parent.mkdir(parents=True, exist_ok=True) JOURNAL = Path(default_journal) except Exception: local_log_dir = Path(__file__).parents[2] / "logs" / "apollo" local_log_dir.mkdir(parents=True, exist_ok=True) JOURNAL = local_log_dir / "trade_journal.jsonl" FAST_MA = int(CFG["indicators"]["fast_ma_period"]) SLOW_MA = int(CFG["indicators"]["slow_ma_period"]) MA_METHOD = CFG["indicators"]["ma_method"] SIG_TF = CFG["indicators"]["signal_timeframe"] TREND_TF = CFG["indicators"]["trend_timeframe"] TREND_MA = int(CFG["indicators"]["trend_ma_period"]) ATR_PERIOD = int(CFG["indicators"]["atr_period"]) RISK_PCT = float(CFG["risk"]["risk_pct"]) MIN_RR = float(CFG["risk"]["min_rr_ratio"]) SL_ATR_MULT = float(CFG["risk"]["sl_atr_multiplier"]) TP_ATR_MULT = float(CFG["risk"]["tp_atr_multiplier"]) MAX_SPREAD = float(CFG["risk"]["max_spread_pips"]) BLOCK_NEWS_MINS = int(CFG["risk"]["block_news_minutes"]) BLOCK_MEDIUM = bool(CFG["risk"]["block_medium_news"]) REQUIRE_TREND = bool(CFG["strictness"]["require_trend_alignment"]) MIN_MA_SEP = float(CFG["strictness"]["min_ma_separation_pct"]) MIN_ADX = float(CFG["strictness"]["min_adx"]) COOLDOWN_SECS = int(CFG["strictness"]["cooldown_seconds"]) START_HOUR = int(CFG["sessions"]["allowed"][0]["start"]) END_HOUR = int(CFG["sessions"]["allowed"][0]["end"]) MAGIC_COMMENT = CFG["strategy"]["comment"] # Resolve safe log path (fallback to local logs/ if system dir not writable) default_log = "/var/log/apollo/apollo_cycle.log" try: Path(default_log).parent.mkdir(parents=True, exist_ok=True) log_file = default_log except Exception: local_log_dir = Path(__file__).parents[2] / "logs" / "apollo" local_log_dir.mkdir(parents=True, exist_ok=True) log_file = str(local_log_dir / "apollo_cycle.log") logging.basicConfig( filename=log_file, level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" ) log = logging.getLogger(__name__) # Cooldown state (in-process; reset on restart — acceptable) _last_signal_time: dict = {} # ── Cache ────────────────────────────────────────────────────────────────────── def load_cache() -> dict: try: return json.loads(CACHE_FILE.read_text()) if CACHE_FILE.exists() else {} except: return {} def save_cache(c): CACHE_FILE.write_text(json.dumps(c)) # ── Mt5Bridge (unified adapter) ──────────────────────────────────────────────── import sys sys.path.insert(0, str(Path(__file__).parents[2] / "core")) from mt5_bridge import bridge, get_bars as _bridge_get_bars, pip_size, calc_lot def tg(msg: str): try: requests.post( f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", json={"chat_id": TG_CHAT_ID, "text": msg, "parse_mode": "Markdown"}, timeout=10 ) except: pass # ── Market data (Mt5Bridge primary, yfinance fallback) ──────────────────────── YF_MAP = { "EURUSDxx": "EURUSD=X", "GBPUSDxx": "GBPUSD=X", "USDJPYxx": "USDJPY=X", "XAUUSDxx": "GC=F", "GBPJPYxx": "GBPJPY=X", "EURUSD": "EURUSD=X", "GBPUSD": "GBPUSD=X", "USDJPY": "USDJPY=X", "XAUUSD": "GC=F", "GBPJPY": "GBPJPY=X", } YF_TF = {"M1": "1m", "M5": "5m", "M15": "15m", "H1": "1h", "H4": "4h", "D1": "1d"} def get_bars(symbol: str, tf: str = "M5", count: int = 100) -> list: try: bars = _bridge_get_bars(symbol, tf, count) if bars: return bars except Exception as e: log.warning(f"Mt5Bridge get_bars {symbol}/{tf}: {e}, falling back to yfinance") try: import yfinance as yf, pandas as pd yf_sym = YF_MAP.get(symbol, symbol.replace("xx", "=X") if symbol.lower().endswith("xx") else symbol + "=X") interval = YF_TF.get(tf, "5m") period = {"1m": "5d", "5m": "5d", "15m": "5d", "1h": "60d", "4h": "60d", "1d": "365d"}.get(interval, "5d") df = yf.download(yf_sym, period=period, interval=interval, progress=False, auto_adjust=True) if df.empty: return [] if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) df.columns = [c.lower() for c in df.columns] return df.dropna().tail(count).reset_index().to_dict("records") except Exception as e: log.error(f"get_bars {symbol}/{tf}: {e}") return [] # ── Core indicator calculation ───────────────────────────────────────────────── def compute_ma_crossover(bars: list, fast: int, slow: int, method: str = "EMA") -> dict: """ Compute fast/slow MA and detect crossover on the last two closed candles. Returns crossover dict with current + previous values. Crossover detected by comparing [bar -2] vs [bar -1]: - Use index -3 and -2 (leave -1 as the currently forming candle) """ needed = slow + 5 if len(bars) < needed: return {} try: import pandas as pd, ta df = pd.DataFrame(bars) df.columns = [c.lower() for c in df.columns] df["close"] = df["close"].astype(float) df["high"] = df["high"].astype(float) df["low"] = df["low"].astype(float) # MA calculation if method.upper() == "EMA": fast_ma = ta.trend.ema_indicator(df["close"], window=fast) slow_ma = ta.trend.ema_indicator(df["close"], window=slow) else: fast_ma = ta.trend.sma_indicator(df["close"], window=fast) slow_ma = ta.trend.sma_indicator(df["close"], window=slow) # ADX for trend strength adx = ta.trend.adx(df["high"], df["low"], df["close"], window=14) adx_pos = ta.trend.adx_pos(df["high"], df["low"], df["close"], window=14) adx_neg = ta.trend.adx_neg(df["high"], df["low"], df["close"], window=14) # ATR for dynamic SL/TP atr = ta.volatility.average_true_range( df["high"], df["low"], df["close"], window=ATR_PERIOD) # MACD for momentum confirmation macd_hist = ta.trend.macd_diff(df["close"]) def safe(s, i=-2): try: v = float(s.iloc[i]) return None if math.isnan(v) else round(v, 6) except: return None # Current = last closed candle (index -2), Prev = one before (index -3) curr_fast = safe(fast_ma, -2) curr_slow = safe(slow_ma, -2) prev_fast = safe(fast_ma, -3) prev_slow = safe(slow_ma, -3) if None in (curr_fast, curr_slow, prev_fast, prev_slow): return {} # Crossover detection golden_cross = (prev_fast <= prev_slow) and (curr_fast > curr_slow) death_cross = (prev_fast >= prev_slow) and (curr_fast < curr_slow) # MA separation check (filter weak crosses) separation = abs(curr_fast - curr_slow) / curr_slow if curr_slow > 0 else 0 return { "curr_fast": curr_fast, "curr_slow": curr_slow, "prev_fast": prev_fast, "prev_slow": prev_slow, "separation": round(separation, 6), "golden_cross": golden_cross, "death_cross": death_cross, "adx": safe(adx), "adx_plus": safe(adx_pos), "adx_minus": safe(adx_neg), "atr": safe(atr), "macd_hist": safe(macd_hist), "close": round(float(df["close"].iloc[-2]), 6), } except Exception as e: log.error(f"compute_ma_crossover: {e}") return {} def get_trend_ma(symbol: str) -> float | None: """H1 SMA(50) for higher-timeframe trend direction.""" cache = load_cache() key = f"apollo_h1sma_{symbol}" now = time.time() if key in cache and now - cache[key].get("ts", 0) < 300: return cache[key].get("val") try: import pandas as pd, ta bars = get_bars(symbol, "H1", TREND_MA + 10) if len(bars) < TREND_MA: return None df = pd.DataFrame(bars) df.columns = [c.lower() for c in df.columns] df["close"] = df["close"].astype(float) sma = ta.trend.sma_indicator(df["close"], window=TREND_MA) val = round(float(sma.iloc[-2]), 6) cache[key] = {"ts": now, "val": val} save_cache(cache) return val except Exception as e: log.error(f"get_trend_ma {symbol}: {e}") return None # ── Helpers (mirrors ares_cycle.py) ─────────────────────────────────────────── def pip_size(symbol: str) -> float: if "JPY" in symbol.upper(): return 0.01 if "XAU" in symbol.upper(): return 0.1 return 0.0001 def price_to_pips(diff: float, symbol: str) -> float: return abs(diff) / pip_size(symbol) def calculate_lot(equity: float, sl_pips: float, symbol: str) -> float: risk_eur = equity * RISK_PCT pip_val = 10.0 if "JPY" in symbol.upper(): pip_val = 9.0 if "GBP" in symbol.upper(): pip_val = 12.5 if "XAU" in symbol.upper(): pip_val = 1.0 raw = risk_eur / (sl_pips * pip_val) if sl_pips > 0 else 0.01 return round(max(0.01, min(round(raw / 0.01) * 0.01, 5.0)), 2) def check_news_block(symbol: str) -> tuple[bool, list]: now_utc = datetime.now(timezone.utc) warnings = [] blocked = set() cache = load_cache() for evt in cache.get("ff_cal", {}).get("data", []): try: et = datetime.fromisoformat(evt.get("date","")).astimezone(timezone.utc) mins = (et - now_utc).total_seconds() / 60 imp = evt.get("impact","") if imp == "High" and -15 < mins < BLOCK_NEWS_MINS: blocked.add(evt.get("currency","")[:3]) warnings.append(f"High: {evt.get('title')} in {int(mins)}min") except: pass sym_up = symbol.upper() is_block = any(c and c in sym_up for c in blocked if c) return is_block, warnings def is_trade_time() -> bool: now = datetime.now(timezone.utc) wd, hr = now.weekday(), now.hour if (wd == 4 and hr >= 22) or wd == 5 or (wd == 6 and hr < 22): return False return START_HOUR <= hr < END_HOUR def has_apollo_position() -> bool: pos = bridge("/positions") if isinstance(pos, list): for p in pos: if "APOLLO" in str(p.get("comment", "")).upper(): return True return False # ── Main analysis ────────────────────────────────────────────────────────────── def run_analysis(symbol: str) -> dict: """ Full MA Crossover trend-following analysis. Returns signal dict with action='trade' or action='wait'. Never places a trade — apollo_tool.py handles execution. """ symbol = symbol.upper() if not symbol.endswith("XX"): symbol = symbol + "xx" symbol = symbol[:-2] + "xx" log.info(f"=== Apollo MA Crossover Analysis: {symbol} ===") # ── Account ──────────────────────────────────────────────────── account = bridge("/balance") if "error" in account: return {"action": "wait", "reason": f"Bridge unreachable: {account['error']}"} equity = float(account.get("equity", 0)) if equity <= 0: return {"action": "wait", "reason": "Account equity unavailable."} # ── Existing Apollo position ─────────────────────────────────── if has_apollo_position(): return {"action": "wait", "reason": "Apollo position already open."} # ── Cooldown ─────────────────────────────────────────────────── last = _last_signal_time.get(symbol, 0) if time.time() - last < COOLDOWN_SECS: remaining = int(COOLDOWN_SECS - (time.time() - last)) return {"action": "wait", "reason": f"Cooldown active: {remaining}s remaining."} # ── Session ──────────────────────────────────────────────────── if not is_trade_time(): return {"action": "wait", "reason": f"Outside session (GMT {START_HOUR}:00–{END_HOUR}:00)."} # ── Quote + spread ───────────────────────────────────────────── quote = bridge(f"/quote?symbol={symbol}") if "error" in quote or not quote.get("bid"): return {"action": "wait", "reason": f"No live quote for {symbol}."} bid = float(quote["bid"]) ask = float(quote["ask"]) spread_pips = price_to_pips(ask - bid, symbol) if spread_pips > MAX_SPREAD: return {"action": "wait", "reason": f"Spread {spread_pips:.2f} pips > max {MAX_SPREAD}."} # ── News ─────────────────────────────────────────────────────── blocked, news_warn = check_news_block(symbol) if blocked: return {"action": "wait", "reason": f"News block: {'; '.join(news_warn[:2])}"} # ── M5 bars + MA crossover ───────────────────────────────────── bars_m5 = get_bars(symbol, SIG_TF, SLOW_MA + 20) if len(bars_m5) < SLOW_MA + 5: return {"action": "wait", "reason": "Insufficient M5 bar data."} ind = compute_ma_crossover(bars_m5, FAST_MA, SLOW_MA, MA_METHOD) if not ind: return {"action": "wait", "reason": "MA calculation failed."} golden = ind["golden_cross"] death = ind["death_cross"] if not golden and not death: return { "action": "wait", "reason": ( f"No crossover. Fast={ind['curr_fast']:.5f} " f"Slow={ind['curr_slow']:.5f} " f"(prev: {ind['prev_fast']:.5f}/{ind['prev_slow']:.5f})" ) } direction = "Buy" if golden else "Sell" signal_type = "GOLDEN_CROSS" if golden else "DEATH_CROSS" # ── H1 trend alignment ───────────────────────────────────────── trend_ma = get_trend_ma(symbol) close_price = ind["close"] trend_ok = True trend_note = "Trend filter skipped (MA unavailable)" if trend_ma is not None and REQUIRE_TREND: if direction == "Buy": trend_ok = close_price > trend_ma trend_note = (f"H1 SMA{TREND_MA}={trend_ma:.5f} — " f"{'aligned ✓' if trend_ok else 'AGAINST trend ✗'}") else: trend_ok = close_price < trend_ma trend_note = (f"H1 SMA{TREND_MA}={trend_ma:.5f} — " f"{'aligned ✓' if trend_ok else 'AGAINST trend ✗'}") # ── Confluence checks ────────────────────────────────────────── adx = ind.get("adx") sep = ind.get("separation", 0) mhist = ind.get("macd_hist") atr = ind.get("atr") conds = [] if direction == "Buy": conds = [ (golden, f"Golden Cross: EMA{FAST_MA} crossed above EMA{SLOW_MA}"), (trend_ok, trend_note), (adx is not None and adx > MIN_ADX, f"ADX trend strength {adx:.1f} > {MIN_ADX}"), (sep >= MIN_MA_SEP, f"MA separation {sep*100:.3f}% ≥ {MIN_MA_SEP*100:.3f}%"), (mhist is not None and mhist > 0, f"MACD histogram positive ({mhist:.6f})"), ] else: conds = [ (death, f"Death Cross: EMA{FAST_MA} crossed below EMA{SLOW_MA}"), (trend_ok, trend_note), (adx is not None and adx > MIN_ADX, f"ADX trend strength {adx:.1f} > {MIN_ADX}"), (sep >= MIN_MA_SEP, f"MA separation {sep*100:.3f}% ≥ {MIN_MA_SEP*100:.3f}%"), (mhist is not None and mhist < 0, f"MACD histogram negative ({mhist:.6f})"), ] passed = [(m, d) for m, d in conds if m] failed = [(m, d) for m, d in conds if not m] # Require at least 4/5 for Apollo (trend-following can be less strict than Ares) min_conf = 4 if len(passed) < min_conf: return { "action": "wait", "reason": f"Only {len(passed)}/{min_conf} conditions met.", "conditions_met": [d for _, d in passed], "conditions_failed": [d for _, d in failed], } # ── ATR-based SL/TP ─────────────────────────────────────────── if atr is None or atr <= 0: atr = 0.001 # fallback if direction == "Buy": entry = ask sl = round(entry - atr * SL_ATR_MULT, 6) tp = round(entry + atr * TP_ATR_MULT, 6) else: entry = bid sl = round(entry + atr * SL_ATR_MULT, 6) tp = round(entry - atr * TP_ATR_MULT, 6) sl_pips = price_to_pips(entry - sl, symbol) tp_pips = price_to_pips(tp - entry, symbol) rr = round(tp_pips / sl_pips, 2) if sl_pips > 0 else 0 if rr < MIN_RR: return {"action": "wait", "reason": f"R:R {rr} below minimum {MIN_RR}."} volume = calculate_lot(equity, sl_pips, symbol) # Update cooldown _last_signal_time[symbol] = time.time() reason = ( f"Apollo {signal_type}: EMA{FAST_MA}={ind['curr_fast']:.5f} " f"{'>' if golden else '<'} EMA{SLOW_MA}={ind['curr_slow']:.5f}. " f"ADX={adx:.1f}, ATR={atr:.5f}, R:R={rr}, Spread={spread_pips:.2f}pips." ) log.info(f"SIGNAL: {direction} {symbol} | {signal_type} | SL={sl} TP={tp} Vol={volume}") return { "action": "trade", "strategy": "apollo-ma-crossover", "signal_type": signal_type, "symbol": symbol, "direction": direction, "entry": entry, "stop_loss": sl, "take_profit": tp, "volume": volume, "rr_ratio": rr, "sl_pips": round(sl_pips, 1), "tp_pips": round(tp_pips, 1), "confidence": "high" if len(passed) == len(conds) else "medium", "conditions_met": [d for _, d in passed], "conditions_failed": [d for _, d in failed], "warnings": news_warn, "reason": reason, "indicators": { "fast_ma": ind["curr_fast"], "slow_ma": ind["curr_slow"], "adx": adx, "atr": atr, "macd_hist": mhist, "h1_trend_ma": trend_ma, "spread_pips": spread_pips, }, "analysed_at": datetime.now(timezone.utc).isoformat(), } if __name__ == "__main__": import sys sym = sys.argv[1] if len(sys.argv) > 1 else "EURUSDxx" print(f"Running Apollo analysis for {sym}...") result = run_analysis(sym) print(json.dumps(result, indent=2, default=str))