commit 0764a9a07feb16e97d50f10ba881ec1b4ba98629 Author: gavindiaz Date: Sat Jul 11 02:42:55 2026 +0800 改为自己的mt5api diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff09977 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__ +*.pyc +*.pyo +.env +.git +*.md +LICENSE +setup.sh +install.sh \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9bd8539 --- /dev/null +++ b/.env.example @@ -0,0 +1,66 @@ +# GENESIS Trading System — Environment Template +# ───────────────────────────────────────────────────────────────────────────── +# Copy this to .env and fill in your values: +# cp .env.example .env +# +# Or run the interactive setup wizard (recommended): +# bash setup.sh +# ───────────────────────────────────────────────────────────────────────────── + +# ── Mt5Bridge API ───────────────────────────────────────────────────────────── +# Your own Mt5Bridge REST API server. +# See: Mt5Bridge使用指南.md for setup instructions. +# +# MT5_BRIDGE_URL = Base URL of your Mt5Bridge server (e.g. http://127.0.0.1:5000) +# MT5_BRIDGE_TOKEN = API token for authentication (if required) +# MT5_BRIDGE_ACCOUNT = MT5 account number (login ID) +# MT5_SYMBOL_MAP = JSON symbol mapping, e.g. {"EURUSDxx":"EURUSD","XAUUSDxx":"XAUUSD"} +# If empty, "xx" suffix is auto-stripped (EURUSDxx → EURUSD) + +MT5_BRIDGE_URL=http://127.0.0.1:5000 +MT5_BRIDGE_TOKEN= +MT5_BRIDGE_ACCOUNT= +MT5_SYMBOL_MAP= + +# ── Telegram ────────────────────────────────────────────────────────────────── +# Create a bot: https://t.me/BotFather → /newbot +# Get your Chat ID: https://t.me/userinfobot + +TELEGRAM_BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN +TELEGRAM_CHAT_ID=YOUR_TELEGRAM_CHAT_ID + +# ── LLM API (Hermes Brain) — OpenAI-Compatible ──────────────────────────────── +# Powers the hourly macro analysis by Hermes. +# Supports ANY OpenAI-compatible API provider. +# +# Provider examples: +# OpenAI: OPENAI_BASE_URL=https://api.openai.com/v1 HERMES_MODEL=gpt-4o-mini +# DeepSeek: OPENAI_BASE_URL=https://api.deepseek.com/v1 HERMES_MODEL=deepseek-chat +# Qwen (Ali): OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 HERMES_MODEL=qwen-plus +# Groq: OPENAI_BASE_URL=https://api.groq.com/openai/v1 HERMES_MODEL=llama-3.3-70b-versatile +# Together AI: OPENAI_BASE_URL=https://api.together.xyz/v1 HERMES_MODEL=meta-llama/Llama-3-70b-chat-hf +# SiliconFlow: OPENAI_BASE_URL=https://api.siliconflow.cn/v1 HERMES_MODEL=deepseek-ai/DeepSeek-V3 +# Ollama local: OPENAI_BASE_URL=http://127.0.0.1:11434/v1 HERMES_MODEL=llama3 +# OpenRouter: OPENAI_BASE_URL=https://openrouter.ai/api/v1 HERMES_MODEL=openai/gpt-4o-mini +# +# Note: Some providers may not support "response_format: json_object". +# If you get errors, try removing it or switching to a provider that does. + +OPENAI_API_KEY=sk-your-api-key-here +OPENAI_BASE_URL=https://api.openai.com/v1 +HERMES_MODEL=gpt-4o-mini +HERMES_JSON_FORMAT=true # Set to "false" if your provider doesn't support response_format: json_object + +# ── Risk Limits ─────────────────────────────────────────────────────────────── +# These are enforced by every strategy module before any trade is placed. +# Hermes will refuse any trade that violates these limits. + +MAX_POSITIONS=4 # Maximum simultaneous open positions +MAX_RISK_PCT=0.02 # Maximum risk per trade (2% of balance) +MAX_LOTS=3.0 # Maximum lot size per single trade + +# ── Optional: Twelve Data ───────────────────────────────────────────────────── +# For economic calendar enrichment (news filter) +# Free tier: https://twelvedata.com + +TWELVE_DATA_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..011ddae --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Environment & secrets +.env +*.env + +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +venv/ +.venv-hermes/ +*.egg-info/ +dist/ +build/ + +# Logs & runtime state +*.log +*.jsonl +grid_state.json +genesis_cache.json +/tmp/ + +# macOS +.DS_Store +.AppleDouble + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/CASE_STUDY.md b/CASE_STUDY.md new file mode 100644 index 0000000..f18fbe6 --- /dev/null +++ b/CASE_STUDY.md @@ -0,0 +1,900 @@ +# GENESIS: Building a Fully Autonomous MT5 Trading System with Six Strategy Bots and a Python REST API + +> **Published by API2TRADE** · [app.api2trade.com](https://app.api2trade.com) +> **Open-source repository:** `api2trade/Genesis-Metatrader-Automatic-AI-Trading-System` (GPL-3.0) +> **Status:** Live | Engine v2.1 | Tested on Deriv Demo ($10,000 USD) and Exness MT5 + +--- + +## Table of Contents + +1. [What Was Actually Built](#1-what-was-actually-built) +2. [Why REST Instead of a Local MT5 Terminal](#2-why-rest-instead-of-a-local-mt5-terminal) +3. [Core Architecture — How All Parts Connect](#3-core-architecture--how-all-parts-connect) +4. [The API2TRADE Integration — Every Real Endpoint Used](#4-the-api2trade-integration--every-real-endpoint-used) +5. [The Six Strategy Gods — Real Implementation Deep Dive](#5-the-six-strategy-gods--real-implementation-deep-dive) +6. [Hermes — The LLM Orchestration Brain](#6-hermes--the-llm-orchestration-brain) +7. [Market Data — Multi-Source Fallback Architecture](#7-market-data--multi-source-fallback-architecture) +8. [Risk Management — The Hard Layer](#8-risk-management--the-hard-layer) +9. [How to Replicate This — Complete Step-by-Step](#9-how-to-replicate-this--complete-step-by-step) +10. [Infrastructure and Real Costs](#10-infrastructure-and-real-costs) +11. [Key Engineering Challenges and How We Solved Them](#11-key-engineering-challenges-and-how-we-solved-them) +12. [Live Test Results — Deriv Demo Account](#12-live-test-results--deriv-demo-account) +13. [What to Do Next](#13-what-to-do-next) + +--- + +## 1. What Was Actually Built + +GENESIS is **8,401 lines of Python** split across 44 files that form a complete autonomous trading engine. It runs six independent strategy bots on a cron schedule, managed by an LLM orchestrator named Hermes, all communicating directly with MetaTrader 5 via the [API2TRADE](https://app.api2trade.com) REST API. + +This is not a demo, a tutorial skeleton, or a toy project. It is a system we built, debugged, deployed to production, tested on a live MT5 account, and then open-sourced so you can replicate it exactly. + +**What the system does every 5 minutes:** + +1. `genesis_autonomous.py` fires via cron +2. It checks the live MT5 account balance and open positions via API2TRADE +3. It runs each active strategy bot as a subprocess +4. Each bot fetches market data (yfinance + API2TRADE), computes indicators, and evaluates its signal conditions +5. If a bot returns `"action": "trade"`, Hermes validates the risk limits +6. If risk passes, `OrderSendSafe` fires directly at the MT5 account via API2TRADE REST +7. Every decision — trade or no trade — is logged to `/var/log/hermes/` and sent to Telegram + +The full system uses **zero local MetaTrader installation**, **zero Windows server**, and **zero proprietary SDK**. Just Python and HTTP. + +--- + +## 2. Why REST Instead of a Local MT5 Terminal + +The standard approach to MT5 automation is to run an Expert Advisor (EA) in a Windows-based MetaTrader 5 terminal and use either the built-in MQL5 language or the `MetaTrader5` Python package, which requires a local terminal running. + +This creates several real problems: + +| Problem | Local MT5 Approach | API2TRADE Approach | +|---------|-------------------|-------------------| +| OS requirement | Windows only | Any OS, any cloud | +| Always-on requirement | Terminal must be running 24/5 | API2TRADE handles connectivity | +| Language | MQL5 (proprietary) | Pure Python | +| Deployment | Manual terminal management | `docker compose up` | +| Remote access | RDP into Windows VPS | REST calls from anywhere | +| Broker switching | Reinstall terminal | Change UUID in `.env` | + +We originally built GENESIS with a local FastAPI bridge (`127.0.0.1:8000`) that translated Python calls into the MetaTrader5 Python library. This worked, but added a fragile extra layer: the bridge had to be running, the MT5 terminal had to be running, and both had to be on the same Windows machine. + +**We removed the bridge entirely** in GENESIS v2.1. Every strategy now calls the API2TRADE REST endpoints directly from Python. The architecture became dramatically simpler: + +``` +Before (v1): Python → FastAPI Bridge → MetaTrader5 SDK → MT5 Terminal → Broker +After (v2): Python → API2TRADE REST → Broker +``` + +--- + +## 3. Core Architecture — How All Parts Connect + +![GENESIS Architecture](/Users/sudo/.gemini/antigravity-ide/brain/32917d79-3b86-40d2-b479-1514fff3f799/genesis_architecture_1779961432596.png) +*The complete GENESIS v2.1 architecture: six strategy bots connect directly to MetaTrader 5 via API2TRADE — no local bridge, no Windows server required.* + +### File Structure + +``` +Genesis-Metatrader-Automatic-AI-Trading-System/ +├── setup.sh # Interactive setup wizard (run first) +├── install.sh # Ubuntu 22.04 VPS installer +├── docker-compose.yml # Docker deployment +│ +├── core/ +│ ├── genesis_autonomous.py # Main engine — runs every 5 min via cron +│ ├── trading_cycle.py # Hermes LLM brain — runs every hour +│ ├── genesis_daily_report.py # 06:00 UTC daily P&L summary +│ ├── genesis_brain_feed.py # :30 min Telegram market summary +│ ├── heartbeat.py # Health check every 10 min +│ └── tg_notify.py # Telegram helper +│ +├── strategies/ +│ ├── ares/ # BB+RSI M1 mean reversion +│ │ ├── ares_cycle.py # 643 lines — signal logic + API calls +│ │ └── ares_tool.py # CLI entry point +│ ├── apollo/ # EMA trend following (613 lines) +│ ├── athena/ # BB+RSI multi-TF ranging (622 lines) +│ ├── artemis/ # Ichimoku H1 breakout (498 lines) +│ ├── zeus/ # ICT Smart Money M5 (643 lines) +│ └── hephaestus/ # Grid/Martingale (549 lines) +│ +├── configs/ # YAML config per strategy +└── backtest/ # Python backtester + MT5 EA +``` + +### The Cron Schedule + +``` +*/5 * * * * genesis_autonomous.py # Strategy scan — every 5 min +0 * * * * trading_cycle.py # Hermes LLM macro — hourly +30 * * * * genesis_brain_feed.py # Market summary Telegram — :30 min +0 6 * * * genesis_daily_report.py # Daily P&L — 06:00 UTC +0 7 * * 1-5 genesis_market_open.py # Market open alert — weekdays +*/10 * * * * heartbeat.py # Health check — every 10 min +``` + +--- + +## 4. The API2TRADE Integration — Every Real Endpoint Used + +API2TRADE ([app.api2trade.com](https://app.api2trade.com)) provides a REST API that connects to your live MetaTrader 5 account. You authenticate with Basic HTTP auth on every request, and pass a **session UUID** (the `id` parameter) that identifies which MT5 account you're targeting. + +**Authentication:** +``` +Username + Password: From your API2TRADE dashboard +Session UUID: Created via /ConnectEx when you link your MT5 account +Base URL: https://mt5.mt4api.dev +``` + +### Every endpoint GENESIS actually calls: + +```python +# ── Account ────────────────────────────────────────────────────────────────── + +# Check balance, equity, margin, leverage +GET /AccountSummary?id={uuid} +→ {"balance": 10000.0, "equity": 10000.0, "currency": "USD", + "leverage": 1000.0, "type": "demo", "method": "Hedging"} + +# ── Positions ───────────────────────────────────────────────────────────────── + +# All currently open positions +GET /OpenedOrders?id={uuid} +→ [{"Ticket": 12345, "Symbol": "EURUSDxx", "Type": "Buy", + "Volume": 0.1, "Price": 1.08542, "Profit": 12.30, "Comment": "GENESIS-ARES"}] + +# ── Market Data ─────────────────────────────────────────────────────────────── + +# Live bid/ask quote (used for spread check + entry price) +GET /Quote?id={uuid}&symbol=EURUSDxx +→ {"Bid": 1.08540, "Ask": 1.08542} + +# OHLCV bars (M1, M5, H1, H4, D1) +GET /QuoteHistory?id={uuid}&symbol=EURUSDxx&timeFrame=M5&count=200 + +# ── Trade Execution ─────────────────────────────────────────────────────────── + +# Open a position (MT5 "Safe" variant = validates before firing) +GET /OrderSendSafe?id={uuid}&symbol=EURUSDxx&operation=Buy + &volume=0.1&stoploss=1.08392&takeprofit=1.08842&comment=GENESIS-ARES +→ {"ticket": 12345678} + +# Modify SL/TP on open position +GET /OrderModifySafe?id={uuid}&ticket=12345678 + &stoploss=1.08450&takeprofit=1.08900 + +# Close position fully or partially +GET /OrderCloseSafe?id={uuid}&ticket=12345678&lots=0.1 +``` + +### How the bridge() function works inside each strategy: + +Every strategy module has a single `bridge()` function that routes logical API calls to the correct endpoint: + +```python +# Simplified from ares_cycle.py — the real function is ~70 lines +MT5_API = os.getenv("MT5_API_URL", "https://mt5.mt4api.dev") +MT5_ID = os.getenv("MT5_ACCOUNT_ID", "") +MT5_AUTH = (os.getenv("MT5_API_USER", ""), os.getenv("MT5_API_PASS", "")) + +def bridge(path, data=None) -> dict: + """Single entry point for all MT5 API calls.""" + ep_map = { + "/balance": "AccountSummary", + "/positions": "OpenedOrders", + } + if path.startswith("/quote"): + symbol = path.split("symbol=")[-1] + r = requests.get(f"{MT5_API}/Quote", + params={"id": MT5_ID, "symbol": symbol}, + auth=MT5_AUTH, timeout=8) + raw = r.json() + return {"bid": raw["Bid"], "ask": raw["Ask"]} + + if path == "/market" and data: + r = requests.get(f"{MT5_API}/OrderSendSafe", + params={ + "id": MT5_ID, + "symbol": data["symbol"], + "operation": data["type"], # "Buy" or "Sell" + "volume": data["volume"], + "stoploss": data["stop_loss"], + "takeprofit": data["take_profit"], + "comment": data.get("comment", "GENESIS"), + }, auth=MT5_AUTH, timeout=15) + ticket = r.json().get("ticket") or r.json().get("integerResponse") + return {"ticket": ticket} + + # AccountSummary, OpenedOrders, etc. + cloud_ep = ep_map.get(path, path.lstrip("/")) + r = requests.get(f"{MT5_API}/{cloud_ep}", + params={"id": MT5_ID}, auth=MT5_AUTH, timeout=10) + return r.json() +``` + +This design means **adding a new strategy only requires copying one of the existing cycle files and modifying the signal logic** — the API integration layer is already there. + +--- + +## 5. The Six Strategy Gods — Real Implementation Deep Dive + +![Strategy Overview](/Users/sudo/.gemini/antigravity-ide/brain/32917d79-3b86-40d2-b479-1514fff3f799/zeus_ict_layers_1779961446917.png) +*Each strategy has a distinct market regime where it performs best. Running all six in parallel means the system is productive in trending, ranging, and volatile conditions.* + +Every strategy follows the same interface contract. The `run_analysis(symbol)` function always returns: + +```json +{ + "action": "trade" | "wait", + "reason": "Human-readable explanation", + "direction": "Buy" | "Sell", + "symbol": "EURUSDxx", + "entry": 1.08542, + "stop_loss": 1.08392, + "take_profit": 1.08842, + "volume": 0.10, + "sl_pips": 15.0, + "rr_ratio": 2.0, + "confidence": "high" | "medium", + "conditions_met": ["RSI oversold", "BB lower touch", ...], + "conditions_failed": [...] +} +``` + +This uniform interface is what allows `genesis_autonomous.py` to run all six bots and process results identically. + +--- + +### ARES — Bollinger Bands + RSI Mean Reversion (M1) +**File:** `strategies/ares/ares_cycle.py` · 643 lines + +ARES is designed for scalping in choppy, ranging markets. The core thesis: when price closes *outside* the Bollinger Band AND RSI is in extreme territory, it tends to snap back to the mean. + +**Signal conditions (both required for a trade):** + +```python +# BUY signal — from the actual implementation +bull_signal = ( + close < bb_lower # Price closed below lower BB (2.0 std dev, 20 period) + and rsi < RSI_OVERSOLD # RSI < 30 (oversold) + and REQUIRE_OUTSIDE # Must be a true band pierce, not just touch + and REQUIRE_RSI # Both conditions must hold simultaneously +) + +# Additional strictness gate +# ADX < 25 = ranging market (preferred for mean reversion) +# M15 context MA check = trend not strongly against us +``` + +**Indicator computation (using the `ta` library):** +```python +def compute_bb_rsi(bars, bb_period=20, bb_dev=2.0, rsi_period=14): + df = pd.DataFrame(bars) + bb_lower = ta.volatility.bollinger_lband(df["close"], window=bb_period, window_dev=bb_dev) + bb_upper = ta.volatility.bollinger_uband(df["close"], window=bb_period, window_dev=bb_dev) + rsi = ta.momentum.rsi(df["close"], window=rsi_period) + adx = ta.trend.adx(df["high"], df["low"], df["close"], window=14) + return { + "bb_lower": float(bb_lower.iloc[-2]), # Last CLOSED bar, not forming bar + "bb_upper": float(bb_upper.iloc[-2]), + "rsi": float(rsi.iloc[-2]), + "adx": float(adx.iloc[-2]), + } +``` + +**Config parameters (`configs/ares_config.yaml`):** + +| Parameter | Value | Reason | +|-----------|-------|--------| +| BB period | 20 | Standard deviation window | +| BB std dev | 2.0 | ~95% price capture | +| RSI period | 14 | Standard Wilder smoothing | +| RSI oversold | 30 | Classic threshold | +| RSI overbought | 70 | Classic threshold | +| Max spread | 1.0 pip | M1 scalp — tight spread critical | +| Session | 07:00–21:00 GMT | London + NY overlap | +| SL | 20 pips | Fixed for M1 regime | +| TP | 40 pips | 1:2 minimum R:R | + +**Key implementation detail:** We use `iloc[-2]` (second-to-last row) everywhere, not `iloc[-1]`. This is because the last bar is still forming — computing indicators on an incomplete bar causes lookahead bias. This single detail separates a realistic backtest from a profitable-looking one. + +--- + +### APOLLO — EMA 9/21 Trend Following (M5) +**File:** `strategies/apollo/apollo_cycle.py` · 613 lines + +APOLLO trades momentum. It only enters when price is moving strongly in one direction, confirmed by two EMAs and volume context. + +**Signal logic:** + +```python +# EMA crossover + price position +bull_signal = ( + ema9 > ema21 # Fast EMA above slow EMA (trend up) + and close > ema9 # Price above both EMAs (momentum confirmed) + and atr > atr_threshold # Sufficient volatility (not dead market) + and rsi > 50 # Momentum bias — not overbought enough to fade + and trend_ma_50 < close # 50-period MA context: overall uptrend +) +``` + +APOLLO avoids the most common EMA crossover failure mode — entering on a fake cross during consolidation — by requiring **three separate confirmations**: the cross itself, price position relative to both MAs, and ATR-based volatility floor. + +--- + +### ATHENA — Multi-Timeframe BB+RSI Ranging (M5) +**File:** `strategies/athena/athena_cycle.py` · 622 lines + +ATHENA is ARES's older sibling. Same BB+RSI core but on M5 (more signal stability, fewer noise trades) with an additional multi-timeframe filter: the H1 chart must not be in a strong trend (ADX check) before ATHENA enters a mean-reversion trade. + +The key difference from ARES: +- ARES: fast, M1, tighter spreads, more signals +- ATHENA: slower, M5, wider spread tolerance (1.5 pips), multi-TF confirmation required + +--- + +### ARTEMIS — Ichimoku Kumo Breakout (H1) +**File:** `strategies/artemis/artemis_cycle.py` · 498 lines + +ARTEMIS uses the full five-component Ichimoku system: Tenkan-sen (9), Kijun-sen (26), Senkou Span A, Senkou Span B (52), and Chikou Span (26-bar displacement). + +**The Ichimoku computation challenge — displacement:** + +This is the part that trips up most Ichimoku implementations. Senkou Spans A and B are plotted 26 bars *forward* in the future. To get the cloud at the *current bar*, you read the Span values at index `-(DISP + 2)` in the historical series: + +```python +def compute_ichimoku(bars): + DISP = 26 # displacement + tenkan = midpoint(highs, lows, period=9) + kijun = midpoint(highs, lows, period=26) + span_a = (tenkan + kijun) / 2 # Will be plotted DISP bars ahead + span_b = midpoint(highs, lows, period=52) # Will be plotted DISP bars ahead + + # Current cloud = values that were calculated DISP bars AGO (now displayed at current bar) + cloud_idx = -(DISP + 2) + sa_current = float(span_a.iloc[cloud_idx]) # ← This is the key + sb_current = float(span_b.iloc[cloud_idx]) + kumo_top = max(sa_current, sb_current) + kumo_bottom = min(sa_current, sb_current) + + # Chikou Span: current close vs close 26 bars ago + chikou_bullish = close_now > close_disp # Current price above historical +``` + +**Signal conditions (7 total, minimum 5 must pass for a trade):** + +```python +buy_conditions = [ + (close > kumo_top, "Price above Kumo"), + (future_cloud == "green", "Future cloud GREEN — SpanA > SpanB ahead"), + (cloud_color == "green", "Current cloud GREEN"), + (chikou_bullish, "Chikou above price 26 bars ago"), + (rsi > 50, "RSI above 50"), + (close > kijun, "Price above Kijun-sen"), + (conf_bars >= CONF_BARS, f"{conf_bars} bars confirmed above Kumo"), +] +# Signal fires only if 5+ conditions pass +if len(passed_conditions) >= 5: + return {"action": "trade", "confidence": "high" if all_7 else "medium"} +``` + +ARTEMIS is the most selective strategy — it typically generates 2–8 signals per day on a given symbol and has the highest R:R target (2.5:1 minimum) because H1 setups allow wider SL placement using the Kijun-sen as the natural stop level. + +--- + +### ZEUS — ICT Smart Money Concepts (M5) +**File:** `strategies/zeus/zeus_cycle.py` · 643 lines + +ZEUS implements three ICT concepts in **sequential confirmation** — not parallel. All three must activate in order for a trade to fire. This strict sequencing is what makes it high-conviction but low-frequency. + +![ICT Detection Pipeline](/Users/sudo/.gemini/antigravity-ide/brain/32917d79-3b86-40d2-b479-1514fff3f799/zeus_ict_layers_1779961446917.png) + +**Layer 1 — Liquidity Sweep Detection:** + +A liquidity sweep occurs when price briefly exceeds a recent swing high/low (triggering stop orders clustered there) then reverses. The rejection must happen within the same or next candle: + +```python +def detect_liquidity_sweep(highs, lows, closes, opens, sym): + # Find swing highs/lows in last 30 bars + sw_highs = detect_swing_highs(highs[-32:-2], lookback=SWING_LB) + sw_lows = detect_swing_lows(lows[-32:-2], lookback=SWING_LB) + + # Last closed candle + cur_h = highs[-2]; cur_l = lows[-2]; cur_c = closes[-2]; cur_o = opens[-2] + + for level in sw_highs: + wick_above = cur_h > level * (1 + SWEEP_TOL) # Price pierced the level + closed_below = cur_c < level # But closed back below + if wick_above and closed_below and REQ_REJECT: + return {"type": "BearSweep", "level": level, "candle_idx": -2} +``` + +**Layer 2 — Fair Value Gap (FVG) Detection:** + +An FVG is a 3-candle imbalance: the high of candle N is below the low of candle N+2, leaving a gap that price is expected to eventually fill: + +```python +def detect_fvg(highs, lows, direction): + # Three-candle imbalance + for i in range(-FVG_AGE, -2): + c1_high = highs[i-1]; c3_low = lows[i+1] + if direction == "bull" and c1_high < c3_low: + gap_size = (c3_low - c1_high) / pip_size + if gap_size >= MIN_GAP_PIPS: + return {"top": c3_low, "bottom": c1_high, "age_bars": abs(i)} +``` + +**Layer 3 — Order Block Detection:** + +The Order Block is the last candle before a strong displacement move — typically a large institutional entry point: + +```python +def detect_order_block(bars, direction): + # Find the displacement candle (biggest move in last OB_AGE bars) + # The order block is the candle BEFORE it + # If it's a bullish OB: last bearish candle before bullish surge + # If it's a bearish OB: last bullish candle before bearish dump + body_ratio = abs(close - open) / (high - low) + if body_ratio >= MIN_BODY_RATIO: # Strong displacement candle + ob_candle = bars[displacement_idx - 1] + return {"top": ob_candle["high"], "bottom": ob_candle["low"]} +``` + +**ICT Confluence Score:** + +Each layer contributes points. A minimum score of `MIN_SCORE` (configurable in `zeus_config.yaml`) is required for trade execution. London (07:00–10:00 GMT) and New York (13:00–16:00 GMT) killzones add a +1 bonus: + +```python +score = 0 +if liquidity_sweep: score += 2 # Highest weight — sweep is the trigger +if fvg: score += 1 # Confirms displacement +if order_block: score += 2 # Confirms institutional entry zone +if in_killzone(): score += 1 # Time-based bonus + +if score >= MIN_SCORE: # Default: 4 out of 6 + return {"action": "trade", ...} +``` + +--- + +### HEPHAESTUS — Grid / Martingale (Continuous) +**File:** `strategies/hephaestus/hephaestus_cycle.py` · 549 lines + +HEPHAESTUS operates differently from the signal-based strategies. It maintains a grid of positions at defined price levels and manages the exposure dynamically. It does not use yfinance for bars — it only needs the current price from the API2TRADE Quote endpoint and the list of open positions. + +> ⚠️ **Risk warning:** Grid/Martingale strategies can accumulate significant exposure in trending markets. HEPHAESTUS is included as an implementation example and should be tested thoroughly on demo before using on a live account. + +--- + +## 6. Hermes — The LLM Orchestration Brain + +![MT5 API Flow](/Users/sudo/.gemini/antigravity-ide/brain/32917d79-3b86-40d2-b479-1514fff3f799/mt5_api_flow_1779961500293.png) + +`trading_cycle.py` runs every hour and asks GPT-4o-mini a structured prompt containing: + +- Current account balance and equity +- Open positions and their P&L +- Recent market conditions (pulled from yfinance) +- Which strategies are currently active +- Recent trade journal entries + +GPT-4o-mini responds with a structured JSON decision: + +```json +{ + "market_regime": "ranging", + "preferred_strategies": ["ares", "athena"], + "suppress_strategies": ["apollo"], + "risk_adjustment": 0.8, + "reasoning": "EUR/USD has been oscillating in a 50-pip range since 09:00. Mean reversion strategies favoured. Apollo trend following suppressed until breakout confirmation.", + "macro_notes": "Fed minutes tomorrow 19:00 UTC — reduce position sizes by 20% after 17:00." +} +``` + +`genesis_autonomous.py` reads these instructions before running each strategy cycle. If Hermes has suppressed a strategy, that bot is skipped. If a risk adjustment is in effect, the calculated lot size is multiplied by the adjustment factor. + +**The critical design principle:** Hermes *cannot* override the hard risk limits encoded in each strategy. Even if Hermes tells ARES to trade, ARES still independently checks balance, spread, position count, and R:R. The LLM layer is advisory, not authoritative. + +--- + +## 7. Market Data — Multi-Source Fallback Architecture + +GENESIS has a three-tier fallback for price data. This was an engineering necessity: some MT5 brokers (like Deriv Demo) don't stream all quote symbols via the API, and Yahoo Finance blocks Docker container IPs intermittently. + +![VPS Deployment](/Users/sudo/.gemini/antigravity-ide/brain/32917d79-3b86-40d2-b479-1514fff3f799/genesis_vps_deployment_1779964695175.png) + +**For OHLCV bars (strategy signal computation):** + +```python +# yfinance with symbol mapping +YF_MAP = { + "EURUSDxx": "EURUSD=X", "GBPUSDxx": "GBPUSD=X", + "USDJPYxx": "USDJPY=X", "GBPJPYxx": "GBPJPY=X", + "XAUUSDxx": "GC=F", +} +df = yf.download("EURUSD=X", period="5d", interval="1m", + progress=False, auto_adjust=True) +``` + +**For live quotes (spread check + entry price):** + +```python +# Tier 1: API2TRADE /Quote (broker's live feed — best for spread accuracy) +r = requests.get(f"{MT5_API}/Quote", + params={"id": MT5_ID, "symbol": symbol}, + auth=MT5_AUTH, timeout=8) +if r.status_code == 200 and r.text.strip(): + raw = r.json() + if raw.get("Bid", 0) > 0: + return {"bid": raw["Bid"], "ask": raw["Ask"]} + +# Tier 2: open.er-api.com (free, no API key, updated hourly) +r = requests.get(f"https://open.er-api.com/v6/latest/{base_ccy}", timeout=8) +rates = r.json().get("rates", {}) +price = rates.get(quote_ccy) # e.g. base=EUR, quote=USD → EURUSD +if price: + spread = 0.00015 # 1.5 pip synthetic spread + return {"bid": price - spread/2, "ask": price + spread/2} + +# Tier 3: Frankfurter API (ECB rates, supports XAU/USD) +r = requests.get(f"https://api.frankfurter.app/latest?from=EUR&to=USD", timeout=8) +price = r.json()["rates"]["USD"] +``` + +**Why this matters:** This fallback chain means GENESIS works correctly even when: +- The MT5 broker quote stream is delayed or unavailable +- You're on a cloud provider that Yahoo Finance rate-limits +- You're testing on a broker that only streams certain symbols + +--- + +## 8. Risk Management — The Hard Layer + +Every strategy enforces these checks in order before returning `"action": "trade"`. None can be bypassed by Hermes or any other component. + +``` +1. Account equity > 0 → "Account equity is zero or unavailable" +2. No duplicate position already open → "Strategy X position already open" +3. Cooldown period (configurable) → "Cooldown: 847s remaining" +4. Is it a trading session? → "Outside session GMT 07–21" +5. Spread ≤ max_spread_pips → "Spread 2.3 > max 1.5 pips" +6. No high-impact news ±15 min → "News block: NFP in 8min" +7. Sufficient bar data → "Insufficient M1 bar data" +8. Indicator validity → "Indicator values None" +9. Signal conditions met → "Only 3/7 conditions met" +10. R:R ≥ minimum ratio → "R:R 1.2 < minimum 1.5" +``` + +**Lot sizing — dynamic, risk-based:** + +```python +def calculate_lot(equity, sl_pips, symbol): + # Pip value per lot (approximate) + pip_value = { + "EUR": 10.0, # EURUSD: $10/lot/pip + "GBP": 12.5, # GBPUSD: ~$12.50/lot/pip + "JPY": 9.0, # USDJPY: ~$9/lot/pip + "XAU": 1.0, # XAUUSD: $1/lot/pip (0.1 pip instrument) + } + pv = pip_value.get(symbol[:3], 10.0) + + # Risk amount = equity × risk_pct (e.g. 1% of $10,000 = $100) + risk_amount = equity * RISK_PCT # RISK_PCT = 0.01 per strategy + raw_lots = risk_amount / (sl_pips * pv) + return round(max(0.01, min(raw_lots, 3.0)), 2) # Hard cap: 3.0 lots maximum +``` + +With a $10,000 account, 1% risk, and 20-pip SL on EURUSD: +`$100 ÷ (20 pips × $10/pip) = 0.50 lots` + +--- + +## 9. How to Replicate This — Complete Step-by-Step + +### Step 1: Get Your API2TRADE Account + +Sign up at **[app.api2trade.com](https://app.api2trade.com)**. Connect your MT5 account by providing your broker login, password, and server name. API2TRADE creates a persistent **session UUID** — this is your `MT5_ACCOUNT_UUID` in the `.env` file. + +**Cost:** €12/month per connected MT5 account. + +The session UUID looks like: `a1b2c3d4-e5f6-7890-abcd-ef1234567890` + +You also get an API username and password for Basic HTTP auth on every request. + +### Step 2: Set Up Your Telegram Bot + +1. Message [@BotFather](https://t.me/BotFather) on Telegram → `/newbot` +2. Give it a name (e.g. "GENESIS Alerts") +3. Copy the bot token: `1234567890:AABBccDDeeffGGHHiiJJkkLLmmNNoo` +4. Message [@userinfobot](https://t.me/userinfobot) to get your numeric Chat ID + +### Step 3: Clone and Configure + +```bash +git clone https://github.com/api2trade/Genesis-Metatrader-Automatic-AI-Trading-System.git +cd Genesis-Metatrader-Automatic-AI-Trading-System + +# Option A: Interactive wizard (recommended) +bash setup.sh +# → Asks for each credential, verifies them live, writes .env + +# Option B: Manual +cp .env.example .env +nano .env # Fill in the 6 required fields +``` + +The six required `.env` fields: + +```bash +MT5_ACCOUNT_UUID=your-api2trade-session-uuid +MT5_ACCOUNT_ID=your-api2trade-session-uuid # same value, alias +MT5_API_USER=your_api2trade_username +MT5_API_PASS=your_api2trade_password +TELEGRAM_BOT_TOKEN=your_bot_token +TELEGRAM_CHAT_ID=your_chat_id +``` + +### Step 4: Deploy with Docker (Local or VPS) + +```bash +# Build and start +docker compose up -d + +# Watch startup logs +docker logs -f genesis + +# Expected output: +# ✓ MT5_ACCOUNT_UUID = a1b2c3d4•••• +# ✓ Verifying API2TRADE connection... +# ✓ Connected | Balance: 10,000.00 USD [demo] +# ✓ 6 cron jobs installed +``` + +### Step 5: Run Your First Scan + +```bash +# Analyze EURUSD across all strategies +docker exec -it genesis genesis-scan EURUSDxx + +# Expected output (example): +# === GENESIS FULL SCAN: EURUSDxx === +# --- ares --- WAIT | Outside session GMT 07-21 +# --- apollo --- WAIT | ADX 18.2 < min 20 (trending required) +# --- athena --- WAIT | Spread 1.8 > max 1.5 pips +# --- artemis --- WAIT | Only 3/7 Ichimoku conditions met +# --- zeus --- WAIT | No liquidity sweep detected in last 30 bars + +# Or analyze a single strategy in detail +docker exec -it genesis ares analyze EURUSDxx +``` + +### Step 6: VPS Production Deployment + +For 24/7 autonomous trading, a VPS is essential. Minimum specs: **2 vCPU, 2GB RAM, Ubuntu 22.04**. Recommended providers: Hetzner (€4/month), Contabo (€5/month), DigitalOcean (€8/month). + +```bash +# On your VPS, after git clone + bash setup.sh: +bash install.sh + +# What install.sh does: +# 1. Installs Python 3.11 and pip +# 2. Creates venv at /opt/hermes-agent/.venv-hermes +# 3. Installs all dependencies from requirements.txt +# 4. Creates CLI shortcuts: ares, apollo, athena, artemis, zeus, hephaestus, genesis-scan +# 5. Writes /etc/cron.d/genesis with the 6 cron jobs +# 6. Creates /var/log/hermes/ log directories +# 7. Sends a test Telegram message confirming the installation +``` + +### Step 7: Customise a Strategy + +Each strategy is a self-contained Python module. To modify ARES's signal thresholds without touching code: + +```yaml +# configs/ares_config.yaml +bollinger: + period: 20 # ← Change BB window + std_dev: 2.5 # ← Wider band = fewer but higher quality signals + +rsi: + period: 14 + oversold: 25 # ← Stricter oversold threshold (was 30) + overbought: 75 # ← Stricter overbought threshold + +risk: + risk_pct: 0.01 # ← 1% per trade + max_spread_pips: 1.0 + min_rr_ratio: 2.0 # ← Minimum 1:2 reward-to-risk +``` + +To write a completely new strategy: + +1. Copy `strategies/ares/` to `strategies/mybot/` +2. Rename `ares_cycle.py` to `mybot_cycle.py` and `ares_tool.py` to `mybot_tool.py` +3. Replace the `run_analysis()` logic with your signal conditions +4. Keep the `bridge()` function and the return format unchanged +5. Add your strategy to `genesis_autonomous.py`'s bot list + +--- + +## 10. Infrastructure and Real Costs + +### Monthly Running Costs + +| Service | Cost | What for | +|---------|------|----------| +| API2TRADE | **€12/month** | MT5 REST API — 1 account | +| VPS (Hetzner CX21) | **€4.35/month** | 2 vCPU, 4GB RAM, Ubuntu 22.04 | +| OpenAI (GPT-4o-mini) | **~€5–15/month** | Hermes LLM brain — hourly cycles | +| Telegram Bot | **Free** | All trade alerts and reports | +| open.er-api.com | **Free** | Quote fallback for Forex pairs | +| yfinance | **Free** | OHLCV bars for all strategies | +| **Total** | **~€21–31/month** | Full autonomous system | + +### Development Investment (AI Tokens) + +GENESIS was built over approximately 3 weeks using AI-assisted development (Claude/Gemini models). The estimated token usage across all development conversations: + +| Phase | Description | Approx. Tokens | +|-------|-------------|----------------| +| Architecture design | System design, data flow decisions | ~200K | +| Strategy implementation | All 6 strategy bots coded + debugged | ~800K | +| Bridge removal refactor | Moving from local bridge to direct API | ~200K | +| Docker + deployment | Dockerfile, docker-compose, entrypoints | ~150K | +| Documentation + README | Case study, README, .env.example | ~200K | +| Debugging sessions | Auth issues, import paths, yfinance | ~250K | +| **Total** | | **~1.8M tokens** | + +At current Claude/Gemini pricing (~$3–15 per million tokens depending on model), total AI-assisted development cost: **approximately $5–27**. + +Compare this to hiring a professional quant developer at €100–200/hour to build the equivalent system. GENESIS represents roughly 200+ hours of equivalent development work. + +--- + +## 11. Key Engineering Challenges and How We Solved Them + +### Challenge 1: The Local Bridge Was a Single Point of Failure + +**Problem:** The original v1 used a FastAPI bridge at `localhost:8000` that translated Python calls into the MetaTrader5 Python SDK. This meant three things had to be running simultaneously: the FastAPI bridge, the MT5 terminal, and the Python strategies. Any one crashing silently would stop all trading. + +**Solution:** Remove the bridge entirely. Every `*_cycle.py` file now has a `bridge()` function that calls API2TRADE REST directly. The strategies became self-contained — each one can run independently without any other service. + +```python +# Before (v1) — required local bridge running +def bridge(path, data=None): + r = requests.post(f"http://127.0.0.1:8000{path}", json=data) + return r.json() + +# After (v2) — direct REST call +def bridge(path, data=None): + r = requests.get(f"{MT5_API}/AccountSummary", + params={"id": MT5_ID}, auth=MT5_AUTH, timeout=10) + return r.json() +``` + +### Challenge 2: Ichimoku Displacement — The Lookahead Trap + +**Problem:** Every Ichimoku tutorial shows the cloud "shifted forward" visually, but when computing it in code, most implementations accidentally read the **future** cloud values at `iloc[-1]` instead of the **current** cloud projected at `iloc[-(DISP+2)]`. This creates a massive lookahead bias — the strategy "sees" cloud values that won't exist yet in real-time. + +**Solution:** Explicit displacement indexing. The current Kumo boundaries are the SpanA/B values calculated `DISP` bars ago: + +```python +cloud_idx = -(DISP + 2) # DISP = 26 bars displacement +sa_current = float(span_a.iloc[cloud_idx]) # ← Correct +# NOT: span_a.iloc[-1] ← This is lookahead bias +``` + +### Challenge 3: Yahoo Finance Rate Limiting Inside Docker + +**Problem:** When testing GENESIS in Docker on a Mac, `yf.download("EURUSD=X", ...)` consistently returned empty DataFrames. Yahoo Finance blocks or rate-limits requests from Docker container IP ranges. + +**Solution:** Three-tier quote fallback using free public APIs that don't rate-limit Docker IPs: +1. API2TRADE `/Quote` (broker's live feed) +2. `open.er-api.com` (free ECB/central bank rates, no API key) +3. `Frankfurter.app` (ECB reference rates, supports XAU/USD) + +In production on a VPS, yfinance works normally. The fallback ensures correctness in all environments. + +### Challenge 4: `iloc[-2]` vs `iloc[-1]` — Forming Bar Lookahead + +**Problem:** When downloading 1-minute bars, the last row (`iloc[-1]`) is the bar currently forming — it has incomplete data. Computing indicators on it means your signal conditions are evaluated against a partial candle that will change before the bar closes. + +**Solution:** Every indicator computation uses `iloc[-2]` — the last fully closed bar. This is implemented consistently across all six strategies: + +```python +# Every strategy uses this pattern +bb_lower = float(ta.volatility.bollinger_lband(df["close"], window=20).iloc[-2]) +rsi = float(ta.momentum.rsi(df["close"], window=14).iloc[-2]) +# Never iloc[-1] for signal computation +``` + +### Challenge 5: Symbol Format Differences Between Brokers + +**Problem:** Different MT5 brokers use different symbol names. Exness uses `EURUSDxx`, Deriv uses `EURUSD`, IC Markets uses `EURUSD`, Pepperstone uses `EURUSD.`. A system hardcoded to `EURUSDxx` breaks silently on other brokers. + +**Solution:** Configurable symbol suffixes in YAML configs, plus a `YF_MAP` dictionary that maps MT5 symbols to their yfinance equivalents regardless of broker suffix: + +```yaml +# configs/ares_config.yaml +mt5: + symbol_suffix: "xx" # Change to "" for brokers without suffix +``` + +```python +YF_MAP = { + "EURUSDxx": "EURUSD=X", + "EURUSD": "EURUSD=X", # Works with any suffix variant + "EURUSD.": "EURUSD=X", +} +``` + +--- + +## 12. Live Test Results — Deriv Demo Account + +We validated GENESIS v2.1 on a **Deriv Demo account** ($10,000 USD, 1:1000 leverage) via API2TRADE before the open-source release. Here is what actually happened: + +**API2TRADE Session Connection:** +``` +ConnectEx → 200 OK → Session UUID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +AccountSummary: + balance: $10,000.00 USD + equity: $10,000.00 USD + leverage: 1000:1 + type: demo + method: Hedging +``` + +**Full Five-Strategy Scan on EURUSDxx:** + +``` +=== GENESIS FULL SCAN — Deriv Demo $10k === + + ARES → WAIT | Spread 1.50 pips > max 1.0 pips + APOLLO → WAIT | Spread 1.50 pips > max 1.5 pips + ATHENA → WAIT | Spread 1.50 pips > max 1.5 pips + ARTEMIS → WAIT | Insufficient H1 data (yfinance blocked in Docker) + ZEUS → WAIT | Insufficient M5 data (yfinance blocked in Docker) +``` + +**Reading these results correctly:** + +- ARES, APOLLO, ATHENA all **reached the spread check gate** — meaning they successfully: connected to API2TRADE, fetched account balance ($10k confirmed), checked for open positions, validated the trading session, and obtained a live price quote (via open.er-api.com fallback at 1.50 pip synthetic spread). The spread rejection is correct behaviour — ARES's max is 1.0 pip for M1 scalping. + +- ARTEMIS and ZEUS returned "Insufficient data" because yfinance is blocked from Docker on this test machine. On a VPS, yfinance returns full H1 and M5 data normally, and both strategies complete their full analysis. + +**In other words:** The entire API2TRADE integration, authentication, account data, quote retrieval, and risk layer worked correctly. The "WAIT" decisions are not failures — they are the system correctly declining to trade based on real conditions. + +--- + +## 13. What to Do Next + +If you are reading this as a developer looking to build your own system: + +**Minimum viable starting point:** +1. Sign up at [app.api2trade.com](https://app.api2trade.com) — get your session UUID +2. Clone this repo and run `bash setup.sh` +3. Run `docker exec -it genesis ares analyze EURUSDxx` — confirm it connects to your account +4. Spend 1 week watching the strategy outputs on demo before enabling execution + +**If you want to customise the strategies:** +- ARES's `ares_config.yaml` is the cleanest to start with — change `bb_period`, `std_dev`, and `rsi_oversold` thresholds and observe the effect on signal frequency +- ZEUS is the most institutionally-aligned — if you're familiar with ICT concepts, this is the most interesting codebase to extend + +**If you want to scale across multiple accounts:** +- API2TRADE supports multiple connected MT5 accounts — each gets its own session UUID +- You can run multiple `docker-compose` stacks with different `.env` files pointing to different accounts +- Each additional account costs €12/month + +**The open-source repository** includes everything: all six strategy implementations, all YAML configs, the autonomous engine, Hermes LLM brain, Docker deployment, VPS installer, and the interactive setup wizard. Fork it, adapt it, and build on top of it. + +> **Get started: [app.api2trade.com](https://app.api2trade.com)** + +--- + +*GENESIS is open-source under GPL-3.0. Forks must also remain open source.* +*Trading involves risk. Always test on a demo account before using real funds.* +*Published by API2TRADE · https://app.api2trade.com* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..224a535 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,78 @@ +# ───────────────────────────────────────────────────────────────────────────── +# GENESIS Trading System — Docker Image +# Base: Ubuntu 22.04 (matches production VPS) +# ───────────────────────────────────────────────────────────────────────────── +FROM ubuntu:22.04 + +# Avoid interactive prompts during apt +ENV DEBIAN_FRONTEND=noninteractive +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +# ── System packages ─────────────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y \ + python3.11 \ + python3.11-venv \ + python3-pip \ + cron \ + curl \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Set timezone to UTC (matches trading sessions) +ENV TZ=UTC +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# ── Install directory (mirrors production /opt/hermes-agent) ────────────────── +RUN mkdir -p /opt/hermes-agent +WORKDIR /opt/hermes-agent + +# ── Python virtual environment ──────────────────────────────────────────────── +RUN python3.11 -m venv /opt/hermes-agent/.venv-hermes +ENV PATH="/opt/hermes-agent/.venv-hermes/bin:$PATH" + +# ── Install Python dependencies ─────────────────────────────────────────────── +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +# ── Copy all source files ───────────────────────────────────────────────────── +COPY core/ ./core/ +COPY strategies/ ./strategies/ +COPY configs/ ./configs/ +COPY backtest/ ./backtest/ +COPY services/ ./services/ +COPY .env.example . + +# ── Copy configs into each strategy subfolder (where *_cycle.py expects them) ─ +RUN cp configs/ares_config.yaml strategies/ares/ && \ + cp configs/apollo_config.yaml strategies/apollo/ && \ + cp configs/athena_config.yaml strategies/athena/ && \ + cp configs/artemis_config.yaml strategies/artemis/ && \ + cp configs/zeus_config.yaml strategies/zeus/ && \ + cp configs/hephaestus_config.yaml strategies/hephaestus/ + +# ── Create log directories ──────────────────────────────────────────────────── +RUN mkdir -p \ + /var/log/hermes \ + /var/log/ares \ + /var/log/apollo \ + /var/log/athena \ + /var/log/artemis \ + /var/log/zeus \ + /var/log/hephaestus + +# ── CLI shortcuts ────────────────────────────────────────────────────────────── +RUN for bot in ares apollo athena artemis zeus hephaestus; do \ + printf '#!/bin/bash\n/opt/hermes-agent/.venv-hermes/bin/python3 /opt/hermes-agent/strategies/%s/%s_tool.py "$@"\n' \ + "$bot" "$bot" > /usr/local/bin/$bot && \ + chmod +x /usr/local/bin/$bot; \ +done + +RUN printf '#!/bin/bash\nSYMBOL=${1:-EURUSDxx}\necho ""\necho "=== GENESIS FULL SCAN: $SYMBOL ==="\necho ""\nfor bot in ares apollo athena artemis zeus; do\n echo "--- $bot ---"\n $bot analyze $SYMBOL 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'\'' Action: {d.get(chr(34)+chr(97)+chr(99)+chr(116)+chr(105)+chr(111)+chr(110)+chr(34),chr(63))}'\'')"\ndone\necho ""\n' \ + > /usr/local/bin/genesis-scan && chmod +x /usr/local/bin/genesis-scan + +# ── Entrypoint ──────────────────────────────────────────────────────────────── +COPY docker-entrypoint.sh /entrypoint.sh +RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Mt5Bridge使用指南.md b/Mt5Bridge使用指南.md new file mode 100644 index 0000000..c81c575 --- /dev/null +++ b/Mt5Bridge使用指南.md @@ -0,0 +1,1149 @@ +# Mt5Bridge API 使用指南 + +> 本文档面向**开发者**,假设 Bridge 已在云端部署运行。直接复制代码即可使用。 + +--- + +## 连接信息 + +| 项目 | 值 | +| --------- | ------------------------------------------------------------ | +| 地址 | `http://61.164.252.86:13485` | +| 认证 | `X-API-Key` Header 或 `?key=` URL 参数 | +| 格式 | 所有返回均为 JSON | +| WebSocket | `ws://61.164.252.86:13485`,握手时用 Header `X-API-Key` | +| SSE | `http://.../stream/ticks-sse/{symbol}`,Header 或 `?key=` 均可 | + +> **浏览器注意**:原生 `WebSocket` 不支持自定义 Header,须用 `?key=` URL 参数;SSE 用 `new EventSource(url + '?key=...')` 同理。Node/Python/Java 客户端用 Header 更干净。 + +--- + +## 快速开始(Python) + +```python +import requests + +BRIDGE = "http://61.164.252.86:13485" +KEY = "your-api-key" + +def api(path, params=None): + """统一请求封装""" + resp = requests.get(f"{BRIDGE}{path}", params=params, headers={"X-API-Key": KEY}) + resp.raise_for_status() + return resp.json() + +def api_post(path, data): + """POST 请求封装""" + resp = requests.post(f"{BRIDGE}{path}", json=data, headers={"X-API-Key": KEY}) + resp.raise_for_status() + return resp.json() + +# 测试连接 +print(api("/health")) +``` + +--- + +## API 接口速查 + +| 分类 | 方法 | 端点 | 用途 | +| -------- | ----------------- | ---------------------------- | ------------------------------- | +| **系统** | `GET` | `/health` | 健康检查 + MT5 连接状态 | +| **账户** | `GET` | `/account` | 余额/净值/保证金/杠杆 | +| **行情** | `GET` | `/symbols/{symbol}` | 品种信息(点值/手数/合约) | +| | `GET` | `/symbols/{symbol}/tick` | 拉取一次 tick | +| | `WS` | `/stream/ticks/{symbol}` | **实时 tick 推送**(推荐) | +| | `GET` | `/stream/ticks-sse/{symbol}` | SSE 推送(浏览器/内网代理友好) | +| **K 线** | `GET` | `/rates/from-pos` | K 线(按偏移量) | +| | `GET` | `/rates/from-date` | **K 线(按时间范围)⭐** | +| **持仓** | `GET` | `/positions[?symbol=]` | 当前持仓列表 | +| | `POST` | `/position/close` | 平仓(全/部分) | +| | `POST` | `/position/modify` | 改 SL/TP | +| | `POST` | `/position/close-by` | 对冲平仓(节省点差) | +| | `POST` | `/positions/close-batch` | 批量平仓(按 magic/symbol) | +| **挂单** | `GET` | `/orders[?symbol=]` | 挂单列表 | +| | `POST` | `/order/cancel` | 撤单 | +| | `POST` | `/order/modify` | 改挂单价/SL/TP | +| **下单** | `POST` | `/order/check` | 预检(不成交) | +| | `POST` | `/order/send` | 实际下单 | +| **历史** | `GET` | `/history/deals` | 历史成交 | +| **信号** | `GET/POST/DELETE` | `/gvar[/{name}]` | MQL5 全局变量(指标信号桥) | + +> 推送类端点(WS/SSE):每品种最多 50 个订阅者,超出返回 `429`;每 30 秒发一条心跳包用于穿透 NAT 保持连接。 + +--- + +## ⚠️ 隐含约定与已知陷阱(先读) + +下面这些坑都是踩过的,**不读这节直接调接口几乎必踩**: + +### P1. `/history/deals` 的 `date_to` 是 **EXCLUSIVE**(不含当天) + +```bash +# ❌ 0 deals — 07-08 当天全部丢失 +GET /history/deals?date_from=2026-07-06&date_to=2026-07-08 + +# ✅ 42 deals — date_to 设成"明天"才能取到 07-08 当天 +GET /history/deals?date_from=2026-07-08&date_to=2026-07-09 +``` + +**规则**:永远把 `date_to` 设为"目标日期的下一天"。 + +### P2. `/history/deals` 的 `entry` 字段语义跟 MT5 标准 **相反** + +``` +bridge entry = 1 ⇒ OUT(关仓)—— profit 字段是已实现 P&L(USD) +bridge entry = 0 ⇒ IN (开仓)—— profit 固定为 0 +``` + +MT5 MQL5 原生约定是 `DEAL_ENTRY_IN=0 / DEAL_ENTRY_OUT=1`,这个 bridge 的 C# 实现把语义反过来了。 +**如果不验证就用 close 路径过滤 deal,会一个都匹配不到**(结果 P&L 永远是 0)。 + +```python +# ✅ 正确:找关仓 deal +exits = [d for d in deals if d.get('entry') == 1 and d.get('magic') == 88001] +``` + +### P3. `/order/send` 只返回 `{retcode, order, comment}`,**没有成交价、没有 deal ticket** + +```json +// 实际响应(成功) +{"data": {"retcode": 10009, "order": 1797395084, "comment": "Request executed"}} +``` + +bridge 不返回 `price` 也不返回 `deal` 字段。**所以拿真实 fill 价格和实现 P&L,唯一办法是 close 之后查 `/history/deals`**。 + +```python +# ❌ 永远拿不到正确价格 +result = api_post('/order/send', {...})['data'] +result.get('price') # None + +# ✅ 正确:成交后从 history/deals 拿 +deals = api('/history/deals', params={'date_from': today, 'date_to': tomorrow})['data'] +exit_deal = next(d for d in deals if d['entry'] == 1 and d['symbol'] == sym) +realized_pnl_usd = exit_deal['profit'] # broker 已经换算成 deposit currency +actual_fill_price = exit_deal['price'] +``` + +### P4. `/positions.profit` **是 deposit currency(USD),不是 quote currency** + +```python +# USDCAD SELL 当前浮动 -0.17 +# 这是 USD 真实值 (-0.24 CAD ÷ 1.417 USD/CAD = -0.169 USD ≈ -0.17) +# 不是 CAD! +``` + +MT5 `POSITION_PROFIT` 的官方语义就是 in deposit currency,bridge 严格遵循。 +**如果手动算 USDCAD / USDJPY P&L 时按 quote currency 处理,会差一个汇率倍数**(USDCAD 大约 1.4×)。 + +### P5. bridge 拿到的 tick 跟 broker 实际 fill 差 1-4 ticks + +`/positions` 的 `price_open` / `price_current` 和 `/order/send` 时看到的 tick 跟 broker 服务器**真实成交价**有几毫秒级的时间差,导致值差 0.0001-0.0004(约 0.1-0.4 pip)。 + +``` +broker 实际 fill: 1.41715 +/positions.price_open: 1.41711 ← 差 0.00004 (0.4 pip) +``` + +**永远以 `/history/deals` 里的 `price` 为准做对账,不要用 `/positions` 的 price_open**。 + +### P6. `/position/close` 与 `/order/send` 的成功 retcode 含义不同 + +| 端点 | 成功 retcode | 失败 retcode | 来源 | +| ----------------- | ------------------------ | ---------------- | ------------------------------ | +| `/order/send` | MT5 原生(通常 `10009`) | MT5 原生 | 直接透传 `result.Retcode` | +| `/position/close` | **合成** `10009` | **合成** `10004` | C# 代码 `ok ? 10009u : 10004u` | + +两者都是 `10009 = success`,但**别假设 0 是 success**。建议统一判 `retcode == 10009`。 + +### P7. `data: []` 与"默认值数据"的语义混淆 + +Bridge 的所有 list 端点(`/account`, `/positions`, `/orders`, `/history/deals`, `/symbols` 等)都遵循这个统一约定: + +- **"有数据"** ⇒ `{"data": [{...}], "count": 1, ...}` +- **"无数据"** ⇒ `{"data": [], "count": 0, ...}`(**HTTP 仍 200**,不是错误) + +客户端很容易把"无数据"误判成"默认值数据"。例: + +```python +# ❌ 错:data:[] 时 fallback 到 {},默认零值看着像合法数据 +info = api('/account') +acc = (info.get('data') or [{}])[0] +if not acc.get('trade_allowed'): + raise PermissionError('trading disabled') + # ↑ 实际可能是"账户未登录",不是"Algo Trading 真关闭" + +# ❌ 错:bool({})==True,让健康检查通过 +if api('/account'): + mark_healthy() +``` + +**正确做法**:用 `data` 长度 + 身份字段(如 `login`、`ticket`)作为"真实数据就绪"的标志: + +```python +def data_ready(info, key='login'): + """data 非空 + 身份字段 > 0 ⇒ 真实数据""" + items = info.get('data') or [] + return bool(items) and (items[0].get(key, 0) if items else 0) not in (0, None, '') + +# ✅ +info = api('/account') +if not data_ready(info, 'login'): + raise ConnectionError('account not loaded yet') +``` + +**适用所有 list 端点**。判 `positions`、`orders`、`deals` 同理。 + +--- + +## 1. 健康检查 + +``` +GET /health +``` + +```python +status = api("/health") +# {"status": "healthy", "mt5_connected": true, "api_version": "1.0.0"} +``` + +--- + +## 2. 账户信息 + +``` +GET /account +``` + +```python +acc = api("/account")["data"][0] +print(f"余额: {acc['balance']}, 净值: {acc['equity']}, 浮动盈亏: {acc['profit']}") +print(f"保证金: {acc['margin']}, 可用保证金: {acc['margin_free']}, 比例: {acc['margin_level']}%") +print(f"杠杆: 1:{acc['leverage']}, 币种: {acc['currency']}") +``` + +**返回字段:** + +| 字段 | 含义 | +| ------------- | ---------------- | +| login | 账户号 | +| balance | 余额 | +| equity | 净值 | +| profit | 浮动盈亏 | +| margin | 已用保证金 | +| margin_free | 可用保证金 | +| margin_level | 保证金比例 | +| leverage | 杠杆 | +| currency | 账户币种 | +| trade_allowed | 是否允许交易 | +| trade_expert | 是否允许 EA 交易 | + +--- + +## 3. 实时行情 + +#### 3-1. 主动拉取(一次性) + +``` +GET /symbols/{symbol}/tick +``` + +自动将品种加入 MT5 Market Watch,并等待最多 3 秒获取真实 tick 数据(解决品种未订阅时返回空值的问题)。 + +```python +def get_tick(symbol): + data = api(f"/symbols/{symbol}/tick")["data"][0] + return data["bid"], data["ask"] + +bid, ask = get_tick("XAUUSD") +print(f"XAUUSD Bid: {bid} Ask: {ask} Spread: {ask - bid}") +``` + +**返回字段:** + +| 字段 | 含义 | +| ------ | ---------- | +| bid | 卖价 | +| ask | 买价 | +| last | 最新成交价 | +| volume | 成交量 | +| time | 时间 | + +#### 3-2. 订阅推送(WebSocket 流式)⭐ 推荐 + +``` +WS /stream/ticks/{symbol} +``` + +MT5 每收到一个 tick 就立即推给所有订阅者,省去轮询。 + +- 走 `X-API-Key` 认证(Header `X-API-Key: your-key`,WebSocket 客户端在握手 Header 里带) +- 连上时自动把品种加入 MT5 Market Watch;最后一个订阅者断开时自动移除 +- 每个品种最多 50 个订阅者(含 WS + SSE 总数),超出返回 `429` +- 每 30 秒发一条心跳(无 tick 时也发),客户端可用于保活与断线检测 + +**推送格式(每条 tick 一帧 JSON 文本):** + +```json +{ + "type": "tick", + "symbol": "XAUUSDc", + "time": "2026-07-08T10:30:45", + "bid": 4180.0, + "ask": 4180.5, + "last": 4180.2, + "volume": 100, + "time_msc": "2026-07-08T10:30:45.123000", + "flags": 6 +} +``` + +**心跳包:** + +```json +{"type":"heartbeat","time":"2026-07-08T10:31:15"} +``` + +#### 3-3. SSE 推送(不能用 WebSocket 的环境) + +``` +GET /stream/ticks-sse/{symbol} → Content-Type: text/event-stream +``` + +面向无法建 WebSocket 的客户端(部分老浏览器、内网代理、curl 测试等)。语义同 3-2,每条 tick 一帧 SSE: + +``` +data: {"type":"tick","symbol":"XAUUSDc","bid":4180.0,...} + +data: {"type":"tick","symbol":"XAUUSDc","bid":4181.0,...} + +``` + +**curl 测试:** + +```bash +curl -N -H "X-API-Key: your-api-key" \ + http://61.164.252.86:13485/stream/ticks-sse/XAUUSDc +``` + +**Python SSE 客户端(`sseclient-py`):** + +```python +from sseclient import SSEClient +import json + +messages = SSEClient("http://61.164.252.86:13485/stream/ticks-sse/XAUUSDc", + headers={"X-API-Key": "your-api-key"}) +for msg in messages: + data = json.loads(msg.data) + if data.get("type") == "heartbeat": + continue + print(data["symbol"], data["bid"], data["ask"]) +``` + +**Python 示例(需安装 `websocket-client`):** + +```python +import websocket +import threading + +def on_message(ws, msg): + tick = eval(msg) # 或 json.loads(msg) + print(f"{tick['symbol']} Bid:{tick['bid']} Ask:{tick['ask']}") + +def on_open(ws): + print("connected") + +def on_close(ws, code, reason): + print(f"disconnected: {code} {reason}") + +ws = websocket.WebSocketApp( + f"ws://61.164.252.86:13485/stream/ticks/XAUUSDc", + header=[f"X-API-Key: your-api-key"], + on_message=on_message, + on_open=on_open, + on_close=on_close, +) +ws.run_forever() +``` + +**`websockets` 库(asyncio 版):** + +```python +import asyncio +import websockets +import json + +async def watch_ticks(): + headers = {"X-API-Key": "your-api-key"} + async with websockets.connect( + "ws://61.164.252.86:13485/stream/ticks/XAUUSDc", + additional_headers=headers, + ) as ws: + async for raw in ws: + tick = json.loads(raw) + print(tick["symbol"], tick["bid"], tick["ask"]) + +asyncio.run(watch_ticks()) +``` + +**浏览器控制台测试:** + +```js +const ws = new WebSocket("ws://61.164.252.86:13485/stream/ticks/XAUUSDc", { + headers: { "X-API-Key": "your-api-key" } // 浏览器原生 WS 不支持自定义 Header,需走 ?key= 参数,见下 +}); +// 浏览器场景:用 query 参数传 key +const ws2 = new WebSocket("ws://61.164.252.86:13485/stream/ticks/XAUUSDc?key=your-api-key"); +ws2.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + +--- + +## 4. 品种信息 + +``` +GET /symbols/{symbol} +``` + +bid/ask 从实时 tick 数据获取(自动等待最多 3 秒),避免品种刚加入 Market Watch 时返回 0 的问题。 + +```python +def get_symbol_info(symbol): + info = api(f"/symbols/{symbol}")["data"][0] + print(f"品种: {info['name']}, 描述: {info['description']}") + print(f"小数位: {info['digits']}, 点值: {info['point']}") + print(f"最小手数: {info['volume_min']}, 最大: {info['volume_max']}, 步长: {info['volume_step']}") + print(f"合约大小: {info['trade_contract_size']}") + return info +``` + +--- + +## 5. 历史 K 线(按偏移量) + +``` +GET /rates/from-pos?symbol={symbol}&timeframe={timeframe}&start_pos={start}&count={count} +``` + +| 参数 | 可选值 | +| --------- | ---------------------------------------------------------- | +| timeframe | `TIMEFRAME_M1` / `M5` / `M15` / `M30` / `H1` / `H4` / `D1` | +| start_pos | 0 = 最新,1 = 前一根,以此类推 | +| count | 获取数量,最大 10000 | + +```python +import pandas as pd + +def get_rates(symbol, timeframe, count): + """获取 K 线并转为 DataFrame""" + data = api("/rates/from-pos", params={ + "symbol": symbol, + "timeframe": f"TIMEFRAME_{timeframe}", + "start_pos": 0, + "count": count + })["data"] + df = pd.DataFrame(data) + df["time"] = pd.to_datetime(df["time"]) + df.set_index("time", inplace=True) + return df + +# 获取最近 100 根 H1 K 线 +df = get_rates("XAUUSD", "H1", 100) +print(df.head()) +``` + +**返回字段:** `time`, `open`, `high`, `low`, `close`, `tick_volume`, `spread`, `real_volume` + +--- + +### 5-2. 历史 K 线(按时间范围)⭐ 推荐 + +``` +GET /rates/from-date?symbol={symbol}&timeframe={timeframe}&date_from={date_from}&date_to={date_to} +``` + +| 参数 | 可选值 | +| --------- | ---------------------------------------------------------- | +| timeframe | `TIMEFRAME_M1` / `M5` / `M15` / `M30` / `H1` / `H4` / `D1` | +| date_from | 起始日期,ISO-8601 或 `yyyy-MM-dd` | +| date_to | 结束日期,ISO-8601 或 `yyyy-MM-dd` | + +```python +def get_rates_by_date(symbol, timeframe, date_from, date_to): + """按时间范围获取 K 线""" + data = api("/rates/from-date", params={ + "symbol": symbol, + "timeframe": f"TIMEFRAME_{timeframe}", + "date_from": date_from, + "date_to": date_to, + })["data"] + df = pd.DataFrame(data) + df["time"] = pd.to_datetime(df["time"]) + df.set_index("time", inplace=True) + return df + +# 获取 2026年7月1日 ~ 7月3日 的 H1 K 线 +df = get_rates_by_date("XAUUSD", "H1", "2026-07-01", "2026-07-03") +print(df.head()) +``` + +**返回字段:** 同 `/rates/from-pos` + +--- + +## 6. 当前持仓 + +``` +GET /positions[?symbol={symbol}] +``` + +symbol 可选过滤。 + +```python +def get_positions(symbol=None): + return api("/positions", params={"symbol": symbol} if symbol else None)["data"] + +positions = get_positions() +for pos in positions: + print(f"{pos['ticket']} {pos['symbol']} " + f"{'买' if pos['type'] == 0 else '卖'} " + f"手数:{pos['volume']} 盈亏:{pos['profit']}") +``` + +**返回字段:** `ticket`, `symbol`, `type`(0=买,1=卖), `volume`, `price_open`, `sl`, `tp`, `price_current`, `swap`, `profit`, `comment`, `magic` + +### 6-1. 平仓 + +``` +POST /position/close +``` + +```json +// 全平 +{ "ticket": 12345678 } +// 部分平仓 +{ "ticket": 12345678, "volume": 0.05 } +``` + +| 字段 | 必填 | 说明 | +| --------- | ---- | -------------------------------------- | +| ticket | ✅ | 持仓编号 | +| volume | ❌ | 平仓手数;不传/0 = 全平;>0 = 部分平仓 | +| deviation | ❌ | 允许滑点(默认 10) | + +```python +def close_position(ticket, volume=None): + body = {"ticket": ticket} + if volume: body["volume"] = volume + return api_post("/position/close", body)["data"] + +close_position(12345678) # 全平 +close_position(12345678, volume=0.05) # 部分平 +``` + +### 6-2. 改持仓 SL/TP + +``` +POST /position/modify +``` + +```json +{ "ticket": 12345678, "sl": 4170.0, "tp": 4190.0 } +``` + +SL/TP 设为 0 表示清除对应止损/止盈。 + +```python +def modify_position(ticket, sl=0, tp=0): + return api_post("/position/modify", {"ticket": ticket, "sl": sl, "tp": tp})["data"] +``` + +### 6-3. 对冲平仓(节省点差) + +``` +POST /position/close-by +``` + +用一张反向持仓对冲平仓,只收一次点差(MT5 净额结算),适合双向网格 / 锁仓策略快速离场。 + +```json +{ "position": 111, "position_by": 222 } +``` + +要求:两张持仓 **同品种 + 反向**。 + +```python +def close_by(ticket_a, ticket_b): + return api_post("/position/close-by", {"position": ticket_a, "position_by": ticket_b})["data"] +``` + +### 6-4. 移动止损(客户端轮询模式) + +Bridge 不在服务端跑轮询,暴露 `/position/modify` 由客户端自己做: + +```python +def trail_stop(ticket, distance, step): + """distance: 跟踪距离(如 50 点);step: 最小推进步长(如 10 点)""" + pos = next((p for p in bridge.positions() if p["ticket"] == ticket), None) + if not pos: + return + current = pos["price_current"] + if pos["type"] == 0: # 多单 + new_sl = current - distance + if new_sl - pos["sl"] >= step: + bridge.modify_position(ticket, sl=new_sl) + else: # 空单 + new_sl = current + distance + if pos["sl"] - new_sl >= step: + bridge.modify_position(ticket, sl=new_sl) + +# 每秒跑一次 +while True: + for p in bridge.positions(): + trail_stop(p["ticket"], distance=50, step=10) + time.sleep(1) +``` + +也可以用 WebSocket tick 流推送驱动,把 `time.sleep(1)` 换成 tick 回调,反应更快。 + +### 6-5. 批量平仓 + +``` +POST /positions/close-batch +``` + +按 `symbol` 和/或 `magic` 批量平仓。**至少传一个**过滤条件,避免误清整个账户。 + +```json +{ "magic": 123456 } +{ "symbol": "XAUUSDc", "magic": 123456 } +{ "symbol": "XAUUSDc" } +``` + +| 字段 | 必填 | 说明 | +| --------- | ---- | ------------------- | +| symbol | 一 | 品种过滤 | +| magic | 一 | Magic Number 过滤 | +| deviation | ❌ | 允许滑点(默认 10) | + +```python +def close_by_magic(magic): + return api_post("/positions/close-batch", {"magic": magic})["data"] + +result = close_by_magic(123456) +print(f"已平 {result['closed']} 单,失败 {result['failed']} 单") +# data 数组里有每张单的 ticket / symbol / retcode / comment +``` + +--- + +## 7. 挂单 + +``` +GET /orders?symbol={symbol} +``` + +symbol 可选,不传返回全部。 + +```python +orders = api("/orders")["data"] +for o in orders: + print(f"{o['ticket']} {o['symbol']} 类型:{o['type']} 手数:{o['volume_initial']}") +``` + +### 7-1. 撤单 + +``` +POST /order/cancel +``` + +```json +{ "ticket": 87654321 } +``` + +```python +def cancel_order(ticket): + return api_post("/order/cancel", {"ticket": ticket})["data"] + +cancel_order(87654321) +``` + +### 7-2. 改挂单 + +``` +POST /order/modify +``` + +```json +{ "ticket": 87654321, "price": 4180.0, "sl": 4170.0, "tp": 4190.0 } +``` + +sl/tp 设为 0 表示清除。 + +```python +def modify_order(ticket, price, sl=0, tp=0): + return api_post("/order/modify", {"ticket": ticket, "price": price, "sl": sl, "tp": tp})["data"] +``` + +--- + +## 8. 订单预检 + +``` +POST /order/check +``` + +下单前验证,不会真正执行。检查保证金是否足够、价格是否有效等。 + +**填充模式自动适配**:服务端会根据品种的 `SYMBOL_FILLING_MODE` 自动选择经纪商支持的填充模式。如果请求的 `type_filling` 不被支持,会按 IOC(1) → FOK(0) → RETURN(2) 顺序降级,无需客户端手动判断。 + +```python +def check_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, comment=""): + """预检订单""" + data = { + "action": 1, # 1=即时成交 + "symbol": symbol, + "volume": volume, + "order_type": order_type, # 0=市价买, 1=市价卖 + "price": price, + "sl": sl or 0, + "tp": tp or 0, + "magic": magic, + "comment": comment, + "deviation": 10, + "type_filling": 0 # 0=FOK, 1=IOC, 2=RETURN — 服务端自动适配,不传也行 + } + result = api_post("/order/check", data)["data"] + print(f"预检结果: retcode={result['retcode']}, comment={result['comment']}") + if result['retcode'] == 0: + print("✅ 可以下单") + else: + print("❌ 不可下单") + return result + +check_order("XAUUSD", 0.01, 0, 4180.0, sl=4170.0, tp=4190.0) +``` + +**type_filling 说明:** + +| 值 | 含义 | 说明 | +| ---- | ------------------------- | ---------------------- | +| 0 | FOK (Fill or Kill) | 必须全部成交,否则取消 | +| 1 | IOC (Immediate or Cancel) | 能成交多少成交多少 | +| 2 | RETURN | 剩余部分留在订单簿 | + +> 不同经纪商支持的填充模式不同(如 ICMarkets 只支持 IOC),服务端会自动降级,客户端无需关心。 + +--- + +## 9. 下单 + +``` +POST /order/send +``` + +与 `/order/check` 相同,`type_filling` 会自动适配经纪商支持的填充模式。 + +```python +def send_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, comment=""): + """下单""" + data = { + "request": { + "action": 1, + "symbol": symbol, + "volume": volume, + "order_type": order_type, + "price": price, + "sl": sl or 0, + "tp": tp or 0, + "magic": magic, + "comment": comment, + "deviation": 10, + "type_filling": 0 # 自动适配,不传也行 + } + } + result = api_post("/order/send", data)["data"] + print(f"下单结果: retcode={result['retcode']}, order={result['order']}, comment={result['comment']}") + return result + +# 市价买入 0.01 手 XAUUSD +result = send_order("XAUUSD", 0.01, 0, 4180.0, sl=4170.0, tp=4190.0) +``` + +**order_type 说明:** + +| 值 | 含义 | +| ---- | -------- | +| 0 | 市价买入 | +| 1 | 市价卖出 | +| 2 | 限价买入 | +| 3 | 限价卖出 | +| 4 | 止损买入 | +| 5 | 止损卖出 | + +--- + +## 10. 历史成交 + +``` +GET /history/deals?date_from={from}&date_to={to}&symbol={symbol} +``` + +```python +deals = api("/history/deals", params={ + "date_from": "2026-07-01", + "date_to": "2026-07-03", + "symbol": "XAUUSD" +})["data"] + +for d in deals: + print(f"{d['ticket']} {d['time']} {d['symbol']} " + f"手数:{d['volume']} 价格:{d['price']} 盈亏:{d['profit']}") +``` + +--- + +## 11. 全局变量(MQL5 信号桥) + +> 让 MQL5 指标/EA 把信号写出来,Python 通过 Bridge 读取。不用翻译 MQL5 代码。 + +说明: + +- 这部分属于“MT5 指标/EA 信号导出”能力,不影响 Bridge 的账户、行情、历史、持仓、挂单、预检、下单等基础 API。 +- 如果你只更新了远程 `Mt5Bridge.dll`,没有替换 `Alpha Trend.ex5`,Bridge 仍然可以正常工作,`/gvar` 也仍会返回旧版指标写出的变量名。 +- 只有在你需要“已收盘 K 线信号”和“带周期/参数作用域的新变量名”时,才需要重新编译并替换新版 `Alpha Trend.ex5`。 + +#### 列出所有全局变量 + +``` +GET /gvar +``` + +```python +gvars = api("/gvar") +print(gvars) +# {"data": [{"name": "AT_Trend_XAUUSD", "value": 1}, ...], "count": 5} +``` + +#### 读取指定变量 + +``` +GET /gvar/{name} +``` + +```python +trend = api("/gvar/AT_Trend_XAUUSD")["value"] +print(f"趋势方向: {'多头' if trend == 1 else '空头'}") +``` + +#### 写入变量 + +``` +POST /gvar/{name} +``` + +Body: `{"value": 75.5}` + +```python +def set_gvar(name, value): + return api_post(f"/gvar/{name}", {"value": value}) + +set_gvar("MY_RSI", 75.5) +``` + +#### 删除变量 + +``` +DELETE /gvar/{name} +``` + +--- + +## MQL5 指标 → Python 完整流程 + +### 第一步:改指标源码,输出已收盘 K 线信号 + +这一步是“升级指标导出行为”的可选步骤,不是 Bridge 基础 API 的必需步骤。 + +```mql5 +// 在 OnCalculate 末尾加 +int last_closed = rates_total - 2; +string key = StringFormat("MY_SIGNAL_%s_%s", _Symbol, EnumToString(_Period)); +GlobalVariableSet(key, signal_value[last_closed]); +``` + +### 第二步:Python 读取信号 + +```python +def read_signal(): + try: + return api(f"/gvar/MY_SIGNAL_XAUUSD_PERIOD_H1")["value"] + except: + return None + +signal = read_signal() +print(f"指标信号: {signal}") +``` + +如果你仍在使用旧版 `Alpha Trend.ex5`,则读取方式应继续对应旧键名,例如: + +```python +trend = api("/gvar/AT_Trend_XAUUSD")["value"] +buy_signal = api("/gvar/AT_Buy_XAUUSD")["value"] +``` + +### 第三步:根据信号做决策 + +```python +def on_tick(): + signal = read_signal() + if signal != 1: + return # 没信号,不动 + + if not bridge.has_position("XAUUSD"): + bid, ask = bridge.tick("XAUUSD") + bridge.buy("XAUUSD", 0.01, ask, sl=ask - 50, tp=ask + 100) + print("指标发出买入信号,已开多") +``` + +## 完整策略模板 + +```python +import requests +import pandas as pd +import time +from datetime import datetime + +BRIDGE = "http://61.164.252.86:13485" +KEY = "your-api-key" + +class Mt5Bridge: + def __init__(self): + self.headers = {"X-API-Key": KEY} + + def _get(self, path, params=None): + r = requests.get(f"{BRIDGE}{path}", params=params, headers=self.headers) + r.raise_for_status() + return r.json() + + def _post(self, path, data): + r = requests.post(f"{BRIDGE}{path}", json=data, headers=self.headers) + r.raise_for_status() + return r.json() + + def stream_ticks(self, symbol, on_tick): + """订阅 tick 推送,on_tick 回调收到 dict,阻塞运行。需 pip install websocket-client""" + import websocket + url = f"{BRIDGE.replace('http', 'ws', 1)}/stream/ticks/{symbol}" + ws = websocket.WebSocketApp( + url, + header=[f"X-API-Key: {KEY}"], + on_message=lambda ws, msg: on_tick(eval(msg)), + on_error=lambda ws, err: print(f"ws error: {err}"), + ) + ws.run_forever() + + def stream_ticks_async(self, symbol, on_tick): + """后台线程订阅 tick,不阻塞主线程""" + import threading + t = threading.Thread(target=self.stream_ticks, args=(symbol, on_tick), daemon=True) + t.start() + return t + + # ── 行情 ── + def tick(self, symbol): + d = self._get(f"/symbols/{symbol}/tick")["data"][0] + return d["bid"], d["ask"] + + def rates(self, symbol, timeframe, count): + return self._get("/rates/from-pos", params={ + "symbol": symbol, "timeframe": f"TIMEFRAME_{timeframe}", + "start_pos": 0, "count": count + })["data"] + + def to_df(self, symbol, timeframe, count): + df = pd.DataFrame(self.rates(symbol, timeframe, count)) + df["time"] = pd.to_datetime(df["time"]) + df.set_index("time", inplace=True) + return df + + # ── 账户 ── + def account(self): + return self._get("/account")["data"][0] + + # ── 持仓 ── + def positions(self, symbol=None): + return self._get("/positions", params={"symbol": symbol} if symbol else None)["data"] + + def has_position(self, symbol): + return any(p["symbol"] == symbol for p in self.positions()) + + def close(self, ticket, volume=None): + body = {"ticket": ticket} + if volume: body["volume"] = volume + return self._post("/position/close", body)["data"] + + def modify_position(self, ticket, sl=0, tp=0): + return self._post("/position/modify", {"ticket": ticket, "sl": sl, "tp": tp})["data"] + + def close_by(self, ticket_a, ticket_b): + return self._post("/position/close-by", {"position": ticket_a, "position_by": ticket_b})["data"] + + def close_batch(self, magic=None, symbol=None, deviation=None): + body = {k: v for k, v in {"magic": magic, "symbol": symbol, "deviation": deviation}.items() if v is not None} + return self._post("/positions/close-batch", body) + + # ── 挂单 ── + def orders(self, symbol=None): + return self._get("/orders", params={"symbol": symbol} if symbol else None)["data"] + + def cancel_order(self, ticket): + return self._post("/order/cancel", {"ticket": ticket})["data"] + + def modify_order(self, ticket, price, sl=0, tp=0): + return self._post("/order/modify", {"ticket": ticket, "price": price, "sl": sl, "tp": tp})["data"] + + # ── 下单 ── + def buy(self, symbol, volume, price, sl=0, tp=0, magic=0, comment=""): + return self._send(symbol, volume, 0, price, sl, tp, magic, comment) + + def sell(self, symbol, volume, price, sl=0, tp=0, magic=0, comment=""): + return self._send(symbol, volume, 1, price, sl, tp, magic, comment) + + def _send(self, symbol, volume, order_type, price, sl, tp, magic, comment): + return self._post("/order/send", { + "request": { + "action": 1, "symbol": symbol, "volume": volume, + "order_type": order_type, "price": price, + "sl": sl, "tp": tp, "magic": magic, + "comment": comment, "deviation": 10 + } + })["data"] + + def check(self, symbol, volume, order_type, price, sl=0, tp=0): + return self._post("/order/check", { + "action": 1, "symbol": symbol, "volume": volume, + "order_type": order_type, "price": price, + "sl": sl, "tp": tp, "magic": 0, "comment": "", "deviation": 10 + })["data"] + + +# ══════════════════════════════════════════════ +# 策略示例:均线金叉死叉 +# ══════════════════════════════════════════════ + +class MAStrategy: + def __init__(self, bridge, symbol, fast=20, slow=60): + self.bridge = bridge + self.symbol = symbol + self.fast = fast + self.slow = slow + + def signal(self): + """计算信号:1=买入, -1=卖出, 0=观望""" + df = self.bridge.to_df(self.symbol, "H1", self.slow + 5) + df["ma_fast"] = df["close"].rolling(self.fast).mean() + df["ma_slow"] = df["close"].rolling(self.slow).mean() + + # 使用最近两根已收盘 K 线 + prev = df.iloc[-3] + curr = df.iloc[-2] + + # 金叉 + if prev["ma_fast"] <= prev["ma_slow"] and curr["ma_fast"] > curr["ma_slow"]: + return 1 + # 死叉 + if prev["ma_fast"] >= prev["ma_slow"] and curr["ma_fast"] < curr["ma_slow"]: + return -1 + return 0 + + def run(self): + sig = self.signal() + bid, ask = self.bridge.tick(self.symbol) + acc = self.bridge.account() + print(f"[{datetime.now()}] {self.symbol} Bid:{bid} Ask:{ask} " + f"Balance:{acc['balance']} Equity:{acc['equity']} Signal:{sig}") + + if sig == 1 and not self.bridge.has_position(self.symbol): + print(" → 金叉,开多") + self.bridge.buy(self.symbol, 0.01, ask, sl=ask - 50, tp=ask + 100) + elif sig == -1 and not self.bridge.has_position(self.symbol): + print(" → 死叉,开空") + self.bridge.sell(self.symbol, 0.01, bid, sl=bid + 50, tp=bid - 100) + + +# ══════════════════════════════════════════════ +# 运行 +# ══════════════════════════════════════════════ + +if __name__ == "__main__": + bridge = Mt5Bridge() + strategy = MAStrategy(bridge, "XAUUSD", fast=20, slow=60) + + while True: + try: + strategy.run() + except Exception as e: + print(f"Error: {e}") + time.sleep(60) # 每分钟检查一次 +``` + +--- + +## 浏览器快速验证 + +在浏览器地址栏直接输入: + +``` +http://61.164.252.86:13485/health?key=your-api-key +http://61.164.252.86:13485/account?key=your-api-key +http://61.164.252.86:13485/symbols/XAUUSD/tick?key=your-api-key +http://61.164.252.86:13485/rates/from-date?symbol=XAUUSD&timeframe=TIMEFRAME_H1&date_from=2026-07-01&date_to=2026-07-03&key=your-api-key +``` + +--- + +## PowerShell 快速测试 + +```powershell +$headers = @{ "X-API-Key" = "your-api-key" } +Invoke-RestMethod "http://61.164.252.86:13485/health" -Headers $headers +Invoke-RestMethod "http://61.164.252.86:13485/account" -Headers $headers +Invoke-RestMethod "http://61.164.252.86:13485/symbols/XAUUSD/tick" -Headers $headers +Invoke-RestMethod "http://61.164.252.86:13485/rates/from-date?symbol=XAUUSD&timeframe=TIMEFRAME_H1&date_from=2026-07-01&date_to=2026-07-03" -Headers $headers +``` + +--- + +## 常见问题 + +> **第一次调这个 bridge?先去读 [§「⚠️ 隐含约定与已知陷阱」](#-隐含约定与已知陷阱先读)** —— 7 个非显而易见的约定(date_to 排他、`entry` 字段语义反、`/order/send` 不返回 fill 价、`/account` 空 data 误判 等),不读这一节直接调接口几乎必踩。 + +### 返回 "Unauthorized" + +API Key 错误或没带。检查 Header 中的 `X-API-Key` 或 URL 中的 `?key=`。 + +### 返回 "对于该符号,不支持市场执行" + +`order_type` 填错了,MT5 中有些品种不支持市价单,有些不支持挂单。先调用 `/order/check` 预检。 + +### 返回 "没有足够的资金" + +保证金不足,减小手数或检查 `account.margin_free`。 + +### 闭仓 P&L 永远是 0 / 跟 MT5 terminal 对不上 + +大概率是过滤了 `entry==0` 的 deal(以为 OUT),实际这个 bridge 是 `entry==1` 才是 OUT(关仓)。详见 §隐含约定 P2。 + +### 算出来的 P&L 跟 broker 对差 1.4× / 1.5× + +多半是手动把 `positions.profit` 当 quote currency 处理。`positions.profit` 是 deposit currency(USD),不是 quote currency。详见 §隐含约定 P4。 + +### 返回 "无法连接到远程服务器" + +Bridge 未运行或网络不通,先检查 `/health`。 + +### 业务字段返回默认值 → 怎么区分"无数据"和"真状态" + +`/account` 在账户未加载完成时返回 `data:[]` + 全零字段(HTTP 200)。客户端若只看 `bool(response)` 或 `field == 0`,会把"无数据"误判成"零值正常状态"。详见 §隐含约定 P7。 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..19b2b1d --- /dev/null +++ b/README.md @@ -0,0 +1,379 @@ +# GENESIS — Autonomous MT5 Trading System + +> **Powered by [API2TRADE](https://app.api2trade.com)** — the REST API for MetaTrader 5 + +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Python 3.11](https://img.shields.io/badge/Python-3.11-brightgreen)](https://python.org) +[![MT5 via API2TRADE](https://img.shields.io/badge/MT5-API2TRADE-orange)](https://app.api2trade.com) + +GENESIS is a fully autonomous, multi-strategy Forex trading system that connects to any **MetaTrader 5** account via the [API2TRADE](https://app.api2trade.com) REST API. It runs six independent strategy bots in parallel, managed by **Hermes** — an LLM-powered orchestration brain. + +--- + +## 🏗 Architecture + +``` + ┌─────────────────────────────┐ + │ HERMES (Brain) │ + │ GPT-4o-mini · Hourly LLM │ + │ Macro analysis + routing │ + └──────────────┬──────────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ GENESIS Autonomous Engine │ + │ (genesis_autonomous.py · every 5min) │ + └──┬──────┬──────┬──────┬──────┬──────────────────┘ + │ │ │ │ │ + ARES APOLLO ATHENA ARTEMIS ZEUS HEPHAESTUS + M1 M5 M5 H1 M5 Grid + BB+RSI EMA BB+RSI Ichimoku ICT Martingale + │ │ │ │ │ │ + └──────┴──────┴──────┴──────┴────────┘ + │ + ┌─────────▼─────────┐ + │ API2TRADE REST │ + │ app.api2trade.com│ + └─────────┬─────────┘ + │ + ┌─────────▼─────────┐ + │ MetaTrader 5 │ + │ (any broker) │ + └───────────────────┘ +``` + +--- + +## 🤖 Strategy Bots + +| Bot | Timeframe | Strategy | Symbols | Expected Signals | +|-----|-----------|----------|---------|-----------------| +| **ARES** | M1 | Bollinger Bands + RSI mean reversion | EURUSD, GBPUSD | 10–30/day | +| **APOLLO** | M5 | EMA 9/21 crossover trend following | EURUSD, GBPUSD | 5–15/day | +| **ATHENA** | M5 | Multi-TF BB + RSI ranging | EURUSD | 5–15/day | +| **ARTEMIS** | H1 | Ichimoku Kumo breakout | EURUSD, GBPUSD, GBPJPY | 2–8/day | +| **ZEUS** | M5 | ICT Smart Money — Liquidity + FVG + OB | EURUSD, GBPUSD, XAUUSD | 3–10/day | +| **HEPHAESTUS** | — | Grid / Martingale cycling | Any | Continuous | + +--- + +## 📋 Prerequisites + +### 1. API2TRADE Account (Required) + +GENESIS communicates with MT5 exclusively through the [API2TRADE](https://app.api2trade.com) REST API — no local MT5 installation, no Windows VPS required. + +1. Sign up at **[app.api2trade.com](https://app.api2trade.com)** +2. Connect your MetaTrader 5 account +3. Copy your **Account UUID** and **API Key** from the dashboard + +> **Cost:** €12/month per connected MT5 account · No per-call fees · Unlimited requests + +### 2. Telegram Bot (Required for alerts) + +1. Message [@BotFather](https://t.me/BotFather) → `/newbot` +2. Copy the bot token +3. Get your Chat ID from [@userinfobot](https://t.me/userinfobot) + +### 3. OpenAI API Key (Optional — for Hermes brain) + +1. Get a key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys) +2. Cost: ~$0.10–0.50/day using `gpt-4o-mini` +3. Without it: GENESIS runs strategies autonomously without LLM macro analysis + +--- + +## 🚀 Quick Start + +### Option A — Docker (Recommended for local testing) + +```bash +# 1. Clone the repo +git clone https://github.com/api2trade/Genesis-Metatrader-Automatic-AI-Trading-System.git +cd Genesis-Metatrader-Automatic-AI-Trading-System + +# 2. Run the setup wizard (generates your .env) +bash setup.sh + +# 3. Start the container +docker compose up -d + +# 4. Watch it run +docker logs -f genesis +``` + +### Option B — VPS Production (Ubuntu 22.04) + +```bash +# 1. Clone the repo on your VPS +git clone https://github.com/api2trade/Genesis-Metatrader-Automatic-AI-Trading-System.git +cd Genesis-Metatrader-Automatic-AI-Trading-System + +# 2. Run setup wizard +bash setup.sh + +# 3. Install cron jobs, CLI shortcuts and log folders +bash install.sh + +# 4. Verify cron schedule is active +crontab -l + +# 5. Watch live logs +tail -f /var/log/hermes/autonomous.log +``` + +--- + +## 🔧 Manual Setup + +If you prefer to configure manually instead of using `setup.sh`: + +```bash +cp .env.example .env +nano .env # Fill in your credentials +``` + +Required fields: + +| Variable | Where to get it | +|----------|----------------| +| `MT5_ACCOUNT_UUID` | [app.api2trade.com](https://app.api2trade.com) → Dashboard | +| `MT5_API_KEY` | [app.api2trade.com](https://app.api2trade.com) → API Keys | +| `MT5_API_USER` | Your API2TRADE username | +| `MT5_API_PASS` | Your API2TRADE password | +| `TELEGRAM_BOT_TOKEN` | [@BotFather](https://t.me/BotFather) | +| `TELEGRAM_CHAT_ID` | [@userinfobot](https://t.me/userinfobot) | + +--- + +## 💻 Usage + +### Strategy Analysis + +```bash +# Single strategy analysis (no trade placed) +docker exec -it genesis ares analyze EURUSDxx +docker exec -it genesis apollo analyze GBPUSDxx +docker exec -it genesis athena analyze EURUSDxx +docker exec -it genesis artemis analyze GBPUSDxx +docker exec -it genesis zeus analyze XAUUSDxx + +# Scan ALL strategies on one symbol at once +docker exec -it genesis genesis-scan EURUSDxx +docker exec -it genesis genesis-scan GBPUSDxx +``` + +### Live Logs + +```bash +# All decisions +docker exec -it genesis tail -f /var/log/hermes/autonomous.log + +# Trade journal (JSONL) +docker exec -it genesis tail -f /var/log/hermes/trade_journal.jsonl + +# LLM macro cycles +docker exec -it genesis tail -f /var/log/hermes/trading_cycle.log +``` + +### Strategy Output Format + +Every strategy returns a JSON object: + +```json +{ + "action": "trade", + "strategy": "ares-bb-rsi-m1", + "symbol": "EURUSDxx", + "direction": "Buy", + "entry": 1.08542, + "stop_loss": 1.08392, + "take_profit": 1.08842, + "volume": 0.1, + "rr_ratio": 2.0, + "sl_pips": 15.0, + "confidence": "high", + "reason": "RSI oversold + BB lower touch + session active" +} +``` + +--- + +## 📁 Project Structure + +``` +Genesis-Metatrader-Automatic-AI-Trading-System/ +├── setup.sh # ← Interactive setup wizard (start here) +├── install.sh # ← VPS production installer +├── docker-compose.yml # ← Docker deployment +├── Dockerfile +├── .env.example # ← Credential template +│ +├── core/ +│ ├── genesis_autonomous.py # Main engine — runs every 5min via cron +│ ├── trading_cycle.py # Hermes LLM macro cycle — hourly +│ ├── genesis_daily_report.py # Daily P&L Telegram report +│ ├── genesis_brain_feed.py # Hourly Telegram market summary +│ ├── heartbeat.py # System health check +│ └── tg_notify.py # Telegram helper +│ +├── strategies/ +│ ├── ares/ # BB+RSI M1 mean reversion +│ │ ├── ares_cycle.py # Strategy logic + API2TRADE bridge +│ │ └── ares_tool.py # CLI: ares analyze/execute EURUSDxx +│ ├── apollo/ # EMA trend following +│ ├── athena/ # BB+RSI multi-TF ranging +│ ├── artemis/ # Ichimoku H1 breakout +│ ├── zeus/ # ICT Smart Money M5 +│ └── hephaestus/ # Grid/Martingale +│ +├── configs/ +│ ├── ares_config.yaml # ARES parameters (BB period, RSI thresholds, etc.) +│ ├── apollo_config.yaml +│ ├── athena_config.yaml +│ ├── artemis_config.yaml +│ ├── zeus_config.yaml +│ └── hephaestus_config.yaml +│ +└── backtest/ + ├── backtest.py # Python backtester using yfinance + └── BB_RSI_MeanReversion.mq5 # MT5 Expert Advisor (manual backtest) +``` + +--- + +## ⚡ API2TRADE — How It Works + +GENESIS uses API2TRADE as the bridge between Python and MetaTrader 5. Every strategy calls these endpoints directly: + +```python +# Get account balance +GET /AccountSummary?id={session_uuid} +→ {"balance": 10000.0, "equity": 10000.0, "currency": "USD"} + +# Get live quote +GET /Quote?id={session_uuid}&symbol=EURUSDxx +→ {"Bid": 1.08540, "Ask": 1.08542} + +# Place a trade +GET /OrderSendSafe?id={session_uuid}&symbol=EURUSDxx&operation=Buy&volume=0.1&stoploss=1.083&takeprofit=1.090&comment=GENESIS-ARES +→ {"ticket": 12345678} + +# Close a position +GET /OrderCloseSafe?id={session_uuid}&ticket=12345678&lots=0.1 +``` + +No WebSocket setup, no local MT5 terminal, no Windows server. Just REST calls from any Python environment. + +> 💡 **Sign up at [app.api2trade.com](https://app.api2trade.com) to get your session UUID and API key.** + +--- + +## 🛡 Risk Management + +Hard limits enforced before **every** trade — cannot be bypassed: + +| Limit | Default | Config | +|-------|---------|--------| +| Max risk per trade | **2% of balance** | `MAX_RISK_PCT` in `.env` | +| Max lot size | **3.0 lots** | `MAX_LOTS` in `.env` | +| Max open positions | **4** | `MAX_POSITIONS` in `.env` | +| Stop loss | **Required** (5–150 pips) | Per strategy config | +| Spread filter | **1.0–3.0 pips** | Per strategy YAML | +| News filter | **±15 min** around high-impact | Per strategy YAML | + +--- + +## ⚙️ Configuration + +Each strategy has a dedicated YAML config in `configs/`. Example for ARES: + +```yaml +# configs/ares_config.yaml +strategy: + name: ARES + magic_number: 1001 + +bollinger: + period: 20 + std_dev: 2.0 + +rsi: + period: 14 + oversold: 30 + overbought: 70 + +risk: + risk_pct: 0.01 # 1% per trade + max_spread_pips: 1.0 + min_rr_ratio: 1.5 + +sessions: + allowed: + - {start: 7, end: 20} # GMT hours +``` + +--- + +## 🖥 VPS Deployment (Recommended) + +For 24/7 autonomous trading, deploy on a Ubuntu 22.04 VPS: + +```bash +# Minimum spec: 2 vCPU, 2GB RAM, 20GB SSD +# Cost: ~€4–8/month (Hetzner, Contabo, DigitalOcean) + +bash setup.sh # Configure credentials +bash install.sh # Install Python, venv, cron jobs, CLI shortcuts +``` + +The installer sets up: +- Python 3.11 virtual environment at `/opt/hermes-agent/.venv-hermes` +- Cron job: `genesis_autonomous.py` every 5 minutes +- Cron job: `trading_cycle.py` (Hermes LLM) every hour +- Log rotation at `/var/log/hermes/` +- CLI shortcuts: `ares`, `apollo`, `athena`, `artemis`, `zeus`, `hephaestus`, `genesis-scan` + +--- + +## 📊 Backtesting + +```bash +# Backtest ARES on EURUSD (last 12 months) +python3 backtest/backtest.py --strategy ares --symbol EURUSD --days 365 + +# Or open the MT5 EA in MetaEditor for native backtesting +# backtest/BB_RSI_MeanReversion.mq5 +``` + +--- + +## 🤝 Contributing + +GENESIS is open source under **GPL v3** — forks must also be open source. + +1. Fork the repo +2. Create your feature branch (`git checkout -b feature/my-strategy`) +3. Commit your changes +4. Open a Pull Request + +--- + +## ⚠️ Disclaimer + +This software is for **educational and research purposes**. Forex trading involves substantial risk of loss. Past performance does not guarantee future results. Always test on a **demo account** before trading with real money. The authors accept no responsibility for financial losses. + +--- + +## 📄 License + +GPL-3.0 · See [LICENSE](LICENSE) + +--- + +
+ +**Built with API2TRADE · [app.api2trade.com](https://app.api2trade.com)** + +*Connect any MT5 account to Python in minutes · €12/month per account* + +
diff --git a/backtest/BB_RSI_MeanReversion.mq5 b/backtest/BB_RSI_MeanReversion.mq5 new file mode 100644 index 0000000..cabe489 --- /dev/null +++ b/backtest/BB_RSI_MeanReversion.mq5 @@ -0,0 +1,597 @@ +//+------------------------------------------------------------------+ +//| BB_RSI_MeanReversion.mq5 | +//| Version: 1.0 | +//| Description: Mean reversion EA using Bollinger Bands + RSI | +//| on M1 with optional M15 higher-timeframe context. | +//| | +//| RISK WARNING: This EA is for educational purposes only. | +//| Live trading requires proper risk assessment, forward testing, | +//| and understanding of all risks involved in Forex trading. | +//| Past performance does not guarantee future results. | +//+------------------------------------------------------------------+ +#property copyright "GENESIS Strategy B — Ares" +#property version "1.00" +#property strict + +#include +#include + +//+------------------------------------------------------------------+ +//| INPUT GROUPS | +//+------------------------------------------------------------------+ + +// --- 1. Trade Filters --- +input string Inp_TradeComment = "BB_RSI_M1"; // EA comment +input bool Inp_AllowLong = true; // Allow long trades +input bool Inp_AllowShort = true; // Allow short trades +input int Inp_MagicNumber = 20250514; // EA magic number + +// --- 2. Bollinger Bands --- +input int Inp_BB_Period = 20; // BB period +input double Inp_BB_Deviation = 2.0; // BB deviation +input int Inp_BB_Shift = 0; // BB shift +input ENUM_MA_METHOD Inp_BB_MA_Method = MODE_SMA; // BB MA method +input ENUM_APPLIED_PRICE Inp_BB_Price = PRICE_CLOSE; // BB applied price + +// --- 3. RSI --- +input int Inp_RSI_Period = 14; // RSI period +input double Inp_RSI_Oversold = 30.0; // RSI oversold level +input double Inp_RSI_Overbought = 70.0; // RSI overbought level +input ENUM_APPLIED_PRICE Inp_RSI_Price = PRICE_CLOSE; // RSI applied price + +// --- 4. Higher Timeframe Context (M15) --- +input bool Inp_UseM15Context = true; // Use M15 context +input ENUM_TIMEFRAMES Inp_ContextTF = PERIOD_M15; // Context timeframe +input int Inp_ContextMAPeriod = 50; // Context MA period +input double Inp_ContextMATol = 0.0002; // Distance tolerance from MA + +// --- 5. Entry Logic --- +input bool Inp_RequireOutsideBand = true; // Price must close outside BB +input bool Inp_RequireRSIFilter = true; // Require RSI filter +input int Inp_CandlesSinceSignal = 1; // Candle index (1=last closed) + +// --- 6. Risk & Money Management --- +input double Inp_RiskPercent = 1.0; // % account risked per trade +input bool Inp_UseFixedLot = false; // Use fixed lot +input double Inp_FixedLot = 0.01; // Fixed lot size +input int Inp_StopLossPips = 20; // SL in pips +input int Inp_TakeProfitPips = 40; // TP in pips +input bool Inp_UseTrailingStop = false; // Enable trailing stop +input int Inp_TrailingStartPips = 15; // Profit pips to start trailing +input int Inp_TrailingStepPips = 5; // Trailing step in pips + +// --- 7. Time & Session Filters --- +input bool Inp_UseTimeFilter = true; // Restrict trading hours +input int Inp_StartHour = 5; // Start hour (GMT) +input int Inp_StartMinute = 0; // Start minute +input int Inp_EndHour = 17; // End hour (GMT) +input int Inp_EndMinute = 0; // End minute +input bool Inp_UseNewsFilter = true; // Avoid news events +input string Inp_NewsFile = "news.txt"; // News timestamps file + +// --- 8. Spread & Slippage --- +input double Inp_MaxSpreadPips = 1.0; // Max allowed spread (pips) +input int Inp_Slippage = 10; // Slippage tolerance (points) +input int Inp_MaxRetries = 3; // Max order send retries + +// --- 9. Drawdown Protection --- +input bool Inp_UseDailyLossLimit = true; // Stop after daily loss +input double Inp_DailyLossPercent = 6.0; // Max daily loss % +input bool Inp_UseGlobalDDLimit = true; // Global drawdown halt +input double Inp_GlobalDDPercent = 25.0; // Max total DD % +input bool Inp_CloseAllOnDD = true; // Close all on DD breach + +// --- 10. Execution --- +input bool Inp_UseOnePositionPerDir = true; // One position per direction +input int Inp_MinSecondsBetweenTrades = 30; // Cooldown seconds + +//+------------------------------------------------------------------+ +//| GLOBAL VARIABLES | +//+------------------------------------------------------------------+ +CTrade g_Trade; +CPositionInfo g_Position; + +int g_BB_Handle = INVALID_HANDLE; +int g_RSI_Handle = INVALID_HANDLE; +int g_MA_Handle = INVALID_HANDLE; + +double g_PipSize = 0.0; +double g_PeakEquity = 0.0; +double g_DayStartBal = 0.0; +datetime g_LastBarTime = 0; +datetime g_LastTradeCloseTime = 0; +bool g_TradingDisabled = false; +datetime g_CurrentDayStart = 0; + +datetime g_NewsTimes[]; +int g_NewsCount = 0; +int g_NewsMinutes = 15; // minutes before/after to block + +//+------------------------------------------------------------------+ +//| OnInit | +//+------------------------------------------------------------------+ +int OnInit() + { + // Determine pip size (4-digit vs 5-digit broker) + int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS); + g_PipSize = (digits == 3 || digits == 5) ? _Point * 10.0 : _Point; + + // Create indicator handles + g_BB_Handle = iBands(_Symbol, PERIOD_M1, Inp_BB_Period, Inp_BB_Shift, + Inp_BB_Deviation, Inp_BB_Price); + g_RSI_Handle = iRSI(_Symbol, PERIOD_M1, Inp_RSI_Period, Inp_RSI_Price); + g_MA_Handle = iMA(_Symbol, Inp_ContextTF, Inp_ContextMAPeriod, 0, + MODE_SMA, PRICE_CLOSE); + + if(g_BB_Handle == INVALID_HANDLE || + g_RSI_Handle == INVALID_HANDLE || + g_MA_Handle == INVALID_HANDLE) + { + Print("ERROR: Failed to create indicator handles. EA stopping."); + return INIT_FAILED; + } + + // Configure trade object + g_Trade.SetExpertMagicNumber(Inp_MagicNumber); + g_Trade.SetDeviationInPoints(Inp_Slippage); + g_Trade.SetTypeFilling(ORDER_FILLING_FOK); + + // Initialise equity tracking + g_PeakEquity = AccountInfoDouble(ACCOUNT_EQUITY); + g_DayStartBal = AccountInfoDouble(ACCOUNT_BALANCE); + g_CurrentDayStart = GetDayStart(TimeCurrent()); + + // Load news filter file + if(Inp_UseNewsFilter) LoadNewsFile(); + + Print("BB_RSI_MeanReversion EA initialised. PipSize=", g_PipSize, + " | Magic=", Inp_MagicNumber); + return INIT_SUCCEEDED; + } + +//+------------------------------------------------------------------+ +//| OnDeinit | +//+------------------------------------------------------------------+ +void OnDeinit(const int reason) + { + if(g_BB_Handle != INVALID_HANDLE) IndicatorRelease(g_BB_Handle); + if(g_RSI_Handle != INVALID_HANDLE) IndicatorRelease(g_RSI_Handle); + if(g_MA_Handle != INVALID_HANDLE) IndicatorRelease(g_MA_Handle); + } + +//+------------------------------------------------------------------+ +//| OnTick | +//+------------------------------------------------------------------+ +void OnTick() + { + // 0. If globally disabled, just manage trailing on existing positions + if(g_TradingDisabled) + { + if(Inp_UseTrailingStop) ManageTrailingStop(); + return; + } + + // 1. Only act on new bar + if(!IsNewBar()) return; + + // 2. Update peak equity + double equity = AccountInfoDouble(ACCOUNT_EQUITY); + if(equity > g_PeakEquity) g_PeakEquity = equity; + + // 3. Reset day tracking if new day + datetime today = GetDayStart(TimeCurrent()); + if(today != g_CurrentDayStart) + { + g_CurrentDayStart = today; + g_DayStartBal = AccountInfoDouble(ACCOUNT_BALANCE); + Print("New trading day. Starting balance: ", g_DayStartBal); + } + + // 4. Global drawdown check + if(Inp_UseGlobalDDLimit && g_PeakEquity > 0) + { + double ddPct = (g_PeakEquity - equity) / g_PeakEquity * 100.0; + if(ddPct >= Inp_GlobalDDPercent) + { + Print("GLOBAL DRAWDOWN LIMIT HIT: ", DoubleToString(ddPct, 2), + "% >= ", Inp_GlobalDDPercent, "%. Halting EA."); + if(Inp_CloseAllOnDD) CloseAllPositions(); + g_TradingDisabled = true; + return; + } + } + + // 5. Daily loss check + if(Inp_UseDailyLossLimit && g_DayStartBal > 0) + { + double dayLossPct = (g_DayStartBal - AccountInfoDouble(ACCOUNT_BALANCE)) + / g_DayStartBal * 100.0; + if(dayLossPct >= Inp_DailyLossPercent) + { + Print("DAILY LOSS LIMIT HIT: ", DoubleToString(dayLossPct, 2), + "% >= ", Inp_DailyLossPercent, "%. Skipping until tomorrow."); + return; + } + } + + // 6. Time filter + if(Inp_UseTimeFilter && !IsTradeTime()) return; + + // 7. Spread filter + double spreadPips = GetCurrentSpreadPips(); + if(spreadPips > Inp_MaxSpreadPips) + { + Print("Spread too high: ", DoubleToString(spreadPips, 2), + " pips > max ", Inp_MaxSpreadPips); + return; + } + + // 8. News filter + if(Inp_UseNewsFilter && IsNewsTime()) return; + + // 9. Cooldown check + if((int)(TimeCurrent() - g_LastTradeCloseTime) < Inp_MinSecondsBetweenTrades) + return; + + // 10. Get indicator values + double bbUpper[], bbLower[], bbMiddle[]; + double rsiVal[]; + ArraySetAsSeries(bbUpper, true); + ArraySetAsSeries(bbLower, true); + ArraySetAsSeries(bbMiddle, true); + ArraySetAsSeries(rsiVal, true); + + int idx = Inp_CandlesSinceSignal; // 1 = last closed candle + int need = idx + 2; + + if(CopyBuffer(g_BB_Handle, 1, 0, need, bbUpper) < need) return; // Upper + if(CopyBuffer(g_BB_Handle, 2, 0, need, bbLower) < need) return; // Lower + if(CopyBuffer(g_BB_Handle, 0, 0, need, bbMiddle) < need) return; // Middle + if(CopyBuffer(g_RSI_Handle, 0, 0, need, rsiVal) < need) return; + + double closePrice = iClose(_Symbol, PERIOD_M1, idx); + double rsi = rsiVal[idx]; + double bbUp = bbUpper[idx]; + double bbLow = bbLower[idx]; + + // 11. M15 context + double contextMA = 0.0; + if(Inp_UseM15Context) + { + double maArr[]; + ArraySetAsSeries(maArr, true); + if(CopyBuffer(g_MA_Handle, 0, 0, 2, maArr) < 2) return; + contextMA = maArr[0]; + } + + // 12. Signal generation + bool longSignal = false; + bool shortSignal = false; + + // Long + if(Inp_AllowLong) + { + bool bbOk = !Inp_RequireOutsideBand || (closePrice < bbLow); + bool rsiOk = !Inp_RequireRSIFilter || (rsi < Inp_RSI_Oversold); + bool ctxOk = !Inp_UseM15Context || (closePrice > contextMA - Inp_ContextMATol); + longSignal = bbOk && rsiOk && ctxOk; + } + + // Short + if(Inp_AllowShort) + { + bool bbOk = !Inp_RequireOutsideBand || (closePrice > bbUp); + bool rsiOk = !Inp_RequireRSIFilter || (rsi > Inp_RSI_Overbought); + bool ctxOk = !Inp_UseM15Context || (closePrice < contextMA + Inp_ContextMATol); + shortSignal = bbOk && rsiOk && ctxOk; + } + + // 13. Position check + if(longSignal && Inp_UseOnePositionPerDir && HasPositionInDirection(POSITION_TYPE_BUY)) + longSignal = false; + if(shortSignal && Inp_UseOnePositionPerDir && HasPositionInDirection(POSITION_TYPE_SELL)) + shortSignal = false; + + // 14. Execute + if(longSignal) + { + double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); + double sl = ask - Inp_StopLossPips * g_PipSize; + double tp = ask + Inp_TakeProfitPips * g_PipSize; + sl = NormalizeDouble(sl, (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS)); + tp = NormalizeDouble(tp, (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS)); + double lot = CalculateLot(Inp_StopLossPips); + OpenOrder(ORDER_TYPE_BUY, lot, ask, sl, tp); + } + else if(shortSignal) + { + double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); + double sl = bid + Inp_StopLossPips * g_PipSize; + double tp = bid - Inp_TakeProfitPips * g_PipSize; + sl = NormalizeDouble(sl, (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS)); + tp = NormalizeDouble(tp, (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS)); + double lot = CalculateLot(Inp_StopLossPips); + OpenOrder(ORDER_TYPE_SELL, lot, bid, sl, tp); + } + + // 15. Trailing stop management + if(Inp_UseTrailingStop) ManageTrailingStop(); + } + +//+------------------------------------------------------------------+ +//| IsNewBar — returns true only once per M1 candle | +//+------------------------------------------------------------------+ +bool IsNewBar() + { + datetime barTime = iTime(_Symbol, PERIOD_M1, 0); + if(barTime == g_LastBarTime) return false; + g_LastBarTime = barTime; + return true; + } + +//+------------------------------------------------------------------+ +//| IsTradeTime — returns true if current time is in session | +//+------------------------------------------------------------------+ +bool IsTradeTime() + { + MqlDateTime dt; + TimeToStruct(TimeCurrent(), dt); + int nowMins = dt.hour * 60 + dt.min; + int startMin = Inp_StartHour * 60 + Inp_StartMinute; + int endMin = Inp_EndHour * 60 + Inp_EndMinute; + return (nowMins >= startMin && nowMins < endMin); + } + +//+------------------------------------------------------------------+ +//| GetCurrentSpreadPips | +//+------------------------------------------------------------------+ +double GetCurrentSpreadPips() + { + double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); + double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); + return (ask - bid) / g_PipSize; + } + +//+------------------------------------------------------------------+ +//| LoadNewsFile — parse news.txt (format: "YYYY.MM.DD HH:MM") | +//+------------------------------------------------------------------+ +void LoadNewsFile() + { + int fh = FileOpen(Inp_NewsFile, FILE_READ | FILE_TXT | FILE_COMMON); + if(fh == INVALID_HANDLE) + { + Print("News file '", Inp_NewsFile, "' not found — news filter skipped."); + return; + } + g_NewsCount = 0; + ArrayResize(g_NewsTimes, 0); + while(!FileIsEnding(fh)) + { + string line = FileReadString(fh); + StringTrimRight(line); + StringTrimLeft(line); + if(StringLen(line) < 16) continue; + datetime t = StringToTime(line); + if(t > 0) + { + ArrayResize(g_NewsTimes, g_NewsCount + 1); + g_NewsTimes[g_NewsCount++] = t; + } + } + FileClose(fh); + Print("News filter loaded: ", g_NewsCount, " events from ", Inp_NewsFile); + } + +//+------------------------------------------------------------------+ +//| IsNewsTime — returns true if within news window | +//+------------------------------------------------------------------+ +bool IsNewsTime() + { + if(g_NewsCount == 0) return false; + datetime now = TimeCurrent(); + int windowSec = g_NewsMinutes * 60; + for(int i = 0; i < g_NewsCount; i++) + { + if(MathAbs((double)(now - g_NewsTimes[i])) <= windowSec) + return true; + } + return false; + } + +//+------------------------------------------------------------------+ +//| CalculateLot — risk-based or fixed | +//+------------------------------------------------------------------+ +double CalculateLot(int slPips) + { + if(Inp_UseFixedLot) return NormaliseLot(Inp_FixedLot); + + double balance = AccountInfoDouble(ACCOUNT_BALANCE); + double riskAmt = balance * Inp_RiskPercent / 100.0; + double tickVal = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE); + double tickSize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE); + + // pip value per lot in account currency + double pipValuePerLot = (g_PipSize / tickSize) * tickVal; + if(pipValuePerLot <= 0) return NormaliseLot(Inp_FixedLot); + + double rawLot = riskAmt / ((double)slPips * pipValuePerLot); + return NormaliseLot(rawLot); + } + +//+------------------------------------------------------------------+ +//| NormaliseLot — round to lot step, clamp to min/max | +//+------------------------------------------------------------------+ +double NormaliseLot(double lot) + { + double lotStep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); + double lotMin = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); + double lotMax = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX); + lot = MathFloor(lot / lotStep) * lotStep; + lot = MathMax(lot, lotMin); + lot = MathMin(lot, lotMax); + return NormalizeDouble(lot, 2); + } + +//+------------------------------------------------------------------+ +//| OpenOrder — send with retry loop | +//+------------------------------------------------------------------+ +void OpenOrder(ENUM_ORDER_TYPE type, double lot, double price, + double sl, double tp) + { + for(int attempt = 1; attempt <= Inp_MaxRetries; attempt++) + { + bool sent = false; + if(type == ORDER_TYPE_BUY) + sent = g_Trade.Buy(lot, _Symbol, price, sl, tp, Inp_TradeComment); + else + sent = g_Trade.Sell(lot, _Symbol, price, sl, tp, Inp_TradeComment); + + if(sent) + { + ulong ticket = g_Trade.ResultOrder(); + string dir = (type == ORDER_TYPE_BUY) ? "BUY" : "SELL"; + Print(TimeToString(TimeCurrent()), " | ORDER OPENED | ", dir, + " | Ticket=", ticket, + " | Lot=", DoubleToString(lot, 2), + " | Price=", DoubleToString(price, _Digits), + " | SL=", DoubleToString(sl, _Digits), + " | TP=", DoubleToString(tp, _Digits)); + return; + } + + int err = GetLastError(); + Print("Order attempt ", attempt, " failed. Error=", err, + " | Retcode=", g_Trade.ResultRetcode()); + + // Don't retry on hard errors + if(err == ERR_MARKET_CLOSED || err == ERR_TRADE_DISABLED) break; + Sleep(500); + } + Print("Order FAILED after ", Inp_MaxRetries, " retries."); + } + +//+------------------------------------------------------------------+ +//| HasPositionInDirection | +//+------------------------------------------------------------------+ +bool HasPositionInDirection(ENUM_POSITION_TYPE dir) + { + for(int i = PositionsTotal() - 1; i >= 0; i--) + { + if(g_Position.SelectByIndex(i)) + { + if(g_Position.Magic() == Inp_MagicNumber && + g_Position.Symbol() == _Symbol && + g_Position.PositionType() == dir) + return true; + } + } + return false; + } + +//+------------------------------------------------------------------+ +//| ManageTrailingStop | +//+------------------------------------------------------------------+ +void ManageTrailingStop() + { + double trailStart = Inp_TrailingStartPips * g_PipSize; + double trailStep = Inp_TrailingStepPips * g_PipSize; + + for(int i = PositionsTotal() - 1; i >= 0; i--) + { + if(!g_Position.SelectByIndex(i)) continue; + if(g_Position.Magic() != Inp_MagicNumber) continue; + if(g_Position.Symbol() != _Symbol) continue; + + double sl = g_Position.StopLoss(); + double openPx = g_Position.PriceOpen(); + double digits = (double)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS); + + if(g_Position.PositionType() == POSITION_TYPE_BUY) + { + double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); + double profit = bid - openPx; + if(profit >= trailStart) + { + double newSL = NormalizeDouble(bid - trailStep, (int)digits); + if(newSL > sl + _Point) + g_Trade.PositionModify(g_Position.Ticket(), newSL, + g_Position.TakeProfit()); + } + } + else // SELL + { + double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); + double profit = openPx - ask; + if(profit >= trailStart) + { + double newSL = NormalizeDouble(ask + trailStep, (int)digits); + if(newSL < sl - _Point || sl == 0) + g_Trade.PositionModify(g_Position.Ticket(), newSL, + g_Position.TakeProfit()); + } + } + } + } + +//+------------------------------------------------------------------+ +//| CloseAllPositions | +//+------------------------------------------------------------------+ +void CloseAllPositions() + { + for(int i = PositionsTotal() - 1; i >= 0; i--) + { + if(g_Position.SelectByIndex(i)) + { + if(g_Position.Magic() == Inp_MagicNumber && + g_Position.Symbol() == _Symbol) + { + g_Trade.PositionClose(g_Position.Ticket()); + Print(TimeToString(TimeCurrent()), + " | EMERGENCY CLOSE | Ticket=", g_Position.Ticket(), + " | Reason: Drawdown limit"); + g_LastTradeCloseTime = TimeCurrent(); + } + } + } + } + +//+------------------------------------------------------------------+ +//| GetDayStart — midnight of given datetime | +//+------------------------------------------------------------------+ +datetime GetDayStart(datetime t) + { + MqlDateTime dt; + TimeToStruct(t, dt); + dt.hour = 0; dt.min = 0; dt.sec = 0; + return StructToTime(dt); + } + +//+------------------------------------------------------------------+ +//| OnTradeTransaction — track close time for cooldown | +//+------------------------------------------------------------------+ +void OnTradeTransaction(const MqlTradeTransaction &trans, + const MqlTradeRequest &request, + const MqlTradeResult &result) + { + if(trans.type == TRADE_TRANSACTION_DEAL_ADD) + { + if(trans.deal_type == DEAL_TYPE_BUY || trans.deal_type == DEAL_TYPE_SELL) + { + // Check if this deal closes a position + if((ENUM_DEAL_ENTRY)HistoryDealGetInteger(trans.deal, DEAL_ENTRY) + == DEAL_ENTRY_OUT) + { + if((long)HistoryDealGetInteger(trans.deal, DEAL_MAGIC) + == Inp_MagicNumber) + { + double profit = HistoryDealGetDouble(trans.deal, DEAL_PROFIT); + Print(TimeToString(TimeCurrent()), + " | POSITION CLOSED | Deal=", trans.deal, + " | Profit=", DoubleToString(profit, 2)); + g_LastTradeCloseTime = TimeCurrent(); + } + } + } + } + } +//+------------------------------------------------------------------+ diff --git a/backtest/backtest.py b/backtest/backtest.py new file mode 100644 index 0000000..8997760 --- /dev/null +++ b/backtest/backtest.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +GENESIS Backtesting Engine v1 +Uses yfinance for 1-year H1 historical data. +Runs the exact same EMA/RSI/ATR strategy as the live system. +Outputs performance report + sends results to Telegram. +""" +import os, json, requests +from datetime import datetime, timezone +from pathlib import Path + +TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") + +# Symbol mapping: MT5 broker suffix → Yahoo Finance ticker +SYMBOL_MAP = { + "EURUSDxx": "EURUSD=X", + "XAUUSDxx": "GC=F", + "GBPUSDxx": "GBPUSD=X", + "GBPJPYxx": "GBPJPY=X", + "USDJPYxx": "USDJPY=X", + "EURUSD": "EURUSD=X", + "XAUUSD": "GC=F", + "GBPUSD": "GBPUSD=X", + "GBPJPY": "GBPJPY=X", + "USDJPY": "USDJPY=X", +} + +def tg(msg): + try: + requests.post(f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", + json={"chat_id": TG_CHAT_ID, "text": msg, "parse_mode": "Markdown"}, timeout=15) + except: pass + +def backtest_symbol(mt5_sym, yf_sym): + import yfinance as yf + import pandas as pd + import ta + + print(f"\n{'='*50}") + print(f"Backtesting: {mt5_sym} ({yf_sym})") + + df = yf.download(yf_sym, period="1y", interval="1h", progress=False, auto_adjust=True) + if df.empty or len(df) < 100: + print(f" Insufficient data: {len(df)} bars") + return None + + # Flatten multi-index if present + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + df.columns = [c.lower() for c in df.columns] + df = df.rename(columns={"adj close": "close"}) + df = df.dropna() + + # Calculate indicators using 'ta' instead of 'pandas-ta' + df["ema20"] = ta.trend.ema_indicator(df["close"], window=20) + df["ema50"] = ta.trend.ema_indicator(df["close"], window=50) + df["rsi"] = ta.momentum.rsi(df["close"], window=14) + df["atr"] = ta.volatility.average_true_range(df["high"], df["low"], df["close"], window=14) + df = df.dropna() + + print(f" Downloaded {len(df)} H1 bars | {df.index[0].date()} → {df.index[-1].date()}") + + # Strategy: EMA20 > EMA50 + RSI < 45 → Buy | EMA20 < EMA50 + RSI > 55 → Sell + # SL = 2x ATR below/above entry | TP = 4x ATR (2:1 R:R minimum) + trades = [] + in_trade = False + entry_price = sl = tp = direction = entry_idx = None + + for i in range(1, len(df)): + row = df.iloc[i] + prev = df.iloc[i-1] + spread_est = row["atr"] * 0.05 # rough spread estimate + + if not in_trade: + # Entry signals + if row["ema20"] > row["ema50"] and prev["rsi"] < 45 and row["rsi"] > 45: + direction = "Buy" + entry_price = row["close"] + spread_est + sl = round(entry_price - 2.0 * row["atr"], 5) + tp = round(entry_price + 4.0 * row["atr"], 5) + in_trade = True + entry_idx = i + elif row["ema20"] < row["ema50"] and prev["rsi"] > 55 and row["rsi"] < 55: + direction = "Sell" + entry_price = row["close"] - spread_est + sl = round(entry_price + 2.0 * row["atr"], 5) + tp = round(entry_price - 4.0 * row["atr"], 5) + in_trade = True + entry_idx = i + else: + # Check SL/TP hit + high, low = row["high"], row["low"] + result = None + if direction == "Buy": + if low <= sl: + result = "loss"; exit_price = sl + elif high >= tp: + result = "win"; exit_price = tp + else: + if high >= sl: + result = "loss"; exit_price = sl + elif low <= tp: + result = "win"; exit_price = tp + + # Max hold: 48 bars (2 days) + if result is None and (i - entry_idx) >= 48: + result = "timeout"; exit_price = row["close"] + + if result: + diff = (exit_price - entry_price) if direction == "Buy" else (entry_price - exit_price) + if "JPY" in mt5_sym: + pips = round(diff * 100.0, 1) + elif "XAU" in mt5_sym or "GC" in yf_sym: + pips = round(diff, 2) + else: + pips = round(diff * 10000.0, 1) + + trades.append({ + "direction": direction, + "entry": entry_price, + "exit": exit_price, + "result": result, + "pips": pips, + "bars_held": i - entry_idx, + "date": df.index[entry_idx].strftime("%Y-%m-%d"), + }) + in_trade = False + + if not trades: + print(" No trades generated") + return None + + wins = [t for t in trades if t["result"] == "win"] + losses = [t for t in trades if t["result"] == "loss"] + timeouts= [t for t in trades if t["result"] == "timeout"] + total_pips = sum(t["pips"] for t in trades) + win_pips = sum(t["pips"] for t in wins) + loss_pips = sum(t["pips"] for t in losses) + winrate = len(wins) / len(trades) * 100 + + # Profit factor + pf = round(abs(win_pips / loss_pips), 2) if loss_pips != 0 else float("inf") + + # Max drawdown (running pip balance) + running = 0; peak = 0; max_dd = 0 + for t in trades: + running += t["pips"] + if running > peak: peak = running + dd = peak - running + if dd > max_dd: max_dd = dd + + result = { + "symbol": mt5_sym, + "yf": yf_sym, + "total_trades": len(trades), + "wins": len(wins), + "losses": len(losses), + "timeouts": len(timeouts), + "win_rate": round(winrate, 1), + "total_pips": round(total_pips, 1), + "profit_factor": pf, + "max_drawdown_pips": round(max_dd, 1), + "avg_hold_bars": round(sum(t["bars_held"] for t in trades) / len(trades), 1), + } + + print(f" Trades: {result['total_trades']} | W:{result['wins']} L:{result['losses']} T:{result['timeouts']}") + print(f" Win rate: {result['win_rate']}% | Total pips: {result['total_pips']}") + print(f" Profit factor: {result['profit_factor']} | Max DD: {result['max_drawdown_pips']} pips") + return result + +def main(): + tg("🔬 *GENESIS Backtest Starting*\nRunning 1-year H1 backtest on 5 symbols using EMA20/50 + RSI + ATR strategy...\n_This will take ~60 seconds._") + + results = [] + for mt5_sym, yf_sym in SYMBOL_MAP.items(): + try: + r = backtest_symbol(mt5_sym, yf_sym) + if r: + results.append(r) + except Exception as e: + print(f" ERROR {mt5_sym}: {e}") + + if not results: + tg("❌ *Backtest Failed*: No results generated.") + return + + # Save results + try: + out_path = Path("/var/log/hermes/backtest_results.json") + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(results, indent=2)) + print(f"\nResults saved to {out_path}") + except Exception as e: + print(f"\nCould not write to /var/log/hermes/backtest_results.json ({e}). Falling back to local workspace.") + out_path = Path("./backtest_results.json") + out_path.write_text(json.dumps(results, indent=2)) + print(f"Results saved to {out_path.resolve()}") + + # Build Telegram report + report = "📊 *GENESIS Backtest Results* (1 Year H1)\n" + report += "Strategy: EMA20/50 crossover + RSI + 2x ATR SL + 4x ATR TP\n\n" + + overall_trades = sum(r["total_trades"] for r in results) + overall_wins = sum(r["wins"] for r in results) + overall_wr = round(overall_wins / overall_trades * 100, 1) if overall_trades else 0 + + for r in sorted(results, key=lambda x: x["win_rate"], reverse=True): + emoji = "✅" if r["win_rate"] >= 50 and r["profit_factor"] >= 1.0 else "⚠️" if r["win_rate"] >= 45 else "❌" + report += f"{emoji} *{r['symbol']}*\n" + report += f" {r['wins']}W/{r['losses']}L | WR: {r['win_rate']}% | PF: {r['profit_factor']}\n" + report += f" Pips: {r['total_pips']} | Max DD: {r['max_drawdown_pips']} pips\n\n" + + report += f"📈 *Overall:* {overall_wins}/{overall_trades} trades won ({overall_wr}%)\n" + + # Strategy verdict + viable = [r for r in results if r["win_rate"] >= 50 and r["profit_factor"] >= 1.2] + if viable: + report += f"\n✅ *Viable symbols*: {', '.join(r['symbol'] for r in viable)}\n" + report += "_These pairs have >50% win rate and >1.2 profit factor historically._" + else: + report += "\n⚠️ *No symbol meets viability criteria (>50% WR + >1.2 PF)*\n" + report += "_Strategy needs tuning before live deployment._" + + print("\n" + report) + tg(report) + + # Save markdown report + md = f"# GENESIS Backtest Report\n*Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC*\n\n" + md += report.replace("*", "**").replace("_", "*") + try: + report_path = Path("/var/log/hermes/backtest_report.md") + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(md) + print(f"Report saved to {report_path}") + except Exception as e: + print(f"Could not write to /var/log/hermes/backtest_report.md ({e}). Falling back to local workspace.") + report_path = Path("./backtest_report.md") + report_path.write_text(md) + print(f"Report saved to {report_path.resolve()}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/configs/apollo_config.yaml b/configs/apollo_config.yaml new file mode 100644 index 0000000..6a846ac --- /dev/null +++ b/configs/apollo_config.yaml @@ -0,0 +1,64 @@ +# GENESIS — Apollo Strategy C Configuration +# MA Crossover Trend Following — runs alongside Hermes + Ares without interference + +bridge: + url: "http://127.0.0.1:8000" + timeout_seconds: 15 + +mt5_api: + url: "MT5_BRIDGE_URL env var" + api_key: "MT5_BRIDGE_KEY env var" + +telegram: + chat_id: "YOUR_TELEGRAM_CHAT_ID" + +# Symbols Apollo watches (trend following works best on trending pairs) +symbols: + - "EURUSDxx" + - "GBPUSDxx" + - "USDJPYxx" + - "XAUUSDxx" + - "GBPJPYxx" + +# MA Crossover parameters +indicators: + fast_ma_period: 9 # Fast MA — golden/death cross + slow_ma_period: 21 # Slow MA + ma_method: "EMA" # EMA or SMA + signal_timeframe: "M5" # M5 for entries + trend_timeframe: "H1" # H1 for trend direction filter + trend_ma_period: 50 # H1 SMA50 — only trade in trend direction + atr_period: 14 # ATR for dynamic SL + +risk: + risk_pct: 0.01 # 1% risk per trade + min_rr_ratio: 1.5 # Minimum R:R + sl_atr_multiplier: 1.5 # SL = ATR × this value + tp_atr_multiplier: 3.0 # TP = ATR × this value + max_spread_pips: 1.5 + block_news_minutes: 45 # Slightly less strict than Ares (trend can absorb news) + block_medium_news: false + +strictness: + # Trend following is MORE patient — requires H1 trend alignment + require_trend_alignment: true # H1 MA must agree with signal direction + min_ma_separation_pct: 0.001 # MAs must be at least 0.1% apart to confirm crossover + min_adx: 20 # Minimum trend strength (lower than Ares — trend needs less extreme) + cooldown_seconds: 120 # 2min cooldown between signals (prevent whipsaw) + +sessions: + # Trend following works best during high-liquidity sessions + allowed: + - {start: 7, end: 20} # London + NY + +journal: + path: "/var/log/apollo/trade_journal.jsonl" + +cache: + path: "/tmp/genesis_cache.json" # Shared with Hermes + Ares + +strategy: + name: "Apollo" + version: "1.0" + comment: "APOLLO-v1" # Distinguishes from GENESIS-v2 and ARES-v1 + magic_number: 20250515 \ No newline at end of file diff --git a/configs/ares_config.yaml b/configs/ares_config.yaml new file mode 100644 index 0000000..6a3c31f --- /dev/null +++ b/configs/ares_config.yaml @@ -0,0 +1,51 @@ +# GENESIS — Strategy B (Ares) Configuration +# Controls ONLY Strategy B. Strategy A (Hermes) is completely untouched. + +bridge: + url: "http://127.0.0.1:8000" + timeout_seconds: 15 + +mt5_api: + url: "MT5_BRIDGE_URL env var" + api_key: "MT5_BRIDGE_KEY env var" + +telegram: + chat_id: "YOUR_TELEGRAM_CHAT_ID" + +symbols: + - "EURUSDxx" + - "XAUUSDxx" + - "GBPUSDxx" + +risk: + risk_pct: 0.01 # 1% risk per trade + max_simultaneous: 1 # Max 1 open position + min_rr_ratio: 1.5 # Minimum R:R + block_news_minutes: 60 # Block X minutes before high-impact events + block_medium_news: true # Also block medium-impact + +# BB+RSI EA pip settings (mirrors MQL5 EA inputs) +sl_pips: 20 +tp_pips: 40 + +strictness: + min_confluence_count: 3 # N conditions must align before signalling + required_timeframes: ["H1", "H4"] # All must agree on direction + min_adx: 22 + rsi_oversold: 30 + rsi_overbought: 70 + +sessions: + allowed: + - {start: 7, end: 21} # London + NY UTC hours + +journal: + path: "/var/log/ares/trade_journal.jsonl" + +cache: + path: "/tmp/genesis_cache.json" # Shared with Hermes — zero extra API calls + +strategy: + name: "Ares" + version: "1.0" + comment: "ARES-v1" # MT5 order comment — distinguishes from GENESIS-v2 \ No newline at end of file diff --git a/configs/artemis_config.yaml b/configs/artemis_config.yaml new file mode 100644 index 0000000..87263ad --- /dev/null +++ b/configs/artemis_config.yaml @@ -0,0 +1,75 @@ +# GENESIS — Artemis Strategy E Configuration +# Ichimoku Kumo Breakout — H1 timeframe, high-quality low-frequency signals +# Complements: Ares (BB+RSI M1), Apollo (MA Cross M5), Athena (BB+RSI M5) + +bridge: + url: "http://127.0.0.1:8000" + timeout_seconds: 15 + +mt5_api: + url: "MT5_BRIDGE_URL env var" + api_key: "MT5_BRIDGE_KEY env var" + +telegram: + chat_id: "YOUR_TELEGRAM_CHAT_ID" + +symbols: + - "EURUSDxx" + - "GBPJPYxx" # Ichimoku originated in Japan — JPY pairs are ideal + - "GBPUSDxx" + - "USDJPYxx" + - "XAUUSDxx" + +ichimoku: + tenkan_period: 9 # Conversion Line — short-term trend + kijun_period: 26 # Base Line — medium-term trend / SL reference + senkou_b_period: 52 # Slow cloud boundary + displacement: 26 # Kumo is plotted 26 bars AHEAD of price + + # Signal quality gates + require_cloud_color_alignment: true # Buy only if future cloud is green (SpanA > SpanB) + confirmation_bars: 1 # Must close outside Kumo for N full bars + require_chikou_confirmation: true # Chikou Span must be above/below price + require_price_above_kijun: true # Price above Kijun for buys, below for sells + +confirmation: + use_rsi: true + rsi_period: 14 + rsi_buy_threshold: 50 # RSI > 50 for buys + rsi_sell_threshold: 50 # RSI < 50 for sells + + use_awesome_oscillator: false # AO is optional — disabled by default (RSI is enough) + ao_fast: 5 + ao_slow: 34 + +risk: + risk_pct: 0.0075 # 0.75% per trade — higher quality signal justifies larger size + min_rr_ratio: 2.0 # H1 signals justify higher R:R requirement + sl_kijun_buffer: 0.0002 # SL placed just below/above Kijun-sen + buffer + use_kumo_sl: true # Alternative: SL at near edge of Kumo + tp_multiplier: 2.5 # TP = SL distance × 2.5 + max_spread_pips: 2.0 # H1 allows wider spread tolerance + block_news_minutes: 60 + block_medium_news: false + +strictness: + cooldown_seconds: 300 # 5 min cooldown — H1 signals are rare, no need to spam + signal_timeframe: "H1" + trend_timeframe: "D1" # Daily trend context (optional double-check) + +sessions: + # Ichimoku works best during liquid sessions + allowed: + - {start: 7, end: 21} + +journal: + path: "/var/log/artemis/trade_journal.jsonl" + +cache: + path: "/tmp/genesis_cache.json" + +strategy: + name: "Artemis" + version: "1.0" + comment: "ARTEMIS-v1" # Unique tag — 5th strategy + magic_number: 20250517 \ No newline at end of file diff --git a/configs/athena_config.yaml b/configs/athena_config.yaml new file mode 100644 index 0000000..7806be2 --- /dev/null +++ b/configs/athena_config.yaml @@ -0,0 +1,68 @@ +# GENESIS — Athena Strategy D Configuration +# BB+RSI Mean Reversion on M5 — faster signals than Ares (M1 strict) +# Complements: Ares (M1 strict), Apollo (MA Trend), Hermes (free AI) + +bridge: + url: "http://127.0.0.1:8000" + timeout_seconds: 15 + +mt5_api: + url: "MT5_BRIDGE_URL env var" + api_key: "MT5_BRIDGE_KEY env var" + +telegram: + chat_id: "YOUR_TELEGRAM_CHAT_ID" + +symbols: + - "EURUSDxx" + - "GBPUSDxx" + - "XAUUSDxx" + - "USDJPYxx" + - "GBPJPYxx" + +indicators: + bb_period: 20 + bb_deviation: 2.0 + bb_ma_method: "SMA" + rsi_period: 14 + rsi_oversold: 30 + rsi_overbought: 70 + signal_timeframe: "M5" # M5 — faster than Ares (M1), captures intraday moves + trend_timeframe: "H4" # H4 context filter — broader than Ares (M15) + trend_ma_period: 50 + atr_period: 14 + volume_ma_period: 20 # Volume confirmation (OBV direction) + +risk: + risk_pct: 0.005 # 0.5% — half of Ares/Apollo (more signals, smaller size) + min_rr_ratio: 1.5 + sl_atr_multiplier: 1.2 # Tighter SL than Ares (M5 ATR is smaller) + tp_atr_multiplier: 2.5 # TP slightly lower (M5 moves less than M1 extremes) + max_spread_pips: 1.5 + block_news_minutes: 30 # Less strict than Ares (M5 recovers faster) + block_medium_news: false + +strictness: + # Athena is SIMPLER than Ares — only 2 hard conditions (BB + RSI) + # ADX is a soft bonus, not a gate — allows trading in quieter markets too + require_band_close: true # Candle must CLOSE outside BB (not just wick) + soft_adx_bonus: true # ADX > 18 adds confidence but doesn't block + require_h4_context: true # H4 SMA50 must agree with direction + cooldown_seconds: 90 # 90s between signals per symbol + max_signals_per_hour: 4 # Rate limit — prevents overtrading on choppy M5 + +sessions: + allowed: + - {start: 6, end: 20} # Slightly wider than Ares + +journal: + path: "/var/log/athena/trade_journal.jsonl" + +cache: + path: "/tmp/genesis_cache.json" + +strategy: + name: "Athena" + version: "1.0" + comment: "ATHENA-v1" # Unique tag — distinguishes from GENESIS-v2, ARES-v1, APOLLO-v1 + magic_number: 20250516 \ No newline at end of file diff --git a/configs/hephaestus_config.yaml b/configs/hephaestus_config.yaml new file mode 100644 index 0000000..fb4c806 --- /dev/null +++ b/configs/hephaestus_config.yaml @@ -0,0 +1,51 @@ +# GENESIS — Hephaestus Strategy F Configuration +# Grid + Martingale — EXTREME RISK — circuit breakers MANDATORY +# confirm_risk_acknowledged MUST be true before strategy runs + +strategy: + name: "Hephaestus" + version: "1.0" + comment: "HEPH-v1" + magic_number: 20250518 + confirm_risk_acknowledged: true # SET TO true ONLY AFTER READING WARNING + +bridge: + url: "http://127.0.0.1:8000" + timeout_seconds: 15 + +mt5_api: + url: "MT5_BRIDGE_URL env var" + api_key: "MT5_BRIDGE_KEY env var" + +telegram: + chat_id: "YOUR_TELEGRAM_CHAT_ID" + +symbol: "EURUSDxx" # Single pair only — never multi-pair for grid +direction: "both" # "buy_only", "sell_only", "both" + +grid: + initial_lot: 0.01 # START TINY — martingale compounds fast + martingale_multiplier: 1.5 # 1.5x safer than 2x. 2x will blow account in 6 levels + max_lot_per_order: 0.20 # Hard cap — never exceeded regardless of martingale + grid_spacing_pips: 20 # Distance between grid levels + max_grid_levels: 4 # HARD LIMIT — level 4 = 0.01 × 1.5³ = 0.034 lot + take_profit_pips: 15 # TP per individual trade + basket_tp_pips: 25 # Close all if total basket profit ≥ this + +circuit_breakers: + max_equity_drawdown_pct: 8.0 # Kill all if equity drops 8% from peak + max_daily_loss_pct: 4.0 # Stop for day if daily loss ≥ 4% + max_consecutive_losses: 4 # Reset grid after 4 consecutive losing levels + cooldown_after_reset_sec: 600 # 10 min cooldown before restarting grid + max_spread_pips: 1.5 # No new levels if spread too wide + max_total_lots: 0.5 # Total exposure cap across all grid levels + +sessions: + allowed: + - {start: 7, end: 20} # Only trade during liquid hours + +journal: + path: "/var/log/hephaestus/trade_journal.jsonl" + +cache: + path: "/tmp/genesis_cache.json" \ No newline at end of file diff --git a/configs/zeus_config.yaml b/configs/zeus_config.yaml new file mode 100644 index 0000000..f4a5a17 --- /dev/null +++ b/configs/zeus_config.yaml @@ -0,0 +1,92 @@ +# GENESIS — Zeus Strategy G Configuration +# ICT Smart Money: Liquidity Sweep + FVG + Order Block on M5 +# Three-layer sequential confirmation: Sweep → FVG → OB + +bridge: + url: "http://127.0.0.1:8000" + timeout_seconds: 15 + +mt5_api: + url: "MT5_BRIDGE_URL env var" + api_key: "MT5_BRIDGE_KEY env var" + +telegram: + chat_id: "YOUR_TELEGRAM_CHAT_ID" + +symbols: + - "EURUSDxx" + - "GBPUSDxx" + - "GBPJPYxx" + - "XAUUSDxx" + +ict: + entry_timeframe: "M5" # Entry signals + context_timeframe: "M15" # Structure analysis + + liquidity: + swing_lookback: 5 # Bars each side for pivot detection + sweep_tolerance: 0.0003 # Price must exceed level by this to count + require_rejection: true # Close must return inside structure (rejection wick) + session_highs_lows: true # Also detect previous session H/L sweeps + + fvg: + min_gap_pips: 1.5 # Minimum FVG size to count + max_age_bars: 10 # Ignore FVGs older than N bars + require_unmitigated: true # FVG must not have been filled yet + + order_block: + max_age_bars: 15 # Stale OBs ignored + min_body_ratio: 0.35 # Candle body must be ≥35% of full range + max_wick_ratio: 0.40 # Total wick ≤40% of full range + + structure: + swing_lookback: 5 + bos_bars: 2 # Bars to confirm structure break + +confluence: + min_score: 65 # 0-100 — minimum to generate signal + max_daily_trades: 2 # ICT is low frequency — 5-15 signals/month + allow_third_if_score_ge: 90 + + killzone: + enabled: true + london: {start: 8, end: 11} # GMT hours + ny: {start: 13, end: 16} + + scoring: + bos_strength: 20 + sweep_quality: 15 + fvg_presence: 15 + ob_quality: 20 + killzone: 10 + mtf_confluence: 10 + ob_freshness: 10 + +risk: + risk_pct: 0.0075 # 0.75% per trade + min_rr: 2.0 + sl_ob_buffer: 0.0002 # SL placed just below/above OB + buffer + tp_multiplier: 2.5 + max_spread_pips: 2.0 + cooldown_seconds: 120 + +circuit_breakers: + max_equity_drawdown_pct: 15.0 + max_daily_loss_pct: 6.0 + max_consecutive_losses: 4 + +sessions: + allowed: + - {start: 7, end: 21} + +journal: + path: "/var/log/zeus/trade_journal.jsonl" + +cache: + path: "/tmp/genesis_cache.json" + +strategy: + name: "Zeus" + version: "1.0" + comment: "ZEUS-v1" + magic_number: 20250519 \ No newline at end of file diff --git a/core/genesis_autonomous.py b/core/genesis_autonomous.py new file mode 100644 index 0000000..fc6230e --- /dev/null +++ b/core/genesis_autonomous.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +GENESIS Autonomous Strategy Execution Engine +Runs every 5 minutes. Scans all strategies, executes signals, manages risk. +No AI/LLM cost — the strategies decide everything algorithmically. +Uses Mt5Bridge REST API — no API2TRADE required. +""" +import json, subprocess, logging, requests, os, sys +from datetime import datetime, timezone +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed +from dotenv import load_dotenv + +load_dotenv(Path(__file__).parent.parent / ".env") + +# ── Config ──────────────────────────────────────────────────────────────────── +ROOT = Path(__file__).parent.parent +AGENT = os.getenv("GENESIS_AGENT_DIR", str(ROOT)) +PY = os.getenv("GENESIS_PYTHON", sys.executable) +TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +CHAT = os.getenv("TELEGRAM_CHAT_ID", "") +LOG_DIR = Path(os.getenv("GENESIS_LOG_DIR", "/var/log/hermes")) +LOG_DIR.mkdir(parents=True, exist_ok=True) +LOG = LOG_DIR / "autonomous.log" +SNAP = LOG_DIR / "position_snapshot.json" + +MAX_TOTAL_POSITIONS = int(os.getenv("MAX_POSITIONS", 4)) +MAX_PER_STRATEGY = 1 + +logging.basicConfig( + filename=str(LOG), level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger("genesis") + +# ── Mt5Bridge (unified adapter) ──────────────────────────────────────────────── +sys.path.insert(0, str(Path(__file__).parent)) +from mt5_bridge import bridge as _bridge + +# ── Strategies: (name, short, symbols_to_scan) ─────────────────────────────── +STRATEGIES = [ + ("ARES", "ares", ["EURUSDxx", "GBPUSDxx"]), + ("APOLLO", "apollo", ["EURUSDxx", "GBPUSDxx", "XAUUSDxx"]), + ("ATHENA", "athena", ["EURUSDxx", "GBPUSDxx"]), + ("ARTEMIS", "artemis", ["EURUSDxx", "GBPUSDxx", "GBPJPYxx"]), + ("ZEUS", "zeus", ["EURUSDxx", "GBPUSDxx", "XAUUSDxx"]), +] + +def tg(msg): + """Send Telegram message.""" + if not TOKEN: return + try: + requests.post(f"https://api.telegram.org/bot{TOKEN}/sendMessage", + json={"chat_id": CHAT, "text": msg, "parse_mode": "Markdown"}, + timeout=10) + except Exception as e: + log.warning(f"Telegram failed: {e}") + +def api(endpoint, method="GET", data=None): + """Mt5Bridge unified API call.""" + return _bridge(endpoint, method, data) + +def run_tool(tool, cmd, sym=""): + """Run a strategy tool as subprocess. Path: strategies/{tool}/{tool}_tool.py""" + tool_path = f"{AGENT}/strategies/{tool}/{tool}_tool.py" + args = [PY, tool_path, cmd] + if sym: args.append(sym) + try: + r = subprocess.run(args, capture_output=True, text=True, timeout=50, + cwd=f"{AGENT}/strategies/{tool}") + out = r.stdout.strip() + return json.loads(out) if out else {"error": r.stderr.strip()[:120]} + except Exception as e: + return {"error": str(e)[:80]} + +def pip_val(sym): + if "JPY" in sym: return 0.01 + if "XAU" in sym: return 0.1 + return 0.0001 + +def already_has_position(open_pos, strategy_tag): + """Check if a strategy already has an open position.""" + return any(strategy_tag.lower() in str(p.get("comment","")).lower() for p in open_pos) + +def execute_signal(tool, sym, result, open_pos, balance): + """Execute a trade signal with risk validation.""" + direction = result.get("direction", "") + sl = result.get("stop_loss") + tp = result.get("take_profit") + vol = result.get("volume", 0.1) + + if not all([direction, sl, tp, vol]): + log.warning(f"{tool}/{sym}: Signal missing fields: {result}") + return False + + # Risk check: SL distance reasonable? + q = api(f"/quote?symbol={sym}") + price = float(q.get("ask" if direction=="Buy" else "bid", 0)) + if price <= 0: + log.warning(f"{tool}/{sym}: Cannot get quote") + return False + + pip = pip_val(sym) + sl_pips = abs(price - float(sl)) / pip + if sl_pips > 150: + log.warning(f"{tool}/{sym}: SL too wide ({sl_pips:.0f} pips), skipping") + return False + if sl_pips < 3: + log.warning(f"{tool}/{sym}: SL too tight ({sl_pips:.0f} pips), skipping") + return False + + # Execute + order = api("/market", "POST", { + "symbol": sym, + "volume": vol, + "type": direction, + "stop_loss": float(sl), + "take_profit": float(tp), + "comment": f"{tool.upper()}-v1" + }) + + ticket = order.get("ticket") or order.get("Ticket") + if ticket: + rr = result.get("rr_ratio", "?") + msg = ( + f"🟢 *TRADE OPENED*\n" + f"Strategy: {tool.upper()}\n" + f"{sym} {direction} {vol}lot\n" + f"Entry: {price:.5f} | SL: {sl} | TP: {tp}\n" + f"R:R = {rr} | SL = {sl_pips:.0f}pips\n" + f"Balance: €{balance:,.2f}" + ) + tg(msg) + log.info(f"OPENED: {tool}/{sym} {direction} {vol}lot ticket={ticket} SL={sl} TP={tp}") + return True + else: + err = order.get("message", str(order))[:100] + log.error(f"Order failed {tool}/{sym}: {err}") + return False + +def scan_and_execute(): + now = datetime.now(timezone.utc) + log.info(f"=== Autonomous cycle {now.strftime('%Y-%m-%d %H:%M')} UTC ===") + + # Account state + acc = api("/balance") + pos_data = api("/positions") + balance = float(acc.get("balance", 0)) + equity = float(acc.get("equity", 0)) + open_pos = pos_data if isinstance(pos_data, list) else [] + n_open = len(open_pos) + + log.info(f"Balance=€{balance:.2f} Equity=€{equity:.2f} OpenPositions={n_open}") + + if n_open >= MAX_TOTAL_POSITIONS: + log.info(f"Max positions reached ({n_open}/{MAX_TOTAL_POSITIONS}). Skipping scans.") + return + + # Position snapshot for trade-monitor (detect closes) + curr_snap = {str(p.get("ticket")): p for p in open_pos} + prev_snap = {} + if SNAP.exists(): + try: prev_snap = json.loads(SNAP.read_text()) + except: pass + + # Detect closed positions and notify + for ticket, p in prev_snap.items(): + if ticket not in curr_snap: + hist = api("/history") + pnl = None + if isinstance(hist, list): + for h in reversed(hist): + if str(h.get("ticket")) == ticket: + pnl = float(h.get("profit", h.get("pnl", 0))) + break + icon = "🟢" if (pnl or 0) >= 0 else "🔴" + tg(f"{icon} *TRADE CLOSED*\n" + f"{p.get('symbol')} {p.get('orderType','').upper()} " + f"{p.get('lots')}lot [{p.get('comment')}]\n" + f"Result: €{pnl:+.2f}" if pnl is not None else "Result: see MT5") + log.info(f"CLOSED: ticket={ticket} {p.get('symbol')} pnl={pnl}") + + SNAP.parent.mkdir(parents=True, exist_ok=True) + SNAP.write_text(json.dumps(curr_snap)) + + # Slots available + slots = MAX_TOTAL_POSITIONS - n_open + log.info(f"Available slots: {slots}") + + # Parallel scans + scan_tasks = [] + for strat_name, tool, symbols in STRATEGIES: + if already_has_position(open_pos, strat_name): + log.info(f"{strat_name}: position already open, skipping scan") + continue + for sym in symbols: + scan_tasks.append((strat_name, tool, sym)) + + if not scan_tasks: + log.info("No scan tasks — all strategies have open positions") + return + + results = {} + with ThreadPoolExecutor(max_workers=8) as ex: + futures = {ex.submit(run_tool, tool, "analyze", sym): (strat, sym) + for strat, tool, sym in scan_tasks} + for fut in as_completed(futures, timeout=60): + strat, sym = futures[fut] + try: + r = fut.result(timeout=1) + key = f"{strat}/{sym}" + results[key] = r + action = r.get("action", "?") + reason = r.get("reason", "")[:70] + log.info(f" {key}: {action} | {reason}") + except Exception as e: + log.warning(f" Scan error: {e}") + + # Find signals + signals = [(k, v) for k, v in results.items() if v.get("action") == "trade"] + log.info(f"Signals found: {len(signals)}") + + # Execute best signals (up to available slots) + executed = 0 + for key, result in signals: + if executed >= slots: + break + strat_name, sym = key.split("/", 1) + tool = strat_name.lower() + + # Double-check position not opened by another signal in this cycle + if already_has_position(open_pos, strat_name): + continue + + log.info(f"Executing: {key} {result.get('direction')}") + success = execute_signal(tool, sym, result, open_pos, balance) + if success: + executed += 1 + # Refresh positions so next iteration sees the new position + pos_data = api("/positions") + open_pos = pos_data if isinstance(pos_data, list) else open_pos + + if executed == 0 and not signals: + log.info("No signals this cycle — all strategies waiting") + + # Run Hephaestus grid tick + heph_r = run_tool("hephaestus", "tick") # path: strategies/hephaestus/hephaestus_tool.py + log.info(f"Hephaestus tick: {str(heph_r)[:80]}") + + log.info(f"=== Cycle complete. Executed {executed} trade(s) ===") + +if __name__ == "__main__": + try: + scan_and_execute() + except Exception as e: + log.error(f"CRASH: {e}", exc_info=True) + tg(f"🚨 *GENESIS AUTONOMOUS CRASH*: {str(e)[:200]}") \ No newline at end of file diff --git a/core/genesis_brain_feed.py b/core/genesis_brain_feed.py new file mode 100644 index 0000000..f81ca55 --- /dev/null +++ b/core/genesis_brain_feed.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +""" +GENESIS Brain Feed — Hourly Telegram Broadcast +Every 60 minutes: scans all strategies in parallel, reports open trades, +P&L, what each strategy is seeing, and the outlook for the next hour. +""" +import os, json, time, logging, threading, sys +from datetime import datetime, timezone, timedelta +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError +import subprocess, requests, yaml + +sys.path.insert(0, str(Path(__file__).parent)) +from mt5_bridge import bridge as _bridge + +# ── Config ───────────────────────────────────────────────────────────────────── +TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") +AGENT = "/opt/hermes-agent" +PYTHON = f"{AGENT}/.venv-hermes/bin/python3" +SCAN_TIMEOUT = 55 # seconds per strategy scan + +JOURNALS = { + "GENESIS-v2": "/var/log/hermes/trade_journal.jsonl", + "ARES-v1": "/var/log/ares/trade_journal.jsonl", + "APOLLO-v1": "/var/log/apollo/trade_journal.jsonl", + "ATHENA-v1": "/var/log/athena/trade_journal.jsonl", + "ARTEMIS-v1": "/var/log/artemis/trade_journal.jsonl", + "HEPH-v1": "/var/log/hephaestus/trade_journal.jsonl", + "ZEUS-v1": "/var/log/zeus/trade_journal.jsonl", +} + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger(__name__) + +def tg(msg: str): + """Send message, splitting if >4000 chars.""" + for chunk in [msg[i:i+4000] for i in range(0, len(msg), 4000)]: + try: + requests.post(f"https://api.telegram.org/bot{TOKEN}/sendMessage", + json={"chat_id": CHAT_ID, "text": chunk, "parse_mode": "Markdown"}, + timeout=10) + time.sleep(0.3) + except Exception as e: + log.error(f"tg send error: {e}") + +def bridge(path) -> dict: + return _bridge(path) + +def run_tool(name: str, tool_file: str, cmd: str, symbol: str = "") -> dict: + """Run a strategy tool and return parsed JSON result with timeout.""" + args = [PYTHON, f"{AGENT}/{tool_file}", cmd] + if symbol: args.append(symbol) + try: + result = subprocess.run(args, capture_output=True, text=True, + timeout=SCAN_TIMEOUT, cwd=AGENT) + if result.stdout.strip(): + return json.loads(result.stdout.strip()) + return {"error": result.stderr.strip()[:100] or "No output"} + except subprocess.TimeoutExpired: + return {"error": "timeout"} + except Exception as e: + return {"error": str(e)[:100]} + +def journal_summary(path: str, hours: int = 1) -> dict: + """Summarize trades in the last N hours from a journal file.""" + p = Path(path) + if not p.exists(): return {"trades": 0, "wins": 0, "losses": 0, "pnl": 0.0, "open": 0} + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + trades = wins = losses = open_t = 0 + pnl = 0.0 + try: + for line in p.read_text().strip().split("\n"): + if not line: continue + try: + t = json.loads(line) + opened = t.get("opened","") + if opened: + try: + dt = datetime.fromisoformat(opened.replace("Z","+00:00")) + if dt < cutoff: continue + except: continue + trades += 1 + result = t.get("result") + p_val = float(t.get("pnl") or 0) + if result == "win": wins += 1; pnl += p_val + elif result == "loss": losses += 1; pnl += p_val + elif result is None: open_t += 1 + except: continue + except: pass + return {"trades": trades, "wins": wins, "losses": losses, "pnl": round(pnl,2), "open": open_t} + +def icon(action): + return "🟢" if action == "trade" else "⚪" + +def fmt_strategy_result(name: str, result: dict) -> str: + action = result.get("action","wait") + if action == "trade": + return (f"{icon(action)} *{name}*: SIGNAL {result.get('direction','?')} " + f"`{result.get('symbol','?')}` " + f"R:R {result.get('rr_ratio','?')} | " + f"Vol {result.get('volume','?')} | " + f"Score {result.get('confidence_score',result.get('confidence','?'))}") + reason = result.get("reason","")[:80] + layer = result.get("layer","") + prog = f" [{layer}]" if layer else "" + return f"{icon(action)} *{name}*: WAIT{prog} — {reason}" + +def fmt_heph_status(result: dict) -> str: + if "error" in result: + return f"⚙️ *HEPHAESTUS*: {result['error'][:80]}" + enabled = result.get("enabled", False) + bl = result.get("buy_level",0); sl = result.get("sell_level",0) + lots = result.get("total_lots",0); pnl = result.get("unrealized_pnl",0) + status = "RUNNING" if enabled else f"DISABLED — {result.get('killed_reason','?')}" + return (f"⚙️ *HEPHAESTUS*: {status} | " + f"Buy L{bl} Sell L{sl} | {lots}lots | PnL €{pnl:.2f}") + +def estimate_next_hour(scan_results: dict) -> list[str]: + """Generate outlook text based on current scan results + session timing.""" + now_utc = datetime.now(timezone.utc) + hr = now_utc.hour + now_utc.minute/60 + weekday = now_utc.weekday() # 0=Mon 4=Fri 5=Sat 6=Sun + + signals = [(n,r) for n,r in scan_results.items() if r.get("action")=="trade"] + waits = [(n,r) for n,r in scan_results.items() if r.get("action")!="trade"] + lines = [] + + # ── Session context ──────────────────────────────────────────── + is_weekend = weekday >= 5 + in_asia = 0 <= hr < 5 + in_london = 7 <= hr < 11 + in_ny = 12 <= hr < 17 + in_session = not is_weekend and (5 <= hr < 21) + + if is_weekend: + opens_in = (6 - weekday) * 24 - hr + 22 # hours until Mon 22:00 UTC + lines.append(f"🌙 *Weekend* — markets closed.") + lines.append(f" Forex opens ~{round(opens_in,1)}h from now (Mon 22:00 UTC)") + lines.append(f" Hermes resumes scanning at session open.") + elif in_london: + lines.append("🏦 *London session active* (07:00–11:00 UTC)") + lines.append(" Zeus + Apollo most active here. Expect 1–3 scan cycles.") + elif in_ny: + lines.append("🗽 *New York session active* (12:00–17:00 UTC)") + lines.append(" Zeus + Apollo most active. Ares/Athena also scanning.") + elif in_asia: + lines.append("🌏 *Asian session* (00:00–05:00 UTC) — lower volatility") + lines.append(" Ares + Athena may fire on GBPJPY/XAUUSD ranging moves.") + elif not in_session: + next_open = 5 - hr if hr < 5 else 29 - hr # next 05:00 UTC + lines.append(f"🌙 *Off-hours* — strategies paused.") + lines.append(f" Next session opens in ~{abs(round(next_open,1))}h (05:00 UTC)") + lines.append(f" London killzone in ~{round(max(0,7-hr),1)}h — Zeus high-probability window") + + lines.append("") + + # ── Live signals ─────────────────────────────────────────────── + if signals: + lines.append(f"🔥 *{len(signals)} live signal(s) ready:*") + for name, r in signals: + lines.append(f" → {name}: {r.get('direction')} `{r.get('symbol')}` " + f"— Hermes will evaluate for execution") + + # ── Almost-ready signals ─────────────────────────────────────── + for name, r in waits: + reason = r.get("reason","") + layer = r.get("layer","") + score = r.get("score", r.get("confidence_score", 0)) or 0 + if "cooldown" in reason.lower(): + lines.append(f"⏱ {name}: cooldown — resets shortly") + elif "2/3" in layer or "3/3" in layer: + lines.append(f"🔶 {name}: *{layer}* — one more confirmation needed") + elif isinstance(score, (int, float)) and score > 50: + lines.append(f"🔶 {name}: score {score}/100 — approaching threshold (65)") + + if not signals and not any( + "cooldown" in r.get("reason","").lower() or + r.get("score",0) > 50 + for _, r in waits + ): + if in_session: + lines.append("📊 All strategies in WAIT — market likely in low-conviction state") + lines.append(" Hermes scanning every 5min cycle. Will fire when conditions align.") + elif not is_weekend: + lines.append("📊 Strategies dormant during off-hours — normal behaviour") + + return lines + +def build_report(acc, positions, scan_results, heph_result, journal_summaries) -> str: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + balance = float(acc.get("balance",0)) + equity = float(acc.get("equity",0)) + profit = float(acc.get("profit",0)) + + lines = [ + f"🧠 *GENESIS — Hourly Brain Feed*", + f"📅 {now}", + f"", + f"━━━━━ 💰 ACCOUNT ━━━━━", + f"Balance: `€{balance:,.2f}` | Equity: `€{equity:,.2f}`", + f"Floating P&L: `€{profit:+.2f}`", + f"", + ] + + # ── Open Positions ───────────────────────────────────────────── + lines.append("━━━━━ 📈 OPEN POSITIONS ━━━━━") + if isinstance(positions, list) and positions: + for p in positions: + pnl_val = float(p.get("profit",0)) + pnl_icon = "🟢" if pnl_val >= 0 else "🔴" + lines.append( + f"{pnl_icon} `{p.get('symbol')}` {p.get('orderType')} " + f"{p.get('lots')}lot | P&L: `€{pnl_val:+.2f}` | [{p.get('comment')}]" + ) + else: + lines.append(" No open positions") + + # ── Journal summary (last hour) ──────────────────────────────── + lines.append(f"") + lines.append("━━━━━ 📒 LAST HOUR TRADES ━━━━━") + total_trades = total_wins = total_losses = 0 + total_pnl = 0.0 + any_activity = False + for strat, summ in journal_summaries.items(): + if summ["trades"] > 0: + any_activity = True + total_trades += summ["trades"] + total_wins += summ["wins"] + total_losses += summ["losses"] + total_pnl += summ["pnl"] + lines.append( + f" `{strat}`: {summ['trades']} trade(s) | " + f"{summ['wins']}W {summ['losses']}L | " + f"P&L: `€{summ['pnl']:+.2f}`" + ) + if not any_activity: + lines.append(" No completed trades in the last hour") + else: + lines.append(f" *Total:* {total_trades} trades | " + f"{total_wins}W {total_losses}L | `€{total_pnl:+.2f}`") + + # ── Strategy Scans ───────────────────────────────────────────── + lines.append(f"") + lines.append("━━━━━ 🔬 STRATEGY SCANS ━━━━━") + for name, result in scan_results.items(): + lines.append(fmt_strategy_result(name, result)) + lines.append(fmt_heph_status(heph_result)) + + # ── Next Hour Outlook ────────────────────────────────────────── + lines.append(f"") + lines.append("━━━━━ 🔭 NEXT HOUR OUTLOOK ━━━━━") + for l in estimate_next_hour(scan_results): + lines.append(l) + + lines.append(f"") + lines.append("_Next broadcast in ~60 min_") + + return "\n".join(lines) + +def run_broadcast(): + log.info("=== Brain Feed broadcast starting ===") + start = time.time() + + # ── Parallel strategy scans ──────────────────────────────────── + scan_tasks = { + "ARES": ("strategies/ares/ares_tool.py", "analyze", "EURUSDxx"), + "APOLLO": ("strategies/apollo/apollo_tool.py", "analyze", "EURUSDxx"), + "ATHENA": ("strategies/athena/athena_tool.py", "analyze", "EURUSDxx"), + "ARTEMIS": ("strategies/artemis/artemis_tool.py", "analyze", "EURUSDxx"), + "ZEUS": ("strategies/zeus/zeus_tool.py", "analyze", "EURUSDxx"), + } + scan_results = {} + heph_result = {} + + with ThreadPoolExecutor(max_workers=6) as ex: + futures = { + ex.submit(run_tool, name, tool, cmd, sym): name + for name, (tool, cmd, sym) in scan_tasks.items() + } + futures[ex.submit(run_tool, "HEPH", "strategies/hephaestus/hephaestus_tool.py", "status", "")] = "HEPH" + + for fut in as_completed(futures, timeout=SCAN_TIMEOUT+10): + name = futures[fut] + try: + result = fut.result(timeout=1) + if name == "HEPH": + heph_result = result + else: + scan_results[name] = result + except Exception as e: + if name == "HEPH": heph_result = {"error": str(e)[:60]} + else: scan_results[name] = {"action":"wait","reason":f"Scan error: {str(e)[:60]}"} + + # ── Account + positions ──────────────────────────────────────── + acc = bridge("/balance") + positions = bridge("/positions") + if not isinstance(positions, list): positions = [] + + # ── Journal summaries ────────────────────────────────────────── + journal_summaries = {tag: journal_summary(path, hours=1) + for tag, path in JOURNALS.items()} + + # ── Build and send ───────────────────────────────────────────── + report = build_report(acc, positions, scan_results, heph_result, journal_summaries) + tg(report) + elapsed = round(time.time() - start, 1) + log.info(f"=== Brain Feed sent ({elapsed}s) ===") + +if __name__ == "__main__": + run_broadcast() \ No newline at end of file diff --git a/core/genesis_daily_report.py b/core/genesis_daily_report.py new file mode 100644 index 0000000..58b79ee --- /dev/null +++ b/core/genesis_daily_report.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +GENESIS Daily P&L Report +Runs at 17:00 UTC. Sends a full daily performance summary to Telegram. +""" +import json, os, sys, requests +from datetime import datetime, timezone, timedelta +from pathlib import Path + +TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +CHAT = os.getenv("TELEGRAM_CHAT_ID", "") + +sys.path.insert(0, str(Path(__file__).parent)) +from mt5_bridge import bridge as _bridge + +def api(path): + return _bridge(path) + +def tg(msg): + if not TOKEN: return + try: + requests.post(f"https://api.telegram.org/bot{TOKEN}/sendMessage", + json={"chat_id": CHAT, "text": msg, "parse_mode": "Markdown"}, timeout=10) + except: pass + +now = datetime.now(timezone.utc) +today = now.date() + +acc = api("/balance") +balance = float(acc.get("balance", 0)) +equity = float(acc.get("equity", 0)) +profit = float(acc.get("profit", 0)) + +pos = api("/positions") +open_pos = pos if isinstance(pos, list) else [] + +hist = api("/history") +trades_today = [] +if isinstance(hist, list): + for t in hist: + if t.get("entry") != 1: + continue + try: + deal_time = t.get("time", "") + if deal_time: + ct = datetime.fromisoformat(str(deal_time).replace("Z", "+00:00")) + if ct.date() == today: + trades_today.append(t) + except: + pass + +wins = [t for t in trades_today if float(t.get("profit", 0)) > 0] +losses = [t for t in trades_today if float(t.get("profit", 0)) < 0] +be = [t for t in trades_today if float(t.get("profit", 0)) == 0] +total_pnl = sum(float(t.get("profit", 0)) for t in trades_today) +best = max(trades_today, key=lambda t: float(t.get("profit",0)), default=None) +worst = min(trades_today, key=lambda t: float(t.get("profit",0)), default=None) +win_rate = round(len(wins)/len(trades_today)*100) if trades_today else 0 + +by_strat = {} +for t in trades_today: + tag = t.get("comment", "UNKNOWN").split("-")[0] + by_strat.setdefault(tag, {"trades": 0, "pnl": 0.0}) + by_strat[tag]["trades"] += 1 + by_strat[tag]["pnl"] += float(t.get("profit", 0)) + +open_lines = [] +for p in open_pos: + sym = p.get("symbol","?") + side = p.get("orderType","?").upper() + lots = p.get("lots","?") + pnl = float(p.get("profit", 0)) + tag = p.get("comment","?") + icon = "📈" if side == "BUY" else "📉" + open_lines.append(f" {icon} {sym} {side} {lots}L [{tag}] €{pnl:+.2f}") + +pnl_icon = "🟢" if total_pnl >= 0 else "🔴" +msg = ( + f"📊 *GENESIS Daily Report — {today.strftime('%d %b %Y')}*\n" + f"{'─'*32}\n" + f"*Account*\n" + f" Balance: €{balance:,.2f}\n" + f" Equity: €{equity:,.2f}\n" + f" Float: €{profit:+.2f}\n\n" + f"*Today's Performance*\n" + f" {pnl_icon} P&L: €{total_pnl:+.2f}\n" + f" 📋 Trades: {len(trades_today)} ({len(wins)}W / {len(losses)}L / {len(be)}BE)\n" + f" 🎯 Win Rate: {win_rate}%\n" +) + +if best: + msg += f" 🏆 Best: €{float(best.get('profit',0)):+.2f} ({best.get('symbol','')} [{best.get('comment','')}])\n" +if worst and worst != best: + msg += f" 💀 Worst: €{float(worst.get('profit',0)):+.2f} ({worst.get('symbol','')} [{worst.get('comment','')}])\n" + +if by_strat: + msg += f"\n*By Strategy*\n" + for tag, data in sorted(by_strat.items()): + icon = "🟢" if data["pnl"] >= 0 else "🔴" + msg += f" {icon} {tag}: {data['trades']} trades | €{data['pnl']:+.2f}\n" + +if open_pos: + msg += f"\n*Open Positions ({len(open_pos)})*\n" + msg += "\n".join(open_lines) + "\n" +else: + msg += f"\n*Open Positions:* None\n" + +if not trades_today: + msg += "\n_No closed trades today._\n" + +tg(msg) +print(msg) \ No newline at end of file diff --git a/core/genesis_market_open.py b/core/genesis_market_open.py new file mode 100644 index 0000000..bc63429 --- /dev/null +++ b/core/genesis_market_open.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +GENESIS Market Open Intensive Scanner — runs every 30 seconds. +Only does heavy work during the first 30 minutes of London, NY, and Asia opens. +Exits silently the rest of the time (no AI cost, no noise). +""" +import json, subprocess, sys +from datetime import datetime, timezone +from pathlib import Path + +PYTHON = "/opt/hermes-agent/.venv-hermes/bin/python3" +AGENT = "/opt/hermes-agent" + +sys.path.insert(0, str(Path(__file__).parent)) +from mt5_bridge import bridge as _bridge + +now = datetime.now(timezone.utc) +hr = now.hour +mn = now.minute +wd = now.weekday() + +# Market open windows (first 30 minutes of each session) +# London: 07:00–07:30 UTC +# NY: 13:00–13:30 UTC (13:00 = 9am NY time) +# Asia: 22:00–22:30 UTC (22:00 = Tokyo midnight open) + +OPEN_WINDOWS = [ + {"name": "London Open", "h": 7, "pairs": ["EURUSDxx","GBPUSDxx","EURGBPxx"]}, + {"name": "New York Open","h": 13, "pairs": ["EURUSDxx","GBPUSDxx","XAUUSDxx"]}, + {"name": "Asia Open", "h": 22, "pairs": ["XAUUSDxx","GBPJPYxx","USDJPYxx"]}, +] + +# Only fire during an open window +active_window = None +for w in OPEN_WINDOWS: + if hr == w["h"] and mn < 30 and wd < 5: + active_window = w + break + +if not active_window: + sys.exit(0) # Silent exit — not an open window + +import requests + +def api(path): + return _bridge(path) + +def tool(name, cmd, sym): + args = [PYTHON, f"{AGENT}/{name}_tool.py", cmd, sym] + try: + r = subprocess.run(args, capture_output=True, text=True, timeout=25, cwd=AGENT) + return json.loads(r.stdout.strip()) if r.stdout.strip() else {} + except: return {} + +acc = api("/balance") +balance = float(acc.get("balance", 0)) +equity = float(acc.get("equity", 0)) + +print(f"=== {active_window['name'].upper()} INTENSIVE SCAN ===") +print(f"Time: {now.strftime('%H:%M')} UTC | Balance=€{balance:.2f} Equity=€{equity:.2f}") +print(f"Scanning: {', '.join(active_window['pairs'])}") + +signals = [] +for sym in active_window["pairs"]: + # Zeus is best at market opens (killzone) + r = tool("zeus", "analyze", sym) + if r.get("action") == "trade": + signals.append(("ZEUS", sym, r)) + print(f" ⚡ ZEUS/{sym}: SIGNAL {r.get('direction')} | Score={r.get('confidence_score','?')} | RR={r.get('rr_ratio','?')}") + continue + # Apollo for trend-following at opens + r = tool("apollo", "analyze", sym) + if r.get("action") == "trade": + signals.append(("APOLLO", sym, r)) + print(f" 🏹 APOLLO/{sym}: SIGNAL {r.get('direction')} | RR={r.get('rr_ratio','?')}") + continue + print(f" ⚪ {sym}: no signal") + +if signals: + print(f"\n{len(signals)} signal(s) at {active_window['name']}!") + for strat, sym, r in signals: + clean = sym.replace("xx","") + print(f" EXECUTE: {PYTHON} {AGENT}/{strat.lower()}_tool.py execute {clean}") + print(f"\nThis is a HIGH-PRIORITY window ({active_window['name']}).") + print("Execute the best signal immediately if conditions confirm.") +else: + print(f"\nNo signals at {active_window['name']} open yet. Continue monitoring.") \ No newline at end of file diff --git a/core/genesis_trade_monitor.py b/core/genesis_trade_monitor.py new file mode 100644 index 0000000..6e7e53c --- /dev/null +++ b/core/genesis_trade_monitor.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +GENESIS Trade Monitor — zero LLM cost. +Runs every minute. Sends Telegram ONLY when a trade opens or closes. +No AI, no summaries, no noise. +""" +import json, os, sys, requests +from datetime import datetime, timezone +from pathlib import Path + +TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +CHAT = os.getenv("TELEGRAM_CHAT_ID", "") +SNAP = Path("/var/log/hermes/position_snapshot.json") + +sys.path.insert(0, str(Path(__file__).parent)) +from mt5_bridge import bridge as _bridge + +def tg(msg): + try: + requests.post(f"https://api.telegram.org/bot{TOKEN}/sendMessage", + json={"chat_id": CHAT, "text": msg}, timeout=10) + except: pass + +def api(path): + return _bridge(path) + +def pip_val(sym): + if "JPY" in sym: return 0.01 + if "XAU" in sym or "GOLD" in sym: return 0.1 + return 0.0001 + +acc = api("/balance") +pos = api("/positions") +balance = float(acc.get("balance", 0)) +equity = float(acc.get("equity", 0)) +open_pos = pos if isinstance(pos, list) else [] +now = datetime.now(timezone.utc).strftime("%H:%M UTC") + +prev = {} +if SNAP.exists(): + try: prev = json.loads(SNAP.read_text()) + except: prev = {} + +curr = {str(p.get("ticket")): p for p in open_pos} + +for ticket, p in curr.items(): + if ticket not in prev: + sym = p.get("symbol","?") + side = p.get("orderType","?").upper() + lots = p.get("lots","?") + entry = p.get("openPrice","?") + sl = p.get("sl","?") + tp = p.get("tp","?") + strat = p.get("comment","?") + pip = pip_val(sym) + sl_pip = round(abs(float(entry)-float(sl))/pip, 1) if sl and entry and float(sl) != 0 else "?" + tp_pip = round(abs(float(tp)-float(entry))/pip, 1) if tp and entry and float(tp) != 0 else "?" + tg( + f"🟢 TRADE OPENED — {now}\n" + f"Strategy: {strat}\n" + f"{sym} {side} {lots} lot\n" + f"Entry: {entry}\n" + f"SL: {sl} (-{sl_pip} pips)\n" + f"TP: {tp} (+{tp_pip} pips)\n" + f"Balance: €{balance:,.2f}" + ) + +for ticket, p in prev.items(): + if ticket not in curr: + sym = p.get("symbol","?") + side = p.get("orderType","?").upper() + lots = p.get("lots","?") + entry = p.get("openPrice","?") + strat = p.get("comment","?") + + hist = api("/history") + pnl = None + if isinstance(hist, list): + for h in reversed(hist): + if h.get("entry") != 1: + continue + if h.get("symbol","").upper() == sym.upper(): + h_comment = str(h.get("comment","")) + p_comment = str(strat) + if h_comment and p_comment and h_comment.split("-")[0] == p_comment.split("-")[0]: + pnl = float(h.get("profit", 0)) + break + if pnl is None: + for h in reversed(hist): + if h.get("entry") != 1: + continue + if h.get("symbol","").upper() == sym.upper(): + pnl = float(h.get("profit", 0)) + break + + icon = "🟢" if (pnl or 0) >= 0 else "🔴" + pnl_str = f"€{pnl:+.2f}" if pnl is not None else "see MT5" + tg( + f"{icon} TRADE CLOSED — {now}\n" + f"Strategy: {strat}\n" + f"{sym} {side} {lots} lot\n" + f"Entry: {entry}\n" + f"Result: {pnl_str}\n" + f"Balance: €{balance:,.2f}" + ) + +SNAP.parent.mkdir(parents=True, exist_ok=True) +SNAP.write_text(json.dumps(curr)) \ No newline at end of file diff --git a/core/heartbeat.py b/core/heartbeat.py new file mode 100644 index 0000000..dc1c244 --- /dev/null +++ b/core/heartbeat.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +GENESIS Heartbeat Monitor +Runs every 60 minutes. Sends a system health report to Telegram. +If this message stops appearing, the VPS is down. +""" +import os, requests, json, subprocess, sys +from datetime import datetime, timezone +from pathlib import Path + +try: + from dotenv import load_dotenv + load_dotenv() +except: + pass + +TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") + +sys.path.insert(0, str(Path(__file__).parent)) +from mt5_bridge import bridge as _bridge + +default_journal = "/var/log/hermes/trade_journal.jsonl" +try: + Path(default_journal).parent.mkdir(parents=True, exist_ok=True) + JOURNAL = Path(default_journal) +except Exception: + JOURNAL = Path(__file__).parents[1] / "logs" / "hermes" / "trade_journal.jsonl" + JOURNAL.parent.mkdir(parents=True, exist_ok=True) + +default_ares_journal = "/var/log/ares/trade_journal.jsonl" +try: + Path(default_ares_journal).parent.mkdir(parents=True, exist_ok=True) + ARES_JOURNAL = Path(default_ares_journal) +except Exception: + ARES_JOURNAL = Path(__file__).parents[1] / "logs" / "ares" / "trade_journal.jsonl" + ARES_JOURNAL.parent.mkdir(parents=True, exist_ok=True) + +def tg(msg): + 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 + +def check_bridge_health(): + try: + d = _bridge("/health") + if isinstance(d, dict): + s = d.get("status", "").lower() + return s in ("ok", "healthy", "running", "alive") + return True + except: + return False + +def check_llm_health(): + try: + import requests as _req + base = os.getenv("OPENAI_BASE_URL", "") + key = os.getenv("OPENAI_API_KEY", "") + if not base or not key: + return False + r = _req.get(base.replace("/v1", ""), + headers={"Authorization": f"Bearer {key}"}, + timeout=10) + return r.status_code == 200 + except: + return False + +def main(): + now = datetime.now(timezone.utc) + issues = [] + + balance_str = "N/A" + equity_str = "N/A" + pnl_str = "N/A" + try: + d = _bridge("/balance") + balance_str = f"€{d.get('balance', 0):.2f}" + equity_str = f"€{d.get('equity', 0):.2f}" + pnl_str = f"€{d.get('profit', 0):.2f}" + except: + issues.append("🔴 Bridge DOWN") + + positions_str = "None" + pos = [] + try: + pos = _bridge("/positions") + if isinstance(pos, list) and len(pos) > 0: + p = pos[0] + positions_str = f"{p.get('symbol')} {p.get('orderType')} {p.get('lots')}lot | P&L: €{p.get('profit', 0):.2f}" + except: + issues.append("🔴 Cannot read positions") + + if not check_bridge_health(): + issues.append("🔴 MT5 bridge not reachable") + + if not check_llm_health(): + issues.append("🟡 LLM API not reachable") + + ares_balance_str = balance_str + if equity_str != "N/A": + ares_balance_str = f"{balance_str} (eq {equity_str})" + + ares_pos_str = "None" + if isinstance(pos, list): + ares_positions = [ + f"{p.get('symbol')} {p.get('orderType')} {p.get('lots')}lot | P&L: €{p.get('profit', 0):.2f}" + for p in pos if "ARES" in str(p.get("comment", "")).upper() + ] + if ares_positions: + ares_pos_str = ares_positions[0] + + ares_wins = ares_losses = 0 + if ARES_JOURNAL.exists(): + for line in ARES_JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t = json.loads(line) + if t.get("result") == "win": ares_wins += 1 + if t.get("result") == "loss": ares_losses += 1 + except: pass + + wins, losses = 0, 0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t = json.loads(line) + if t.get("result") == "win": wins += 1 + if t.get("result") == "loss": losses += 1 + except: pass + + status = "✅ ALL SYSTEMS NOMINAL" if not issues else "\n".join(issues) + + msg = ( + f"🤖 *GENESIS HEARTBEAT*\n" + f"🕐 {now.strftime('%Y-%m-%d %H:%M')} UTC\n\n" + f"*System Status:* {status}\n\n" + f"━━━ ⚡ HERMES (Account A) ━━━\n" + f"💰 Balance: {balance_str}\n" + f"📊 Equity: {equity_str}\n" + f"📈 Open P&L: {pnl_str}\n" + f"🔓 Position: {positions_str}\n" + f"📒 History: {wins}W / {losses}L\n\n" + f"━━━ ⚔️ ARES (Account B) ━━━\n" + f"💰 Balance: {ares_balance_str}\n" + f"🔓 Position: {ares_pos_str}\n" + f"📒 History: {ares_wins}W / {ares_losses}L\n\n" + f"_Next heartbeat in 60 minutes._" + ) + tg(msg) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/core/mt5_bridge.py b/core/mt5_bridge.py new file mode 100644 index 0000000..9d119f8 --- /dev/null +++ b/core/mt5_bridge.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +""" +GENESIS — Mt5Bridge Unified Adapter + +Provides a bridge() function with the SAME call interface as the old API2TRADE version, +but internally routes all calls to the Mt5Bridge REST API. + +Usage: + from core.mt5_bridge import bridge, get_bars, pip_size + + acc = bridge("/balance") + pos = bridge("/positions") + q = bridge("/quote?symbol=EURUSD") + ord = bridge("/market", "POST", {"symbol":"EURUSD","type":"Buy","volume":0.1, + "stop_loss":1.08,"take_profit":1.09,"comment":"TEST"}) + bridge("/close", "POST", {"ticket": 12345}) + bridge("/modify", "POST", {"ticket": 12345, "stop_loss": 1.07}) + +Environment variables: + MT5_BRIDGE_URL — Base URL (default: http://61.164.252.86:13485) + MT5_BRIDGE_KEY — API Key for X-API-Key header + MT5_SYMBOL_MAP — JSON string mapping xx-suffix symbols to broker symbols + e.g. '{"EURUSDxx":"EURUSD","XAUUSDxx":"XAUUSDc"}' + +Mt5Bridge API docs: see Mt5Bridge使用指南.md +""" +import os, json, logging, math +from datetime import datetime, timezone, timedelta +from pathlib import Path + +import requests + +log = logging.getLogger(__name__) + +BRIDGE_URL = os.getenv("MT5_BRIDGE_URL", "http://61.164.252.86:13485") +BRIDGE_KEY = os.getenv("MT5_BRIDGE_KEY", "") + +_SYMBOL_MAP_RAW = os.getenv("MT5_SYMBOL_MAP", "") +if _SYMBOL_MAP_RAW: + try: + SYMBOL_MAP = json.loads(_SYMBOL_MAP_RAW) + except json.JSONDecodeError: + SYMBOL_MAP = {} +else: + SYMBOL_MAP = {} + +_HEADERS = {"X-API-Key": BRIDGE_KEY, "Content-Type": "application/json"} + + +def resolve_symbol(sym: str) -> str: + if sym in SYMBOL_MAP: + resolved = SYMBOL_MAP[sym] + if resolved != sym: + log.debug(f"resolve_symbol: {sym} → {resolved} (MAP)") + return resolved + if sym.endswith("xx"): + base = sym[:-2] + if base in SYMBOL_MAP: + resolved = SYMBOL_MAP[base] + log.debug(f"resolve_symbol: {sym} → {resolved} (MAP via base)") + return resolved + log.debug(f"resolve_symbol: {sym} → {base} (strip xx)") + return base + return sym + + +def _api_get(path: str, params=None) -> dict: + try: + r = requests.get(f"{BRIDGE_URL}{path}", params=params, + headers=_HEADERS, timeout=15) + r.raise_for_status() + return r.json() + except Exception as e: + log.error(f"Mt5Bridge GET {path}: {e}") + return {} + + +def _api_post(path: str, data: dict) -> dict: + try: + r = requests.post(f"{BRIDGE_URL}{path}", json=data, + headers=_HEADERS, timeout=15) + r.raise_for_status() + return r.json() + except Exception as e: + log.error(f"Mt5Bridge POST {path}: {e}") + return {} + + +def bridge(path, method="GET", data=None) -> dict: + """ + Unified bridge interface — same signature as the old API2TRADE version. + + Supported paths: + /balance → GET /account + /positions → GET /positions + /history → GET /history/deals (today) + /quote?symbol=X → GET /symbols/{sym}/tick + /market (POST) → POST /order/send + /close (POST) → POST /position/close + /modify (POST) → POST /position/modify + /symbols/{sym} → GET /symbols/{sym} + /rates?symbol=X&tf=M5&count=100 → GET /rates/from-pos + """ + # ── Quote ────────────────────────────────────────────────────────────── + if path.startswith("/quote"): + sym = path.split("symbol=")[-1] if "symbol=" in path else "" + if not sym and data: + sym = data.get("symbol", "") + sym = resolve_symbol(sym) + raw = _api_get(f"/symbols/{sym}/tick") + items = raw.get("data", []) + if items: + t = items[0] + return { + "bid": float(t.get("bid", 0)), + "ask": float(t.get("ask", 0)), + "symbol": sym, + } + return {"bid": 0, "ask": 0, "symbol": sym} + + # ── Balance / Account ────────────────────────────────────────────────── + if path == "/balance": + raw = _api_get("/account") + items = raw.get("data", []) + if not items: + raw = _api_get("/account") + items = raw.get("data", []) + if items: + a = items[0] + return { + "balance": float(a.get("balance", 0)), + "equity": float(a.get("equity", 0)), + "margin": float(a.get("margin", 0)), + "profit": float(a.get("profit", 0)), + "margin_free": float(a.get("margin_free", 0)), + "margin_level": float(a.get("margin_level", 0)), + "leverage": int(a.get("leverage", 0)), + "currency": a.get("currency", "USD"), + } + return {"balance": 0, "equity": 0, "margin": 0, "profit": 0} + + # ── Positions ────────────────────────────────────────────────────────── + if path == "/positions": + sym_filter = None + if data and data.get("symbol"): + sym_filter = resolve_symbol(data["symbol"]) + raw = _api_get("/positions", params={"symbol": sym_filter} if sym_filter else None) + items = raw.get("data", []) + return [{ + "ticket": p.get("ticket", 0), + "symbol": p.get("symbol", ""), + "orderType": "BUY" if p.get("type", 0) == 0 else "SELL", + "type": p.get("type", 0), + "lots": float(p.get("volume", 0)), + "volume": float(p.get("volume", 0)), + "openPrice": float(p.get("price_open", 0)), + "price_open": float(p.get("price_open", 0)), + "price_current": float(p.get("price_current", 0)), + "sl": float(p.get("sl", 0)), + "tp": float(p.get("tp", 0)), + "profit": float(p.get("profit", 0)), + "swap": float(p.get("swap", 0)), + "comment": p.get("comment", ""), + "magic": p.get("magic", 0), + } for p in items] + + # ── History ──────────────────────────────────────────────────────────── + if path == "/history": + now = datetime.now(timezone.utc) + date_from = now.strftime("%Y-%m-%d") + date_to = (now + timedelta(days=1)).strftime("%Y-%m-%d") + raw = _api_get("/history/deals", params={ + "date_from": date_from, + "date_to": date_to, + }) + items = raw.get("data", []) + return [{ + "ticket": d.get("ticket", 0), + "symbol": d.get("symbol", ""), + "type": d.get("type", 0), + "entry": d.get("entry", 0), + "volume": float(d.get("volume", 0)), + "price": float(d.get("price", 0)), + "profit": float(d.get("profit", 0)), + "commission": float(d.get("commission", 0)), + "swap": float(d.get("swap", 0)), + "comment": d.get("comment", ""), + "magic": d.get("magic", 0), + "time": d.get("time", ""), + } for d in items] + + # ── Place order ──────────────────────────────────────────────────────── + if path == "/market" and data: + sym = resolve_symbol(data.get("symbol", "")) + direction = data.get("type", "Buy") + order_type = 0 if direction.lower() in ("buy", "long") else 1 + tick_data = _api_get(f"/symbols/{sym}/tick") + tick_items = tick_data.get("data", []) + price = 0 + if tick_items: + price = float(tick_items[0].get("ask" if order_type == 0 else "bid", 0)) + request_obj = { + "action": 1, + "symbol": sym, + "volume": float(data.get("volume", 0.01)), + "order_type": order_type, + "price": price, + "sl": 0, + "tp": 0, + "magic": int(data.get("magic", 88001)), + "comment": data.get("comment", "GENESIS"), + "deviation": 10, + "type_filling": 0, + } + sl_val = data.get("stop_loss") + tp_val = data.get("take_profit") + if sl_val is not None and float(sl_val) != 0: + request_obj["sl"] = float(sl_val) + if tp_val is not None and float(tp_val) != 0: + request_obj["tp"] = float(tp_val) + payload = {"request": request_obj} + raw = _api_post("/order/send", payload) + resp_data = raw.get("data", raw) + ticket = resp_data.get("order") or resp_data.get("ticket") + retcode = resp_data.get("retcode", 0) + if retcode == 10009 and ticket: + return {"ticket": ticket} + return {"ticket": ticket, "retcode": retcode, + "comment": resp_data.get("comment", "")} + + # ── Close position ───────────────────────────────────────────────────── + if path == "/close" and data: + ticket = data.get("ticket") + payload = {"ticket": int(ticket)} + if data.get("volume"): + payload["volume"] = float(data["volume"]) + raw = _api_post("/position/close", payload) + resp_data = raw.get("data", raw) + retcode = resp_data.get("retcode", 0) + if retcode == 10009: + return {"message": "ok"} + return {"retcode": retcode, "comment": resp_data.get("comment", "")} + + # ── Modify position ──────────────────────────────────────────────────── + if path == "/modify" and data: + ticket = data.get("ticket") + payload = {"ticket": int(ticket)} + if data.get("stop_loss") is not None: + payload["sl"] = float(data["stop_loss"]) + if data.get("take_profit") is not None: + payload["tp"] = float(data["take_profit"]) + raw = _api_post("/position/modify", payload) + resp_data = raw.get("data", raw) + retcode = resp_data.get("retcode", 0) + if retcode == 10009: + return {"ok": True} + return {"retcode": retcode, "comment": resp_data.get("comment", "")} + + # ── Symbol info ──────────────────────────────────────────────────────── + if path.startswith("/symbols/"): + sym = path.split("/symbols/")[-1].split("?")[0] + sym = resolve_symbol(sym) + raw = _api_get(f"/symbols/{sym}") + items = raw.get("data", []) + return items[0] if items else {} + + # ── K-line rates ─────────────────────────────────────────────────────── + if path == "/rates" and data: + sym = resolve_symbol(data.get("symbol", "")) + tf = data.get("timeframe", "M5") + count = data.get("count", 100) + raw = _api_get("/rates/from-pos", params={ + "symbol": sym, + "timeframe": f"TIMEFRAME_{tf}", + "start_pos": 0, + "count": count, + }) + return raw.get("data", []) + + # ── Health check ─────────────────────────────────────────────────────── + if path == "/health": + return _api_get("/health") + + # ── Fallback: pass through to bridge ─────────────────────────────────── + if method == "POST" and data: + return _api_post(path, data) + return _api_get(path, params=data if isinstance(data, dict) else None) + + +def pip_size(symbol: str) -> float: + s = symbol.upper() + if "JPY" in s: + return 0.01 + if "XAU" in s or "GOLD" in s: + return 0.1 + return 0.0001 + + +def get_bars(symbol: str, tf: str = "M5", count: int = 100) -> list: + """ + Fetch OHLCV bars from Mt5Bridge /rates/from-pos. + tf: M1, M5, M15, M30, H1, H4, D1 + Returns list of dicts with: time, open, high, low, close, tick_volume + """ + sym = resolve_symbol(symbol) + raw = _api_get("/rates/from-pos", params={ + "symbol": sym, + "timeframe": f"TIMEFRAME_{tf}", + "start_pos": 0, + "count": count, + }) + items = raw.get("data", []) + if not items: + return [] + import pandas as pd + df = pd.DataFrame(items) + if "time" in df.columns: + df["time"] = pd.to_datetime(df["time"]) + df.columns = [c.lower().replace("tick_volume", "volume") for c in df.columns] + return df.to_dict("records") + + +def get_bars_by_date(symbol: str, tf: str = "H1", + date_from: str = "", date_to: str = "") -> list: + sym = resolve_symbol(symbol) + params = { + "symbol": sym, + "timeframe": f"TIMEFRAME_{tf}", + } + if date_from: + params["date_from"] = date_from + if date_to: + params["date_to"] = date_to + raw = _api_get("/rates/from-date", params=params) + items = raw.get("data", []) + if not items: + return [] + import pandas as pd + df = pd.DataFrame(items) + if "time" in df.columns: + df["time"] = pd.to_datetime(df["time"]) + df.columns = [c.lower().replace("tick_volume", "volume") for c in df.columns] + return df.to_dict("records") + + +def calc_lot(equity: float, sl_pips: float, symbol: str, risk_pct: float = 0.01) -> float: + pip_val_per_lot = 10.0 + s = symbol.upper() + if "JPY" in s: + pip_val_per_lot = 9.0 + if "GBP" in s: + pip_val_per_lot = 12.5 + if "XAU" in s or "GOLD" in s: + pip_val_per_lot = 1.0 + raw_lot = (equity * risk_pct) / (sl_pips * pip_val_per_lot) if sl_pips > 0 else 0.01 + raw_lot = max(0.01, min(raw_lot, 5.0)) + return round(round(raw_lot / 0.01) * 0.01, 2) \ No newline at end of file diff --git a/core/tg_notify.py b/core/tg_notify.py new file mode 100644 index 0000000..3293ec5 --- /dev/null +++ b/core/tg_notify.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Quick Telegram notification helper used by cron scripts.""" +import os, requests +from dotenv import load_dotenv +from pathlib import Path + +load_dotenv(Path(__file__).parent.parent / ".env") + +TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +CHAT = os.getenv("TELEGRAM_CHAT_ID", "") + +def notify(msg: str) -> bool: + if not TOKEN or not CHAT: + return False + try: + r = requests.post( + f"https://api.telegram.org/bot{TOKEN}/sendMessage", + json={"chat_id": CHAT, "text": msg, "parse_mode": "Markdown"}, + timeout=10, + ) + return r.status_code == 200 + except Exception: + return False + +if __name__ == "__main__": + import sys + notify(sys.argv[1] if len(sys.argv) > 1 else "GENESIS test notification") diff --git a/core/trading_cycle.py b/core/trading_cycle.py new file mode 100644 index 0000000..42948b8 --- /dev/null +++ b/core/trading_cycle.py @@ -0,0 +1,1015 @@ +#!/usr/bin/env python3 +""" +GENESIS Enhanced Autonomous Trading Cycle v2.1 — Phase 1 Safety Upgrades +Data sources: MT5 Bridge, pandas-ta, Twelve Data, FRED, CNN Fear & Greed, DXY +""" +import os, json, time, requests, logging, sys +from datetime import datetime, timezone, timedelta +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from mt5_bridge import bridge as _bridge + +# ─── Config ─────────────────────────────────────────────────── +OPENAI_KEY = os.getenv("OPENAI_API_KEY") +OPENAI_BASE = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") +HERMES_MODEL = os.getenv("HERMES_MODEL", "gpt-4o-mini") +TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") +TWELVE_KEY = os.getenv("TWELVE_DATA_API_KEY", os.getenv("TWELVE_DATA_KEY", "")) +FRED_KEY = os.getenv("FRED_API_KEY", "") + +SYMBOLS = ["EURUSDxx", "XAUUSDxx", "GBPUSDxx", "GBPJPYxx", "USDJPYxx"] +TD_SYMBOLS = {"EURUSDxx":"EUR/USD","XAUUSDxx":"XAU/USD","GBPUSDxx":"GBP/USD","GBPJPYxx":"GBP/JPY","USDJPYxx":"USD/JPY"} +RISK_PCT = 0.01 +JOURNAL = Path("/var/log/hermes/trade_journal.jsonl") +CACHE_FILE = Path("/tmp/genesis_cache.json") + +# Full symbol map: MT5 broker symbol → Yahoo Finance ticker +YF_MAP = { + "EURUSDxx": "EURUSD=X", "GBPUSDxx": "GBPUSD=X", "USDJPYxx": "USDJPY=X", + "XAUUSDxx": "GC=F", "GBPJPYxx": "GBPJPY=X", "AUDUSDxx": "AUDUSD=X", + "USDCHFxx": "USDCHF=X", "USDCADxx": "USDCAD=X", "EURJPYxx": "EURJPY=X", + "NZDUSDxx": "NZDUSD=X", "EURGBPxx": "EURGBP=X", "XAGUSDxx": "SI=F", + "USOILxx": "CL=F", "NAS100xx": "NQ=F", "US30xx": "YM=F", + "SPX500xx": "ES=F", "GER40xx": "FDAX=F", "BTCUSDxx": "BTC-USD", + "ETHUSDxx": "ETH-USD", + "EURUSD": "EURUSD=X", "GBPUSD": "GBPUSD=X", "USDJPY": "USDJPY=X", + "XAUUSD": "GC=F", "GBPJPY": "GBPJPY=X", "AUDUSD": "AUDUSD=X", + "USDCHF": "USDCHF=X", "USDCAD": "USDCAD=X", "EURJPY": "EURJPY=X", + "NZDUSD": "NZDUSD=X", "EURGBP": "EURGBP=X", "XAGUSD": "SI=F", + "USOIL": "CL=F", "NAS100": "NQ=F", "US30": "YM=F", + "SPX500": "ES=F", "GER40": "FDAX=F", "BTCUSD": "BTC-USD", + "ETHUSD": "ETH-USD", +} +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/hermes/trading_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[1] / "logs" / "hermes" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "trading_cycle.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) + +# ─── Cache (avoid hammering external APIs) ──────────────────── +def load_cache(): + 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)) + +# ─── Helpers ────────────────────────────────────────────────── +def tg(msg): + 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 Exception as e: log.error(f"TG: {e}") + +def calc_risk_eur(equity: float) -> float: + """Dynamic 1% risk sizing — grows with account, compounds automatically.""" + return round(equity * RISK_PCT, 2) + +# ─── Phase 1: Pre-flight Health Check ───────────────────────── +def preflight_check() -> bool: + """Verify all critical systems are alive before any trading logic runs.""" + failures = [] + + # Check 1: Mt5Bridge + try: + d = _bridge("/balance") + if d.get("error"): + failures.append(f"Bridge error: {d.get('error')}") + except Exception as e: + failures.append(f"Bridge unreachable: {e}") + + # Check 3: LLM API + try: + r = requests.get(f"{OPENAI_BASE.replace('/v1','')}", + headers={"Authorization": f"Bearer {OPENAI_KEY}"}, + timeout=5) + except Exception as e: + failures.append(f"LLM API unreachable: {e}") + + if failures: + msg = "⚠️ *GENESIS PRE-FLIGHT FAILED*\n" + "\n".join(f"- {f}" for f in failures) + log.error(f"Pre-flight failures: {failures}") + tg(msg) + return False + + log.info("Pre-flight: all systems nominal") + return True + +def bridge(path, method="GET", data=None): + return _bridge(path, method, data) + +# ─── Phase 2: Emergency Close (retry loop for open positions) ── +def emergency_close_with_retry(ticket: str, symbol: str): + """If bridge drops while a trade is open, retry for 5 min then alert.""" + for attempt in range(10): # 10 x 30s = 5 minutes + try: + result = bridge("/close", "POST", {"ticket": ticket}) + if result.get("message") == "ok" or result.get("ticket"): + log.info(f"Emergency close succeeded on attempt {attempt+1}") + tg(f"🚨 *GENESIS EMERGENCY CLOSE*: {symbol} closed after {attempt+1} retries.") + return True + except Exception as e: + log.error(f"Emergency close attempt {attempt+1} failed: {e}") + time.sleep(30) + tg(f"🚨 *GENESIS CRITICAL*: Cannot close {symbol} (ticket {ticket}) after 5min of retries.\nManual intervention required NOW!") + return False + + + +YF_TF = {"M15": "15m", "H1": "1h", "H4": "4h", "D1": "1d"} + +# ─── yfinance Multi-Timeframe Bars (MT5 Bars API is VPS IP-blocked) ── +def get_bars(symbol, tf="H1", count=100): + try: + import yfinance as yf, pandas as pd + yf_sym = YF_MAP.get(symbol, symbol.replace("xx", "=X")) + interval = YF_TF.get(tf, "1h") + period = {"15m": "5d", "1h": "60d", "4h": "60d", "1d": "365d"}.get(interval, "60d") + 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] + df = df.rename(columns={"adj close": "close"}) + df = df.dropna().tail(count).reset_index() + return df.to_dict("records") + except Exception as e: + log.error(f"get_bars {symbol}/{tf}: {e}") + return [] + +# ─── Currency Strength Index (all 8 majors via yfinance, cached 10min) ──── +def get_currency_strength(): + cache = load_cache() + now = time.time() + if "cs" in cache and now - cache["cs"].get("ts", 0) < 600: + return cache["cs"].get("data", {}) + try: + import yfinance as yf, pandas as pd + pairs = { + "EURUSD=X": ("EUR","USD"), "GBPUSD=X": ("GBP","USD"), "USDJPY=X": ("USD","JPY"), + "USDCHF=X": ("USD","CHF"), "AUDUSD=X": ("AUD","USD"), "USDCAD=X": ("USD","CAD"), + "NZDUSD=X": ("NZD","USD"), "EURGBP=X": ("EUR","GBP"), "EURJPY=X": ("EUR","JPY"), + "GBPJPY=X": ("GBP","JPY"), "AUDJPY=X": ("AUD","JPY"), "CADJPY=X": ("CAD","JPY"), + } + tickers = list(pairs.keys()) + hist = yf.download(tickers, period="2d", interval="1h", progress=False, auto_adjust=True) + closes = hist["Close"] if "Close" in hist else hist + if isinstance(closes.columns, pd.MultiIndex): closes.columns = closes.columns.get_level_values(0) + strength = {c: 0.0 for c in ["EUR","GBP","USD","JPY","CHF","AUD","CAD","NZD"]} + counts = {c: 0 for c in strength} + for ticker, (base, quote) in pairs.items(): + if ticker not in closes.columns: continue + pct = closes[ticker].pct_change(periods=4).iloc[-1] + if pd.isna(pct): continue + strength[base] = strength.get(base, 0) + float(pct) + strength[quote] = strength.get(quote, 0) - float(pct) + counts[base] = counts.get(base, 0) + 1 + counts[quote] = counts.get(quote, 0) + 1 + result = {c: round(strength[c]/counts[c]*100, 3) if counts[c] > 0 else 0 for c in strength} + cache["cs"] = {"ts": now, "data": result} + save_cache(cache) + return result + except Exception as e: + log.error(f"CurrencyStrength: {e}") + return {} + +# ─── Multi-asset snapshot via yfinance (indices, gold, oil, crypto) ── +def get_asset_snapshot(): + cache = load_cache() + now = time.time() + if "snap" in cache and now - cache["snap"].get("ts", 0) < 300: + return cache["snap"].get("data", {}) + try: + import yfinance as yf + tickers = {"SPX": "^GSPC", "NASDAQ": "^IXIC", "DOW": "^DJI", "VIX": "^VIX", + "GOLD": "GC=F", "OIL": "CL=F", "DXY": "DX-Y.NYB", + "BTC": "BTC-USD", "SILVER": "SI=F", "BONDS_10Y": "^TNX"} + result = {} + for name, sym in tickers.items(): + try: + h = yf.Ticker(sym).history(period="2d", interval="1h") + if not h.empty: + c = float(h["Close"].iloc[-1]) + prev = float(h["Close"].iloc[-2]) if len(h) > 1 else c + result[name] = {"price": round(c, 4), "change_pct": round((c-prev)/prev*100, 3)} + except: pass + cache["snap"] = {"ts": now, "data": result} + save_cache(cache) + return result + except Exception as e: + log.error(f"AssetSnapshot: {e}") + return {} + + + +# ─── CFTC COT Report (Hedge Fund Positioning, weekly, cached 6h) ────────────── +def get_cot_positioning(): + """Download CFTC Commitment of Traders (Leveraged Money = hedge funds). + This shows HOW hedge funds are positioned — the single highest quality + directional signal for medium-term forex forecasting.""" + cache = load_cache() + now = time.time() + if "cot" in cache and now - cache["cot"].get("ts", 0) < 21600: + return cache["cot"].get("data", {}) + try: + import zipfile, io, pandas as pd + year = datetime.now(timezone.utc).strftime("%Y") + url = f"https://www.cftc.gov/files/dea/history/fut_fin_xls_{year}.zip" + r = requests.get(url, timeout=30) + if r.status_code != 200: + return {} + with zipfile.ZipFile(io.BytesIO(r.content)) as z: + fname = [f for f in z.namelist() if f.endswith(".xls")][0] + df = pd.read_excel(io.BytesIO(z.read(fname)), engine="xlrd") + + date_col = "As_of_Date_In_Form_YYMMDD" + name_col = "Market_and_Exchange_Names" + long_col = "Lev_Money_Positions_Long_All" + short_col = "Lev_Money_Positions_Short_All" + chg_l_col = "Change_in_Lev_Money_Long_All" + chg_s_col = "Change_in_Lev_Money_Short_All" + + latest = df[date_col].max() + df_latest = df[df[date_col] == latest] + report_date = str(latest) + + result = {"report_date": report_date} + pairs_map = { + "EURO FX": "EUR", "BRITISH POUND": "GBP", "JAPANESE YEN": "JPY", + "SWISS FRANC": "CHF", "CANADIAN DOLLAR": "CAD", "AUSTRALIAN": "AUD", + "NZ DOLLAR": "NZD", "GOLD - COMMODITY": "XAU", + } + for keyword, ccy in pairs_map.items(): + rows = df_latest[df_latest[name_col].str.contains(keyword, case=False, na=False)] + if rows.empty: continue + r_row = rows.iloc[0] + try: + longs = int(r_row[long_col]) + shorts = int(r_row[short_col]) + chg_l = int(r_row[chg_l_col]) + chg_s = int(r_row[chg_s_col]) + net = longs - shorts + total = longs + shorts + bull_pct = round(longs / total * 100, 1) if total > 0 else 50 + result[ccy] = { + "longs": longs, "shorts": shorts, "net": net, + "bull_pct": bull_pct, + "wk_change": chg_l - chg_s, # net change this week + "bias": "bullish" if net > 0 else "bearish", + } + except: pass + cache["cot"] = {"ts": now, "data": result} + save_cache(cache) + log.info(f"COT loaded: {report_date} | {len(result)-1} instruments") + return result + except Exception as e: + log.error(f"COT: {e}") + return {} + +# ─── Interest Rate Differentials (2Y bond yields, cached 6h) ───────────────── +def get_rate_differentials(): + """2-year government bond yield differentials drive short-term FX flows. + The pair with the highest positive differential attracts carry trade inflows.""" + cache = load_cache() + now = time.time() + if "rates" in cache and now - cache["rates"].get("ts", 0) < 21600: + return cache["rates"].get("data", {}) + # FRED series IDs for 2Y government bond yields + series = { + "USD": "DGS2", # US 2Y Treasury + "EUR": "IRLTLT01EZM156N", # Euro area 10Y (2Y not available, use as proxy) + "GBP": "IRLTLT01GBM156N", # UK + "JPY": "IRLTLT01JPM156N", # Japan + "CHF": "IRLTLT01CHM156N", # Switzerland + "CAD": "IRLTLT01CAM156N", # Canada + "AUD": "IRLTLT01AUM156N", # Australia + } + yields = {} + for ccy, sid in series.items(): + val = get_fred(sid) + if val is not None: + yields[ccy] = round(float(val), 3) + + # Compute differentials for major pairs (base - quote) + diffs = {} + pair_map = { + "EURUSD": ("USD", "EUR"), "GBPUSD": ("USD", "GBP"), + "USDJPY": ("JPY", "USD"), "USDCHF": ("CHF", "USD"), + "USDCAD": ("CAD", "USD"), "AUDUSD": ("USD", "AUD"), + } + for pair, (quote_ccy, base_ccy) in pair_map.items(): + if base_ccy in yields and quote_ccy in yields: + diff = round(yields[base_ccy] - yields[quote_ccy], 3) + diffs[pair] = {"diff_pct": diff, + "favors": base_ccy if diff > 0 else quote_ccy} + + result = {"yields": yields, "differentials": diffs} + cache["rates"] = {"ts": now, "data": result} + save_cache(cache) + return result + +# ─── Key Price Levels (prev day/week OHLC — magnetic prices) ───────────────── +def get_key_levels(symbol): + """Previous day and previous week OHLC. These are the most watched price + levels by institutional traders. Markets frequently revisit them.""" + try: + import yfinance as yf, pandas as pd + yf_sym = YF_MAP.get(symbol, symbol.replace("xx", "=X")) + df = yf.download(yf_sym, period="10d", interval="1d", + progress=False, auto_adjust=True) + if df.empty or len(df) < 3: return {} + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + df.columns = [c.lower() for c in df.columns] + df = df.dropna() + prev_day = df.iloc[-2] + prev_week_df = df.iloc[-6:-1] # last 5 trading days = last week + return { + "prev_day_high": round(float(prev_day["high"]), 5), + "prev_day_low": round(float(prev_day["low"]), 5), + "prev_day_close": round(float(prev_day["close"]), 5), + "prev_week_high": round(float(prev_week_df["high"].max()), 5), + "prev_week_low": round(float(prev_week_df["low"].min()), 5), + "week_range_pct": round((prev_week_df["high"].max() - prev_week_df["low"].min()) / prev_week_df["close"].mean() * 100, 3), + } + except Exception as e: + log.error(f"KeyLevels {symbol}: {e}") + return {} + +# ─── Pair Correlation Matrix (20-day rolling, cached 30min) ────────────────── +def get_correlations(): + """20-day rolling correlation between major pairs. + Helps Hermes avoid taking the same directional bet twice (e.g. long EURUSD + AND long GBPUSD when they're 95% correlated).""" + cache = load_cache() + now = time.time() + if "corr" in cache and now - cache["corr"].get("ts", 0) < 1800: + return cache["corr"].get("data", {}) + try: + import yfinance as yf, pandas as pd + syms = {"EURUSD": "EURUSD=X", "GBPUSD": "GBPUSD=X", "USDJPY": "USDJPY=X", + "XAUUSD": "GC=F", "GBPJPY": "GBPJPY=X", "AUDUSD": "AUDUSD=X"} + hist = yf.download(list(syms.values()), period="30d", interval="1d", + progress=False, auto_adjust=True) + closes = hist["Close"] if "Close" in hist else hist + if isinstance(closes.columns, pd.MultiIndex): + closes.columns = closes.columns.get_level_values(0) + closes = closes.rename(columns={v: k for k, v in syms.items()}) + corr_matrix = closes.pct_change().dropna().tail(20).corr() + result = {} + pairs = list(syms.keys()) + for i, p1 in enumerate(pairs): + for p2 in pairs[i+1:]: + if p1 in corr_matrix.columns and p2 in corr_matrix.columns: + c = round(float(corr_matrix.loc[p1, p2]), 3) + result[f"{p1}/{p2}"] = c + cache["corr"] = {"ts": now, "data": result} + save_cache(cache) + return result + except Exception as e: + log.error(f"Correlations: {e}") + return {} + +# ─── ForexFactory Economic Calendar (this week + next week) ───────────────── +def get_forex_calendar(): + """Real economic calendar from ForexFactory JSON feed. + Far more detailed than MT5 news: includes forecast vs actual vs previous, + currency tag, and impact level for every event this and next week.""" + cache = load_cache() + now = time.time() + if "ff_cal" in cache and now - cache["ff_cal"].get("ts", 0) < 1800: + return cache["ff_cal"].get("data", []) + events = [] + for period in ["thisweek", "nextweek"]: + try: + r = requests.get(f"https://nfs.faireconomy.media/ff_calendar_{period}.json", + timeout=10, headers={"User-Agent": "Mozilla/5.0"}) + if r.status_code == 200: + events.extend(r.json()) + except: pass + cache["ff_cal"] = {"ts": now, "data": events} + save_cache(cache) + return events + +# ─── Sentiment Aggregator (multi-source, cached 15 min) ─────────────────────── +def get_sentiment_dashboard(): + """Aggregates sentiment signals from multiple independent sources into + a unified dashboard. Hermes uses this to gauge overall market mood.""" + cache = load_cache() + now = time.time() + if "sent" in cache and now - cache["sent"].get("ts", 0) < 900: + return cache["sent"].get("data", {}) + result = {} + + # 1. Crypto Fear & Greed (5-day trend — risk-on/off proxy) + try: + r = requests.get("https://api.alternative.me/fng/?limit=5", timeout=8) + items = r.json().get("data", []) + if items: + scores = [int(x["value"]) for x in items] + result["crypto_fg_today"] = scores[0] + result["crypto_fg_label"] = items[0]["value_classification"] + result["crypto_fg_trend"] = "improving" if scores[0] > scores[-1] else "deteriorating" + result["crypto_fg_3d_avg"] = round(sum(scores[:3]) / 3, 1) + except: pass + + # 2. CME Currency Futures Volume (vs 5-day avg — unusual volume = conviction) + try: + import yfinance as yf + futures = {"EUR": "6E=F", "GBP": "6B=F", "JPY": "6J=F", "AUD": "6A=F", "GOLD": "GC=F"} + vol_ratios = {} + for ccy, sym in futures.items(): + h = yf.Ticker(sym).history(period="6d", interval="1d") + if not h.empty and len(h) >= 2: + today_vol = float(h["Volume"].iloc[-1]) + avg_vol = float(h["Volume"].iloc[:-1].mean()) + if avg_vol > 0: + vol_ratios[ccy] = round(today_vol / avg_vol, 2) + result["futures_volume_ratios"] = vol_ratios + # Flag any unusually high volume (>2x avg = strong conviction) + high_vol = [f"{c}:{r}x" for c, r in vol_ratios.items() if r > 2.0] + result["high_volume_conviction"] = high_vol if high_vol else ["none"] + except: pass + + # 3. Volatility regime (VIX level interpretation) + try: + import yfinance as yf + vix_h = yf.Ticker("^VIX").history(period="5d", interval="1d") + if not vix_h.empty: + vix_now = float(vix_h["Close"].iloc[-1]) + vix_prev = float(vix_h["Close"].iloc[-2]) if len(vix_h) > 1 else vix_now + result["vix_live"] = round(vix_now, 2) + result["vix_change"] = round(vix_now - vix_prev, 2) + result["vix_regime"] = "extreme_fear" if vix_now > 35 else ("high_vol" if vix_now > 25 else ("elevated" if vix_now > 18 else "calm")) + except: pass + + # 4. Gold/JPY safe-haven demand (risk-off indicator) + try: + import yfinance as yf + for sym, name in [("GC=F", "gold_1d_pct"), ("USDJPY=X", "jpy_1d_pct")]: + h = yf.Ticker(sym).history(period="3d", interval="1d") + if len(h) >= 2: + pct = (float(h["Close"].iloc[-1]) - float(h["Close"].iloc[-2])) / float(h["Close"].iloc[-2]) * 100 + result[name] = round(pct, 3) + # Rising gold + falling USDJPY = risk-off + if "gold_1d_pct" in result and "jpy_1d_pct" in result: + risk_off_score = result["gold_1d_pct"] - result["jpy_1d_pct"] + result["risk_off_score"] = round(risk_off_score, 3) + result["risk_sentiment"] = "risk_off" if risk_off_score > 0.3 else ("risk_on" if risk_off_score < -0.3 else "neutral") + except: pass + + cache["sent"] = {"ts": now, "data": result} + save_cache(cache) + return result + +def get_news(): + try: + return get_forex_calendar() + except: + return [] + +# ─── Technical Analysis (pandas-ta from MT5 bars) ───────────── +def compute_indicators(bars): + """Compute a full suite of technical indicators from raw OHLCV bars.""" + if len(bars) < 30: + return {} + try: + import pandas as pd + import ta + df = pd.DataFrame(bars) + df.columns = [c.lower() for c in df.columns] + df = df.rename(columns={"tickvolume": "volume"}) + for col in ["close", "high", "low", "open"]: + df[col] = df[col].astype(float) + if "volume" not in df.columns: + df["volume"] = 1.0 + df["volume"] = df["volume"].astype(float) + + # ── Core Momentum ────────────────────────────────────────── + rsi = ta.momentum.rsi(df["close"], window=14) + williams_r = ta.momentum.williams_r(df["high"], df["low"], df["close"], lbp=14) + cci = ta.trend.cci(df["high"], df["low"], df["close"], window=20) + stoch_k = ta.momentum.stoch(df["high"], df["low"], df["close"], window=14) + stoch_d = ta.momentum.stoch_signal(df["high"], df["low"], df["close"], window=14) + + # ── Trend ────────────────────────────────────────────────── + macd = ta.trend.macd(df["close"]) + macd_signal = ta.trend.macd_signal(df["close"]) + macd_hist = ta.trend.macd_diff(df["close"]) + ema20 = ta.trend.ema_indicator(df["close"], window=20) + ema50 = ta.trend.ema_indicator(df["close"], window=50) + ema200 = ta.trend.ema_indicator(df["close"], window=200) + 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) + psar = ta.trend.psar_down(df["high"], df["low"], df["close"]) # Parabolic SAR + + # ── Volatility ───────────────────────────────────────────── + bb_upper = ta.volatility.bollinger_hband(df["close"], window=20) + bb_lower = ta.volatility.bollinger_lband(df["close"], window=20) + bb_mid = ta.volatility.bollinger_mavg(df["close"], window=20) + bb_pct = ta.volatility.bollinger_pband(df["close"], window=20) + atr = ta.volatility.average_true_range(df["high"], df["low"], df["close"], window=14) + keltner_u = ta.volatility.keltner_channel_hband(df["high"], df["low"], df["close"]) + keltner_l = ta.volatility.keltner_channel_lband(df["high"], df["low"], df["close"]) + + # ── Volume ───────────────────────────────────────────────── + obv = ta.volume.on_balance_volume(df["close"], df["volume"]) + + # ── Ichimoku Cloud ───────────────────────────────────────── + ich_conv = ta.trend.ichimoku_conversion_line(df["high"], df["low"]) # Tenkan-sen + ich_base = ta.trend.ichimoku_base_line(df["high"], df["low"]) # Kijun-sen + ich_a = ta.trend.ichimoku_a(df["high"], df["low"]) # Senkou A + ich_b = ta.trend.ichimoku_b(df["high"], df["low"]) # Senkou B + + # ── Pivot Points (classic daily pivots) ──────────────────── + pp = (df["high"].iloc[-2] + df["low"].iloc[-2] + df["close"].iloc[-2]) / 3 + r1 = 2 * pp - df["low"].iloc[-2] + s1 = 2 * pp - df["high"].iloc[-2] + r2 = pp + (df["high"].iloc[-2] - df["low"].iloc[-2]) + s2 = pp - (df["high"].iloc[-2] - df["low"].iloc[-2]) + + last = -1 + close_last = float(df["close"].iloc[last]) + + # Ichimoku cloud position + ich_cloud_top = max(float(ich_a.iloc[last] or 0), float(ich_b.iloc[last] or 0)) + ich_cloud_bottom = min(float(ich_a.iloc[last] or 0), float(ich_b.iloc[last] or 0)) + ich_position = "above_cloud" if close_last > ich_cloud_top else ("below_cloud" if close_last < ich_cloud_bottom else "in_cloud") + + def safe(series): return round(float(series.iloc[last]), 5) if series is not None and not series.isna().all() else None + + return { + # Momentum + "rsi": safe(rsi), + "williams_r": safe(williams_r), + "cci": safe(cci), + "stoch_k": safe(stoch_k), + "stoch_d": safe(stoch_d), + # Trend + "macd": safe(macd), + "macd_signal": safe(macd_signal), + "macd_hist": safe(macd_hist), + "ema20": safe(ema20), + "ema50": safe(ema50), + "ema200": safe(ema200), + "adx": safe(adx), + "adx_plus": safe(adx_pos), + "adx_minus": safe(adx_neg), + "psar": safe(psar), + # Volatility + "bb_upper": safe(bb_upper), + "bb_lower": safe(bb_lower), + "bb_mid": safe(bb_mid), + "bb_pct": safe(bb_pct), # 0=at lower band, 1=at upper band + "atr": safe(atr), + "keltner_u": safe(keltner_u), + "keltner_l": safe(keltner_l), + # Volume + "obv": safe(obv), + # Ichimoku + "ich_conv": safe(ich_conv), + "ich_base": safe(ich_base), + "ich_a": safe(ich_a), + "ich_b": safe(ich_b), + "ich_position": ich_position, + # Pivot Points + "pivot": round(pp, 5), + "r1": round(r1, 5), + "s1": round(s1, 5), + "r2": round(r2, 5), + "s2": round(s2, 5), + # Price + "close": round(close_last, 5), + "trend": "bullish" if float(ema20.iloc[last]) > float(ema50.iloc[last]) else "bearish", + } + except Exception as e: + log.error(f"compute_indicators error: {e}") + return {} + +# ─── Twelve Data (5 indicators, cached 15 min) ───────────────── +def get_twelve_data(symbol): + td_sym = TD_SYMBOLS.get(symbol) + if not td_sym: return {} + cache = load_cache() + key = f"td_{symbol}" + now = time.time() + if key in cache and now - cache[key].get("ts", 0) < 900: + return cache[key].get("data", {}) + data = {} + try: + # RSI + r = requests.get("https://api.twelvedata.com/rsi", timeout=10, + params={"symbol": td_sym, "interval": "1h", "apikey": TWELVE_KEY, "outputsize": 1}) + rsi_val = r.json().get("values", [{}])[0].get("rsi") if r.status_code == 200 else None + if rsi_val: data["td_rsi_1h"] = round(float(rsi_val), 2) + except: pass + try: + # MACD + r = requests.get("https://api.twelvedata.com/macd", timeout=10, + params={"symbol": td_sym, "interval": "1h", "apikey": TWELVE_KEY, "outputsize": 1}) + mv = r.json().get("values", [{}])[0] if r.status_code == 200 else {} + if mv.get("macd"): data["td_macd"] = round(float(mv["macd"]), 5) + if mv.get("macd_signal"): data["td_macd_signal"] = round(float(mv["macd_signal"]), 5) + except: pass + try: + # ADX (trend strength) + r = requests.get("https://api.twelvedata.com/adx", timeout=10, + params={"symbol": td_sym, "interval": "1h", "apikey": TWELVE_KEY, "outputsize": 1}) + adx_val = r.json().get("values", [{}])[0].get("adx") if r.status_code == 200 else None + if adx_val: data["td_adx_1h"] = round(float(adx_val), 2) + except: pass + try: + # Stochastic + r = requests.get("https://api.twelvedata.com/stoch", timeout=10, + params={"symbol": td_sym, "interval": "1h", "apikey": TWELVE_KEY, "outputsize": 1}) + sv = r.json().get("values", [{}])[0] if r.status_code == 200 else {} + if sv.get("slow_k"): data["td_stoch_k"] = round(float(sv["slow_k"]), 2) + if sv.get("slow_d"): data["td_stoch_d"] = round(float(sv["slow_d"]), 2) + except: pass + cache[key] = {"ts": now, "data": data} + save_cache(cache) + return data + +# ─── CNN Fear & Greed (cached 30 min) ───────────────────────── +def get_fear_greed(): + cache = load_cache() + now = time.time() + if "fg" in cache and now - cache["fg"].get("ts", 0) < 1800: + return cache["fg"].get("data", {}) + try: + r = requests.get("https://production.dataviz.cnn.io/index/fearandgreed/graphdata", timeout=10, + headers={"User-Agent": "Mozilla/5.0"}) + d = r.json() + score = d["fear_and_greed"]["score"] + rating = d["fear_and_greed"]["rating"] + data = {"fear_greed_score": round(score, 1), "fear_greed_rating": rating} + cache["fg"] = {"ts": now, "data": data} + save_cache(cache) + return data + except Exception as e: + log.error(f"Fear&Greed: {e}") + return {} + +# ─── FRED Macroeconomic Data (cached 6 hours) ───────────────── +def get_fred(series_id): + cache = load_cache() + key = f"fred_{series_id}" + now = time.time() + if key in cache and now - cache[key].get("ts", 0) < 21600: + return cache[key].get("val") + try: + r = requests.get("https://api.stlouisfed.org/fred/series/observations", timeout=10, + params={"series_id": series_id, "api_key": FRED_KEY, + "sort_order": "desc", "limit": 1, "file_type": "json"}) + val = float(r.json()["observations"][0]["value"]) + cache[key] = {"ts": now, "val": val} + save_cache(cache) + return val + except: return None + +def get_macro(): + return { + "fed_funds_rate": get_fred("DFF"), + "yield_curve_10y2y": get_fred("T10Y2Y"), + "yield_curve_10y3m": get_fred("T10Y3M"), + "vix": get_fred("VIXCLS"), + "us_cpi_yoy": get_fred("CPIAUCSL"), + "us_unemployment": get_fred("UNRATE"), + "us_gdp_growth": get_fred("A191RL1Q225SBEA"), + "eur_cpi": get_fred("CP0000EZ19M086NEST"), + "us_m2_money_supply": get_fred("M2SL"), + "us_retail_sales_mom": get_fred("RSXFS"), + } + +# ─── DXY via yfinance (cached 10 min) ───────────────────────── +def get_dxy(): + cache = load_cache() + now = time.time() + if "dxy" in cache and now - cache["dxy"].get("ts", 0) < 600: + return cache["dxy"].get("val") + try: + import yfinance as yf + dxy = yf.Ticker("DX-Y.NYB") + hist = dxy.history(period="2d", interval="1h") + if not hist.empty: + val = round(float(hist["Close"].iloc[-1]), 3) + cache["dxy"] = {"ts": now, "val": val} + save_cache(cache) + return val + except Exception as e: + log.error(f"DXY: {e}") + return None + +# ─── Trade Journal ──────────────────────────────────────────── +def journal_write(entry: dict): + with open(JOURNAL, "a") as f: + f.write(json.dumps(entry) + "\n") + +def journal_read_last(n=10): + if not JOURNAL.exists(): return [] + lines = JOURNAL.read_text().strip().split("\n") + return [json.loads(l) for l in lines[-n:] if l] + +def update_journal_results(closed_orders): + """Match closed orders to open journal entries and update P&L.""" + if not JOURNAL.exists(): return + lines = JOURNAL.read_text().strip().split("\n") + updated = [] + closed_tickets = {str(o.get("ticket")): o for o in closed_orders if isinstance(o, dict)} + for line in lines: + if not line: continue + try: + entry = json.loads(line) + ticket = str(entry.get("ticket")) + if ticket in closed_tickets and "result" not in entry: + o = closed_tickets[ticket] + entry["result"] = "win" if o.get("profit", 0) > 0 else "loss" + entry["pnl"] = round(o.get("profit", 0), 2) + entry["closed"] = True + except: pass + updated.append(json.dumps(entry)) + JOURNAL.write_text("\n".join(updated) + "\n") + +# ─── Main Cycle ─────────────────────────────────────────────── +def run_cycle(): + now_utc = datetime.now(timezone.utc) + log.info(f"=== Cycle {now_utc.strftime('%Y-%m-%d %H:%M')} ===") + + # Market hours check + wd, hr = now_utc.weekday(), now_utc.hour + if (wd == 4 and hr >= 22) or wd == 5 or (wd == 6 and hr < 22): + log.info("Market closed — weekend. Skipping.") + return + + # ── Phase 1: Pre-flight health check before ANY trading logic ── + if not preflight_check(): + return # Alert already sent inside preflight_check() + + # 1. Account (real data) + account = bridge("/balance") + if "error" in account: + tg("⚠️ *GENESIS*: Bridge unreachable!") + return + balance = account.get("balance", 0) + equity = account.get("equity", 0) + max_risk_eur = calc_risk_eur(equity) + + # 2. Open positions + positions = bridge("/positions") + if isinstance(positions, list) and len(positions) > 0: + pos = positions[0] + log.info(f"Position open — skipping new trade. P&L: {pos.get('profit')}") + # ── Phase 2: Guard open trade — if bridge becomes unreachable, emergency close + if "error" in bridge("/balance"): # double-check bridge is alive + emergency_close_with_retry(str(pos.get("ticket")), pos.get("symbol", "")) + return + + # 3. Update journal with closed orders + try: + today = now_utc.strftime("%Y-%m-%dT00:00:00") + tomorrow = (now_utc.replace(hour=0, minute=0, second=0) + timedelta(days=2)).strftime("%Y-%m-%dT00:00:00") + closed = _bridge("/history") + if isinstance(closed, list): + update_journal_results(closed) + except Exception as e: + log.error(f"ClosedOrders fetch failed: {e}") + + # 4. Economic calendar + news = get_news() + blocked_ccys, upcoming_high = set(), [] + for evt in news: + try: + et = datetime.fromisoformat(evt["date"]).astimezone(timezone.utc) + mins = (et - now_utc).total_seconds() / 60 + if evt.get("impact") == "High" and -30 < mins < 120: + ccy = evt.get("currency", evt.get("country", "")) + blocked_ccys.add(ccy) + upcoming_high.append(f"{evt.get('title','')} ({ccy}) in {int(mins)}min") + except: pass + + # 5. Full intelligence layer + macro = get_macro() + dxy = get_dxy() + fg = get_fear_greed() + cstrength = get_currency_strength() + assets = get_asset_snapshot() + cot = get_cot_positioning() + rates = get_rate_differentials() + correlations = get_correlations() + sentiment = get_sentiment_dashboard() + ff_calendar = get_forex_calendar() + log.info(f"Intelligence loaded | VIX={sentiment.get('vix_live')} | CryptoFG={sentiment.get('crypto_fg_today')} | Risk={sentiment.get('risk_sentiment')}") + + # 6. Trade history (last 20 for learning) + past_trades = journal_read_last(20) + wins = sum(1 for t in past_trades if t.get("result") == "win") + losses = sum(1 for t in past_trades if t.get("result") == "loss") + + # 7. Expand symbol universe from MT5 + multi-TF indicators + available_symbols = bridge("/symbols") + if isinstance(available_symbols, list): + tradeable = [str(s) for s in available_symbols + if any(k in str(s) for k in ["USD","EUR","GBP","JPY","XAU","XAG","NAS","US30","SPX","GER","BTC","ETH","OIL"])] + scan_symbols = list(dict.fromkeys(SYMBOLS + tradeable[:6])) + else: + scan_symbols = SYMBOLS + + market_data = {} + for sym in scan_symbols: + quote = bridge(f"/quote?symbol={sym}") + if "error" in quote or not quote.get("bid"): continue + + bars_m15 = get_bars(sym, "M15", 100) + bars_h1 = get_bars(sym, "H1", 100) + bars_h4 = get_bars(sym, "H4", 80) + bars_d1 = get_bars(sym, "D1", 60) + ind_m15 = compute_indicators(bars_m15) + ind_h1 = compute_indicators(bars_h1) + ind_h4 = compute_indicators(bars_h4) + ind_d1 = compute_indicators(bars_d1) + td_data = get_twelve_data(sym) + key_levels = get_key_levels(sym) + + market_data[sym] = { + "bid": quote.get("bid"), + "ask": quote.get("ask"), + "spread": round((quote.get("ask", 0) - quote.get("bid", 0)) * 10000, 1), + "m15": ind_m15, + "h1": ind_h1, + "h4": ind_h4, + "d1": ind_d1, + "key_levels": key_levels, + "twelve_data": td_data, + } + time.sleep(0.3) + + if not market_data: + log.error("No market data") + return + + + # 9. Build prompt — rich intelligence feed, zero rules + strongest = sorted(cstrength.items(), key=lambda x: x[1], reverse=True)[:3] if cstrength else [] + weakest = sorted(cstrength.items(), key=lambda x: x[1])[:3] if cstrength else [] + prompt = f"""You are Hermes, an elite autonomous forex AI with full access to real market intelligence. +You have complete freedom. No rules. No limits. Use everything below to make your best autonomous decision. + +ACCOUNT: Balance=€{balance:.2f} | Equity=€{equity:.2f} | 1% risk = €{max_risk_eur:.2f} +SESSION: {now_utc.strftime('%Y-%m-%d %H:%M')} UTC | {['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'][wd]} + +--- MACRO INTELLIGENCE --- +Fed Rate: {macro.get('fed_funds_rate')}% | Yield Curve 10y-2y: {macro.get('yield_curve_10y2y')} | 10y-3m: {macro.get('yield_curve_10y3m')} +US CPI YoY: {macro.get('us_cpi_yoy')} | US Unemployment: {macro.get('us_unemployment')}% | GDP Growth: {macro.get('us_gdp_growth')}% +EUR CPI: {macro.get('eur_cpi')} | M2: {macro.get('us_m2_money_supply')}B | Retail Sales: {macro.get('us_retail_sales_mom')} +VIX: {macro.get('vix')} | CNN Fear & Greed: {fg.get('fear_greed_score')}/100 ({fg.get('fear_greed_rating')}) + +--- ASSET PRICES & MOMENTUM --- +{json.dumps(assets, indent=2)} + +--- CURRENCY STRENGTH (4h momentum, % vs peers) --- +Strongest: {strongest} +Weakest: {weakest} +Full: {cstrength} + +--- CFTC COT REPORT (Hedge Fund Positioning — latest: {cot.get('report_date','?')}) --- +{json.dumps({k: v for k, v in cot.items() if k != 'report_date'}, indent=2)} + +--- INTEREST RATE DIFFERENTIALS (2Y bond yields — carry trade flows) --- +Yields: {rates.get('yields', {})} +Pair Differentials: {rates.get('differentials', {})} + +--- PAIR CORRELATIONS (20-day rolling, avoid doubling up correlated positions) --- +{json.dumps(correlations, indent=2)} + +--- SENTIMENT DASHBOARD (multi-source aggregation) --- +{json.dumps(sentiment, indent=2)} + +--- ECONOMIC CALENDAR (ForexFactory — high impact events this & next week) --- +{json.dumps([{"date":e.get("date"),"currency":e.get("currency"),"event":e.get("title"),"impact":e.get("impact"),"forecast":e.get("forecast"),"previous":e.get("previous"),"actual":e.get("actual")} for e in ff_calendar if e.get("impact") in ["High","Medium"]][:20], indent=2)} + +--- HIGH-IMPACT NEWS (MT5, next 2h) --- +{upcoming_high if upcoming_high else 'None'} + +--- HERMES TRADE HISTORY (last {len(past_trades)} trades: {wins}W / {losses}L) --- +{json.dumps([{{'sym':t.get('symbol'),'dir':t.get('direction'),'result':t.get('result'),'pnl':t.get('pnl'),'entry':t.get('entry')}} for t in past_trades[-10:]], indent=2)} + +--- LIVE MARKET DATA (M15/H1/H4/D1 + TwelveData + Key Levels on all scanned symbols) --- +{json.dumps(market_data, indent=2)} + +Respond ONLY with valid JSON: +{{ + "action": "trade" or "wait", + "reason": "your full autonomous reasoning", + "symbol": "MT5 symbol e.g. EURUSDxx or null", + "direction": "Buy" or "Sell" or null, + "stop_loss": number or null, + "take_profit": number or null, + "volume": number or null, + "confidence": "low/medium/high", + "signals_aligned": ["signals you identified"] +}}""" + + try: + payload = {"model": HERMES_MODEL, "messages": [{"role": "user", "content": prompt}], + "max_tokens": 600, "temperature": 0.2} + json_format = os.getenv("HERMES_JSON_FORMAT", "true").lower() in ("true", "1", "yes") + if json_format: + payload["response_format"] = {"type": "json_object"} + r = requests.post(f"{OPENAI_BASE}/chat/completions", + headers={"Authorization": f"Bearer {OPENAI_KEY}", "Content-Type": "application/json"}, + json=payload, timeout=60) + content = r.json()["choices"][0]["message"]["content"] + import re as _re + content = _re.sub(r'^[\s\S]*?\s*', '', content) + content = _re.sub(r'^\u003cthink\u003e[\s\S]*?\u003c/think\u003e\s*', '', content) + content = content.strip() + decision = json.loads(content) + except json.JSONDecodeError: + try: + import re + m = re.search(r'\{[\s\S]*\}', content) + decision = json.loads(m.group()) if m else {} + except: + log.error(f"Failed to parse LLM response as JSON") + return + except Exception as e: + log.error(f"LLM error: {e}") + return + + log.info(f"Decision: {decision}") + + # Execute — Hermes decides everything + if decision.get("action") == "trade": + sym = decision.get("symbol") + dire = decision.get("direction") + sl = decision.get("stop_loss") + tp = decision.get("take_profit") + vol = decision.get("volume", 0.1) + + if not all([sym, dire, sl, tp]): return + + # Block check + for ccy in blocked_ccys: + if ccy and ccy[:2] in sym.upper(): + tg(f"⏸ *GENESIS*: Blocked {sym} — {ccy} news in 2h") + return + + order = bridge("/market", "POST", {"symbol": sym, "volume": vol, "type": dire, + "stop_loss": sl, "take_profit": tp, "comment": "GENESIS-v2"}) + log.info(f"Order: {order}") + + ticket = order.get("ticket") or order.get("Ticket") + if ticket: + # ── Phase 1: Partial fill verification ────────────────── + time.sleep(2) # Give broker 2s to settle the order + live_positions = bridge("/positions") + actual_vol = 0.0 + if isinstance(live_positions, list): + for p in live_positions: + if str(p.get("ticket")) == str(ticket): + actual_vol = p.get("lots", 0.0) + break + if actual_vol > 0 and abs(actual_vol - vol) > 0.001: + log.warning(f"Partial fill: requested {vol}, filled {actual_vol}") + tg(f"⚠️ *GENESIS PARTIAL FILL*: Requested {vol} lot, got {actual_vol} lot. Treating as open.") + # ──────────────────────────────────────────────────────── + + journal_write({"ticket": str(ticket), "symbol": sym, "direction": dire, + "volume": actual_vol if actual_vol > 0 else vol, + "sl": sl, "tp": tp, "entry": market_data.get(sym, {}).get("bid"), + "opened": now_utc.isoformat(), "result": None, "pnl": None, + "max_risk_eur": max_risk_eur}) + tg(f"✅ *GENESIS TRADE*\n" + f"📈 {sym} {dire} | Vol: {actual_vol if actual_vol > 0 else vol}\n" + f"Entry: {market_data.get(sym,{}).get('bid')} | SL: {sl} | TP: {tp}\n" + f"🎯 Confidence: {decision.get('confidence')}\n" + f"📊 Signals: {', '.join(decision.get('signals_aligned', []))}\n" + f"💡 {decision.get('reason','')[:150]}\n" + f"💰 Equity: €{equity:.2f} | Risk: €{max_risk_eur:.2f}") + else: + err = order.get("message", str(order)) + tg(f"⚠️ *GENESIS*: Order failed — {err}") + else: + log.info(f"No trade: {decision.get('reason','')}") + + log.info("=== Cycle complete ===") + +if __name__ == "__main__": + try: + run_cycle() + except Exception as e: + log.error(f"CRASH: {e}", exc_info=True) + tg(f"🚨 *GENESIS CRASH*: {str(e)[:200]}") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fabab6f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + genesis: + build: + context: . + dockerfile: Dockerfile + container_name: genesis + restart: unless-stopped + + # ── Mount source code + config ────────────────────────────────────────── + # Code changes only need: docker compose restart + # Dependency changes need: docker compose up -d --build + volumes: + - ./core:/opt/hermes-agent/core + - ./strategies:/opt/hermes-agent/strategies + - ./configs:/opt/hermes-agent/configs + - ./backtest:/opt/hermes-agent/backtest + - ./.env:/opt/hermes-agent/.env:ro + - genesis_logs:/var/log/hermes + - genesis_strategy_logs:/var/log + - genesis_cache:/tmp + + # ── Environment overrides (optional) ──────────────────────────────────── + environment: + - TZ=UTC + - GENESIS_MODE=live # Set to "analyze" to disable execution + + # ── Health check — confirms Mt5Bridge is reachable ────────────────────── + healthcheck: + test: ["CMD", "python3", "-c", + "import os,requests; r=requests.get(os.getenv('MT5_BRIDGE_URL','http://localhost:13485')+'/health',headers={'X-API-Key':os.getenv('MT5_BRIDGE_KEY','')},timeout=5); exit(0 if r.status_code==200 else 1)"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + # ── Resource limits ────────────────────────────────────────────────────── + mem_limit: 512m + cpus: "0.5" + + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +volumes: + genesis_logs: + genesis_strategy_logs: + genesis_cache: \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..41a660f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,194 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════════════════ +# GENESIS Docker Entrypoint +# Validates credentials → sets up cron → starts autonomous engine +# ═══════════════════════════════════════════════════════════════════════════════ +set -e + +PYTHON="/opt/hermes-agent/.venv-hermes/bin/python3" +AGENT="/opt/hermes-agent" + +echo "" +echo " ██████ ███████ ███ ██ ███████ ███████ ██ ███████" +echo " ██ ██ ████ ██ ██ ██ ██ ██" +echo " ██ ███ █████ ██ ██ ██ █████ ███████ ██ ███████" +echo " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██" +echo " ██████ ███████ ██ ████ ███████ ███████ ██ ███████" +echo "" +echo " Autonomous MT5 Trading System — Docker Mode" +echo " Powered by Mt5Bridge" +echo "─────────────────────────────────────────────────────" + +# ── Load .env ───────────────────────────────────────────────────────────────── +if [ -f /opt/hermes-agent/.env ]; then + cp /opt/hermes-agent/.env /tmp/.env.fixed + sed -i 's/\r$//' /tmp/.env.fixed + set -a + source /tmp/.env.fixed + set +a +else + echo "" + echo " ╔═══════════════════════════════════════════════════╗" + echo " ║ NO .env FILE FOUND ║" + echo " ║ ║" + echo " ║ Mount your credentials file: ║" + echo " ║ docker run -v \$(pwd)/.env:/opt/hermes-agent/.env ║" + echo " ║ ║" + echo " ║ Or generate it first: ║" + echo " ║ bash setup.sh ║" + echo " ╚═══════════════════════════════════════════════════╝" + echo "" + exit 1 +fi + +# ── Validate required credentials ───────────────────────────────────────────── +echo "" +MISSING=0 +PLACEHOLDER_PATTERN="YOUR_|CHANGE_ME|example|placeholder|sk-your" + +check_var() { + local var_name="$1" + local var_val="${!var_name}" + if [ -z "$var_val" ] || echo "$var_val" | grep -qiE "$PLACEHOLDER_PATTERN"; then + echo " ✗ $var_name — not configured" + MISSING=$((MISSING + 1)) + else + local masked="${var_val:0:8}••••" + echo " ✓ $var_name = $masked" + fi +} + +echo " Credential check:" +check_var MT5_BRIDGE_URL +check_var MT5_BRIDGE_KEY +check_var OPENAI_API_KEY +check_var TELEGRAM_BOT_TOKEN +check_var TELEGRAM_CHAT_ID + +if [ "$MISSING" -gt 0 ]; then + echo "" + echo " ╔═══════════════════════════════════════════════════════╗" + echo " ║ $MISSING required credential(s) missing. ║" + echo " ║ ║" + echo " ║ Edit your .env file and restart: ║" + echo " ║ docker compose restart ║" + echo " ╚═══════════════════════════════════════════════════════╝" + echo "" + exit 1 +fi + +# ── Verify Mt5Bridge connectivity ───────────────────────────────────────────── +echo "" +echo " Verifying Mt5Bridge connection..." +BRIDGE_URL="${MT5_BRIDGE_URL:-http://localhost:13485}" +BRIDGE_KEY="${MT5_BRIDGE_KEY:-}" + +ACCT_JSON=$(curl -s \ + -H "X-API-Key: $BRIDGE_KEY" \ + "$BRIDGE_URL/account" \ + --max-time 10 2>/dev/null || echo '{}') + +BALANCE=$(echo "$ACCT_JSON" | $PYTHON -c " +import sys, json +try: + d = json.load(sys.stdin) + items = d.get('data', []) + if items and len(items) > 0: + a = items[0] + b = a.get('balance', 0) + c = a.get('currency', 'USD') + print(f' ✓ Connected | Balance: {b:,.2f} {c}') + elif d.get('balance') is not None: + b = d.get('balance', 0) + c = d.get('currency', 'USD') + print(f' ✓ Connected | Balance: {b:,.2f} {c}') + else: + err = d.get('message', d.get('error', 'no data')) + if err: + print(f' ✗ API error: {err}') + else: + print(' ✗ No account data returned') +except Exception as e: + print(' ✗ Could not parse API response') +" 2>/dev/null || echo " ✗ Connection failed") +echo "$BALANCE" + +if echo "$BALANCE" | grep -q "✗"; then + echo "" + echo " WARNING: Mt5Bridge connection failed." + echo " Check MT5_BRIDGE_URL and MT5_BRIDGE_KEY in .env" + echo " Starting anyway — strategies will retry each cycle." + echo "" +fi + +# ── Export env for cron ──────────────────────────────────────────────────────── +printenv | grep -E "^(MT5_|TELEGRAM_|OPENAI_|HERMES_|TWELVE_|FRED_|MAX_|SYMBOL_|HTTPS_PROXY)" > /etc/environment + +# ── Install cron jobs ────────────────────────────────────────────────────────── +echo "" +echo " Installing cron jobs..." + +cat > /etc/cron.d/genesis << CRONEOF +SHELL=/bin/bash +PATH=/opt/hermes-agent/.venv-hermes/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +BASH_ENV=/etc/environment + +# GENESIS autonomous strategy scan — every 5 minutes +*/5 * * * * root cd $AGENT && $PYTHON $AGENT/core/genesis_autonomous.py >> /var/log/hermes/autonomous.log 2>&1 + +# Hermes LLM macro cycle — every hour +0 * * * * root cd $AGENT && $PYTHON $AGENT/core/trading_cycle.py >> /var/log/hermes/trading_cycle.log 2>&1 + +# Brain feed Telegram report — every hour at :30 +30 * * * * root cd $AGENT && $PYTHON $AGENT/core/genesis_brain_feed.py >> /var/log/hermes/autonomous.log 2>&1 + +# Market open alert — weekdays 07:00 UTC +0 7 * * 1-5 root cd $AGENT && $PYTHON $AGENT/core/genesis_market_open.py >> /var/log/hermes/autonomous.log 2>&1 + +# Daily P&L report — 06:00 UTC +0 6 * * * root cd $AGENT && $PYTHON $AGENT/core/genesis_daily_report.py >> /var/log/hermes/autonomous.log 2>&1 + +# Heartbeat — every 10 minutes +*/10 * * * * root cd $AGENT && $PYTHON $AGENT/core/heartbeat.py >> /var/log/hermes/autonomous.log 2>&1 +CRONEOF + +chmod 644 /etc/cron.d/genesis +echo " ✓ 6 cron jobs installed" + +# ── Start Telegram Bots ────────────────────────────────────────────────────── +echo "" +echo " Starting Telegram bots..." +for bot in ares apollo athena zeus; do + BOT_SCRIPT="$AGENT/strategies/${bot}/${bot}_telegram_bot.py" + if [ -f "$BOT_SCRIPT" ]; then + nohup $PYTHON "$BOT_SCRIPT" >> /var/log/hermes/${bot}_bot.log 2>&1 & + elif [ "$bot" = "zeus" ] && [ -f "$AGENT/strategies/zeus/zeus_tool.py" ]; then + nohup $PYTHON "$AGENT/strategies/zeus/zeus_tool.py" bot >> /var/log/hermes/zeus_bot.log 2>&1 & + else + echo " ✗ ${bot}_telegram_bot not found" + continue + fi + echo " ✓ ${bot}_telegram_bot started (PID $!)" +done + +# ── Ready ───────────────────────────────────────────────────────────────────── +echo "" +echo "─────────────────────────────────────────────────────" +echo " ✓ GENESIS is running" +echo "" +echo " Telegram commands:" +echo " /ares_help /apollo_help /athena_help" +echo "" +echo " CLI commands:" +echo " docker exec -it genesis ares analyze EURUSD" +echo " docker exec -it genesis zeus analyze GBPUSD" +echo " docker exec -it genesis genesis-scan EURUSD" +echo " docker exec -it genesis tail -f /var/log/hermes/autonomous.log" +echo "─────────────────────────────────────────────────────" +echo "" + +# ── Start cron + tail logs ──────────────────────────────────────────────────── +service cron start + +touch /var/log/hermes/autonomous.log /var/log/hermes/trading_cycle.log +tail -f /var/log/hermes/autonomous.log /var/log/hermes/trading_cycle.log \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..225561c --- /dev/null +++ b/install.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# GENESIS Auto-Installer +# Tested on: Ubuntu 22.04 LTS +# Usage: bash install.sh +# ───────────────────────────────────────────────────────────────────────────── + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +INSTALL_DIR="/opt/hermes-agent" +VENV="$INSTALL_DIR/.venv-hermes" +PYTHON="$VENV/bin/python3" +PIP="$VENV/bin/pip" + +echo "" +echo -e "${CYAN}${BOLD}" +echo " ██████ ███████ ███ ██ ███████ ███████ ██ ███████ " +echo " ██ ██ ████ ██ ██ ██ ██ ██ " +echo " ██ ███ █████ ██ ██ ██ █████ ███████ ██ ███████ " +echo " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ " +echo " ██████ ███████ ██ ████ ███████ ███████ ██ ███████ " +echo "" +echo -e "${NC}${BOLD} Autonomous Forex Trading System — Auto Installer${NC}" +echo -e " Powered by ${CYAN}API2TRADE${NC} (https://app.api2trade.com)" +echo "" +echo "─────────────────────────────────────────────────────" + +# ── Step 1: System packages ─────────────────────────────────────────────────── +echo -e "\n${YELLOW}[1/8] Installing system packages...${NC}" +sudo apt-get update -qq +sudo apt-get install -y -qq python3.11 python3.11-venv python3-pip git curl wget unzip + +# ── Step 2: Create install directory ───────────────────────────────────────── +echo -e "\n${YELLOW}[2/8] Creating install directory at $INSTALL_DIR...${NC}" +sudo mkdir -p "$INSTALL_DIR" +sudo chown "$USER":"$USER" "$INSTALL_DIR" + +# Copy all repo files into install dir +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cp -r "$SCRIPT_DIR"/. "$INSTALL_DIR/" +echo -e " ${GREEN}✓${NC} Files copied to $INSTALL_DIR" + +# ── Step 3: Python virtual environment ─────────────────────────────────────── +echo -e "\n${YELLOW}[3/8] Creating Python virtual environment...${NC}" +python3.11 -m venv "$VENV" +"$PIP" install --upgrade pip -q +"$PIP" install -r "$INSTALL_DIR/requirements.txt" -q +echo -e " ${GREEN}✓${NC} Virtual environment ready at $VENV" + +# ── Step 4: Log directories ─────────────────────────────────────────────────── +echo -e "\n${YELLOW}[4/8] Creating log directories...${NC}" +for strat in hermes ares apollo athena artemis zeus hephaestus; do + sudo mkdir -p "/var/log/$strat" + sudo chown "$USER":"$USER" "/var/log/$strat" + echo -e " ${GREEN}✓${NC} /var/log/$strat" +done + +# ── Step 5: Environment file ────────────────────────────────────────────────── +echo -e "\n${YELLOW}[5/8] Setting up environment variables...${NC}" +ENV_FILE="$INSTALL_DIR/.env" + +if [ -f "$ENV_FILE" ]; then + echo -e " ${CYAN}ℹ${NC} .env already exists — skipping (edit manually if needed)" +else + cp "$INSTALL_DIR/.env.example" "$ENV_FILE" + echo "" + echo -e " ${BOLD}You need to fill in your credentials.${NC}" + echo -e " Get your API2TRADE UUID + API Key at: ${CYAN}https://app.api2trade.com${NC}" + echo "" + + read -p " API2TRADE Account UUID: " uuid + read -p " API2TRADE API Key: " apikey + read -p " Telegram Bot Token: " tgtoken + read -p " Telegram Chat ID: " tgchat + read -p " OpenRouter API Key: " openkey + + sed -i "s/YOUR_API2TRADE_ACCOUNT_UUID/$uuid/g" "$ENV_FILE" + sed -i "s/YOUR_API2TRADE_API_KEY/$apikey/g" "$ENV_FILE" + sed -i "s/YOUR_TELEGRAM_BOT_TOKEN/$tgtoken/g" "$ENV_FILE" + sed -i "s/YOUR_TELEGRAM_CHAT_ID/$tgchat/g" "$ENV_FILE" + sed -i "s/YOUR_OPENAI_OR_OPENROUTER_KEY/$openkey/g" "$ENV_FILE" + + chmod 600 "$ENV_FILE" + echo -e " ${GREEN}✓${NC} .env created and secured (chmod 600)" +fi + +# ── Step 6: Config files — inject env values ────────────────────────────────── +echo -e "\n${YELLOW}[6/8] Linking config files...${NC}" +ln -sf "$INSTALL_DIR/configs" "$INSTALL_DIR/configs_active" 2>/dev/null || true +echo -e " ${GREEN}✓${NC} Configs ready at $INSTALL_DIR/configs/" +echo -e " ${CYAN}ℹ${NC} Edit configs/_config.yaml to tune each strategy" + +# ── Step 7: CLI shortcuts ───────────────────────────────────────────────────── +echo -e "\n${YELLOW}[7/8] Installing CLI shortcuts...${NC}" + +install_shortcut() { + local name=$1 + local tool=$2 + cat > "/usr/local/bin/$name" << EOF +#!/usr/bin/env bash +$PYTHON $INSTALL_DIR/$tool "\$@" +EOF + sudo chmod +x "/usr/local/bin/$name" +} + +sudo bash -c "cat > /usr/local/bin/ares << 'EOF' +#!/usr/bin/env bash +$PYTHON $INSTALL_DIR/strategies/ares/ares_tool.py \"\$@\" +EOF" +sudo bash -c "cat > /usr/local/bin/apollo << 'EOF' +#!/usr/bin/env bash +$PYTHON $INSTALL_DIR/strategies/apollo/apollo_tool.py \"\$@\" +EOF" +sudo bash -c "cat > /usr/local/bin/athena << 'EOF' +#!/usr/bin/env bash +$PYTHON $INSTALL_DIR/strategies/athena/athena_tool.py \"\$@\" +EOF" +sudo bash -c "cat > /usr/local/bin/artemis << 'EOF' +#!/usr/bin/env bash +$PYTHON $INSTALL_DIR/strategies/artemis/artemis_tool.py \"\$@\" +EOF" +sudo bash -c "cat > /usr/local/bin/zeus << 'EOF' +#!/usr/bin/env bash +$PYTHON $INSTALL_DIR/strategies/zeus/zeus_tool.py \"\$@\" +EOF" +sudo bash -c "cat > /usr/local/bin/hephaestus << 'EOF' +#!/usr/bin/env bash +$PYTHON $INSTALL_DIR/strategies/hephaestus/hephaestus_tool.py \"\$@\" +EOF" +sudo bash -c "cat > /usr/local/bin/genesis-scan << 'EOF' +#!/usr/bin/env bash +SYMBOL=\${1:-EURUSDxx} +echo \"\" +echo \"=== GENESIS FULL SCAN: \$SYMBOL ===\" +echo \"\" +for bot in ares apollo athena artemis zeus; do + echo \"--- \$bot ---\" + \$bot analyze \$SYMBOL 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); print(f' Action: {d.get(\\\"action\\\",\\\"?\\\")}' + (f' | {d.get(\\\"direction\\\",\\\"?\\\")} | R:R {d.get(\\\"rr_ratio\\\",\\\"?\\\")}' if d.get(\\\"action\\\")==\\\"trade\\\" else f' — {str(d.get(\\\"reason\\\",\\\"\\\"))[:80]}'))\" 2>/dev/null || echo \" (scan error)\" +done +echo \"\" +EOF" +sudo chmod +x /usr/local/bin/ares /usr/local/bin/apollo /usr/local/bin/athena \ + /usr/local/bin/artemis /usr/local/bin/zeus /usr/local/bin/hephaestus \ + /usr/local/bin/genesis-scan +echo -e " ${GREEN}✓${NC} CLI shortcuts: ares, apollo, athena, artemis, zeus, hephaestus, genesis-scan" + +# ── Step 8: Cron jobs ───────────────────────────────────────────────────────── +echo -e "\n${YELLOW}[8/8] Installing cron jobs...${NC}" +CRON_TMP=$(mktemp) +crontab -l 2>/dev/null > "$CRON_TMP" || true + +# Only add if not already present +add_cron() { + local entry=$1 + grep -qF "$entry" "$CRON_TMP" || echo "$entry" >> "$CRON_TMP" +} + +add_cron "*/5 * * * * cd $INSTALL_DIR && $PYTHON $INSTALL_DIR/core/genesis_autonomous.py >> /var/log/hermes/autonomous.log 2>&1" +add_cron "0 * * * * cd $INSTALL_DIR && $PYTHON $INSTALL_DIR/core/trading_cycle.py >> /var/log/hermes/trading_cycle.log 2>&1" +add_cron "30 * * * * cd $INSTALL_DIR && $PYTHON $INSTALL_DIR/core/genesis_brain_feed.py >> /var/log/hermes/autonomous.log 2>&1" +add_cron "0 7 * * 1-5 cd $INSTALL_DIR && $PYTHON $INSTALL_DIR/core/genesis_market_open.py >> /var/log/hermes/autonomous.log 2>&1" +add_cron "0 6 * * * cd $INSTALL_DIR && $PYTHON $INSTALL_DIR/core/genesis_daily_report.py >> /var/log/hermes/autonomous.log 2>&1" +add_cron "*/10 * * * * cd $INSTALL_DIR && $PYTHON $INSTALL_DIR/core/heartbeat.py >> /var/log/hermes/autonomous.log 2>&1" + +crontab "$CRON_TMP" +rm "$CRON_TMP" +echo -e " ${GREEN}✓${NC} Cron jobs installed" + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +echo "─────────────────────────────────────────────────────" +echo -e "${GREEN}${BOLD} ✓ GENESIS installed successfully!${NC}" +echo "─────────────────────────────────────────────────────" +echo "" +echo -e " ${BOLD}Quick test:${NC}" +echo -e " ares analyze EURUSDxx" +echo -e " zeus analyze GBPUSDxx" +echo -e " genesis-scan EURUSDxx" +echo "" +echo -e " ${BOLD}Logs:${NC}" +echo -e " tail -f /var/log/hermes/autonomous.log" +echo -e " tail -f /var/log/hermes/trading_cycle.log" +echo "" +echo -e " ${BOLD}Docs:${NC}" +echo -e " ${CYAN}https://app.api2trade.com${NC}" +echo "" +echo -e " ${YELLOW}⚠ Hephaestus (Grid/Martingale) is DISABLED by default.${NC}" +echo -e " Read configs/hephaestus_config.yaml risk warning before enabling." +echo "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..43bd3b8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +# GENESIS — Python Dependencies +# Install with: pip install -r requirements.txt + +# HTTP & API +requests==2.31.0 +python-dotenv==1.0.0 + +# Config +pyyaml==6.0.1 + +# Market data +yfinance==0.2.36 +pandas==2.1.4 + +# Technical indicators +ta==0.11.0 +numpy==1.26.3 + +# Telegram bot +python-telegram-bot==20.7 + +# Scheduling (used by some telegram bots) +APScheduler==3.10.4 diff --git a/services/ares.service b/services/ares.service new file mode 100644 index 0000000..8596bb4 --- /dev/null +++ b/services/ares.service @@ -0,0 +1,23 @@ +[Unit] +Description=GENESIS Ares Strategy B — Telegram Command Bot +Documentation=https://github.com/your-repo/genesis +After=network-online.target hermes-gateway.service +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/root/GENESIS +EnvironmentFile=/root/GENESIS/hermes_openrouter_env +ExecStart=/usr/bin/python3 /root/GENESIS/ares_telegram_bot.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +# Ares bot logs +LogsDirectory=ares +LogsDirectoryMode=0755 + +[Install] +WantedBy=multi-user.target diff --git a/services/hermes_openrouter.service b/services/hermes_openrouter.service new file mode 100644 index 0000000..8729ad4 --- /dev/null +++ b/services/hermes_openrouter.service @@ -0,0 +1,20 @@ +[Unit] +Description=Hermes Agent Gateway (Telegram) +After=network.target mt5-bridge.service +Wants=mt5-bridge.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/hermes-agent +TimeoutStopSec=210 +Environment="HOME=/root" +Environment="PATH=/root/.local/bin:/opt/hermes-agent/.venv-hermes/bin:/usr/local/bin:/usr/bin:/bin" +ExecStart=/opt/hermes-agent/.venv-hermes/bin/python3 /opt/hermes-agent/hermes gateway run --accept-hooks +Restart=always +RestartSec=15 +StandardOutput=append:/var/log/hermes/hermes-gateway.log +StandardError=append:/var/log/hermes/hermes-gateway.log + +[Install] +WantedBy=multi-user.target diff --git a/services/hermes_openrouter_config.yaml b/services/hermes_openrouter_config.yaml new file mode 100644 index 0000000..0d1b0b6 --- /dev/null +++ b/services/hermes_openrouter_config.yaml @@ -0,0 +1,14 @@ +model: + default: gpt-4o-mini + provider: custom:openai-compatible +custom_providers: + - name: openai-compatible + base_url: ${OPENAI_BASE_URL:-https://api.openai.com/v1} +memory: + enabled: true + path: /root/.hermes/memory +agent: + max_iterations: 50 + shell: bash + restart_drain_timeout: 180 +version: 23 \ No newline at end of file diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..c387d51 --- /dev/null +++ b/setup.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════════════════════════════ +# GENESIS Trading System — Interactive Setup Wizard +# Powered by API2TRADE · https://app.api2trade.com +# ═══════════════════════════════════════════════════════════════════════════════ +set -e + +GENESIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$GENESIS_DIR/.env" + +# ── Colours ─────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GRN='\033[0;32m'; YLW='\033[1;33m' +BLU='\033[0;34m'; CYN='\033[0;36m'; WHT='\033[1;37m'; NC='\033[0m' + +banner() { + echo "" + echo -e "${CYN} ██████ ███████ ███ ██ ███████ ███████ ██ ███████${NC}" + echo -e "${CYN} ██ ██ ████ ██ ██ ██ ██ ██ ${NC}" + echo -e "${CYN} ██ ███ █████ ██ ██ ██ █████ ███████ ██ ███████${NC}" + echo -e "${CYN} ██ ██ ██ ██ ██ ██ ██ ██ ██ ██${NC}" + echo -e "${CYN} ██████ ███████ ██ ████ ███████ ███████ ██ ███████${NC}" + echo "" + echo -e "${WHT} Autonomous MT5 Trading System — Setup Wizard${NC}" + echo -e "${BLU} Powered by API2TRADE · https://app.api2trade.com${NC}" + echo -e " ─────────────────────────────────────────────────────" + echo "" +} + +ok() { echo -e " ${GRN}✓${NC} $1"; } +warn() { echo -e " ${YLW}⚠${NC} $1"; } +err() { echo -e " ${RED}✗${NC} $1"; } +hdr() { echo -e "\n ${WHT}$1${NC}"; echo " $(printf '─%.0s' {1..50})"; } + +banner + +# ── Check if .env already configured ───────────────────────────────────────── +if [ -f "$ENV_FILE" ] && grep -q "^MT5_ACCOUNT_UUID=[a-f0-9-]" "$ENV_FILE" 2>/dev/null; then + echo -e " ${GRN}Existing .env found.${NC}" + echo "" + read -rp " Re-run setup and overwrite? [y/N]: " REDO + [[ "$REDO" =~ ^[Yy]$ ]] || { echo " Skipping setup."; exit 0; } + echo "" +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 1: API2TRADE CREDENTIALS +# ═══════════════════════════════════════════════════════════════════════════════ +hdr "Step 1 — API2TRADE Account" +echo "" +echo -e " Sign up at ${BLU}https://app.api2trade.com${NC} if you haven't already." +echo -e " You need: ${WHT}Account UUID${NC} and ${WHT}API Key${NC} from your dashboard." +echo -e " Cost: ${GRN}€12/month per connected MT5 account${NC}" +echo "" + +read -rp " API2TRADE Account UUID (from dashboard): " MT5_ACCOUNT_UUID +while [[ ! "$MT5_ACCOUNT_UUID" =~ ^[0-9a-f-]{36}$ ]]; do + err "Invalid UUID format. Should look like: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + read -rp " API2TRADE Account UUID: " MT5_ACCOUNT_UUID +done + +read -rp " API2TRADE API Key: " MT5_API_KEY +while [[ ${#MT5_API_KEY} -lt 10 ]]; do + err "API key looks too short. Check your API2TRADE dashboard." + read -rp " API2TRADE API Key: " MT5_API_KEY +done + +read -rp " API2TRADE Username (from dashboard): " MT5_API_USER +read -rsp " API2TRADE Password (hidden): " MT5_API_PASS +echo "" + +MT5_API_URL="https://mt5.mt4api.dev" + +# ── Verify API2TRADE connection ─────────────────────────────────────────────── +echo "" +echo " Verifying API2TRADE credentials..." +HTTP_STATUS=$(curl -s -o /tmp/genesis_api_check.json -w "%{http_code}" \ + --user "$MT5_API_USER:$MT5_API_PASS" \ + "$MT5_API_URL/AccountSummary?id=$MT5_ACCOUNT_UUID" \ + --max-time 10 2>/dev/null || echo "000") + +if [ "$HTTP_STATUS" = "200" ]; then + BALANCE=$(python3 -c "import json; d=json.load(open('/tmp/genesis_api_check.json')); print(f\"\${d.get('balance',0):,.2f} {d.get('currency','USD')}\")" 2>/dev/null || echo "connected") + ok "API2TRADE connected — Balance: $BALANCE" +else + ERRMSG=$(cat /tmp/genesis_api_check.json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message',''))" 2>/dev/null || echo "unknown error") + warn "Could not verify connection (HTTP $HTTP_STATUS): $ERRMSG" + warn "Credentials saved — you can verify manually later." +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 2: TELEGRAM BOT +# ═══════════════════════════════════════════════════════════════════════════════ +hdr "Step 2 — Telegram Notifications" +echo "" +echo " GENESIS sends trade alerts, P&L reports and heartbeats via Telegram." +echo -e " Create a bot at ${BLU}https://t.me/BotFather${NC} → /newbot → copy the token." +echo -e " Get your Chat ID at ${BLU}https://t.me/userinfobot${NC}" +echo "" + +read -rp " Telegram Bot Token (e.g. 123456:ABC-...): " TELEGRAM_BOT_TOKEN +read -rp " Your Telegram Chat ID (numeric, e.g. 123456789): " TELEGRAM_CHAT_ID + +# Quick send test +echo "" +echo " Sending test message..." +TG_RESP=$(curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + -d "text=🤖 *GENESIS* setup complete. System is online." \ + -d "parse_mode=Markdown" \ + --max-time 8 2>/dev/null) +TG_OK=$(echo "$TG_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('ok',''))" 2>/dev/null) + +if [ "$TG_OK" = "True" ]; then + ok "Telegram working — check your chat for the test message!" +else + warn "Telegram test failed. Check token and chat ID. (Skipping — you can fix in .env)" +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 3: LLM API KEY (Hermes Brain — OpenAI-Compatible) +# ═══════════════════════════════════════════════════════════════════════════════ +hdr "Step 3 — LLM API Key (Hermes Brain)" +echo "" +echo " Hermes uses an LLM to make macro trading decisions every hour." +echo " Supports ANY OpenAI-compatible API provider:" +echo "" +echo -e " ${GRN}1)${NC} OpenAI — https://platform.openai.com/api-keys" +echo -e " ${GRN}2)${NC} DeepSeek — https://platform.deepseek.com" +echo -e " ${GRN}3)${NC} Qwen (Ali) — https://dashscope.aliyun.com" +echo -e " ${GRN}4)${NC} Groq — https://console.groq.com" +echo -e " ${GRN}5)${NC} Together AI — https://api.together.xyz" +echo -e " ${GRN}6)${NC} SiliconFlow — https://cloud.siliconflow.cn" +echo -e " ${GRN}7)${NC} OpenRouter — https://openrouter.ai" +echo -e " ${GRN}8)${NC} Ollama (local)— http://127.0.0.1:11434" +echo -e " ${GRN}0)${NC} Skip — disable LLM brain (strategies still run autonomously)" +echo "" + +read -rp " Select provider [1-8, 0 to skip]: " LLM_CHOICE + +case "$LLM_CHOICE" in + 1) LLM_PROVIDER="openai"; OPENAI_BASE_URL="https://api.openai.com/v1"; DEFAULT_MODEL="gpt-4o-mini" ;; + 2) LLM_PROVIDER="deepseek"; OPENAI_BASE_URL="https://api.deepseek.com/v1"; DEFAULT_MODEL="deepseek-chat" ;; + 3) LLM_PROVIDER="qwen"; OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"; DEFAULT_MODEL="qwen-plus" ;; + 4) LLM_PROVIDER="groq"; OPENAI_BASE_URL="https://api.groq.com/openai/v1"; DEFAULT_MODEL="llama-3.3-70b-versatile" ;; + 5) LLM_PROVIDER="together"; OPENAI_BASE_URL="https://api.together.xyz/v1"; DEFAULT_MODEL="meta-llama/Llama-3-70b-chat-hf" ;; + 6) LLM_PROVIDER="siliconflow"; OPENAI_BASE_URL="https://api.siliconflow.cn/v1"; DEFAULT_MODEL="deepseek-ai/DeepSeek-V3" ;; + 7) LLM_PROVIDER="openrouter"; OPENAI_BASE_URL="https://openrouter.ai/api/v1"; DEFAULT_MODEL="openai/gpt-4o-mini" ;; + 8) LLM_PROVIDER="ollama"; OPENAI_BASE_URL="http://127.0.0.1:11434/v1"; DEFAULT_MODEL="llama3" ;; + *) LLM_PROVIDER="none"; OPENAI_BASE_URL=""; DEFAULT_MODEL="" ;; +esac + +if [[ "$LLM_PROVIDER" == "none" ]]; then + warn "No LLM provider — Hermes LLM brain disabled. Strategies still run autonomously." + OPENAI_API_KEY="sk-your-api-key-here" + OPENAI_BASE_URL="https://api.openai.com/v1" + HERMES_MODEL="gpt-4o-mini" +else + echo "" + echo -e " Provider: ${GRN}${LLM_PROVIDER}${NC} | Base URL: ${CYN}${OPENAI_BASE_URL}${NC}" + echo -e " Default model: ${WHT}${DEFAULT_MODEL}${NC}" + echo "" + + if [[ "$LLM_PROVIDER" == "ollama" ]]; then + OPENAI_API_KEY="ollama" + echo -e " Ollama does not require an API key." + else + read -rp " API Key: " OPENAI_API_KEY + if [[ -z "$OPENAI_API_KEY" ]]; then + warn "No API key entered — Hermes LLM brain disabled." + OPENAI_API_KEY="sk-your-api-key-here" + fi + fi + + read -rp " Model name [default: ${DEFAULT_MODEL}]: " HERMES_MODEL + HERMES_MODEL="${HERMES_MODEL:-$DEFAULT_MODEL}" + + ok "LLM configured: ${HERMES_MODEL} via ${LLM_PROVIDER}" +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 4: RISK LIMITS +# ═══════════════════════════════════════════════════════════════════════════════ +hdr "Step 4 — Risk Management" +echo "" +echo " These limits are enforced by every strategy before any trade." +echo "" + +read -rp " Max open positions at once [default: 4]: " MAX_POSITIONS +MAX_POSITIONS="${MAX_POSITIONS:-4}" + +read -rp " Max risk per trade as % of balance [default: 0.02 = 2%]: " MAX_RISK_PCT +MAX_RISK_PCT="${MAX_RISK_PCT:-0.02}" + +read -rp " Max lot size per trade [default: 3.0]: " MAX_LOTS +MAX_LOTS="${MAX_LOTS:-3.0}" + +# ═══════════════════════════════════════════════════════════════════════════════ +# WRITE .env +# ═══════════════════════════════════════════════════════════════════════════════ +hdr "Writing .env" + +cat > "$ENV_FILE" << ENVEOF +# GENESIS Trading System — Environment Configuration +# Generated by setup.sh on $(date -u +"%Y-%m-%d %H:%M UTC") +# ───────────────────────────────────────────────────────────────────────────── +# SECURITY: Never commit this file to git. It is in .gitignore. +# ───────────────────────────────────────────────────────────────────────────── + +# ── API2TRADE ───────────────────────────────────────────────────────────────── +# Get these from: https://app.api2trade.com → Dashboard → API Keys +MT5_ACCOUNT_UUID=${MT5_ACCOUNT_UUID} +MT5_ACCOUNT_ID=${MT5_ACCOUNT_UUID} +MT5_API_KEY=${MT5_API_KEY} +MT5_API_USER=${MT5_API_USER} +MT5_API_PASS=${MT5_API_PASS} +MT5_API_URL=${MT5_API_URL} + +# ── Telegram ────────────────────────────────────────────────────────────────── +# Create bot: https://t.me/BotFather | Get Chat ID: https://t.me/userinfobot +TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} +TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID} + +# ── LLM API (Hermes Brain — OpenAI-Compatible) ──────────────────────────────── +# Supports: OpenAI, DeepSeek, Qwen, Groq, Together AI, SiliconFlow, OpenRouter, Ollama +OPENAI_API_KEY=${OPENAI_API_KEY} +OPENAI_BASE_URL=${OPENAI_BASE_URL} +HERMES_MODEL=${HERMES_MODEL} + +# ── Risk Limits ─────────────────────────────────────────────────────────────── +MAX_POSITIONS=${MAX_POSITIONS} +MAX_RISK_PCT=${MAX_RISK_PCT} +MAX_LOTS=${MAX_LOTS} + +# ── Optional: Twelve Data (news/economic calendar enrichment) ───────────────── +# Free tier available at: https://twelvedata.com +TWELVE_DATA_API_KEY= +ENVEOF + +chmod 600 "$ENV_FILE" +ok ".env written and locked (chmod 600)" + +# ═══════════════════════════════════════════════════════════════════════════════ +# DONE +# ═══════════════════════════════════════════════════════════════════════════════ +echo "" +echo -e " ─────────────────────────────────────────────────────" +echo -e " ${GRN}✓ GENESIS setup complete!${NC}" +echo "" +echo " Next steps:" +echo "" +echo -e " ${WHT}Docker (local test):${NC}" +echo " docker compose up -d" +echo " docker logs -f genesis" +echo "" +echo -e " ${WHT}VPS (production):${NC}" +echo " bash install.sh" +echo "" +echo -e " ${WHT}Run a live scan right now:${NC}" +echo " docker exec -it genesis ares analyze EURUSDxx" +echo " docker exec -it genesis genesis-scan EURUSDxx" +echo "" +echo -e " ${WHT}Watch live logs:${NC}" +echo " docker logs -f genesis" +echo " docker exec -it genesis tail -f /var/log/hermes/autonomous.log" +echo "" +echo -e " ${BLU}API2TRADE dashboard: https://app.api2trade.com${NC}" +echo -e " ─────────────────────────────────────────────────────" +echo "" \ No newline at end of file diff --git a/strategies/apollo/apollo_cycle.py b/strategies/apollo/apollo_cycle.py new file mode 100644 index 0000000..7fe8765 --- /dev/null +++ b/strategies/apollo/apollo_cycle.py @@ -0,0 +1,509 @@ +#!/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)) \ No newline at end of file diff --git a/strategies/apollo/apollo_telegram_bot.py b/strategies/apollo/apollo_telegram_bot.py new file mode 100644 index 0000000..61b225e --- /dev/null +++ b/strategies/apollo/apollo_telegram_bot.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +""" +GENESIS — Apollo Telegram Bot (Strategy C Command Handler) +Same pattern as ares_telegram_bot.py — manual trigger via Telegram. + +Commands: + /apollo_analyze [SYMBOL] — MA crossover analysis, no trade + /apollo_execute — Execute pending signal + /apollo_scan — Scan all symbols, show best + /apollo_skip — Cancel pending signal + /apollo_status — Account + open Apollo position + /apollo_help — All commands +""" +import os, json, time, logging, threading, sys +from datetime import datetime, timezone +from pathlib import Path +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "core")) +from mt5_bridge import bridge as _bridge + +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) + +import requests + +TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +TG_CHAT_ID = str(os.getenv("TELEGRAM_CHAT_ID", CFG["telegram"]["chat_id"])) +# 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" +STRATEGY = CFG["strategy"]["name"] +COMMENT = CFG["strategy"]["comment"] + +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/apollo/apollo_bot.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_bot.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) + +_pending: dict = {} +_lock = threading.Lock() + +def tg_send(text: str): + try: + requests.post( + f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", + json={"chat_id": TG_CHAT_ID, "text": text, "parse_mode": "Markdown"}, + timeout=10 + ) + except Exception as e: + log.error(f"tg_send: {e}") + +def tg_updates(offset=0): + try: + r = requests.get( + f"https://api.telegram.org/bot{TG_TOKEN}/getUpdates", + params={"timeout": 30, "offset": offset}, timeout=40 + ) + return r.json().get("result", []) + except: + return [] + +def bridge(path, method="GET", data=None): + return _bridge(path, method, data) + +def journal_write(entry: dict): + + with open(JOURNAL, "a") as f: + f.write(json.dumps(entry) + "\n") + +def journal_stats(): + wins = losses = 0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t = json.loads(line) + if t.get("result") == "win": wins += 1 + if t.get("result") == "loss": losses += 1 + except: pass + return wins, losses + +# ── Commands ─────────────────────────────────────────────────────────────────── +def cmd_help(): + fast = CFG["indicators"]["fast_ma_period"] + slow = CFG["indicators"]["slow_ma_period"] + tg_send( + f"🏹 *{STRATEGY} — Strategy C Commands*\n\n" + f"`/apollo_analyze [SYMBOL]` — MA crossover analysis (no trade)\n" + f"`/apollo_scan` — Scan all symbols for best signal\n" + f"`/apollo_execute` — Execute pending signal\n" + f"`/apollo_skip` — Cancel pending signal\n" + f"`/apollo_status` — Open position + journal stats\n" + f"`/apollo_help` — This message\n\n" + f"📊 Strategy: EMA{fast}/EMA{slow} Golden/Death Cross on M5\n" + f"🔖 Tag: `{COMMENT}` | Session: GMT " + f"{CFG['sessions']['allowed'][0]['start']}:00–" + f"{CFG['sessions']['allowed'][0]['end']}:00" + ) + +def cmd_status(): + acc = bridge("/balance") + if "error" in acc: + tg_send(f"🔴 *{STRATEGY}*: Bridge unreachable.") + return + positions = bridge("/positions") + pos_str = "None" + all_str = [] + if isinstance(positions, list): + for p in positions: + comment = str(p.get("comment", "")) + all_str.append(f"`{p.get('symbol')}` {p.get('orderType')} " + f"{p.get('lots')}lot P&L:€{p.get('profit',0):.2f} [{comment}]") + if "APOLLO" in comment.upper(): + pos_str = (f"{p.get('symbol')} {p.get('orderType')} " + f"{p.get('lots')}lot | P&L: €{p.get('profit',0):.2f}") + + wins, losses = journal_stats() + with _lock: + pend = (f"🟡 {_pending.get('symbol')} {_pending.get('direction')} " + f"({_pending.get('signal_type','?')})" + if _pending else "None") + + all_display = "\n".join(all_str) if all_str else "None" + tg_send( + f"🏹 *{STRATEGY} — Status*\n\n" + f"💰 Balance: €{acc.get('balance',0):.2f} | " + f"Equity: €{acc.get('equity',0):.2f}\n" + f"📈 Apollo Position: {pos_str}\n" + f"📋 Pending Signal: {pend}\n" + f"📒 Journal: {wins}W / {losses}L\n\n" + f"*All Open Positions:*\n{all_display}" + ) + +def cmd_skip(): + with _lock: + if not _pending: + tg_send(f"🏹 *{STRATEGY}*: No pending signal to cancel.") + return + sym = _pending.get("symbol") + _pending.clear() + tg_send(f"⏭ *{STRATEGY}*: Signal for `{sym}` cancelled.") + +def cmd_execute(): + with _lock: + if not _pending: + tg_send(f"🏹 *{STRATEGY}*: No pending signal.\n" + f"Run `/apollo_analyze SYMBOL` or `/apollo_scan` first.") + return + signal = dict(_pending) + _pending.clear() + + sym = signal.get("symbol") + dire = signal.get("direction") + sl = signal.get("stop_loss") + tp = signal.get("take_profit") + vol = signal.get("volume", 0.01) + + if not all([sym, dire, sl, tp]): + tg_send(f"🏹 *{STRATEGY}*: Incomplete signal — cannot execute.") + return + + positions = bridge("/positions") + if isinstance(positions, list) and any( + "APOLLO" in str(p.get("comment","")).upper() for p in positions + ): + tg_send(f"⚠️ *{STRATEGY}*: Apollo position already open. Close it first.") + return + + tg_send(f"🏹 *{STRATEGY}*: Placing order…") + order = bridge("/market", "POST", { + "symbol": sym, "volume": vol, "type": dire, + "stop_loss": sl, "take_profit": tp, "comment": COMMENT + }) + + ticket = order.get("ticket") or order.get("Ticket") + if ticket: + now = datetime.now(timezone.utc) + journal_write({ + "ticket": str(ticket), "symbol": sym, "direction": dire, + "volume": vol, "sl": sl, "tp": tp, + "signal_type": signal.get("signal_type"), + "rr": signal.get("rr_ratio"), + "opened": now.isoformat(), "result": None, "pnl": None, + "strategy": "apollo-ma-crossover" + }) + ind = signal.get("indicators", {}) + tg_send( + f"✅ *{STRATEGY} TRADE PLACED*\n" + f"📈 `{sym}` {dire} | {signal.get('signal_type','').replace('_',' ')}\n" + f"Entry: `{signal.get('entry')}` | SL: `{sl}` | TP: `{tp}`\n" + f"R:R: `{signal.get('rr_ratio')}` | Vol: `{vol}`\n" + f"ADX: {ind.get('adx','?')} | ATR: {ind.get('atr','?')}\n" + f"🔖 Ticket: `{ticket}`" + ) + else: + err = order.get("message", str(order)) + tg_send(f"❌ *{STRATEGY}*: Order FAILED — `{err}`") + +def cmd_analyze(symbol: str): + sym = symbol.upper().strip() + if not sym.endswith("XX"): + sym = sym + "xx" + + tg_send(f"🏹 *{STRATEGY}*: Analysing `{sym}`… (30–60s)") + + try: + import importlib.util + spec = importlib.util.spec_from_file_location( + "apollo_cycle", Path(__file__).parent / "apollo_cycle.py" + ) + mod = importlib.util.load_from_spec(spec) + spec.loader.exec_module(mod) + result = mod.run_analysis(sym) + except Exception as e: + log.error(f"Analysis error: {e}", exc_info=True) + tg_send(f"❌ *{STRATEGY}*: Analysis failed — `{str(e)[:200]}`") + return + + _send_analysis_result(result) + +def cmd_scan(): + tg_send(f"🏹 *{STRATEGY}*: Scanning all symbols… (may take 60–90s)") + try: + import importlib.util + spec = importlib.util.spec_from_file_location( + "apollo_cycle", Path(__file__).parent / "apollo_cycle.py" + ) + mod = importlib.util.load_from_spec(spec) + spec.loader.exec_module(mod) + + symbols = CFG["symbols"] + best = None + best_rr = 0 + scan_lines = [] + + for sym in symbols: + r = mod.run_analysis(sym) + action = r.get("action", "wait") + if action == "trade": + rr = r.get("rr_ratio", 0) or 0 + icon = "🟢" + scan_lines.append( + f"{icon} `{sym}`: {r.get('direction')} " + f"{r.get('signal_type','').replace('_',' ')} | " + f"R:R {rr} | {r.get('confidence','?')}" + ) + if rr > best_rr: + best_rr = rr + best = r + else: + scan_lines.append(f"⚪ `{sym}`: {r.get('reason','wait')[:60]}") + import time; time.sleep(0.5) + + summary = "\n".join(scan_lines) + tg_send(f"🏹 *{STRATEGY} SCAN RESULTS*\n\n{summary}") + + if best: + with _lock: + _pending.clear() + _pending.update(best) + _send_analysis_result(best, from_scan=True) + else: + tg_send(f"📊 *{STRATEGY}*: No trade signals found across all symbols.") + + except Exception as e: + log.error(f"Scan error: {e}", exc_info=True) + tg_send(f"❌ *{STRATEGY}*: Scan failed — `{str(e)[:200]}`") + +def _send_analysis_result(result: dict, from_scan: bool = False): + """Format and send analysis result to Telegram, set pending if trade signal.""" + action = result.get("action", "wait") + + if action != "trade": + tg_send( + f"🏹 *{STRATEGY}* — `{result.get('symbol','?')}`\n\n" + f"📊 Signal: *WAIT*\n" + f"💡 {result.get('reason','')[:300]}" + ) + return + + with _lock: + _pending.clear() + _pending.update(result) + + conds = result.get("conditions_met", []) + cond_str = "\n".join(f" ✅ {c}" for c in conds) if conds else " (see reason)" + warn_str = "" + if result.get("warnings"): + warn_str = "\n" + "\n".join(f" ⚠️ {w}" for w in result["warnings"]) + + ind = result.get("indicators", {}) + fast = CFG["indicators"]["fast_ma_period"] + slow = CFG["indicators"]["slow_ma_period"] + scan_note = " _(Best from scan)_" if from_scan else "" + + tg_send( + f"🏹 *{STRATEGY} ANALYSIS*{scan_note} — `{result.get('symbol')}`\n\n" + f"📊 Signal: *{result.get('direction')}* — " + f"{result.get('signal_type','').replace('_',' ')}\n" + f"Entry: `{result.get('entry')}`\n" + f"SL: `{result.get('stop_loss')}` ({result.get('sl_pips','?')} pips)\n" + f"TP: `{result.get('take_profit')}` ({result.get('tp_pips','?')} pips)\n" + f"R:R `{result.get('rr_ratio')}` | Vol: `{result.get('volume')}`\n" + f"🎯 Confidence: {result.get('confidence','?')}\n" + f"ADX: {ind.get('adx','?')} | ATR: {ind.get('atr','?')}\n" + f"EMA{fast}: {ind.get('fast_ma','?')} | EMA{slow}: {ind.get('slow_ma','?')}\n\n" + f"📌 *Conditions ({len(conds)} met):*\n{cond_str}{warn_str}\n\n" + f"💡 {result.get('reason','')[:250]}\n\n" + f"Reply `/apollo_execute` to trade or `/apollo_skip` to cancel." + ) + +# ── Dispatcher ───────────────────────────────────────────────────────────────── +def dispatch(text: str, from_id: str): + if str(from_id) != TG_CHAT_ID: + log.warning(f"Unauthorised: {from_id}") + return + text = text.strip() + lower = text.lower() + + if lower.startswith("/apollo_analyze"): + parts = text.split(maxsplit=1) + sym = parts[1] if len(parts) > 1 else "" + if not sym: + tg_send("Usage: `/apollo_analyze EURUSD`") + else: + threading.Thread(target=cmd_analyze, args=(sym,), daemon=True).start() + elif lower == "/apollo_scan": + threading.Thread(target=cmd_scan, daemon=True).start() + elif lower == "/apollo_execute": + threading.Thread(target=cmd_execute, daemon=True).start() + elif lower == "/apollo_skip": + cmd_skip() + elif lower == "/apollo_status": + threading.Thread(target=cmd_status, daemon=True).start() + elif lower in ("/apollo_help", "/apollo"): + cmd_help() + +# ── Main loop ────────────────────────────────────────────────────────────────── +def main(): + log.info(f"=== {STRATEGY} Telegram Bot started ===") + try: + requests.post( + f"https://api.telegram.org/bot{TG_TOKEN}/setMyCommands", + json={"commands": [ + {"command": "apollo_help", "description": "Show all commands"}, + {"command": "apollo_analyze", "description": "MA crossover analysis on symbol"}, + {"command": "apollo_scan", "description": "Scan all symbols for best signal"}, + {"command": "apollo_execute", "description": "Execute pending signal"}, + {"command": "apollo_skip", "description": "Cancel pending signal"}, + {"command": "apollo_status", "description": "Position + journal stats"}, + ]}, + timeout=10 + ) + except Exception as e: + log.warning(f"setMyCommands failed: {e}") + fast = CFG["indicators"]["fast_ma_period"] + slow = CFG["indicators"]["slow_ma_period"] + tg_send( + f"🏹 *{STRATEGY} Bot Online*\n" + f"Strategy C: EMA{fast}/EMA{slow} Trend Following (M5)\n" + f"Send `/apollo_help` to see commands.\n\n" + f"🤖 _Hermes controls this bot autonomously._\n" + f"_You can also trigger manually via the commands above._" + ) + offset = 0 + while True: + try: + updates = tg_updates(offset) + for upd in updates: + offset = upd["update_id"] + 1 + msg = upd.get("message", {}) + text = msg.get("text", "") + chat_id = str(msg.get("chat", {}).get("id", "")) + if text.startswith("/apollo"): + dispatch(text, chat_id) + except Exception as e: + log.error(f"Polling error: {e}") + time.sleep(5) + time.sleep(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/strategies/apollo/apollo_tool.py b/strategies/apollo/apollo_tool.py new file mode 100644 index 0000000..37d48d3 --- /dev/null +++ b/strategies/apollo/apollo_tool.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +GENESIS — Apollo CLI Tool (Strategy C: MA Crossover) +Hermes calls this autonomously — same pattern as ares_tool.py. + +Usage: + python3 apollo_tool.py analyze EURUSD # MA crossover analysis, no trade + python3 apollo_tool.py execute EURUSD # Analyze + execute if signal found + python3 apollo_tool.py status # Open Apollo position + journal stats + python3 apollo_tool.py close # Close open Apollo position + python3 apollo_tool.py scan # Scan all Apollo symbols, return best signal + python3 apollo_tool.py symbols # List Apollo symbols +""" +import sys, os, json, time +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent)) + +from apollo_cycle import ( + run_analysis, bridge, JOURNAL, MAGIC_COMMENT, + pip_size, tg, CFG, _last_signal_time +) +from datetime import datetime, timezone +from pathlib import Path + +APOLLO_SYMBOLS = CFG["symbols"] + +def journal_write(entry: dict): + JOURNAL.parent.mkdir(parents=True, exist_ok=True) + with open(JOURNAL, "a") as f: + f.write(json.dumps(entry) + "\n") + +def journal_stats() -> tuple[int, int]: + wins = losses = 0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t = json.loads(line) + if t.get("result") == "win": wins += 1 + if t.get("result") == "loss": losses += 1 + except: pass + return wins, losses + +def cmd_analyze(symbol: str) -> dict: + sym = symbol.upper() + if not sym.endswith("XX"): + sym = sym + "xx" + result = run_analysis(sym) + print(json.dumps(result, indent=2, default=str)) + return result + +def cmd_execute(symbol: str) -> dict: + """Analyze + execute if valid signal. Hermes calls when it sees fit.""" + sym = symbol.upper() + if not sym.endswith("XX"): + sym = sym + "xx" + + result = run_analysis(sym) + + if result.get("action") != "trade": + out = { + "executed": False, + "reason": result.get("reason", "No signal"), + "conditions_met": result.get("conditions_met", []), + "signal_type": result.get("signal_type", "none"), + } + print(json.dumps(out, indent=2)) + return out + + order = bridge("/market", "POST", { + "symbol": result["symbol"], + "volume": result["volume"], + "type": result["direction"], + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "comment": MAGIC_COMMENT, # "APOLLO-v1" + }) + + ticket = order.get("ticket") or order.get("Ticket") + now = datetime.now(timezone.utc) + + if ticket: + journal_write({ + "ticket": str(ticket), + "symbol": result["symbol"], + "direction": result["direction"], + "volume": result["volume"], + "sl": result["stop_loss"], + "tp": result["take_profit"], + "entry": result["entry"], + "rr": result["rr_ratio"], + "signal_type": result["signal_type"], + "opened": now.isoformat(), + "result": None, + "pnl": None, + "strategy": "apollo-ma-crossover", + "triggered_by": "hermes-autonomous", + "conditions": result.get("conditions_met", []), + }) + + ind = result.get("indicators", {}) + tg( + f"🏹 *APOLLO TRADE — Hermes Triggered*\n" + f"📈 `{result['symbol']}` {result['direction']} " + f"| {result['signal_type'].replace('_',' ')}\n" + f"Entry: `{result['entry']}` | SL: `{result['stop_loss']}` " + f"| TP: `{result['take_profit']}`\n" + f"R:R: `{result['rr_ratio']}` | Vol: `{result['volume']}`\n" + f"🎯 Strategy: EMA{CFG['indicators']['fast_ma_period']}/" + f"EMA{CFG['indicators']['slow_ma_period']} Crossover (M5)\n" + f"ADX: {ind.get('adx','?')} | ATR: {ind.get('atr','?')}\n" + f"📌 {', '.join(result.get('conditions_met',[])[:3])}" + ) + + out = { + "executed": True, + "ticket": str(ticket), + "symbol": result["symbol"], + "direction": result["direction"], + "signal_type": result["signal_type"], + "volume": result["volume"], + "sl": result["stop_loss"], + "tp": result["take_profit"], + "rr": result["rr_ratio"], + "reason": result["reason"], + } + else: + err = order.get("message", str(order)) + tg(f"⚠️ *APOLLO*: Order FAILED — `{err}`") + out = {"executed": False, "reason": f"Order failed: {err}"} + + print(json.dumps(out, indent=2, default=str)) + return out + +def cmd_status() -> dict: + acc = bridge("/balance") + positions = bridge("/positions") + + apollo_pos = None + all_positions = [] + if isinstance(positions, list): + for p in positions: + comment = str(p.get("comment", "")) + all_positions.append({ + "ticket": p.get("ticket"), + "symbol": p.get("symbol"), + "type": p.get("orderType"), + "lots": p.get("lots"), + "profit": p.get("profit"), + "comment": comment, + }) + if "APOLLO" in comment.upper(): + apollo_pos = p + + wins, losses = journal_stats() + + out = { + "account": acc, + "apollo_position": apollo_pos, + "all_open": all_positions, + "apollo_journal": {"wins": wins, "losses": losses}, + "strategy": "MA Crossover EMA9/EMA21 (M5)", + "comment_tag": MAGIC_COMMENT, + } + print(json.dumps(out, indent=2, default=str)) + return out + +def cmd_close() -> dict: + positions = bridge("/positions") + closed = [] + if isinstance(positions, list): + for p in positions: + if "APOLLO" in str(p.get("comment", "")).upper(): + res = bridge("/close", "POST", {"ticket": p["ticket"]}) + closed.append({"ticket": p["ticket"], "result": res}) + tg(f"🏹 *APOLLO*: Position `{p['ticket']}` closed by Hermes.") + out = ({"closed": len(closed), "positions": closed} + if closed else {"closed": 0, "reason": "No open Apollo positions."}) + print(json.dumps(out, indent=2, default=str)) + return out + +def cmd_scan() -> dict: + """ + Scan ALL Apollo symbols and return the best signal found. + Hermes uses this to find the strongest crossover opportunity across all pairs. + """ + best = None + best_score = 0 + results = {} + + for sym in APOLLO_SYMBOLS: + result = run_analysis(sym) + results[sym] = { + "action": result.get("action"), + "signal_type": result.get("signal_type", "none"), + "confidence": result.get("confidence", "none"), + "rr": result.get("rr_ratio"), + "reason": result.get("reason", "")[:100], + } + if result.get("action") == "trade": + score = (2 if result.get("confidence") == "high" else 1) + score += (result.get("rr_ratio") or 0) * 0.5 + score += len(result.get("conditions_met", [])) * 0.3 + if score > best_score: + best_score = score + best = result + time.sleep(0.5) # Rate limit yfinance + + out = { + "best_signal": best, + "scan_results": results, + "scanned": len(APOLLO_SYMBOLS), + "signals_found": sum(1 for r in results.values() if r["action"] == "trade"), + } + print(json.dumps(out, indent=2, default=str)) + return out + +def cmd_symbols() -> dict: + out = { + "symbols": APOLLO_SYMBOLS, + "strategy": "MA Crossover Trend Following", + "timeframe": f"{CFG['indicators']['signal_timeframe']} entry, {CFG['indicators']['trend_timeframe']} context", + "indicators": f"EMA{CFG['indicators']['fast_ma_period']}/EMA{CFG['indicators']['slow_ma_period']} crossover", + "comment_tag": MAGIC_COMMENT, + } + print(json.dumps(out, indent=2)) + return out + +# ── Entry point ──────────────────────────────────────────────────────────────── +if __name__ == "__main__": + args = sys.argv[1:] + if not args: + print(json.dumps({"error": "Usage: apollo_tool.py [analyze|execute|status|close|scan|symbols] [SYMBOL]"})) + sys.exit(1) + + cmd = args[0].lower() + + if cmd == "analyze": + if len(args) < 2: + print(json.dumps({"error": "analyze requires a symbol"})); sys.exit(1) + cmd_analyze(args[1]) + elif cmd == "execute": + if len(args) < 2: + print(json.dumps({"error": "execute requires a symbol"})); sys.exit(1) + cmd_execute(args[1]) + elif cmd == "status": + cmd_status() + elif cmd == "close": + cmd_close() + elif cmd == "scan": + cmd_scan() + elif cmd == "symbols": + cmd_symbols() + else: + print(json.dumps({"error": f"Unknown command: {cmd}"})); sys.exit(1) diff --git a/strategies/ares/ares_cycle.py b/strategies/ares/ares_cycle.py new file mode 100644 index 0000000..92063b8 --- /dev/null +++ b/strategies/ares/ares_cycle.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +""" +GENESIS — Ares BB+RSI Mean Reversion Strategy (Strategy B) +Python port of BB_RSI_MeanReversion.mq5 — runs via api2trade.com REST API. + +Trigger: Called by ares_telegram_bot.py on /ares_analyze + OR by ares_runner.py timer if auto-mode is enabled in config. + +NEVER trades autonomously — ares_telegram_bot.py gate controls execution. +""" +import os, json, time, logging, math +from datetime import datetime, timezone, timedelta +from pathlib import Path + +import requests +import yaml + +# ── Config ───────────────────────────────────────────────────────────────────── +CONFIG_PATH = Path(__file__).parent / "ares_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "ares_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" / "ares" + local_log_dir.mkdir(parents=True, exist_ok=True) + JOURNAL = local_log_dir / "trade_journal.jsonl" + + +# Strategy parameters — mirroring all EA inputs +BB_PERIOD = 20 +BB_DEVIATION = 2.0 +RSI_PERIOD = 14 +RSI_OVERSOLD = float(CFG["strictness"].get("rsi_oversold", 30)) +RSI_OVERBOUGHT = float(CFG["strictness"].get("rsi_overbought", 70)) +CONTEXT_MA_PER = int(CFG["strictness"].get("min_adx", 50)) # M15 MA period +CONTEXT_MA_TOL = 0.0002 +USE_M15_CONTEXT = CFG["risk"].get("block_medium_news", True) +REQUIRE_OUTSIDE = True # Price must close outside BB +REQUIRE_RSI = True # RSI must confirm +SL_PIPS = int(CFG.get("sl_pips", 20)) +TP_PIPS = int(CFG.get("tp_pips", 40)) +RISK_PCT = float(CFG["risk"]["risk_pct"]) +MIN_RR = float(CFG["risk"]["min_rr_ratio"]) +MAX_SPREAD_PIPS = 1.0 +BLOCK_NEWS_MINS = int(CFG["risk"]["block_news_minutes"]) +START_HOUR = 5 # GMT +END_HOUR = 17 # GMT +MAGIC_COMMENT = CFG["strategy"]["comment"] # "ARES-v1" +MIN_ADX = float(CFG["strictness"]["min_adx"]) # 22 + +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/ares/ares_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" / "ares" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "ares_cycle.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) + +# ── Cache helpers ────────────────────────────────────────────────────────────── +def load_cache() -> dict: + try: + return json.loads(CACHE_FILE.read_text()) if CACHE_FILE.exists() else {} + except: + return {} + +def save_cache(c: dict): + 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 mt5api(path, params=None) -> dict | list: + return bridge(path, data=params) + +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", +} + +TF_MAP = {"1m": "M1", "5m": "M5", "15m": "M15", "30m": "M30", "1h": "H1", "4h": "H4", "1d": "D1"} + +def get_bars(symbol: str, tf: str = "1m", count: int = 150) -> list: + mt5_tf = TF_MAP.get(tf, "M1") + try: + bars = _bridge_get_bars(symbol, mt5_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 + import pandas as pd + yf_sym = YF_MAP.get(symbol, symbol.replace("xx", "=X") if symbol.lower().endswith("xx") else symbol + "=X") + period_map = {"1m": "5d", "15m": "5d", "1h": "60d", "4h": "60d", "1d": "365d"} + period = period_map.get(tf, "5d") + df = yf.download(yf_sym, period=period, interval=tf, + 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] + df = df.rename(columns={"adj close": "close"}) + df = df.dropna().tail(count).reset_index() + return df.to_dict("records") + except Exception as e: + log.error(f"get_bars {symbol}/{tf}: {e}") + return [] + +# ── Indicator calculations ───────────────────────────────────────────────────── +def compute_bb_rsi(bars: list, bb_period=20, bb_dev=2.0, rsi_period=14) -> dict: + """ + Compute Bollinger Bands and RSI from raw OHLCV bars. + Returns dict with bb_upper, bb_lower, bb_middle, rsi for the LAST CLOSED bar. + """ + if len(bars) < max(bb_period, rsi_period) + 5: + return {} + try: + import pandas as pd + import 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) + + # Bollinger Bands + bb_upper = ta.volatility.bollinger_hband(df["close"], window=bb_period, window_dev=bb_dev) + bb_lower = ta.volatility.bollinger_lband(df["close"], window=bb_period, window_dev=bb_dev) + bb_mid = ta.volatility.bollinger_mavg(df["close"], window=bb_period) + bb_pct = ta.volatility.bollinger_pband(df["close"], window=bb_period, window_dev=bb_dev) + + # RSI + rsi = ta.momentum.rsi(df["close"], window=rsi_period) + + # ADX (for trend strength — strictness gate) + 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) + + # MACD histogram for momentum confirmation + macd_hist = ta.trend.macd_diff(df["close"]) + + # EMA trend + ema20 = ta.trend.ema_indicator(df["close"], window=20) + ema50 = ta.trend.ema_indicator(df["close"], window=50) + + # Use index -2 = last FULLY CLOSED bar (index -1 is current forming) + i = -2 + + def safe(s): + try: + v = float(s.iloc[i]) + return None if math.isnan(v) else round(v, 6) + except: + return None + + return { + "bb_upper": safe(bb_upper), + "bb_lower": safe(bb_lower), + "bb_middle": safe(bb_mid), + "bb_pct": safe(bb_pct), + "rsi": safe(rsi), + "adx": safe(adx), + "adx_plus": safe(adx_pos), + "adx_minus": safe(adx_neg), + "macd_hist": safe(macd_hist), + "ema20": safe(ema20), + "ema50": safe(ema50), + "close": round(float(df["close"].iloc[i]), 6), + "trend": "bullish" if (safe(ema20) or 0) > (safe(ema50) or 0) else "bearish", + } + except Exception as e: + log.error(f"compute_bb_rsi: {e}") + return {} + +def get_m15_sma(symbol: str, period: int = 50) -> float | None: + """Get SMA-50 on M15 for context filtering.""" + cache = load_cache() + key = f"ares_m15_sma_{symbol}" + now = time.time() + if key in cache and now - cache[key].get("ts", 0) < 300: # 5-min cache + return cache[key].get("val") + try: + import ta, pandas as pd + bars = get_bars(symbol, "15m", period + 10) + if len(bars) < period: + 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=period) + 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_m15_sma {symbol}: {e}") + return None + +# ── Pip size helper ──────────────────────────────────────────────────────────── +def pip_size(symbol: str) -> float: + if "JPY" in symbol.upper(): + return 0.01 + if "XAU" in symbol.upper() or "GOLD" in symbol.upper(): + return 0.1 + return 0.0001 + +def price_to_pips(diff: float, symbol: str) -> float: + return abs(diff) / pip_size(symbol) + +# ── Lot size calculation ─────────────────────────────────────────────────────── +def calculate_lot(equity: float, sl_pips: int, symbol: str) -> float: + risk_eur = equity * RISK_PCT + # Approximate pip value: €10 per pip per standard lot for EUR pairs + pip_val_per_lot = 10.0 + if "JPY" in symbol.upper(): pip_val_per_lot = 9.0 + if "GBP" in symbol.upper(): pip_val_per_lot = 12.5 + if "XAU" in symbol.upper(): pip_val_per_lot = 1.0 # Gold ~$1/pip/0.01lot + raw_lot = risk_eur / (sl_pips * pip_val_per_lot) + # Clamp and round to 0.01 step + raw_lot = max(0.01, min(raw_lot, 5.0)) + return round(round(raw_lot / 0.01) * 0.01, 2) + +# ── Spread check ─────────────────────────────────────────────────────────────── +def get_spread_pips(quote: dict, symbol: str) -> float: + ask = float(quote.get("ask", 0)) + bid = float(quote.get("bid", 0)) + return price_to_pips(ask - bid, symbol) + +# ── News / calendar block ────────────────────────────────────────────────────── +def check_news_block(symbol: str) -> tuple[bool, list]: + now_utc = datetime.now(timezone.utc) + warnings = [] + blocked_ccys = set() + cache = load_cache() + + # ForexFactory calendar (shared cache with Hermes) + ff_events = cache.get("ff_cal", {}).get("data", []) + for evt in ff_events: + try: + et = datetime.fromisoformat(evt.get("date", "")).astimezone(timezone.utc) + mins = (et - now_utc).total_seconds() / 60 + if evt.get("impact") == "High" and -15 < mins < BLOCK_NEWS_MINS: + blocked_ccys.add(evt.get("currency", "")[:3]) + warnings.append(f"High impact: {evt.get('title')} in {int(mins)}min") + except: + pass + + + + sym_up = symbol.upper() + blocked = any(c and c in sym_up for c in blocked_ccys if c) + return blocked, warnings + +# ── Session filter ───────────────────────────────────────────────────────────── +def is_trade_time() -> bool: + now_utc = datetime.now(timezone.utc) + wd, hr = now_utc.weekday(), now_utc.hour + # Weekend check + if (wd == 4 and hr >= 22) or wd == 5 or (wd == 6 and hr < 22): + return False + # Session hours (GMT) + return START_HOUR <= hr < END_HOUR + +# ── Position check ───────────────────────────────────────────────────────────── +def has_open_position() -> bool: + positions = bridge("/positions") + if isinstance(positions, list): + for p in positions: + comment = str(p.get("comment", "")) + if MAGIC_COMMENT in comment or MAGIC_COMMENT.split("-")[0] in comment: + return True + return False + +# ───────────────────────────────────────────────────────────────────────────── +# MAIN ANALYSIS FUNCTION +# Called by ares_telegram_bot.py on /ares_analyze +# Returns signal dict — NEVER places a trade itself. +# ───────────────────────────────────────────────────────────────────────────── +def run_analysis(symbol: str) -> dict: + """ + Full BB+RSI mean reversion analysis for `symbol`. + Returns signal dict with action='trade' or action='wait'. + """ + symbol = symbol.upper() + if not symbol.endswith("XX"): + symbol = symbol + "xx" + symbol = symbol[:-2] + "xx" + + log.info(f"=== Ares BB+RSI Analysis: {symbol} ===") + + # ── 1. Account state ─────────────────────────────────────────── + account = bridge("/balance") + if "error" in account: + return {"action": "wait", "reason": f"Bridge unreachable: {account['error']}"} + equity = float(account.get("equity", 0)) + balance = float(account.get("balance", 0)) + if equity <= 0: + return {"action": "wait", "reason": "Account equity is zero or unavailable."} + + # ── 2. Existing position check ───────────────────────────────── + if has_open_position(): + return {"action": "wait", "reason": "Ares position already open. Close it first."} + + # ── 3. Session filter ────────────────────────────────────────── + if not is_trade_time(): + return {"action": "wait", "reason": f"Outside trading session (GMT {START_HOUR}:00–{END_HOUR}:00)."} + + # ── 4. Live 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 = get_spread_pips(quote, symbol) + if spread_pips > MAX_SPREAD_PIPS: + return { + "action": "wait", + "reason": f"Spread too wide: {spread_pips:.2f} pips > max {MAX_SPREAD_PIPS} pips." + } + + # ── 5. News block ────────────────────────────────────────────── + blocked, news_warnings = check_news_block(symbol) + if blocked: + return { + "action": "wait", + "reason": f"Blocked by news: {'; '.join(news_warnings[:2])}" + } + + # ── 6. M1 bars + indicators ──────────────────────────────────── + bars_m1 = get_bars(symbol, "1m", 150) + if len(bars_m1) < 50: + return {"action": "wait", "reason": "Insufficient M1 bar data."} + + ind = compute_bb_rsi(bars_m1, BB_PERIOD, BB_DEVIATION, RSI_PERIOD) + if not ind: + return {"action": "wait", "reason": "Indicator computation failed."} + + close = ind["close"] + bb_upper = ind["bb_upper"] + bb_lower = ind["bb_lower"] + rsi = ind["rsi"] + adx = ind["adx"] + adx_p = ind["adx_plus"] + adx_n = ind["adx_minus"] + mhist = ind["macd_hist"] + + if None in (close, bb_upper, bb_lower, rsi): + return {"action": "wait", "reason": "One or more indicator values are None."} + + # ── 7. M15 context (SMA50) ───────────────────────────────────── + m15_sma = get_m15_sma(symbol, 50) + ctx_valid_long = True + ctx_valid_short = True + if m15_sma is not None: + ctx_valid_long = close > m15_sma - CONTEXT_MA_TOL + ctx_valid_short = close < m15_sma + CONTEXT_MA_TOL + + # ── 8. Signal logic (mirrors MQL5 EA exactly) ────────────────── + long_signal = False + short_signal = False + + # Long conditions + bb_long = (close < bb_lower) if REQUIRE_OUTSIDE else True + rsi_long = (rsi < RSI_OVERSOLD) if REQUIRE_RSI else True + long_signal = bb_long and rsi_long and ctx_valid_long + + # Short conditions + bb_short = (close > bb_upper) if REQUIRE_OUTSIDE else True + rsi_short = (rsi > RSI_OVERBOUGHT) if REQUIRE_RSI else True + short_signal = bb_short and rsi_short and ctx_valid_short + + if not long_signal and not short_signal: + return { + "action": "wait", + "reason": ( + f"No signal. Close={close:.5f} | " + f"BB=[{bb_lower:.5f}, {bb_upper:.5f}] | RSI={rsi:.1f}" + ) + } + + # ── 9. Strictness confluence (Strategy B extra gate) ────────── + # ADX confirms trend momentum exists + adx_ok = adx is not None and adx > MIN_ADX + + conditions_met = [] + conditions_failed = [] + + if long_signal: + direction = "Buy" + entry = ask + sl = round(ask - SL_PIPS * pip_size(symbol), 6) + tp = round(ask + TP_PIPS * pip_size(symbol), 6) + + conds = [ + (close < bb_lower, f"Price below lower BB ({close:.5f} < {bb_lower:.5f})"), + (rsi < RSI_OVERSOLD, f"RSI oversold ({rsi:.1f} < {RSI_OVERSOLD})"), + (ctx_valid_long, f"M15 SMA50 context valid (price above SMA-tol)"), + (adx_ok, f"ADX momentum ({adx:.1f} > {MIN_ADX})"), + (mhist is not None and mhist > -0.0001, + f"MACD histogram not strongly bearish ({mhist:.6f})"), + ] + else: + direction = "Sell" + entry = bid + sl = round(bid + SL_PIPS * pip_size(symbol), 6) + tp = round(bid - TP_PIPS * pip_size(symbol), 6) + + conds = [ + (close > bb_upper, f"Price above upper BB ({close:.5f} > {bb_upper:.5f})"), + (rsi > RSI_OVERBOUGHT, f"RSI overbought ({rsi:.1f} > {RSI_OVERBOUGHT})"), + (ctx_valid_short, f"M15 SMA50 context valid (price below SMA+tol)"), + (adx_ok, f"ADX momentum ({adx:.1f} > {MIN_ADX})"), + (mhist is not None and mhist < 0.0001, + f"MACD histogram not strongly bullish ({mhist:.6f})"), + ] + + for met, desc in conds: + (conditions_met if met else conditions_failed).append(desc) + + min_confluence = int(CFG["strictness"]["min_confluence_count"]) + if len(conditions_met) < min_confluence: + return { + "action": "wait", + "reason": f"Only {len(conditions_met)}/{min_confluence} conditions met.", + "conditions_met": conditions_met, + "conditions_failed": conditions_failed, + } + + # ── 10. R:R gate ─────────────────────────────────────────────── + sl_pips_val = price_to_pips(entry - sl, symbol) + tp_pips_val = price_to_pips(tp - entry, symbol) + rr = round(tp_pips_val / sl_pips_val, 2) if sl_pips_val > 0 else 0 + + if rr < MIN_RR: + return {"action": "wait", "reason": f"R:R {rr} below minimum {MIN_RR}."} + + # ── 11. Lot size ─────────────────────────────────────────────── + volume = calculate_lot(equity, SL_PIPS, symbol) + + # ── 12. Build signal ─────────────────────────────────────────── + reason = ( + f"BB+RSI mean reversion — {len(conditions_met)}/{len(conds)} conditions met. " + f"RSI={rsi:.1f}, BB_pct={ind.get('bb_pct', '?')}, ADX={adx:.1f}, " + f"Spread={spread_pips:.2f}pips, R:R={rr}." + ) + log.info(f"SIGNAL: {direction} {symbol} | Entry={entry} SL={sl} TP={tp} Vol={volume} RR={rr}") + + return { + "action": "trade", + "symbol": symbol, + "direction": direction, + "entry": entry, + "stop_loss": sl, + "take_profit": tp, + "volume": volume, + "rr_ratio": rr, + "sl_pips": round(sl_pips_val, 1), + "tp_pips": round(tp_pips_val, 1), + "confidence": "high" if len(conditions_met) >= min_confluence + 1 else "medium", + "conditions_met": conditions_met, + "conditions_failed": conditions_failed, + "warnings": news_warnings, + "reason": reason, + "indicators": { + "close": close, "bb_upper": bb_upper, "bb_lower": bb_lower, + "rsi": rsi, "adx": adx, "macd_hist": mhist, + "m15_sma50": m15_sma, "spread_pips": spread_pips, + }, + "analysed_at": datetime.now(timezone.utc).isoformat(), + } + + +# ── CLI test mode ────────────────────────────────────────────────────────────── +if __name__ == "__main__": + import sys + sym = sys.argv[1] if len(sys.argv) > 1 else "EURUSDxx" + print(f"Running Ares analysis for {sym}...") + result = run_analysis(sym) + print(json.dumps(result, indent=2, default=str)) \ No newline at end of file diff --git a/strategies/ares/ares_telegram_bot.py b/strategies/ares/ares_telegram_bot.py new file mode 100644 index 0000000..86ba5fd --- /dev/null +++ b/strategies/ares/ares_telegram_bot.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +""" +GENESIS — Ares Telegram Bot (Strategy B Command Handler) +Listens for your manual commands. Ares NEVER trades on its own. + +Commands: + /ares_analyze [SYMBOL] — Run full analysis, no trade placed + /ares_execute — Execute the last analysis signal (Account B only) + /ares_skip — Cancel the pending signal + /ares_status — Account B open position + journal stats + /ares_help — Show all commands +""" +import os, json, time, logging, requests, threading, sys +from datetime import datetime, timezone +from pathlib import Path +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "core")) +from mt5_bridge import bridge as _bridge + +# ── Config ──────────────────────────────────────────────────────────────────── +CONFIG_PATH = Path(__file__).parent / "ares_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "ares_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"])) +# 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" / "ares" + local_log_dir.mkdir(parents=True, exist_ok=True) + JOURNAL = local_log_dir / "trade_journal.jsonl" +STRATEGY = CFG["strategy"]["name"] + +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/ares/ares_bot.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" / "ares" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "ares_bot.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) + +# ── Pending signal state (in-memory, one at a time) ─────────────────────────── +_pending: dict = {} # Holds last analysis result awaiting /ares_execute +_lock = threading.Lock() + +# ── Telegram helpers ────────────────────────────────────────────────────────── +def tg_send(text: str): + try: + requests.post( + f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", + json={"chat_id": TG_CHAT_ID, "text": text, "parse_mode": "Markdown"}, + timeout=10 + ) + except Exception as e: + log.error(f"tg_send: {e}") + +def tg_updates(offset=0): + try: + r = requests.get( + f"https://api.telegram.org/bot{TG_TOKEN}/getUpdates", + params={"timeout": 30, "offset": offset}, + timeout=40 + ) + return r.json().get("result", []) + except: + return [] + +# ── Bridge helper (Account B) ───────────────────────────────────────────────── +def bridge(path, method="GET", data=None): + return _bridge(path, method, data) + +# ── Journal helpers ─────────────────────────────────────────────────────────── +def journal_write(entry: dict): + + with open(JOURNAL, "a") as f: + f.write(json.dumps(entry) + "\n") + +def journal_stats(): + wins = losses = 0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t = json.loads(line) + if t.get("result") == "win": wins += 1 + if t.get("result") == "loss": losses += 1 + except: pass + return wins, losses + +# ── Command handlers ────────────────────────────────────────────────────────── +def cmd_help(): + tg_send( + f"⚔️ *{STRATEGY} — Strategy B Commands*\n\n" + f"`/ares_analyze [SYMBOL]` — Full analysis (no trade)\n" + f"`/ares_execute` — Execute pending signal on Account B\n" + f"`/ares_skip` — Cancel pending signal\n" + f"`/ares_status` — Account B position + P&L\n" + f"`/ares_help` — This message\n\n" + f"⚠️ _Ares NEVER trades automatically. YOU must always confirm._" + ) + +def cmd_status(): + acc = bridge("/balance") + if "error" in acc: + tg_send(f"🔴 *{STRATEGY}*: Account B bridge unreachable.\n`{acc['error']}`") + return + + pos_data = bridge("/positions") + pos_str = "None" + if isinstance(pos_data, list) and pos_data: + p = pos_data[0] + pos_str = (f"{p.get('symbol')} {p.get('orderType')} " + f"{p.get('lots')}lot | P&L: €{p.get('profit', 0):.2f}") + + wins, losses = journal_stats() + with _lock: + pending_str = (f"🟡 Pending: {_pending.get('symbol')} {_pending.get('direction')}" + if _pending else "None") + + tg_send( + f"⚔️ *{STRATEGY} — Account B Status*\n\n" + f"💰 Balance: €{acc.get('balance', 0):.2f}\n" + f"📊 Equity: €{acc.get('equity', 0):.2f}\n" + f"📈 Open: {pos_str}\n" + f"📋 Pending Signal: {pending_str}\n" + f"📒 Journal: {wins}W / {losses}L" + ) + +def cmd_skip(): + with _lock: + if not _pending: + tg_send(f"⚔️ *{STRATEGY}*: No pending signal to cancel.") + return + sym = _pending.get("symbol") + _pending.clear() + tg_send(f"⏭ *{STRATEGY}*: Signal for `{sym}` cancelled.") + +def cmd_execute(): + with _lock: + if not _pending: + tg_send( + f"⚔️ *{STRATEGY}*: No pending signal.\n" + f"Run `/ares_analyze [SYMBOL]` first." + ) + return + signal = dict(_pending) + _pending.clear() + + sym = signal.get("symbol") + dire = signal.get("direction") + sl = signal.get("stop_loss") + tp = signal.get("take_profit") + vol = signal.get("volume", 0.1) + + if not all([sym, dire, sl, tp]): + tg_send(f"⚔️ *{STRATEGY}*: Pending signal is incomplete — cannot execute.") + return + + # Check Account B still has no open positions + positions = bridge("/positions") + if isinstance(positions, list) and positions: + tg_send( + f"⚠️ *{STRATEGY}*: Account B already has an open position.\n" + f"Close it first before executing a new trade." + ) + return + + tg_send(f"⚔️ *{STRATEGY}*: Placing order on Account B…") + order = bridge("/market", "POST", { + "symbol": sym, "volume": vol, "type": dire, + "stop_loss": sl, "take_profit": tp, + "comment": CFG["strategy"]["comment"] + }) + log.info(f"Execute order: {order}") + + ticket = order.get("ticket") or order.get("Ticket") + if ticket: + now = datetime.now(timezone.utc) + journal_write({ + "ticket": str(ticket), "symbol": sym, "direction": dire, + "volume": vol, "sl": sl, "tp": tp, + "opened": now.isoformat(), "result": None, "pnl": None, + "strategy": "ares" + }) + tg_send( + f"✅ *{STRATEGY} TRADE PLACED*\n" + f"📈 `{sym}` {dire} | Vol: {vol}\n" + f"SL: {sl} | TP: {tp}\n" + f"🎯 Confidence: {signal.get('confidence', '?')}\n" + f"💡 {signal.get('reason', '')[:200]}\n" + f"🔖 Ticket: `{ticket}`" + ) + else: + err = order.get("message", str(order)) + tg_send(f"❌ *{STRATEGY}*: Order FAILED — `{err}`") + +def cmd_analyze(symbol: str): + """ + Trigger Ares analysis for a given symbol. + Imports ares_cycle.py to run the analysis without placing any trade. + Stores the result in _pending for /ares_execute to act on. + """ + symbol = symbol.upper().strip() + # Ensure symbol has broker suffix + if not symbol.endswith("xx") and not symbol.endswith("XX"): + symbol = symbol + "xx" + + tg_send(f"⚔️ *{STRATEGY}*: Analysing `{symbol}`… (this may take 30–60s)") + + try: + # Import the analysis function from ares_cycle + import importlib.util, sys + spec = importlib.util.spec_from_file_location( + "ares_cycle", + Path(__file__).parent / "ares_cycle.py" + ) + mod = importlib.util.load_from_spec(spec) + spec.loader.exec_module(mod) + + result = mod.run_analysis(symbol) # Returns signal dict or None + except Exception as e: + log.error(f"Analysis error: {e}", exc_info=True) + tg_send(f"❌ *{STRATEGY}*: Analysis failed — `{str(e)[:200]}`") + return + + if not result: + tg_send( + f"⚔️ *{STRATEGY}* — `{symbol}`\n\n" + f"📊 Signal: *NO TRADE*\n" + f"Conditions not met for a strict entry." + ) + return + + with _lock: + _pending.clear() + _pending.update(result) + + action = result.get("action", "wait") + if action != "trade": + tg_send( + f"⚔️ *{STRATEGY}* — `{symbol}`\n\n" + f"📊 Signal: *WAIT*\n" + f"💡 {result.get('reason', '')[:300]}" + ) + return + + conditions = result.get("conditions_met", []) + cond_str = "\n".join(f" ✅ {c}" for c in conditions) if conditions else " (see reason)" + warnings = result.get("warnings", []) + warn_str = ("\n" + "\n".join(f" ⚠️ {w}" for w in warnings)) if warnings else "" + + rr = result.get("rr_ratio", "?") + tg_send( + f"⚔️ *{STRATEGY} ANALYSIS* — `{symbol}`\n\n" + f"📊 Signal: *{result.get('direction')}*\n" + f"Entry: `{result.get('entry')}`\n" + f"SL: `{result.get('stop_loss')}` ({result.get('sl_pips', '?')} pips)\n" + f"TP: `{result.get('take_profit')}` ({result.get('tp_pips', '?')} pips)\n" + f"R:R `{rr}`\n" + f"Vol: `{result.get('volume')} lot`\n" + f"🎯 Confidence: {result.get('confidence', '?')}\n\n" + f"📌 *Conditions met ({len(conditions)}/{CFG['strictness']['min_confluence_count']} required):*\n" + f"{cond_str}{warn_str}\n\n" + f"💡 {result.get('reason', '')[:300]}\n\n" + f"Reply `/ares_execute` to place on Account B, or `/ares_skip` to cancel." + ) + +# ── Dispatcher ──────────────────────────────────────────────────────────────── +def dispatch(text: str, from_id: str): + """Only accept commands from the authorised chat.""" + if str(from_id) != TG_CHAT_ID: + log.warning(f"Ignored message from unauthorised ID: {from_id}") + return + + text = text.strip() + lower = text.lower() + + if lower.startswith("/ares_analyze"): + parts = text.split(maxsplit=1) + sym = parts[1] if len(parts) > 1 else "" + if not sym: + tg_send("Usage: `/ares_analyze EURUSD`") + else: + # Run in thread so bot stays responsive + threading.Thread(target=cmd_analyze, args=(sym,), daemon=True).start() + + elif lower == "/ares_execute": + threading.Thread(target=cmd_execute, daemon=True).start() + + elif lower == "/ares_skip": + cmd_skip() + + elif lower == "/ares_status": + threading.Thread(target=cmd_status, daemon=True).start() + + elif lower in ("/ares_help", "/ares"): + cmd_help() + +# ── Main polling loop ───────────────────────────────────────────────────────── +def main(): + log.info(f"=== {STRATEGY} Telegram Bot started ===") + try: + requests.post( + f"https://api.telegram.org/bot{TG_TOKEN}/setMyCommands", + json={"commands": [ + {"command": "ares_help", "description": "Show all commands"}, + {"command": "ares_analyze", "description": "Run analysis on symbol"}, + {"command": "ares_execute", "description": "Execute pending signal"}, + {"command": "ares_skip", "description": "Cancel pending signal"}, + {"command": "ares_status", "description": "Position + journal stats"}, + ]}, + timeout=10 + ) + except Exception as e: + log.warning(f"setMyCommands failed: {e}") + tg_send( + f"⚔️ *{STRATEGY} Bot Online*\n" + f"Strategy B: BB+RSI Mean Reversion (M1)\n" + f"Send `/ares_help` to see commands.\n\n" + f"🤖 _Hermes controls this bot autonomously._\n" + f"_You can also trigger manually via the commands above._" + ) + + offset = 0 + while True: + try: + updates = tg_updates(offset) + for upd in updates: + offset = upd["update_id"] + 1 + msg = upd.get("message", {}) + text = msg.get("text", "") + chat_id = str(msg.get("chat", {}).get("id", "")) + if text.startswith("/ares"): + dispatch(text, chat_id) + except Exception as e: + log.error(f"Polling error: {e}") + time.sleep(5) + time.sleep(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/strategies/ares/ares_tool.py b/strategies/ares/ares_tool.py new file mode 100644 index 0000000..8d2db78 --- /dev/null +++ b/strategies/ares/ares_tool.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +GENESIS — Ares Strategy B CLI Tool +Called by Hermes autonomously as a shell command. + +Usage: + python3 ares_tool.py analyze EURUSD # Run BB+RSI analysis, returns JSON + python3 ares_tool.py execute EURUSD # Analyze + auto-execute if signal found + python3 ares_tool.py status # Account state + open Ares position + python3 ares_tool.py close # Close any open Ares position + python3 ares_tool.py symbols # List tradeable symbols for Ares + +Hermes uses this tool independently — completely separate from trading_cycle.py. +Ares uses: BB(20,2) + RSI(14) mean reversion on M1 with M15 SMA50 context. +""" +import sys, os, json, yaml +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent)) + +from ares_cycle import run_analysis, bridge, JOURNAL, MAGIC_COMMENT, pip_size, tg +from datetime import datetime, timezone +from pathlib import Path + +_CONFIG_PATH = Path(__file__).parent / "ares_config.yaml" +if not _CONFIG_PATH.exists(): + _CONFIG_PATH = Path(__file__).parents[2] / "configs" / "ares_config.yaml" +with open(_CONFIG_PATH, encoding="utf-8") as _f: + _CFG = yaml.safe_load(_f) +ARES_SYMBOLS = _CFG.get("symbols", ["EURUSDxx", "XAUUSDxx", "GBPUSDxx", "USDJPYxx", "GBPJPYxx"]) + +def journal_write(entry: dict): + JOURNAL.parent.mkdir(parents=True, exist_ok=True) + with open(JOURNAL, "a") as f: + f.write(json.dumps(entry) + "\n") + +def cmd_analyze(symbol: str) -> dict: + """Run full Ares analysis. Returns signal dict.""" + result = run_analysis(symbol.upper()) + print(json.dumps(result, indent=2, default=str)) + return result + +def cmd_execute(symbol: str) -> dict: + """ + Analyze + execute if signal found. Hermes calls this when it decides + the Ares strategy conditions are right. Trades on the main account + with ARES-v1 comment tag so it's distinguishable from Hermes trades. + """ + result = run_analysis(symbol.upper()) + + if result.get("action") != "trade": + print(json.dumps({ + "executed": False, + "reason": result.get("reason", "No signal"), + "conditions_met": result.get("conditions_met", []), + }, indent=2)) + return result + + # Place the order + order = bridge("/market", "POST", { + "symbol": result["symbol"], + "volume": result["volume"], + "type": result["direction"], + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "comment": MAGIC_COMMENT, # "ARES-v1" — distinguishes from GENESIS-v2 + }) + + ticket = order.get("ticket") or order.get("Ticket") + now = datetime.now(timezone.utc) + + if ticket: + journal_write({ + "ticket": str(ticket), + "symbol": result["symbol"], + "direction": result["direction"], + "volume": result["volume"], + "sl": result["stop_loss"], + "tp": result["take_profit"], + "entry": result["entry"], + "rr": result["rr_ratio"], + "opened": now.isoformat(), + "result": None, + "pnl": None, + "strategy": "ares-bb-rsi", + "triggered_by": "hermes-autonomous", + "conditions": result.get("conditions_met", []), + }) + + msg = ( + f"⚔️ *ARES TRADE — Hermes Triggered*\n" + f"📈 `{result['symbol']}` {result['direction']} | Vol: {result['volume']}\n" + f"Entry: `{result['entry']}` | SL: `{result['stop_loss']}` | TP: `{result['take_profit']}`\n" + f"R:R: `{result['rr_ratio']}` | Confidence: {result['confidence']}\n" + f"🎯 Strategy: BB+RSI Mean Reversion (M1)\n" + f"📌 {', '.join(result.get('conditions_met', [])[:3])}" + ) + tg(msg) + + output = { + "executed": True, + "ticket": str(ticket), + "symbol": result["symbol"], + "direction": result["direction"], + "volume": result["volume"], + "sl": result["stop_loss"], + "tp": result["take_profit"], + "rr": result["rr_ratio"], + "reason": result["reason"], + } + else: + err = order.get("message", str(order)) + output = {"executed": False, "reason": f"Order failed: {err}"} + tg(f"⚠️ *ARES*: Order FAILED — `{err}`") + + print(json.dumps(output, indent=2, default=str)) + return output + +def cmd_status() -> dict: + """Return current account state and any open Ares position.""" + acc = bridge("/balance") + positions = bridge("/positions") + + ares_pos = None + if isinstance(positions, list): + for p in positions: + if MAGIC_COMMENT.split("-")[0] in str(p.get("comment", "")): + ares_pos = p + break + + # Journal stats + wins = losses = 0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t = json.loads(line) + if t.get("result") == "win": wins += 1 + if t.get("result") == "loss": losses += 1 + except: pass + + output = { + "account": acc, + "ares_position": ares_pos, + "ares_journal": {"wins": wins, "losses": losses}, + "strategy": "BB+RSI Mean Reversion M1", + } + print(json.dumps(output, indent=2, default=str)) + return output + +def cmd_close() -> dict: + """Close any open Ares position.""" + positions = bridge("/positions") + closed = [] + if isinstance(positions, list): + for p in positions: + if MAGIC_COMMENT.split("-")[0] in str(p.get("comment", "")): + result = bridge("/close", "POST", {"ticket": p["ticket"]}) + closed.append({"ticket": p["ticket"], "result": result}) + tg(f"⚔️ *ARES*: Position `{p['ticket']}` closed by Hermes.") + + if not closed: + output = {"closed": 0, "reason": "No open Ares positions found."} + else: + output = {"closed": len(closed), "positions": closed} + + print(json.dumps(output, indent=2, default=str)) + return output + +def cmd_symbols() -> dict: + output = { + "symbols": ARES_SYMBOLS, + "description": "Ares BB+RSI strategy — optimised for low-spread majors", + "timeframe": "M1 entry, M15 context", + "strategy": "Mean reversion: price outside Bollinger Band + RSI extreme", + } + print(json.dumps(output, indent=2)) + return output + +# ── Entry point ──────────────────────────────────────────────────────────────── +if __name__ == "__main__": + args = sys.argv[1:] + if not args: + print(json.dumps({"error": "Usage: ares_tool.py [analyze|execute|status|close|symbols] [SYMBOL]"})) + sys.exit(1) + + cmd = args[0].lower() + + if cmd == "analyze": + if len(args) < 2: + print(json.dumps({"error": "analyze requires a symbol, e.g.: ares_tool.py analyze EURUSD"})) + sys.exit(1) + cmd_analyze(args[1]) + + elif cmd == "execute": + if len(args) < 2: + print(json.dumps({"error": "execute requires a symbol, e.g.: ares_tool.py execute EURUSD"})) + sys.exit(1) + cmd_execute(args[1]) + + elif cmd == "status": + cmd_status() + + elif cmd == "close": + cmd_close() + + elif cmd == "symbols": + cmd_symbols() + + else: + print(json.dumps({"error": f"Unknown command: {cmd}. Use: analyze, execute, status, close, symbols"})) + sys.exit(1) \ No newline at end of file diff --git a/strategies/artemis/artemis_cycle.py b/strategies/artemis/artemis_cycle.py new file mode 100644 index 0000000..3b91f3f --- /dev/null +++ b/strategies/artemis/artemis_cycle.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +GENESIS — Artemis Cycle (Strategy E: Ichimoku Kumo Breakout on H1) +Tenkan(9), Kijun(26), Senkou B(52), Displacement(26). +BUY: price > kumo + green cloud + RSI>50 + Chikou above price +SELL: price < kumo + red cloud + RSI<50 + Chikou below price +Never places trades — artemis_tool.py handles execution. +""" +import os, json, time, logging, math +from datetime import datetime, timezone +from pathlib import Path +import requests, yaml + +CONFIG_PATH = Path(__file__).parent / "artemis_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "artemis_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" / "artemis" + local_log_dir.mkdir(parents=True, exist_ok=True) + JOURNAL = local_log_dir / "trade_journal.jsonl" + + +TENKAN_P = int(CFG["ichimoku"]["tenkan_period"]) +KIJUN_P = int(CFG["ichimoku"]["kijun_period"]) +SENKOU_B_P = int(CFG["ichimoku"]["senkou_b_period"]) +DISP = int(CFG["ichimoku"]["displacement"]) +CONF_BARS = int(CFG["ichimoku"]["confirmation_bars"]) +REQ_COLOR = bool(CFG["ichimoku"]["require_cloud_color_alignment"]) +REQ_CHIKOU = bool(CFG["ichimoku"]["require_chikou_confirmation"]) +REQ_KIJUN = bool(CFG["ichimoku"]["require_price_above_kijun"]) + +RSI_P = int(CFG["confirmation"]["rsi_period"]) +RSI_BUY = float(CFG["confirmation"]["rsi_buy_threshold"]) +RSI_SELL = float(CFG["confirmation"]["rsi_sell_threshold"]) + +RISK_PCT = float(CFG["risk"]["risk_pct"]) +MIN_RR = float(CFG["risk"]["min_rr_ratio"]) +TP_MULT = float(CFG["risk"]["tp_multiplier"]) +MAX_SPREAD = float(CFG["risk"]["max_spread_pips"]) +BLOCK_NEWS = int(CFG["risk"]["block_news_minutes"]) +COOLDOWN = int(CFG["strictness"]["cooldown_seconds"]) +SIG_TF = CFG["strictness"]["signal_timeframe"] +START_H = int(CFG["sessions"]["allowed"][0]["start"]) +END_H = int(CFG["sessions"]["allowed"][0]["end"]) +COMMENT = CFG["strategy"]["comment"] + +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/artemis/artemis_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" / "artemis" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "artemis_cycle.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) +_last_sig: dict = {} + +def load_cache(): + 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): + 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 + +def pip_size(sym): return 0.01 if "JPY" in sym else (0.1 if "XAU" in sym else 0.0001) +def to_pips(diff, sym): return abs(diff) / pip_size(sym) + +def calculate_lot(equity, sl_pips, sym): + pv = 10.0 + if "JPY" in sym: pv = 9.0 + if "GBP" in sym: pv = 12.5 + if "XAU" in sym: pv = 1.0 + raw = (equity * RISK_PCT) / (sl_pips * pv) if sl_pips > 0 else 0.01 + return round(max(0.01, min(round(raw/0.01)*0.01, 5.0)), 2) + +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 = {"H1":"1h","H4":"4h","D1":"1d","M5":"5m"} + +def get_bars(sym, tf="H1", count=130): + try: + bars = _bridge_get_bars(sym, tf, count) + if bars: + return bars + except Exception as e: + log.warning(f"Mt5Bridge get_bars {sym}/{tf}: {e}, falling back to yfinance") + try: + import yfinance as yf, pandas as pd + ys = YF_MAP.get(sym, sym.replace("xx","=X") if sym.lower().endswith("xx") else sym + "=X") + itv = YF_TF.get(tf, "1h") + per = {"1h":"60d","4h":"60d","1d":"365d","5m":"5d"}.get(itv,"60d") + df = yf.download(ys, period=per, interval=itv, 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 {sym}/{tf}: {e}"); return [] + +def compute_ichimoku(bars: list) -> dict: + """ + Compute all 5 Ichimoku components. Returns values for the LAST CLOSED bar. + Senkou Spans are shifted FORWARD by DISP — to get the cloud at current price, + we read SpanA/B at index -(DISP+2), which is the value plotted at current bar. + Chikou Span = current close plotted DISP bars back → compare to close at -DISP-2. + """ + needed = SENKOU_B_P + DISP + 10 + if len(bars) < needed: return {} + try: + import pandas as pd, ta, numpy as np + df = pd.DataFrame(bars) + df.columns = [c.lower() for c in df.columns] + for col in ["close","high","low"]: + df[col] = df[col].astype(float) + + def midpoint(h, l, p): + return (h.rolling(p).max() + l.rolling(p).min()) / 2 + + tenkan = midpoint(df["high"], df["low"], TENKAN_P) + kijun = midpoint(df["high"], df["low"], KIJUN_P) + span_a = ((tenkan + kijun) / 2) # plotted DISP bars ahead + span_b = midpoint(df["high"], df["low"], SENKOU_B_P) # plotted DISP bars ahead + + rsi = ta.momentum.rsi(df["close"], window=RSI_P) + atr = ta.volatility.average_true_range(df["high"], df["low"], df["close"], window=14) + adx = ta.trend.adx(df["high"], df["low"], df["close"], window=14) + + def s(series, i=-2): + try: + v = float(series.iloc[i]) + return None if math.isnan(v) else round(v, 6) + except: return None + + # Current cloud = SpanA/B shifted forward DISP bars → read at -(DISP+2) in original series + cloud_idx = -(DISP + 2) + sa_current = s(span_a, cloud_idx) + sb_current = s(span_b, cloud_idx) + + # Kumo boundaries at current bar + kumo_top = max(sa_current, sb_current) if sa_current and sb_current else None + kumo_bot = min(sa_current, sb_current) if sa_current and sb_current else None + cloud_color = "green" if (sa_current and sb_current and sa_current > sb_current) else "red" + + # Future cloud (next DISP bars — what SpanA/B are NOW vs price) + sa_future = s(span_a, -2) # Will be plotted DISP bars from now + sb_future = s(span_b, -2) + future_color = "green" if (sa_future and sb_future and sa_future > sb_future) else "red" + + close_now = s(df["close"], -2) + close_disp = s(df["close"], -(DISP + 2)) # Chikou compare point + + # Chikou = current close vs price DISP bars ago + chikou_bullish = (close_now or 0) > (close_disp or 0) + chikou_bearish = (close_now or 0) < (close_disp or 0) + + # Confirmation bars: count how many consecutive bars have closed outside kumo + conf_bull = 0 + conf_bear = 0 + if kumo_top and kumo_bot: + for i in range(2, CONF_BARS + 3): + c = s(df["close"], -i) + kt = max(s(span_a, -(DISP + i)), s(span_b, -(DISP + i)) or 0) + kb = min(s(span_a, -(DISP + i)) or 0, s(span_b, -(DISP + i)) or 0) + if c and kt and c > kt: conf_bull += 1 + elif c and kb and c < kb: conf_bear += 1 + else: break + + return { + "tenkan": s(tenkan), + "kijun": s(kijun), + "span_a_current": sa_current, + "span_b_current": sb_current, + "span_a_future": sa_future, + "span_b_future": sb_future, + "kumo_top": kumo_top, + "kumo_bottom": kumo_bot, + "cloud_color": cloud_color, + "future_color": future_color, + "chikou_bullish": chikou_bullish, + "chikou_bearish": chikou_bearish, + "conf_bars_bull": conf_bull, + "conf_bars_bear": conf_bear, + "rsi": s(rsi), + "atr": s(atr), + "adx": s(adx), + "close": close_now, + "kijun_current": s(kijun, -2), + } + except Exception as e: + log.error(f"compute_ichimoku: {e}"); return {} + +def check_news_block(sym): + now = datetime.now(timezone.utc) + blocked, warns = set(), [] + for evt in load_cache().get("ff_cal", {}).get("data", []): + try: + et = datetime.fromisoformat(evt.get("date","")).astimezone(timezone.utc) + mins = (et - now).total_seconds() / 60 + if evt.get("impact") == "High" and -15 < mins < BLOCK_NEWS: + blocked.add(evt.get("currency","")[:3]) + warns.append(f"{evt.get('title')} in {int(mins)}min") + except: pass + return any(c and c in sym.upper() for c in blocked if c), warns + +def is_trade_time(): + 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_H <= hr < END_H + +def has_artemis_position(): + pos = bridge("/positions") + return isinstance(pos, list) and any("ARTEMIS" in str(p.get("comment","")).upper() for p in pos) + +def run_analysis(symbol: str) -> dict: + symbol = symbol.upper() + if not symbol.endswith("XX"): symbol += "xx" + symbol = symbol[:-2] + "xx" + log.info(f"=== Artemis Ichimoku H1 Analysis: {symbol} ===") + + acc = bridge("/balance") + if "error" in acc: return {"action":"wait","reason":f"Bridge error: {acc['error']}"} + equity = float(acc.get("equity", 0)) + if equity <= 0: return {"action":"wait","reason":"No equity."} + + if has_artemis_position(): return {"action":"wait","reason":"Artemis position already open."} + + if time.time() - _last_sig.get(symbol, 0) < COOLDOWN: + rem = int(COOLDOWN - (time.time() - _last_sig.get(symbol, 0))) + return {"action":"wait","reason":f"Cooldown: {rem}s remaining."} + + if not is_trade_time(): return {"action":"wait","reason":f"Outside session (GMT {START_H}–{END_H})."} + + quote = bridge(f"/quote?symbol={symbol}") + if "error" in quote or not quote.get("bid"): return {"action":"wait","reason":f"No quote for {symbol}."} + bid, ask = float(quote["bid"]), float(quote["ask"]) + spread = to_pips(ask - bid, symbol) + if spread > MAX_SPREAD: return {"action":"wait","reason":f"Spread {spread:.2f} > {MAX_SPREAD} pips."} + + blocked, news_warn = check_news_block(symbol) + if blocked: return {"action":"wait","reason":f"News block: {'; '.join(news_warn[:2])}"} + + bars = get_bars(symbol, SIG_TF, SENKOU_B_P + DISP + 20) + if len(bars) < SENKOU_B_P + DISP + 5: return {"action":"wait","reason":"Insufficient H1 data."} + + ind = compute_ichimoku(bars) + if not ind: return {"action":"wait","reason":"Ichimoku calculation failed."} + + close = ind["close"] + k_top = ind["kumo_top"] + k_bot = ind["kumo_bottom"] + rsi = ind["rsi"] + atr = ind["atr"] + kijun = ind["kijun_current"] + f_color = ind["future_color"] + c_color = ind["cloud_color"] + + if None in (close, k_top, k_bot, rsi): return {"action":"wait","reason":"Indicator values None."} + + # ── Signal detection ────────────────────────────────────────── + bull_break = close > k_top + bear_break = close < k_bot + + if not bull_break and not bear_break: + return {"action":"wait","reason":f"Price inside Kumo. Close={close:.5f} Kumo=[{k_bot:.5f},{k_top:.5f}]"} + + direction = "Buy" if bull_break else "Sell" + + # ── Confluence conditions ───────────────────────────────────── + if direction == "Buy": + conds = [ + (close > k_top, f"Price above Kumo ({close:.5f} > {k_top:.5f})"), + (f_color == "green", f"Future cloud GREEN (Span A > B ahead)"), + (not REQ_COLOR or c_color == "green", f"Current cloud {c_color}"), + (not REQ_CHIKOU or ind["chikou_bullish"], f"Chikou Span above price ({'+' if ind['chikou_bullish'] else '-'})"), + (rsi > RSI_BUY, f"RSI {rsi:.1f} > {RSI_BUY}"), + (not REQ_KIJUN or (kijun and close > kijun), f"Price above Kijun ({kijun:.5f if kijun else '?'})"), + (ind["conf_bars_bull"] >= CONF_BARS, f"{ind['conf_bars_bull']} bar(s) confirmed above Kumo"), + ] + entry = ask + if kijun: sl = round(kijun - 0.0002, 6) + elif atr: sl = round(entry - atr * 1.5, 6) + else: sl = round(entry - 30 * pip_size(symbol), 6) + sl_dist = abs(entry - sl) + tp = round(entry + sl_dist * TP_MULT, 6) + else: + conds = [ + (close < k_bot, f"Price below Kumo ({close:.5f} < {k_bot:.5f})"), + (f_color == "red", f"Future cloud RED (Span B > A ahead)"), + (not REQ_COLOR or c_color == "red", f"Current cloud {c_color}"), + (not REQ_CHIKOU or ind["chikou_bearish"], f"Chikou Span below price"), + (rsi < RSI_SELL, f"RSI {rsi:.1f} < {RSI_SELL}"), + (not REQ_KIJUN or (kijun and close < kijun), f"Price below Kijun ({kijun:.5f if kijun else '?'})"), + (ind["conf_bars_bear"] >= CONF_BARS, f"{ind['conf_bars_bear']} bar(s) confirmed below Kumo"), + ] + entry = bid + if kijun: sl = round(kijun + 0.0002, 6) + elif atr: sl = round(entry + atr * 1.5, 6) + else: sl = round(entry + 30 * pip_size(symbol), 6) + sl_dist = abs(entry - sl) + tp = round(entry - sl_dist * TP_MULT, 6) + + passed = [(m,d) for m,d in conds if m] + failed = [(m,d) for m,d in conds if not m] + + if len(passed) < 5: + return {"action":"wait","reason":f"Only {len(passed)}/7 conditions met.", + "conditions_met":[d for _,d in passed],"conditions_failed":[d for _,d in failed]} + + sl_pips = to_pips(entry - sl, symbol) + tp_pips = 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} < minimum {MIN_RR}."} + + volume = calculate_lot(equity, sl_pips, symbol) + _last_sig[symbol] = time.time() + + log.info(f"SIGNAL: {direction} {symbol} SL={sl} TP={tp} Vol={volume} RR={rr}") + + return { + "action": "trade", + "strategy": "artemis-ichimoku-h1", + "signal_type": "KUMO_BREAKOUT_BULLISH" if direction=="Buy" else "KUMO_BREAKOUT_BEARISH", + "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, + "indicators": { + "tenkan": ind["tenkan"], "kijun": kijun, + "kumo_top": k_top, "kumo_bottom": k_bot, + "cloud_color": c_color, "future_cloud": f_color, + "span_a": ind["span_a_current"], "span_b": ind["span_b_current"], + "rsi": rsi, "atr": atr, "adx": ind.get("adx"), + "chikou_bullish": ind["chikou_bullish"], + "spread_pips": spread, + }, + "signal_schema": { + "strategy_id": "ARTEMIS-v1", + "magic_number": CFG["strategy"]["magic_number"], + "risk_percent": RISK_PCT, + "metadata": { + "tenkan_sen": ind["tenkan"], "kijun_sen": kijun, + "senkou_a": ind["span_a_current"], "senkou_b": ind["span_b_current"], + "kumo_top": k_top, "kumo_bottom": k_bot, + "cloud_color": c_color, "rsi": rsi, + } + }, + "analysed_at": datetime.now(timezone.utc).isoformat(), + } + +if __name__ == "__main__": + import sys + sym = sys.argv[1] if len(sys.argv) > 1 else "EURUSDxx" + print(json.dumps(run_analysis(sym), indent=2, default=str)) \ No newline at end of file diff --git a/strategies/artemis/artemis_tool.py b/strategies/artemis/artemis_tool.py new file mode 100644 index 0000000..60c165b --- /dev/null +++ b/strategies/artemis/artemis_tool.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""GENESIS — Artemis Tool + Telegram Bot (Strategy E: Ichimoku H1) +Combined into one file for efficiency. Hermes calls artemis_tool.py CLI. +Bot listens for /artemis_* commands. +""" +import sys, os, json, time, logging, threading +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent)) + +from datetime import datetime, timezone +from pathlib import Path +import yaml, requests + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "core")) +from mt5_bridge import bridge as _bridge + +CONFIG_PATH = Path(__file__).parent / "artemis_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "artemis_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(CFG["telegram"]["chat_id"]) +# 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" / "artemis" + local_log_dir.mkdir(parents=True, exist_ok=True) + JOURNAL = local_log_dir / "trade_journal.jsonl" +STRATEGY = CFG["strategy"]["name"] +COMMENT = CFG["strategy"]["comment"] +SYMBOLS = CFG["symbols"] + +logging.basicConfig( + filename=f"/var/log/artemis/artemis_bot.log", + level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) +_pending: dict = {} +_lock = threading.Lock() + +def tg_send(text): + try: + requests.post(f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", + json={"chat_id": TG_CHAT_ID, "text": text, "parse_mode": "Markdown"}, timeout=10) + except: pass + +def tg_updates(offset=0): + try: + r = requests.get(f"https://api.telegram.org/bot{TG_TOKEN}/getUpdates", + params={"timeout":30,"offset":offset}, timeout=40) + return r.json().get("result",[]) + except: return [] + +def bridge(path, method="GET", data=None): + return _bridge(path, method, data) + +def journal_write(entry): + + with open(JOURNAL,"a") as f: f.write(json.dumps(entry)+"\n") + +def journal_stats(): + w=l=0; pnl=0.0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t=json.loads(line) + if t.get("result")=="win": w+=1 + if t.get("result")=="loss": l+=1 + pnl+=float(t.get("pnl") or 0) + except: pass + return w,l,round(pnl,2) + +def _load_cycle(): + import importlib.util + spec = importlib.util.spec_from_file_location("artemis_cycle", Path(__file__).parent/"artemis_cycle.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + +def _analyze(sym): + try: return _load_cycle().run_analysis(sym) + except Exception as e: return {"action":"wait","reason":f"Error: {str(e)[:200]}"} + +# ── Core commands ────────────────────────────────────────────────────────────── +def do_analyze(symbol): + sym = symbol.upper(); sym = (sym+"xx") if not sym.endswith("XX") else sym + return _analyze(sym) + +def do_execute(symbol): + sym = symbol.upper(); sym = (sym+"xx") if not sym.endswith("XX") else sym + result = _analyze(sym) + if result.get("action") != "trade": + return {"executed":False,"reason":result.get("reason"),"conditions_met":result.get("conditions_met",[])} + order = bridge("/market","POST",{ + "symbol":result["symbol"],"volume":result["volume"],"type":result["direction"], + "stop_loss":result["stop_loss"],"take_profit":result["take_profit"],"comment":COMMENT + }) + ticket = order.get("ticket") or order.get("Ticket") + if ticket: + journal_write({"ticket":str(ticket),"symbol":result["symbol"],"direction":result["direction"], + "volume":result["volume"],"sl":result["stop_loss"],"tp":result["take_profit"], + "entry":result["entry"],"rr":result["rr_ratio"],"signal_type":result.get("signal_type"), + "opened":datetime.now(timezone.utc).isoformat(),"result":None,"pnl":None, + "strategy":"artemis-ichimoku-h1","triggered_by":"hermes-autonomous"}) + ind=result.get("indicators",{}) + tg_send(f"🏹 *ARTEMIS TRADE — Hermes Triggered*\n" + f"`{result['symbol']}` {result['direction']} | {result.get('signal_type','').replace('_',' ')}\n" + f"Entry: `{result['entry']}` SL: `{result['stop_loss']}` TP: `{result['take_profit']}`\n" + f"R:R: `{result['rr_ratio']}` | Vol: `{result['volume']}`\n" + f"Cloud: {ind.get('cloud_color','?').upper()} | RSI: {ind.get('rsi','?')} | ADX: {ind.get('adx','?')}\n" + f"Ticket: `{ticket}`") + return {"executed":True,"ticket":str(ticket),"direction":result["direction"], + "rr":result["rr_ratio"],"confidence":result.get("confidence")} + else: + err=order.get("message",str(order)); tg_send(f"⚠️ *ARTEMIS*: Order FAILED — `{err}`") + return {"executed":False,"reason":f"Order failed: {err}"} + +def do_scan(): + best=None; best_rr=0; results={} + for sym in SYMBOLS: + r=_analyze(sym); results[sym]={"action":r.get("action"),"rr":r.get("rr_ratio"),"reason":r.get("reason","")[:80]} + if r.get("action")=="trade": + rr=r.get("rr_ratio") or 0 + if rr > best_rr: best_rr=rr; best=r + time.sleep(0.5) + return {"best_signal":best,"scan_results":results,"signals_found":sum(1 for r in results.values() if r["action"]=="trade")} + +def do_status(): + acc=bridge("/balance"); pos=bridge("/positions") + artemis_pos=None; all_pos=[] + if isinstance(pos,list): + for p in pos: + c=str(p.get("comment","")) + all_pos.append({"ticket":p.get("ticket"),"symbol":p.get("symbol"),"type":p.get("orderType"),"profit":p.get("profit"),"comment":c}) + if "ARTEMIS" in c.upper(): artemis_pos=p + w,l,pnl=journal_stats() + return {"account":acc,"artemis_position":artemis_pos,"all_open":all_pos, + "journal":{"wins":w,"losses":l,"pnl":pnl},"strategy":"Ichimoku Kumo Breakout H1"} + +def do_close(): + pos=bridge("/positions"); closed=[] + if isinstance(pos,list): + for p in pos: + if "ARTEMIS" in str(p.get("comment","")).upper(): + bridge("/close","POST",{"ticket":p["ticket"]}); closed.append(p["ticket"]) + tg_send(f"🎯 *ARTEMIS*: Position `{p['ticket']}` closed by Hermes.") + return {"closed":len(closed),"tickets":closed} if closed else {"closed":0,"reason":"No open Artemis positions."} + +# ── Telegram formatting ──────────────────────────────────────────────────────── +def send_result(result, scan=False): + if result.get("action")!="trade": + tg_send(f"🎯 *{STRATEGY}* — `{result.get('symbol','?')}`\n\nSignal: *WAIT*\n💡 {result.get('reason','')[:300]}") + return + with _lock: _pending.clear(); _pending.update(result) + ind = result.get("indicators",{}) + cmet = result.get("conditions_met",[]) + tg_send( + f"🎯 *{STRATEGY} SIGNAL*{'_(scan best)_' if scan else ''} — `{result.get('symbol')}`\n\n" + f"Signal: *{result.get('direction')}* — {result.get('signal_type','').replace('_',' ')}\n" + f"Entry: `{result.get('entry')}` SL: `{result.get('stop_loss')}` TP: `{result.get('take_profit')}`\n" + f"R:R: `{result.get('rr_ratio')}` | Vol: `{result.get('volume')}`\n" + f"Confidence: {result.get('confidence','?')}\n" + f"Cloud: {ind.get('cloud_color','?').upper()} → {ind.get('future_cloud','?').upper()}\n" + f"RSI: {ind.get('rsi','?')} | ADX: {ind.get('adx','?')} | ATR: {ind.get('atr','?')}\n\n" + f"📌 *Conditions ({len(cmet)} met):*\n" + + "\n".join(f" ✅ {c}" for c in cmet[:5]) + + f"\n\n`/artemis_execute` to trade | `/artemis_skip` to cancel" + ) + +def send_status(r): + acc=r.get("account",{}); ap=r.get("artemis_position"); j=r.get("journal",{}) + all_s="\n".join(f"`{p['symbol']}` {p['type']} €{p.get('profit',0):.2f} [{p['comment']}]" for p in r.get("all_open",[])) + tg_send( + f"🎯 *{STRATEGY} — Status*\n\n" + f"💰 Balance: €{acc.get('balance',0):.2f} | Equity: €{acc.get('equity',0):.2f}\n" + f"📈 Artemis Position: {ap.get('symbol','None') if ap else 'None'}\n" + f"📒 Journal: {j.get('wins',0)}W / {j.get('losses',0)}L | PnL: €{j.get('pnl',0)}\n\n" + f"*All Open:*\n{all_s or 'None'}" + ) + +# ── Telegram dispatcher ──────────────────────────────────────────────────────── +def dispatch(text, from_id): + if str(from_id)!=TG_CHAT_ID: return + lower=text.lower().strip() + + def bg(fn, *args): threading.Thread(target=fn,args=args,daemon=True).start() + + if lower.startswith("/artemis_analyze"): + parts=text.split(maxsplit=1) + sym=parts[1] if len(parts)>1 else "" + if not sym: tg_send("Usage: `/artemis_analyze EURUSD`"); return + def run(): + tg_send(f"🎯 *{STRATEGY}*: Analysing `{sym.upper()}` on H1… (30–60s)") + send_result(do_analyze(sym)) + bg(run) + elif lower=="/artemis_scan": + def run(): + tg_send(f"🎯 *{STRATEGY}*: Scanning {len(SYMBOLS)} symbols on H1…") + r=do_scan() + lines=[f"{'🟢' if v['action']=='trade' else '⚪'} `{s}`: {v['reason'][:60]}" + for s,v in r["scan_results"].items()] + tg_send("🎯 *ARTEMIS SCAN*\n\n"+"\n".join(lines)) + if r["best_signal"]: send_result(r["best_signal"],scan=True) + else: tg_send(f"📊 No signals found across {len(SYMBOLS)} symbols.") + bg(run) + elif lower=="/artemis_execute": + def run(): + with _lock: + if not _pending: + tg_send(f"🎯 No pending signal. Run `/artemis_scan` or `/artemis_analyze SYMBOL` first."); return + sig=dict(_pending); _pending.clear() + tg_send(f"🎯 Placing Artemis order…") + r=do_execute(sig.get("symbol","").replace("xx","")) + if not r.get("executed"): tg_send(f"❌ Failed: {r.get('reason')}") + bg(run) + elif lower=="/artemis_skip": + with _lock: + if not _pending: tg_send(f"🎯 No pending signal."); return + sym=_pending.get("symbol"); _pending.clear() + tg_send(f"⏭ *{STRATEGY}*: Signal for `{sym}` cancelled.") + elif lower=="/artemis_status": + bg(lambda: send_status(do_status())) + elif lower in ("/artemis_help","/artemis"): + tg_send( + f"🎯 *{STRATEGY} — Strategy E Commands*\n\n" + f"`/artemis_analyze [SYMBOL]` — Ichimoku H1 analysis\n" + f"`/artemis_scan` — Scan all {len(SYMBOLS)} symbols\n" + f"`/artemis_execute` — Execute pending signal\n" + f"`/artemis_skip` — Cancel pending signal\n" + f"`/artemis_status` — Position + journal\n" + f"`/artemis_help` — This message\n\n" + f"📊 Strategy: Ichimoku Kumo Breakout | H1\n" + f"🔖 Tag: `{COMMENT}` | Risk: 0.75%" + ) + +# ── CLI mode (called by Hermes via ares_tool.py pattern) ────────────────────── +def cli(): + args=sys.argv[1:] + if not args: print(json.dumps({"error":"Usage: artemis_tool.py [analyze|execute|scan|status|close|symbols] [SYMBOL]"})); sys.exit(1) + cmd=args[0].lower() + if cmd=="analyze": print(json.dumps(do_analyze(args[1] if len(args)>1 else "EURUSDxx"),indent=2,default=str)) + elif cmd=="execute": print(json.dumps(do_execute(args[1] if len(args)>1 else "EURUSDxx"),indent=2,default=str)) + elif cmd=="scan": print(json.dumps(do_scan(),indent=2,default=str)) + elif cmd=="status": print(json.dumps(do_status(),indent=2,default=str)) + elif cmd=="close": print(json.dumps(do_close(),indent=2,default=str)) + elif cmd=="symbols": print(json.dumps({"symbols":SYMBOLS,"strategy":"Ichimoku Kumo Breakout H1","comment":COMMENT},indent=2)) + else: print(json.dumps({"error":f"Unknown: {cmd}"})) + +# ── Bot mode ─────────────────────────────────────────────────────────────────── +def bot(): + log.info(f"=== {STRATEGY} Telegram Bot started ===") + tg_send( + f"🎯 *{STRATEGY} Bot Online*\n" + f"Strategy E: Ichimoku Kumo Breakout (H1)\n" + f"Tenkan(9) / Kijun(26) / Senkou B(52)\n" + f"Send `/artemis_help` to see commands.\n\n" + f"🤖 _Hermes controls this bot autonomously._\n" + f"_Manual override available via commands above._" + ) + offset=0 + while True: + try: + for upd in tg_updates(offset): + offset=upd["update_id"]+1 + msg=upd.get("message",{}); text=msg.get("text","") + chat_id=str(msg.get("chat",{}).get("id","")) + if text.startswith("/artemis"): dispatch(text,chat_id) + except Exception as e: log.error(f"Poll error: {e}"); time.sleep(5) + time.sleep(1) + +if __name__=="__main__": + # If called as artemis_tool.py → CLI mode + # If called as artemis_telegram_bot.py → bot mode + if Path(sys.argv[0]).name.startswith("artemis_telegram"): + bot() + else: + cli() \ No newline at end of file diff --git a/strategies/athena/athena_cycle.py b/strategies/athena/athena_cycle.py new file mode 100644 index 0000000..dc454cb --- /dev/null +++ b/strategies/athena/athena_cycle.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +""" +GENESIS — Athena Cycle (Strategy D: BB+RSI Mean Reversion on M5) +Complements Ares (M1 strict) with a faster, simpler 2-condition entry on M5. + +Differences from Ares: + - Timeframe: M5 (vs Ares M1) — catches intraday mean reversion moves + - Entry: Pure BB + RSI only (no ADX gate, no MACD requirement) + - Risk: 0.5% per trade (vs 1%) — more frequent signals, smaller size + - Context: H4 SMA50 (vs Ares M15) — broader trend filter + - SL/TP: ATR-based dynamic (vs Ares fixed pips) + +run_analysis(symbol) → signal dict. Never places trades directly. +""" +import os, json, time, logging, math +from datetime import datetime, timezone +from pathlib import Path + +import requests +import yaml + +CONFIG_PATH = Path(__file__).parent / "athena_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "athena_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" / "athena" + local_log_dir.mkdir(parents=True, exist_ok=True) + JOURNAL = local_log_dir / "trade_journal.jsonl" + + +BB_PERIOD = int(CFG["indicators"]["bb_period"]) +BB_DEV = float(CFG["indicators"]["bb_deviation"]) +RSI_PERIOD = int(CFG["indicators"]["rsi_period"]) +RSI_OS = float(CFG["indicators"]["rsi_oversold"]) +RSI_OB = float(CFG["indicators"]["rsi_overbought"]) +SIG_TF = CFG["indicators"]["signal_timeframe"] +TREND_TF = CFG["indicators"]["trend_timeframe"] +TREND_MA_PER = 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"]) + +REQUIRE_CLOSE = bool(CFG["strictness"]["require_band_close"]) +REQUIRE_CTX = bool(CFG["strictness"]["require_h4_context"]) +COOLDOWN_SECS = int(CFG["strictness"]["cooldown_seconds"]) +MAX_PER_HOUR = int(CFG["strictness"]["max_signals_per_hour"]) + +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/athena/athena_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" / "athena" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "athena_cycle.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) + +_last_signal_time: dict = {} +_signals_this_hour: dict = {} + +# ── Helpers ──────────────────────────────────────────────────────────────────── +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 + +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) + +# ── 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 = 120) -> 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 indicators ──────────────────────────────────────────────────────────── +def compute_indicators(bars: list) -> dict: + """ + Compute BB(20,2), RSI(14), ATR(14), OBV, Stochastic. + Uses index -2 (last CLOSED candle, not the forming one). + """ + if len(bars) < BB_PERIOD + 5: + return {} + try: + import pandas as pd, ta + + df = pd.DataFrame(bars) + df.columns = [c.lower() for c in df.columns] + for col in ["close","high","low","open"]: + df[col] = df[col].astype(float) + if "volume" not in df.columns: + df["volume"] = 1.0 + df["volume"] = df["volume"].astype(float) + + # Bollinger Bands + bb_upper = ta.volatility.bollinger_hband(df["close"], window=BB_PERIOD, window_dev=BB_DEV) + bb_lower = ta.volatility.bollinger_lband(df["close"], window=BB_PERIOD, window_dev=BB_DEV) + bb_mid = ta.volatility.bollinger_mavg(df["close"], window=BB_PERIOD) + bb_pct = ta.volatility.bollinger_pband(df["close"], window=BB_PERIOD, window_dev=BB_DEV) + bb_width = ta.volatility.bollinger_wband(df["close"], window=BB_PERIOD, window_dev=BB_DEV) + + # RSI + rsi = ta.momentum.rsi(df["close"], window=RSI_PERIOD) + + # Stochastic (additional confirmation) + stoch_k = ta.momentum.stoch(df["high"], df["low"], df["close"], window=14) + stoch_d = ta.momentum.stoch_signal(df["high"], df["low"], df["close"], window=14) + + # ATR + atr = ta.volatility.average_true_range(df["high"], df["low"], df["close"], window=ATR_PERIOD) + + # ADX (soft bonus — not a gate for Athena) + 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) + + # OBV direction (volume confirmation) + obv = ta.volume.on_balance_volume(df["close"], df["volume"]) + + # MACD (soft) + 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 + + # OBV trend: is OBV rising or falling over last 3 candles? + obv_now = safe(obv, -2) + obv_prev = safe(obv, -5) + obv_rising = (obv_now or 0) > (obv_prev or 0) + + return { + "bb_upper": safe(bb_upper), + "bb_lower": safe(bb_lower), + "bb_middle": safe(bb_mid), + "bb_pct": safe(bb_pct), + "bb_width": safe(bb_width), + "rsi": safe(rsi), + "stoch_k": safe(stoch_k), + "stoch_d": safe(stoch_d), + "atr": safe(atr), + "adx": safe(adx), + "adx_plus": safe(adx_pos), + "adx_minus": safe(adx_neg), + "obv_rising": obv_rising, + "macd_hist": safe(macd_hist), + "close": round(float(df["close"].iloc[-2]), 6), + "high": round(float(df["high"].iloc[-2]), 6), + "low": round(float(df["low"].iloc[-2]), 6), + } + except Exception as e: + log.error(f"compute_indicators: {e}") + return {} + +def get_h4_context(symbol: str) -> float | None: + """H4 SMA50 for broad trend direction.""" + cache = load_cache() + key = f"athena_h4sma_{symbol}" + now = time.time() + if key in cache and now - cache[key].get("ts", 0) < 900: # 15min cache + return cache[key].get("val") + try: + import pandas as pd, ta + bars = get_bars(symbol, "H4", TREND_MA_PER + 10) + if len(bars) < TREND_MA_PER: 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_PER) + 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_h4_context {symbol}: {e}") + return None + +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 + if evt.get("impact") == "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 check_rate_limit(symbol: str) -> tuple[bool, str]: + """Check cooldown + hourly rate limit.""" + now = time.time() + # Per-symbol cooldown + if now - _last_signal_time.get(symbol, 0) < COOLDOWN_SECS: + remaining = int(COOLDOWN_SECS - (now - _last_signal_time.get(symbol, 0))) + return False, f"Cooldown: {remaining}s remaining for {symbol}" + # Hourly rate limit (across all symbols) + hour_key = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") + count = _signals_this_hour.get(hour_key, 0) + if count >= MAX_PER_HOUR: + return False, f"Rate limit: {count}/{MAX_PER_HOUR} signals this hour" + return True, "" + +def has_athena_position() -> bool: + pos = bridge("/positions") + if isinstance(pos, list): + for p in pos: + if "ATHENA" in str(p.get("comment","")).upper(): + return True + return False + +# ── Main analysis ────────────────────────────────────────────────────────────── +def run_analysis(symbol: str) -> dict: + """ + Full Athena BB+RSI mean reversion analysis on M5. + Returns signal dict. Never executes — athena_tool.py handles that. + """ + symbol = symbol.upper() + if not symbol.endswith("XX"): + symbol = symbol + "xx" + symbol = symbol[:-2] + "xx" + + log.info(f"=== Athena BB+RSI M5 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 Athena position ───────────────────────────────────── + if has_athena_position(): + return {"action":"wait","reason":"Athena position already open."} + + # ── Rate limits ────────────────────────────────────────────────── + ok, reason = check_rate_limit(symbol) + if not ok: + return {"action":"wait","reason":reason} + + # ── Session ────────────────────────────────────────────────────── + if not is_trade_time(): + return {"action":"wait","reason":f"Outside session (GMT {START_HOUR}–{END_HOUR})."} + + # ── 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} > max {MAX_SPREAD} pips."} + + # ── News block ─────────────────────────────────────────────────── + blocked, news_warn = check_news_block(symbol) + if blocked: + return {"action":"wait","reason":f"News block: {'; '.join(news_warn[:2])}"} + + # ── M5 indicators ──────────────────────────────────────────────── + bars = get_bars(symbol, SIG_TF, BB_PERIOD + 30) + if len(bars) < BB_PERIOD + 5: + return {"action":"wait","reason":"Insufficient M5 bar data."} + + ind = compute_indicators(bars) + if not ind: + return {"action":"wait","reason":"Indicator computation failed."} + + close = ind["close"] + bb_upper = ind["bb_upper"] + bb_lower = ind["bb_lower"] + rsi = ind["rsi"] + atr = ind.get("atr") + adx = ind.get("adx") + stoch_k = ind.get("stoch_k") + macd_hist= ind.get("macd_hist") + obv_up = ind.get("obv_rising", True) + + if None in (close, bb_upper, bb_lower, rsi): + return {"action":"wait","reason":"Key indicator values are None."} + + # ── H4 context ─────────────────────────────────────────────────── + h4_sma = get_h4_context(symbol) + ctx_long = True + ctx_short = True + ctx_note = "H4 filter skipped" + if h4_sma is not None and REQUIRE_CTX: + ctx_long = close > h4_sma * 0.9995 # Allow slight dip below H4 SMA + ctx_short = close < h4_sma * 1.0005 + ctx_note = f"H4 SMA{TREND_MA_PER}={h4_sma:.5f}" + + # ── Signal detection (2 hard conditions + soft bonuses) ────────── + buy_hard = (close < bb_lower) and (rsi < RSI_OS) + sell_hard = (close > bb_upper) and (rsi > RSI_OB) + + if not buy_hard and not sell_hard: + return { + "action": "wait", + "reason": ( + f"No signal. Close={close:.5f} BB=[{bb_lower:.5f},{bb_upper:.5f}] " + f"RSI={rsi:.1f}" + ) + } + + direction = "Buy" if buy_hard else "Sell" + + # ── Soft bonus conditions (don't block, but affect confidence) ──── + if direction == "Buy": + conds = [ + (close < bb_lower, f"Price closed below lower BB ({close:.5f} < {bb_lower:.5f})"), + (rsi < RSI_OS, f"RSI oversold ({rsi:.1f} < {RSI_OS})"), + (ctx_long, f"H4 context OK — {ctx_note}"), + (stoch_k is not None and stoch_k < 25, f"Stochastic K oversold ({stoch_k:.1f})"), + (obv_up, f"OBV rising (volume supports buy)"), + (adx is not None and adx < 30, f"ADX={adx:.1f} (ranging market — ideal for reversion)"), + (macd_hist is not None and macd_hist > -0.00005, f"MACD hist not strongly bearish"), + ] + entry = ask + if atr and atr > 0: + sl = round(entry - atr * SL_ATR_MULT, 6) + tp = round(entry + atr * TP_ATR_MULT, 6) + else: + sl = round(entry - 15 * pip_size(symbol), 6) + tp = round(entry + 30 * pip_size(symbol), 6) + else: + conds = [ + (close > bb_upper, f"Price closed above upper BB ({close:.5f} > {bb_upper:.5f})"), + (rsi > RSI_OB, f"RSI overbought ({rsi:.1f} > {RSI_OB})"), + (ctx_short, f"H4 context OK — {ctx_note}"), + (stoch_k is not None and stoch_k > 75, f"Stochastic K overbought ({stoch_k:.1f})"), + (not obv_up, f"OBV falling (volume supports sell)"), + (adx is not None and adx < 30, f"ADX={adx:.1f} (ranging market — ideal for reversion)"), + (macd_hist is not None and macd_hist < 0.00005, f"MACD hist not strongly bullish"), + ] + entry = bid + if atr and atr > 0: + sl = round(entry + atr * SL_ATR_MULT, 6) + tp = round(entry - atr * TP_ATR_MULT, 6) + else: + sl = round(entry + 15 * pip_size(symbol), 6) + tp = round(entry - 30 * pip_size(symbol), 6) + + passed = [(m, d) for m, d in conds if m] + failed = [(m, d) for m, d in conds if not m] + + # Athena only requires 2 hard conditions (already confirmed above) + # Confidence based on how many soft bonuses also fired + n_passed = len(passed) + confidence = "high" if n_passed >= 5 else ("medium" if n_passed >= 3 else "low") + + # ── R:R gate ───────────────────────────────────────────────────── + 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 rate-limit state + _last_signal_time[symbol] = time.time() + hour_key = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") + _signals_this_hour[hour_key] = _signals_this_hour.get(hour_key, 0) + 1 + + reason = ( + f"Athena BB+RSI M5: Close={'below' if direction=='Buy' else 'above'} " + f"{'lower' if direction=='Buy' else 'upper'} BB, RSI={rsi:.1f}. " + f"{n_passed}/7 conditions. ATR={atr:.5f}, R:R={rr}." + ) + log.info(f"SIGNAL: {direction} {symbol} | SL={sl} TP={tp} Vol={volume} RR={rr}") + + return { + "action": "trade", + "strategy": "athena-bb-rsi-m5", + "signal_type": "BB_LOWER_TOUCH" if direction=="Buy" else "BB_UPPER_TOUCH", + "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": confidence, + "conditions_met": [d for _, d in passed], + "conditions_failed": [d for _, d in failed], + "warnings": news_warn, + "reason": reason, + "indicators": { + "close": close, "bb_upper": bb_upper, "bb_lower": bb_lower, + "bb_middle": ind.get("bb_middle"), "bb_width": ind.get("bb_width"), + "rsi": rsi, "stoch_k": stoch_k, "adx": adx, + "atr": atr, "h4_sma50": h4_sma, "spread_pips": spread_pips, + }, + "signal_schema": { # Matches the SignalMessage spec from the prompt + "strategy_id": "ATHENA-v1", + "magic_number": CFG["strategy"]["magic_number"], + "risk_percent": RISK_PCT, + "metadata": { + "bb_lower": bb_lower, "bb_middle": ind.get("bb_middle"), + "bb_upper": bb_upper, "rsi": rsi, + } + }, + "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 Athena analysis for {sym}...") + result = run_analysis(sym) + print(json.dumps(result, indent=2, default=str)) \ No newline at end of file diff --git a/strategies/athena/athena_telegram_bot.py b/strategies/athena/athena_telegram_bot.py new file mode 100644 index 0000000..161f169 --- /dev/null +++ b/strategies/athena/athena_telegram_bot.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +""" +GENESIS — Athena Telegram Bot (Strategy D Command Handler) +Same pattern as ares/apollo bots. Hermes controls autonomously. + +Commands: + /athena_analyze [SYMBOL] — BB+RSI analysis on M5, no trade + /athena_scan — Scan all symbols, queue best signal + /athena_execute — Execute pending signal + /athena_skip — Cancel pending signal + /athena_status — Account + open Athena position + journal + /athena_help — All commands +""" +import os, json, time, logging, threading, sys +from datetime import datetime, timezone +from pathlib import Path +import yaml, requests + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "core")) +from mt5_bridge import bridge as _bridge + +CONFIG_PATH = Path(__file__).parent / "athena_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "athena_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"])) +# 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" / "athena" + local_log_dir.mkdir(parents=True, exist_ok=True) + JOURNAL = local_log_dir / "trade_journal.jsonl" +STRATEGY = CFG["strategy"]["name"] +COMMENT = CFG["strategy"]["comment"] + +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/athena/athena_bot.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" / "athena" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "athena_bot.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) + +_pending: dict = {} +_lock = threading.Lock() + +def tg_send(text: str): + try: + requests.post( + f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", + json={"chat_id": TG_CHAT_ID, "text": text, "parse_mode": "Markdown"}, + timeout=10 + ) + except Exception as e: + log.error(f"tg_send: {e}") + +def tg_updates(offset=0): + try: + r = requests.get( + f"https://api.telegram.org/bot{TG_TOKEN}/getUpdates", + params={"timeout": 30, "offset": offset}, timeout=40 + ) + return r.json().get("result", []) + except: + return [] + +def bridge_call(path, method="GET", data=None): + return _bridge(path, method, data) + +def journal_stats(): + wins = losses = 0 + pnl = 0.0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t = json.loads(line) + if t.get("result") == "win": wins += 1 + if t.get("result") == "loss": losses += 1 + pnl += float(t.get("pnl") or 0) + except: pass + return wins, losses, round(pnl, 2) + +def cmd_help(): + bb = CFG["indicators"]["bb_period"] + dev = CFG["indicators"]["bb_deviation"] + rsi = CFG["indicators"]["rsi_period"] + tf = CFG["indicators"]["signal_timeframe"] + tg_send( + f"🌿 *{STRATEGY} — Strategy D Commands*\n\n" + f"`/athena_analyze [SYMBOL]` — BB+RSI analysis (no trade)\n" + f"`/athena_scan` — Scan all {len(CFG['symbols'])} symbols\n" + f"`/athena_execute` — Execute pending signal\n" + f"`/athena_skip` — Cancel pending signal\n" + f"`/athena_status` — Position + journal stats\n" + f"`/athena_help` — This message\n\n" + f"📊 Strategy: BB({bb},{dev})+RSI({rsi}) on {tf}\n" + f"🎯 Risk: {CFG['risk']['risk_pct']*100}% | ATR-based SL/TP\n" + f"🔖 Tag: `{COMMENT}`" + ) + +def cmd_status(): + acc = bridge_call("/balance") + if "error" in acc: + tg_send(f"🔴 *{STRATEGY}*: Bridge unreachable."); return + positions = bridge_call("/positions") + pos_str = "None" + all_str = [] + if isinstance(positions, list): + for p in positions: + c = str(p.get("comment","")) + all_str.append(f"`{p.get('symbol')}` {p.get('orderType')} " + f"{p.get('lots')}lot €{p.get('profit',0):.2f} [{c}]") + if "ATHENA" in c.upper(): + pos_str = f"{p.get('symbol')} {p.get('orderType')} | €{p.get('profit',0):.2f}" + wins, losses, pnl = journal_stats() + with _lock: + pend = (f"🟡 {_pending.get('symbol')} {_pending.get('direction')} " + f"({_pending.get('signal_type','?')})" + if _pending else "None") + tg_send( + f"🌿 *{STRATEGY} — Status*\n\n" + f"💰 Balance: €{acc.get('balance',0):.2f} | Equity: €{acc.get('equity',0):.2f}\n" + f"📈 Athena Position: {pos_str}\n" + f"📋 Pending: {pend}\n" + f"📒 Journal: {wins}W / {losses}L | PnL: €{pnl}\n\n" + f"*All Open:*\n" + ("\n".join(all_str) if all_str else "None") + ) + +def cmd_skip(): + with _lock: + if not _pending: + tg_send(f"🌿 *{STRATEGY}*: No pending signal."); return + sym = _pending.get("symbol") + _pending.clear() + tg_send(f"⏭ *{STRATEGY}*: Signal for `{sym}` cancelled.") + +def cmd_execute(): + with _lock: + if not _pending: + tg_send(f"🌿 *{STRATEGY}*: No pending signal.\nRun `/athena_analyze SYMBOL` or `/athena_scan` first.") + return + signal = dict(_pending) + _pending.clear() + + sym = signal.get("symbol") + dire = signal.get("direction") + sl = signal.get("stop_loss") + tp = signal.get("take_profit") + vol = signal.get("volume", 0.01) + + if not all([sym, dire, sl, tp]): + tg_send(f"🌿 *{STRATEGY}*: Incomplete signal — cannot execute."); return + + positions = bridge_call("/positions") + if isinstance(positions, list) and any( + "ATHENA" in str(p.get("comment","")).upper() for p in positions + ): + tg_send(f"⚠️ *{STRATEGY}*: Athena position already open."); return + + tg_send(f"🌿 *{STRATEGY}*: Placing order…") + order = bridge_call("/market","POST",{ + "symbol": sym,"volume": vol,"type": dire, + "stop_loss": sl,"take_profit": tp,"comment": COMMENT + }) + + ticket = order.get("ticket") or order.get("Ticket") + if ticket: + now = datetime.now(timezone.utc) + Path(JOURNAL).parent.mkdir(parents=True, exist_ok=True) + with open(JOURNAL,"a") as f: + f.write(json.dumps({ + "ticket": str(ticket),"symbol": sym,"direction": dire, + "volume": vol,"sl": sl,"tp": tp, + "signal_type": signal.get("signal_type"), + "confidence": signal.get("confidence"), + "rr": signal.get("rr_ratio"), + "opened": now.isoformat(),"result": None,"pnl": None, + "strategy": "athena-bb-rsi-m5" + }) + "\n") + ind = signal.get("indicators",{}) + tg_send( + f"✅ *{STRATEGY} TRADE PLACED*\n" + f"📊 `{sym}` {dire} | {signal.get('signal_type','').replace('_',' ')}\n" + f"Entry: `{signal.get('entry')}` | SL: `{sl}` | TP: `{tp}`\n" + f"R:R: `{signal.get('rr_ratio')}` | Vol: `{vol}` " + f"| Confidence: {signal.get('confidence','?')}\n" + f"RSI: {ind.get('rsi','?')} | ATR: {ind.get('atr','?')}\n" + f"🔖 Ticket: `{ticket}`" + ) + else: + err = order.get("message",str(order)) + tg_send(f"❌ *{STRATEGY}*: Order FAILED — `{err}`") + +def _run_analysis(sym): + try: + import importlib.util + spec = importlib.util.spec_from_file_location( + "athena_cycle", Path(__file__).parent / "athena_cycle.py" + ) + mod = importlib.util.load_from_spec(spec) + spec.loader.exec_module(mod) + return mod.run_analysis(sym) + except Exception as e: + log.error(f"Analysis error: {e}", exc_info=True) + return {"action":"wait","reason":f"Error: {str(e)[:200]}"} + +def cmd_analyze(symbol: str): + sym = symbol.upper().strip() + if not sym.endswith("XX"): + sym = sym + "xx" + tg_send(f"🌿 *{STRATEGY}*: Analysing `{sym}` on M5… (30–60s)") + result = _run_analysis(sym) + _format_and_send(result) + +def cmd_scan(): + tg_send(f"🌿 *{STRATEGY}*: Scanning {len(CFG['symbols'])} symbols… (60–90s)") + symbols = CFG["symbols"] + best = None + best_rr = 0 + lines = [] + + for sym in symbols: + r = _run_analysis(sym) + action = r.get("action","wait") + if action == "trade": + rr = r.get("rr_ratio",0) or 0 + conf = r.get("confidence","?") + lines.append(f"🟢 `{sym}`: {r.get('direction')} " + f"{r.get('signal_type','').replace('_',' ')} " + f"R:R {rr} | {conf}") + if rr > best_rr: + best_rr = rr + best = r + else: + lines.append(f"⚪ `{sym}`: {r.get('reason','')[:60]}") + time.sleep(0.5) + + tg_send(f"🌿 *{STRATEGY} SCAN*\n\n" + "\n".join(lines)) + + if best: + with _lock: + _pending.clear() + _pending.update(best) + _format_and_send(best, from_scan=True) + else: + tg_send(f"📊 *{STRATEGY}*: No trade signals found.") + +def _format_and_send(result: dict, from_scan: bool = False): + action = result.get("action","wait") + if action != "trade": + tg_send( + f"🌿 *{STRATEGY}* — `{result.get('symbol','?')}`\n\n" + f"Signal: *WAIT*\n💡 {result.get('reason','')[:300]}" + ) + return + + with _lock: + _pending.clear() + _pending.update(result) + + ind = result.get("indicators",{}) + conds = result.get("conditions_met",[]) + cond_str = "\n".join(f" ✅ {c}" for c in conds) if conds else " BB + RSI conditions met" + scan_tag = " _(Best from scan)_" if from_scan else "" + + tg_send( + f"🌿 *{STRATEGY} ANALYSIS*{scan_tag} — `{result.get('symbol')}`\n\n" + f"Signal: *{result.get('direction')}* — " + f"{result.get('signal_type','').replace('_',' ')}\n" + f"Entry: `{result.get('entry')}`\n" + f"SL: `{result.get('stop_loss')}` ({result.get('sl_pips','?')} pips)\n" + f"TP: `{result.get('take_profit')}` ({result.get('tp_pips','?')} pips)\n" + f"R:R: `{result.get('rr_ratio')}` | Vol: `{result.get('volume')}`\n" + f"🎯 Confidence: {result.get('confidence','?')}\n" + f"RSI: {ind.get('rsi','?')} | ATR: {ind.get('atr','?')} " + f"| ADX: {ind.get('adx','?')}\n\n" + f"📌 *Conditions ({len(conds)} fired):*\n{cond_str}\n\n" + f"💡 {result.get('reason','')[:250]}\n\n" + f"Reply `/athena_execute` to trade or `/athena_skip` to cancel." + ) + +def dispatch(text: str, from_id: str): + if str(from_id) != TG_CHAT_ID: + return + lower = text.lower().strip() + if lower.startswith("/athena_analyze"): + parts = text.split(maxsplit=1) + sym = parts[1] if len(parts) > 1 else "" + if not sym: + tg_send("Usage: `/athena_analyze EURUSD`") + else: + threading.Thread(target=cmd_analyze, args=(sym,), daemon=True).start() + elif lower == "/athena_scan": + threading.Thread(target=cmd_scan, daemon=True).start() + elif lower == "/athena_execute": + threading.Thread(target=cmd_execute, daemon=True).start() + elif lower == "/athena_skip": + cmd_skip() + elif lower == "/athena_status": + threading.Thread(target=cmd_status, daemon=True).start() + elif lower in ("/athena_help", "/athena"): + cmd_help() + +def main(): + log.info(f"=== {STRATEGY} Telegram Bot started ===") + try: + requests.post( + f"https://api.telegram.org/bot{TG_TOKEN}/setMyCommands", + json={"commands": [ + {"command": "athena_help", "description": "Show all commands"}, + {"command": "athena_analyze", "description": "BB+RSI analysis on symbol"}, + {"command": "athena_scan", "description": "Scan all symbols for best signal"}, + {"command": "athena_execute", "description": "Execute pending signal"}, + {"command": "athena_skip", "description": "Cancel pending signal"}, + {"command": "athena_status", "description": "Position + journal stats"}, + ]}, + timeout=10 + ) + except Exception as e: + log.warning(f"setMyCommands failed: {e}") + bb = CFG["indicators"]["bb_period"] + dev = CFG["indicators"]["bb_deviation"] + rsi = CFG["indicators"]["rsi_period"] + tf = CFG["indicators"]["signal_timeframe"] + tg_send( + f"🌿 *{STRATEGY} Bot Online*\n" + f"Strategy D: BB({bb},{dev})+RSI({rsi}) Mean Reversion on {tf}\n" + f"Send `/athena_help` to see commands.\n\n" + f"🤖 _Hermes controls this bot autonomously._\n" + f"_You can also trigger manually via the commands above._" + ) + offset = 0 + while True: + try: + updates = tg_updates(offset) + for upd in updates: + offset = upd["update_id"] + 1 + msg = upd.get("message",{}) + text = msg.get("text","") + chat_id = str(msg.get("chat",{}).get("id","")) + if text.startswith("/athena"): + dispatch(text, chat_id) + except Exception as e: + log.error(f"Polling error: {e}") + time.sleep(5) + time.sleep(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/strategies/athena/athena_tool.py b/strategies/athena/athena_tool.py new file mode 100644 index 0000000..5a21ad8 --- /dev/null +++ b/strategies/athena/athena_tool.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +GENESIS — Athena CLI Tool (Strategy D: BB+RSI Mean Reversion M5) +Hermes calls this autonomously — same pattern as ares_tool.py / apollo_tool.py. + +Usage: + python3 athena_tool.py analyze EURUSD # BB+RSI analysis, no trade + python3 athena_tool.py execute EURUSD # Analyze + execute if signal found + python3 athena_tool.py scan # Scan all symbols, return best signal + python3 athena_tool.py status # Open Athena position + journal stats + python3 athena_tool.py close # Close open Athena position + python3 athena_tool.py symbols # List Athena symbols +""" +import sys, os, json, time +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent)) + +from athena_cycle import ( + run_analysis, bridge, JOURNAL, MAGIC_COMMENT, + tg, CFG, _last_signal_time, pip_size +) +from datetime import datetime, timezone +from pathlib import Path + +ATHENA_SYMBOLS = CFG["symbols"] + +def journal_write(entry: dict): + JOURNAL.parent.mkdir(parents=True, exist_ok=True) + with open(JOURNAL, "a") as f: + f.write(json.dumps(entry) + "\n") + +def journal_stats(): + wins = losses = total_pnl = 0.0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t = json.loads(line) + if t.get("result") == "win": wins += 1 + if t.get("result") == "loss": losses += 1 + total_pnl += float(t.get("pnl") or 0) + except: pass + return int(wins), int(losses), round(total_pnl, 2) + +def cmd_analyze(symbol: str) -> dict: + sym = symbol.upper() + if not sym.endswith("XX"): + sym = sym + "xx" + result = run_analysis(sym) + print(json.dumps(result, indent=2, default=str)) + return result + +def cmd_execute(symbol: str) -> dict: + sym = symbol.upper() + if not sym.endswith("XX"): + sym = sym + "xx" + + result = run_analysis(sym) + + if result.get("action") != "trade": + out = { + "executed": False, + "reason": result.get("reason","No signal"), + "confidence": result.get("confidence","none"), + "conditions_met": result.get("conditions_met",[]), + } + print(json.dumps(out, indent=2)) + return out + + order = bridge("/market", "POST", { + "symbol": result["symbol"], + "volume": result["volume"], + "type": result["direction"], + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "comment": MAGIC_COMMENT, # "ATHENA-v1" + }) + + ticket = order.get("ticket") or order.get("Ticket") + now = datetime.now(timezone.utc) + + if ticket: + journal_write({ + "ticket": str(ticket), + "symbol": result["symbol"], + "direction": result["direction"], + "volume": result["volume"], + "sl": result["stop_loss"], + "tp": result["take_profit"], + "entry": result["entry"], + "rr": result["rr_ratio"], + "signal_type": result.get("signal_type"), + "confidence": result.get("confidence"), + "opened": now.isoformat(), + "result": None, + "pnl": None, + "strategy": "athena-bb-rsi-m5", + "triggered_by": "hermes-autonomous", + "conditions": result.get("conditions_met",[]), + }) + + ind = result.get("indicators",{}) + bb_period = CFG["indicators"]["bb_period"] + rsi_period = CFG["indicators"]["rsi_period"] + tg( + f"🌿 *ATHENA TRADE — Hermes Triggered*\n" + f"📊 `{result['symbol']}` {result['direction']} " + f"| {result.get('signal_type','').replace('_',' ')}\n" + f"Entry: `{result['entry']}` | SL: `{result['stop_loss']}` " + f"| TP: `{result['take_profit']}`\n" + f"R:R: `{result['rr_ratio']}` | Vol: `{result['volume']}` " + f"| Confidence: {result.get('confidence','?')}\n" + f"🎯 BB({bb_period},2.0)+RSI({rsi_period}) on M5\n" + f"RSI: {ind.get('rsi','?')} | ATR: {ind.get('atr','?')} " + f"| ADX: {ind.get('adx','?')}\n" + f"📌 {', '.join(result.get('conditions_met',[])[:3])}\n" + f"🔖 Ticket: `{ticket}`" + ) + + out = { + "executed": True, + "ticket": str(ticket), + "symbol": result["symbol"], + "direction": result["direction"], + "signal_type": result.get("signal_type"), + "volume": result["volume"], + "sl": result["stop_loss"], + "tp": result["take_profit"], + "rr": result["rr_ratio"], + "confidence": result.get("confidence"), + "reason": result["reason"], + } + else: + err = order.get("message", str(order)) + tg(f"⚠️ *ATHENA*: Order FAILED — `{err}`") + out = {"executed":False,"reason":f"Order failed: {err}"} + + print(json.dumps(out, indent=2, default=str)) + return out + +def cmd_scan() -> dict: + """Scan ALL symbols, return the best BB+RSI signal. Hermes calls this first.""" + best = None + best_score = 0 + results = {} + + for sym in ATHENA_SYMBOLS: + r = run_analysis(sym) + action = r.get("action","wait") + results[sym] = { + "action": action, + "signal_type": r.get("signal_type","none"), + "confidence": r.get("confidence","none"), + "rr": r.get("rr_ratio"), + "reason": r.get("reason","")[:100], + } + if action == "trade": + conf_score = {"high":3,"medium":2,"low":1}.get(r.get("confidence","low"),1) + score = conf_score + (r.get("rr_ratio") or 0) * 0.5 + score += len(r.get("conditions_met",[])) * 0.2 + if score > best_score: + best_score = score + best = r + time.sleep(0.5) + + out = { + "best_signal": best, + "scan_results": results, + "scanned": len(ATHENA_SYMBOLS), + "signals_found": sum(1 for r in results.values() if r["action"]=="trade"), + } + print(json.dumps(out, indent=2, default=str)) + return out + +def cmd_status() -> dict: + acc = bridge("/balance") + positions = bridge("/positions") + + athena_pos = None + all_pos_str = [] + if isinstance(positions, list): + for p in positions: + c = str(p.get("comment","")) + all_pos_str.append({ + "ticket": p.get("ticket"), + "symbol": p.get("symbol"), + "type": p.get("orderType"), + "lots": p.get("lots"), + "profit": p.get("profit"), + "comment": c, + }) + if "ATHENA" in c.upper(): + athena_pos = p + + wins, losses, pnl = journal_stats() + out = { + "account": acc, + "athena_position": athena_pos, + "all_open": all_pos_str, + "athena_journal": {"wins": wins, "losses": losses, "total_pnl": pnl}, + "strategy": "BB+RSI Mean Reversion M5", + "comment_tag": MAGIC_COMMENT, + } + print(json.dumps(out, indent=2, default=str)) + return out + +def cmd_close() -> dict: + positions = bridge("/positions") + closed = [] + if isinstance(positions, list): + for p in positions: + if "ATHENA" in str(p.get("comment","")).upper(): + res = bridge("/close","POST",{"ticket": p["ticket"]}) + closed.append({"ticket": p["ticket"], "result": res}) + tg(f"🌿 *ATHENA*: Position `{p['ticket']}` closed by Hermes.") + out = ({"closed": len(closed),"positions": closed} + if closed else {"closed":0,"reason":"No open Athena positions."}) + print(json.dumps(out, indent=2, default=str)) + return out + +def cmd_symbols() -> dict: + bb = CFG["indicators"]["bb_period"] + dev = CFG["indicators"]["bb_deviation"] + rsi = CFG["indicators"]["rsi_period"] + out = { + "symbols": ATHENA_SYMBOLS, + "strategy": "BB+RSI Mean Reversion", + "timeframe": f"{CFG['indicators']['signal_timeframe']} entry, " + f"{CFG['indicators']['trend_timeframe']} context", + "indicators": f"BB({bb},{dev}) + RSI({rsi})", + "comment_tag": MAGIC_COMMENT, + "risk": f"{CFG['risk']['risk_pct']*100}% per trade", + } + print(json.dumps(out, indent=2)) + return out + +# ── Entry point ──────────────────────────────────────────────────────────────── +if __name__ == "__main__": + args = sys.argv[1:] + if not args: + print(json.dumps({"error":"Usage: athena_tool.py [analyze|execute|scan|status|close|symbols] [SYMBOL]"})) + sys.exit(1) + cmd = args[0].lower() + if cmd == "analyze": + if len(args)<2: print(json.dumps({"error":"analyze requires a symbol"})); sys.exit(1) + cmd_analyze(args[1]) + elif cmd == "execute": + if len(args)<2: print(json.dumps({"error":"execute requires a symbol"})); sys.exit(1) + cmd_execute(args[1]) + elif cmd == "scan": + cmd_scan() + elif cmd == "status": + cmd_status() + elif cmd == "close": + cmd_close() + elif cmd == "symbols": + cmd_symbols() + else: + print(json.dumps({"error":f"Unknown: {cmd}"})); sys.exit(1) diff --git a/strategies/hephaestus/hephaestus_cycle.py b/strategies/hephaestus/hephaestus_cycle.py new file mode 100644 index 0000000..20c737b --- /dev/null +++ b/strategies/hephaestus/hephaestus_cycle.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +""" +GENESIS — Hephaestus (Strategy F: Grid + Martingale) + +⚠️ EXTREME RISK WARNING ⚠️ +Grid/Martingale strategies can produce 90%+ win rates but carry the risk of +CATASTROPHIC, UNLIMITED DRAWDOWN if price trends strongly without reversal. +Circuit breakers in this code REDUCE but DO NOT ELIMINATE this risk. + +NEVER run on live account without: +- Backtesting over trending AND ranging regimes +- max_grid_levels ≤ 5 and initial_lot = 0.01 +- Monitoring at least daily +- Setting confirm_risk_acknowledged: true only after understanding the above + +Architecture: This runs as a STATE MACHINE, not a signal generator. +- State is persisted in /var/log/hephaestus/grid_state.json +- Hermes calls hephaestus_tool.py to check status / start / stop +- The tool is NOT meant to run continuously — it's polled by Hermes +""" +import os, json, time, logging, math +from datetime import datetime, timezone, date +from pathlib import Path +import requests, yaml + +CONFIG_PATH = Path(__file__).parent / "hephaestus_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "hephaestus_config.yaml" +with open(CONFIG_PATH, encoding="utf-8") as f: + CFG = yaml.safe_load(f) + +# ── Risk gate — hard stop if user hasn't acknowledged ───────────────────────── +if not CFG["strategy"].get("confirm_risk_acknowledged"): + raise RuntimeError( + "HEPHAESTUS BLOCKED: Set confirm_risk_acknowledged: true in hephaestus_config.yaml " + "after reading the risk warning. This strategy can blow your account." + ) + +TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +TG_CHAT_ID = str(CFG["telegram"]["chat_id"]) +# 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" / "hephaestus" + local_log_dir.mkdir(parents=True, exist_ok=True) + JOURNAL = local_log_dir / "trade_journal.jsonl" +STATE_FILE = Path("/var/log/hephaestus/grid_state.json") + +STATE_FILE.parent.mkdir(parents=True, exist_ok=True) +COMMENT = CFG["strategy"]["comment"] + +SYMBOL = CFG["symbol"] +DIRECTION = CFG["direction"] +INIT_LOT = float(CFG["grid"]["initial_lot"]) +MULTIPLIER = float(CFG["grid"]["martingale_multiplier"]) +MAX_LOT = float(CFG["grid"]["max_lot_per_order"]) +SPACING = int(CFG["grid"]["grid_spacing_pips"]) +MAX_LEVELS = int(CFG["grid"]["max_grid_levels"]) +TP_PIPS = int(CFG["grid"]["take_profit_pips"]) +BASKET_TP = int(CFG["grid"]["basket_tp_pips"]) + +MAX_DD_PCT = float(CFG["circuit_breakers"]["max_equity_drawdown_pct"]) +MAX_DL_PCT = float(CFG["circuit_breakers"]["max_daily_loss_pct"]) +MAX_CONSEC = int(CFG["circuit_breakers"]["max_consecutive_losses"]) +COOLDOWN = int(CFG["circuit_breakers"]["cooldown_after_reset_sec"]) +MAX_SPREAD = float(CFG["circuit_breakers"]["max_spread_pips"]) +MAX_LOTS = float(CFG["circuit_breakers"]["max_total_lots"]) +START_H = int(CFG["sessions"]["allowed"][0]["start"]) +END_H = int(CFG["sessions"]["allowed"][0]["end"]) + +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/hephaestus/hephaestus_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" / "hephaestus" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "hephaestus_cycle.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) + +# ── State management ─────────────────────────────────────────────────────────── +DEFAULT_STATE = { + "enabled": True, + "buy_level": 0, # Current martingale level for buys (0=initial) + "sell_level": 0, + "buy_tickets": [], # Open buy position tickets + "sell_tickets": [], # Open sell position tickets + "consec_buy_loss": 0, + "consec_sell_loss": 0, + "peak_equity": 0.0, + "day_start_bal": 0.0, + "last_day": str(date.today()), + "last_reset_ts": 0, + "total_cycles": 0, + "killed_reason": None, +} + +def load_state() -> dict: + if STATE_FILE.exists(): + try: return json.loads(STATE_FILE.read_text()) + except: pass + return dict(DEFAULT_STATE) + +def save_state(s: dict): + STATE_FILE.write_text(json.dumps(s, default=str)) + +# ── 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 + +def pip(sym): return 0.01 if "JPY" in sym else (0.1 if "XAU" in sym else 0.0001) +def to_pips(diff, sym): return abs(diff) / pip(sym) + +def is_trade_time(): + 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_H <= hr < END_H + +def lot_for_level(level: int) -> float: + """Martingale lot: initial × multiplier^level, capped at MAX_LOT.""" + lot = INIT_LOT * (MULTIPLIER ** level) + return round(min(lot, MAX_LOT), 2) + +def total_exposure(positions: list) -> float: + return sum(float(p.get("lots",0)) for p in positions + if COMMENT.split("-")[0] in str(p.get("comment",""))) + +def get_heph_positions(positions: list, direction: str = None) -> list: + tag = COMMENT.split("-")[0] + res = [p for p in (positions if isinstance(positions,list) else []) + if tag in str(p.get("comment","")).upper()] + if direction: + res = [p for p in res if p.get("orderType","").lower()==direction.lower()] + return res + +# ── Circuit breaker evaluation ───────────────────────────────────────────────── +def check_circuit_breakers(state: dict, acc: dict) -> tuple[bool, str]: + """Returns (killed, reason). Updates state in-place if kill triggered.""" + equity = float(acc.get("equity", 0)) + balance = float(acc.get("balance", 0)) + + # Reset daily tracking if new day + today = str(date.today()) + if state["last_day"] != today: + state["last_day"] = today + state["day_start_bal"] = balance + log.info("New day — daily loss counter reset.") + + if state["peak_equity"] < equity: + state["peak_equity"] = equity + + # 1. Equity drawdown from peak + if state["peak_equity"] > 0: + dd_pct = (state["peak_equity"] - equity) / state["peak_equity"] * 100 + if dd_pct >= MAX_DD_PCT: + return True, f"EQUITY DRAWDOWN {dd_pct:.2f}% ≥ {MAX_DD_PCT}% — EMERGENCY STOP" + + # 2. Daily loss + if state["day_start_bal"] > 0: + daily_loss_pct = (state["day_start_bal"] - balance) / state["day_start_bal"] * 100 + if daily_loss_pct >= MAX_DL_PCT: + return True, f"DAILY LOSS {daily_loss_pct:.2f}% ≥ {MAX_DL_PCT}% — STOPPED FOR DAY" + + # 3. Consecutive losses + if state["consec_buy_loss"] >= MAX_CONSEC or state["consec_sell_loss"] >= MAX_CONSEC: + return True, f"MAX CONSECUTIVE LOSSES ({MAX_CONSEC}) reached — RESET GRID" + + return False, "" + +def emergency_stop(state: dict, reason: str, positions: list) -> dict: + """Close ALL Hephaestus positions and disable strategy.""" + log.error(f"EMERGENCY STOP: {reason}") + tg(f"🚨 *HEPHAESTUS EMERGENCY STOP*\n`{reason}`\nClosing all grid positions now.") + closed = 0 + for p in get_heph_positions(positions): + r = bridge("/close","POST",{"ticket": p["ticket"]}) + if r.get("ticket") or not r.get("error"): closed += 1 + state["enabled"] = False + state["killed_reason"] = reason + state["buy_level"] = 0 + state["sell_level"] = 0 + state["buy_tickets"] = [] + state["sell_tickets"] = [] + save_state(state) + tg(f"🚨 *HEPHAESTUS*: {closed} positions closed. Strategy DISABLED.") + return state + +def reset_grid(state: dict, positions: list, reason: str = "basket TP hit") -> dict: + """Close all positions, reset levels, apply cooldown.""" + log.info(f"Grid reset: {reason}") + closed = 0 + total_pnl = 0.0 + for p in get_heph_positions(positions): + pnl = float(p.get("profit",0)) + r = bridge("/close","POST",{"ticket": p["ticket"]}) + if not r.get("error"): + closed += 1 + total_pnl += pnl + _journal(p, pnl, "reset") + state.update({ + "buy_level":0,"sell_level":0, + "buy_tickets":[],"sell_tickets":[], + "consec_buy_loss":0,"consec_sell_loss":0, + "last_reset_ts": time.time(), + "total_cycles": state.get("total_cycles",0) + 1, + }) + save_state(state) + tg(f"🔄 *HEPHAESTUS GRID RESET* ({reason})\n" + f"Closed {closed} positions | Cycle PnL: €{total_pnl:.2f}\n" + f"Total cycles: {state['total_cycles']} | Cooldown: {COOLDOWN}s") + return state + +def _journal(p, pnl, result): + + with open(JOURNAL,"a") as f: + f.write(json.dumps({ + "ticket":str(p.get("ticket")),"symbol":p.get("symbol"), + "type":p.get("orderType"),"lots":p.get("lots"), + "pnl":round(pnl,2),"result":result, + "ts":datetime.now(timezone.utc).isoformat(),"strategy":"hephaestus-grid" + })+"\n") + +# ── Core cycle tick ──────────────────────────────────────────────────────────── +def run_cycle() -> dict: + """ + Main Hephaestus logic tick. Called by hephaestus_tool.py on schedule. + Returns status dict describing current grid state and any actions taken. + """ + state = load_state() + actions = [] + + if not state["enabled"]: + return {"status":"disabled","reason":state.get("killed_reason","unknown"),"state":state} + + # ── Account ───────────────────────────────────────────────────── + acc = bridge("/balance") + if "error" in acc: + return {"status":"error","reason":f"Bridge: {acc['error']}"} + equity = float(acc.get("equity",0)) + balance = float(acc.get("balance",0)) + + # Initialize peak/day_start + if state["peak_equity"] == 0: state["peak_equity"] = equity + if state["day_start_bal"] == 0: state["day_start_bal"] = balance + + # ── Circuit breakers ───────────────────────────────────────────── + positions = bridge("/positions") + if not isinstance(positions,list): positions = [] + + killed, kill_reason = check_circuit_breakers(state, acc) + if killed: + state = emergency_stop(state, kill_reason, positions) + return {"status":"emergency_stop","reason":kill_reason} + + # ── Session check ──────────────────────────────────────────────── + if not is_trade_time(): + save_state(state) + return {"status":"outside_session","equity":equity,"state":state} + + # ── Cooldown check ─────────────────────────────────────────────── + if time.time() - state["last_reset_ts"] < COOLDOWN: + remaining = int(COOLDOWN - (time.time()-state["last_reset_ts"])) + save_state(state) + return {"status":"cooldown","remaining_seconds":remaining} + + # ── Quote + spread ─────────────────────────────────────────────── + quote = bridge(f"/quote?symbol={SYMBOL}") + if "error" in quote or not quote.get("bid"): + return {"status":"no_quote"} + bid = float(quote["bid"]); ask = float(quote["ask"]) + spread_pips = to_pips(ask-bid, SYMBOL) + if spread_pips > MAX_SPREAD: + return {"status":"spread_too_wide","spread":spread_pips} + + # ── Exposure check ─────────────────────────────────────────────── + heph_pos = get_heph_positions(positions) + total_lots = total_exposure(positions) + if total_lots >= MAX_LOTS: + tg(f"⚠️ *HEPHAESTUS*: Max exposure {total_lots:.2f}lots ≥ {MAX_LOTS}. No new levels.") + save_state(state) + return {"status":"max_exposure","lots":total_lots} + + # ── Check basket TP ────────────────────────────────────────────── + basket_pnl = sum(float(p.get("profit",0)) for p in heph_pos) + basket_tp_eur = BASKET_TP * pip(SYMBOL) * 100000 * INIT_LOT # Approx EUR value + if heph_pos and basket_pnl >= basket_tp_eur: + state = reset_grid(state, heph_pos, f"basket TP hit €{basket_pnl:.2f}") + save_state(state) + return {"status":"basket_tp_hit","pnl":basket_pnl} + + # ── Check individual position outcomes ─────────────────────────── + for p in heph_pos: + ticket = str(p.get("ticket")) + pnl = float(p.get("profit",0)) + tp_eur = TP_PIPS * pip(SYMBOL) * 100000 * float(p.get("lots",0.01)) + # Close if individual TP hit + if pnl >= tp_eur: + r = bridge("/close","POST",{"ticket": p["ticket"]}) + if not r.get("error"): + _journal(p, pnl, "win") + dir_ = p.get("orderType","Buy") + if dir_ == "Buy": + state["consec_buy_loss"] = 0 + if ticket in [str(t) for t in state["buy_tickets"]]: + state["buy_tickets"] = [t for t in state["buy_tickets"] if str(t)!=ticket] + if state["buy_level"] > 0: state["buy_level"] -= 1 + else: + state["consec_sell_loss"] = 0 + if ticket in [str(t) for t in state["sell_tickets"]]: + state["sell_tickets"] = [t for t in state["sell_tickets"] if str(t)!=ticket] + if state["sell_level"] > 0: state["sell_level"] -= 1 + actions.append(f"Closed TP {dir_} ticket {ticket} P&L €{pnl:.2f}") + log.info(f"TP hit: {dir_} ticket {ticket} PnL={pnl:.2f}") + + # ── Open new grid level if no position in that direction ────────── + def open_level(direction: str): + level = state[f"{direction.lower()}_level"] + if level >= MAX_LEVELS: + tg(f"⚠️ *HEPHAESTUS*: Max levels ({MAX_LEVELS}) reached for {direction}. Waiting.") + return None + lot = lot_for_level(level) + sl_price = (round(bid - SPACING*2*pip(SYMBOL),6) if direction=="Buy" + else round(ask + SPACING*2*pip(SYMBOL),6)) + tp_price = (round(ask + TP_PIPS*pip(SYMBOL),6) if direction=="Buy" + else round(bid - TP_PIPS*pip(SYMBOL),6)) + order = bridge("/market","POST",{ + "symbol":SYMBOL,"volume":lot,"type":direction, + "stop_loss":sl_price,"take_profit":tp_price,"comment":COMMENT + }) + ticket = order.get("ticket") or order.get("Ticket") + if ticket: + state[f"{direction.lower()}_tickets"].append(str(ticket)) + state[f"{direction.lower()}_level"] = level + 1 + _journal({"ticket":ticket,"symbol":SYMBOL,"orderType":direction,"lots":lot,"profit":0}, 0, "open") + log.info(f"Grid {direction} Level {level} opened: lot={lot} ticket={ticket}") + actions.append(f"Opened {direction} Level {level} lot={lot} ticket={ticket}") + tg(f"🔩 *HEPHAESTUS*: {direction} Level {level+1} | Lot {lot} | Ticket `{ticket}`") + return ticket + else: + log.error(f"Order failed: {order}") + return None + + # Open buys if no active buy position + active_buys = get_heph_positions(heph_pos, "Buy") + active_sells = get_heph_positions(heph_pos, "Sell") + + if DIRECTION in ("buy_only","both") and not active_buys: + open_level("Buy") + + if DIRECTION in ("sell_only","both") and not active_sells: + open_level("Sell") + + save_state(state) + + return { + "status": "running", + "equity": equity, + "basket_pnl": round(basket_pnl,2), + "total_lots": round(total_lots,2), + "buy_level": state["buy_level"], + "sell_level": state["sell_level"], + "spread": round(spread_pips,2), + "actions": actions, + "open_positions": len(heph_pos), + "state": {k:v for k,v in state.items() if k not in ("buy_tickets","sell_tickets")}, + } + +def get_status() -> dict: + """Status snapshot — does NOT modify state or open orders.""" + state = load_state() + acc = bridge("/balance") + pos = bridge("/positions") + heph = get_heph_positions(pos if isinstance(pos,list) else []) + pnl = sum(float(p.get("profit",0)) for p in heph) + lots = sum(float(p.get("lots",0)) for p in heph) + w=l=0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t=json.loads(line) + if t.get("result")=="win": w+=1 + if t.get("result")=="loss": l+=1 + except: pass + return { + "enabled": state["enabled"], + "killed_reason": state.get("killed_reason"), + "buy_level": state["buy_level"], + "sell_level": state["sell_level"], + "open_positions": len(heph), + "total_lots": round(lots,2), + "unrealized_pnl": round(pnl,2), + "peak_equity": state["peak_equity"], + "total_cycles": state["total_cycles"], + "account": acc, + "journal": {"wins":w,"losses":l}, + "circuit_breakers": { + "max_dd_pct": MAX_DD_PCT, + "max_daily_loss":MAX_DL_PCT, + "max_levels": MAX_LEVELS, + "max_lots": MAX_LOTS, + } + } + +def emergency_kill() -> dict: + """Force-kill from external call (Hermes or manual).""" + state = load_state() + pos = bridge("/positions") + state = emergency_stop(state, "Manual kill via hephaestus_tool.py kill", + pos if isinstance(pos,list) else []) + return {"killed":True,"state":state} + +def enable_strategy() -> dict: + state = load_state() + state["enabled"] = True + state["killed_reason"] = None + state["last_reset_ts"] = 0 + save_state(state) + tg(f"✅ *HEPHAESTUS*: Strategy RE-ENABLED by Hermes.") + return {"enabled":True} + +if __name__ == "__main__": + import sys + print(json.dumps(run_cycle(), indent=2, default=str)) \ No newline at end of file diff --git a/strategies/hephaestus/hephaestus_tool.py b/strategies/hephaestus/hephaestus_tool.py new file mode 100644 index 0000000..edf0884 --- /dev/null +++ b/strategies/hephaestus/hephaestus_tool.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""GENESIS — Hephaestus Tool + Telegram Bot (Strategy F: Grid+Martingale) +CLI for Hermes. Bot for manual monitoring and override. +""" +import sys, os, json, time, logging, threading +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent)) + +from pathlib import Path +from datetime import datetime, timezone +import yaml, requests + +CONFIG_PATH = Path(__file__).parent / "hephaestus_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "hephaestus_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(CFG["telegram"]["chat_id"]) +STRATEGY = CFG["strategy"]["name"] +COMMENT = CFG["strategy"]["comment"] + +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/hephaestus/hephaestus_bot.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" / "hephaestus" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "hephaestus_bot.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) +_lock = threading.Lock() + +def tg_send(text): + try: + requests.post(f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", + json={"chat_id": TG_CHAT_ID, "text": text, "parse_mode": "Markdown"}, timeout=10) + except: pass + +def tg_updates(offset=0): + try: + r = requests.get(f"https://api.telegram.org/bot{TG_TOKEN}/getUpdates", + params={"timeout":30,"offset":offset}, timeout=40) + return r.json().get("result",[]) + except: return [] + +def _load(): + import importlib.util + spec = importlib.util.spec_from_file_location("hephaestus_cycle", + Path(__file__).parent/"hephaestus_cycle.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + +# ── Commands ─────────────────────────────────────────────────────────────────── +def do_run_tick(): + try: return _load().run_cycle() + except Exception as e: return {"error":str(e)} + +def do_status(): + try: return _load().get_status() + except Exception as e: return {"error":str(e)} + +def do_kill(): + try: return _load().emergency_kill() + except Exception as e: return {"error":str(e)} + +def do_enable(): + try: return _load().enable_strategy() + except Exception as e: return {"error":str(e)} + +def send_status_tg(): + r = do_status() + if "error" in r: + tg_send(f"🔴 *{STRATEGY}*: {r['error']}"); return + enabled = r.get("enabled") + acc = r.get("account",{}) + j = r.get("journal",{}) + cb = r.get("circuit_breakers",{}) + killed = r.get("killed_reason") + tg_send( + f"{'🔩' if enabled else '💀'} *{STRATEGY} — Grid Status*\n\n" + f"Status: {'🟢 RUNNING' if enabled else f'🔴 DISABLED — {killed}'}\n" + f"💰 Balance: €{acc.get('balance',0):.2f} | Equity: €{acc.get('equity',0):.2f}\n" + f"📊 Open Positions: {r.get('open_positions',0)} | " + f"Lots: {r.get('total_lots',0)} | PnL: €{r.get('unrealized_pnl',0):.2f}\n" + f"📈 Buy Level: {r.get('buy_level',0)}/{cb.get('max_levels','?')} | " + f"Sell Level: {r.get('sell_level',0)}/{cb.get('max_levels','?')}\n" + f"🎯 Cycles: {r.get('total_cycles',0)} | " + f"Journal: {j.get('wins',0)}W / {j.get('losses',0)}L\n\n" + f"🛡 Circuit Breakers:\n" + f" Max DD: {cb.get('max_dd_pct','?')}% | Daily Loss: {cb.get('max_daily_loss','?')}%\n" + f" Max Levels: {cb.get('max_levels','?')} | Max Lots: {cb.get('max_lots','?')}" + ) + +def dispatch(text, from_id): + if str(from_id) != TG_CHAT_ID: return + lower = text.lower().strip() + + def bg(fn, *args): threading.Thread(target=fn, args=args, daemon=True).start() + + if lower == "/heph_status": + bg(send_status_tg) + elif lower == "/heph_tick": + # BLOCKED — only Hermes can run ticks via CLI + tg_send( + f"🔒 *{STRATEGY}*: `/heph_tick` is reserved for Hermes only.\n" + f"Hermes runs grid ticks autonomously via CLI.\n" + f"_You will see every action here automatically._" + ) + elif lower == "/heph_kill": + def run(): + tg_send(f"🚨 *{STRATEGY}*: EMERGENCY KILL initiated…") + r = do_kill() + tg_send(f"🚨 *{STRATEGY}*: {'All positions closed. DISABLED.' if r.get('killed') else 'Kill failed — check logs.'}") + bg(run) + elif lower == "/heph_enable": + def run(): + r = do_enable() + tg_send(f"✅ *{STRATEGY}*: {'RE-ENABLED. Hermes will resume grid ticks.' if r.get('enabled') else 'Enable failed.'}") + bg(run) + elif lower in ("/heph_help", "/heph"): + tg_send( + f"🔩 *{STRATEGY} — Strategy F (Monitor & Override)*\n\n" + f"🤖 *Hermes runs this strategy autonomously.*\n" + f"_You will receive automatic updates for every grid action._\n\n" + f"`/heph_status` — Full grid status + circuit breaker state\n" + f"`/heph_kill` — 🚨 EMERGENCY: close all positions + disable\n" + f"`/heph_enable` — Re-enable after kill\n" + f"`/heph_help` — This message\n\n" + f"⚠️ Max {CFG['grid']['max_grid_levels']} levels | " + f"{CFG['circuit_breakers']['max_equity_drawdown_pct']}% equity DD kill\n" + f"🔖 Tag: `{COMMENT}` | Pair: `{CFG['symbol']}`" + ) + +# ── CLI mode (Hermes calls this) ─────────────────────────────────────────────── +def cli(): + args = sys.argv[1:] + if not args: + print(json.dumps({"error":"Usage: hephaestus_tool.py [tick|status|kill|enable|symbols]"})) + sys.exit(1) + cmd = args[0].lower() + if cmd=="tick": print(json.dumps(do_run_tick(), indent=2, default=str)) + elif cmd=="status": print(json.dumps(do_status(), indent=2, default=str)) + elif cmd=="kill": print(json.dumps(do_kill(), indent=2, default=str)) + elif cmd=="enable": print(json.dumps(do_enable(), indent=2, default=str)) + elif cmd=="symbols": print(json.dumps({"symbol":CFG["symbol"],"comment":COMMENT,"strategy":"Grid+Martingale"},indent=2)) + else: print(json.dumps({"error":f"Unknown: {cmd}"})) + +# ── Bot mode ─────────────────────────────────────────────────────────────────── +def bot(): + log.info(f"=== {STRATEGY} Telegram Bot started ===") + tg_send( + f"🔩 *{STRATEGY} Bot Online*\n" + f"Strategy F: Grid + Martingale on `{CFG['symbol']}`\n" + f"Max Levels: {CFG['grid']['max_grid_levels']} | " + f"DD Kill: {CFG['circuit_breakers']['max_equity_drawdown_pct']}%\n\n" + f"🤖 *Hermes runs this strategy. You cannot trigger it.*\n" + f"_You will see every grid action, level open, and TP here automatically._\n\n" + f"✅ Your controls: `/heph_status` `/heph_kill` `/heph_enable`\n" + f"⚠️ Use `/heph_kill` anytime to emergency stop all grid positions." + ) + offset = 0 + while True: + try: + for upd in tg_updates(offset): + offset = upd["update_id"]+1 + msg = upd.get("message",{}) + text = msg.get("text","") + chat_id = str(msg.get("chat",{}).get("id","")) + if text.startswith("/heph"): dispatch(text, chat_id) + except Exception as e: + log.error(f"Poll error: {e}"); time.sleep(5) + time.sleep(1) + +if __name__ == "__main__": + if Path(sys.argv[0]).name.startswith("hephaestus_telegram"): + bot() + else: + cli() \ No newline at end of file diff --git a/strategies/zeus/zeus_cycle.py b/strategies/zeus/zeus_cycle.py new file mode 100644 index 0000000..c0e6d3a --- /dev/null +++ b/strategies/zeus/zeus_cycle.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python3 +""" +GENESIS — Zeus Cycle (Strategy G: ICT Smart Money Concepts) +Three-layer sequential confirmation — NOT parallel detection: + Layer 1: Liquidity Sweep (price takes out swing high/low with rejection) + Layer 2: Fair Value Gap (3-candle imbalance after displacement) + Layer 3: Order Block (last candle before displacement, institutional anchor) + +Only when ALL THREE confirm in sequence → confluence score → signal. +Expected: 5-15 signals/month. Win rate target: 65-70%. +""" +import os, json, time, logging, math +from datetime import datetime, timezone, date +from pathlib import Path +import requests, yaml + +CONFIG_PATH = Path(__file__).parent / "zeus_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "zeus_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"])) +# 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" / "zeus" + local_log_dir.mkdir(parents=True, exist_ok=True) + JOURNAL = local_log_dir / "trade_journal.jsonl" + +COMMENT = CFG["strategy"]["comment"] + +# ICT config +SWING_LB = int(CFG["ict"]["liquidity"]["swing_lookback"]) +SWEEP_TOL = float(CFG["ict"]["liquidity"]["sweep_tolerance"]) +REQ_REJECT = bool(CFG["ict"]["liquidity"]["require_rejection"]) +MIN_GAP_P = float(CFG["ict"]["fvg"]["min_gap_pips"]) +FVG_AGE = int(CFG["ict"]["fvg"]["max_age_bars"]) +OB_AGE = int(CFG["ict"]["order_block"]["max_age_bars"]) +MIN_BODY = float(CFG["ict"]["order_block"]["min_body_ratio"]) +MAX_WICK = float(CFG["ict"]["order_block"]["max_wick_ratio"]) + +MIN_SCORE = int(CFG["confluence"]["min_score"]) +MAX_DT = int(CFG["confluence"]["max_daily_trades"]) +KZ_EN = bool(CFG["confluence"]["killzone"]["enabled"]) +KZ_LON = CFG["confluence"]["killzone"]["london"] +KZ_NY = CFG["confluence"]["killzone"]["ny"] + +RISK_PCT = float(CFG["risk"]["risk_pct"]) +MIN_RR = float(CFG["risk"]["min_rr"]) +TP_MULT = float(CFG["risk"]["tp_multiplier"]) +MAX_SPREAD = float(CFG["risk"]["max_spread_pips"]) +COOLDOWN = int(CFG["risk"]["cooldown_seconds"]) +OB_BUF = float(CFG["risk"]["sl_ob_buffer"]) + +MAX_DD_PCT = float(CFG["circuit_breakers"]["max_equity_drawdown_pct"]) +MAX_DL_PCT = float(CFG["circuit_breakers"]["max_daily_loss_pct"]) +START_H = int(CFG["sessions"]["allowed"][0]["start"]) +END_H = int(CFG["sessions"]["allowed"][0]["end"]) + +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/zeus/zeus_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" / "zeus" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "zeus_cycle.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) +_last_sig: dict = {} +_daily: dict = {} + +# ── 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): + 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 + +def pip(sym): return 0.01 if "JPY" in sym else (0.1 if "XAU" in sym else 0.0001) +def to_pips(d,s): return abs(d)/pip(s) +def calc_lot(equity,sl_pips,sym): + pv=10.0 + if "JPY" in sym: pv=9.0 + if "GBP" in sym: pv=12.5 + if "XAU" in sym: pv=1.0 + raw=(equity*RISK_PCT)/(sl_pips*pv) if sl_pips>0 else 0.01 + return round(max(0.01,min(round(raw/0.01)*0.01,5.0)),2) + +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"} + +def get_bars(sym,tf="M5",count=100): + try: + bars = _bridge_get_bars(sym, tf, count) + if bars: + return bars + except Exception as e: + log.warning(f"Mt5Bridge get_bars {sym}/{tf}: {e}, falling back to yfinance") + try: + import yfinance as yf, pandas as pd + yf_sym=YF_MAP.get(sym,sym.replace("xx","=X") if sym.lower().endswith("xx") else sym+"=X") + itv={"M5":"5m","M15":"15m","H1":"1h"}.get(tf,"5m") + per={"5m":"5d","15m":"5d","1h":"60d"}.get(itv,"5d") + df=yf.download(yf_sym,period=per,interval=itv,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 {sym}/{tf}: {e}"); return [] + +# ── Layer 1: Liquidity Sweep Detection ──────────────────────────────────────── +def detect_swing_highs(highs: list, lookback: int) -> list: + """Pivot high: bar[i] is highest in [i-lb, i+lb] window.""" + pivots = [] + for i in range(lookback, len(highs)-lookback): + if highs[i] == max(highs[i-lookback:i+lookback+1]): + pivots.append((i, highs[i])) + return pivots + +def detect_swing_lows(lows: list, lookback: int) -> list: + pivots = [] + for i in range(lookback, len(lows)-lookback): + if lows[i] == min(lows[i-lookback:i+lookback+1]): + pivots.append((i, lows[i])) + return pivots + +def detect_liquidity_sweep(highs, lows, closes, opens, sym) -> dict | None: + """ + Layer 1: Detect if the LAST candle (index -2, last closed) swept a swing level + with rejection (wick beyond level, close back inside). + Returns sweep info dict or None. + """ + if len(highs) < SWING_LB*2+5: return None + + cur_h = highs[-2]; cur_l = lows[-2]; cur_c = closes[-2]; cur_o = opens[-2] + # Check last 30 bars for swing levels + h_slice = highs[-32:-2]; l_slice = lows[-32:-2] + sw_highs = detect_swing_highs(h_slice, SWING_LB) + sw_lows = detect_swing_lows(l_slice, SWING_LB) + + # ── Bearish sweep: price wicks above a swing high but closes below ───────── + for idx, level in sw_highs[-3:]: # Check last 3 swing highs + if (cur_h > level + SWEEP_TOL # Wick penetrated + and (not REQ_REJECT or cur_c < level)): # Close back below + body_up = cur_h - max(cur_c, cur_o) + body_dn = min(cur_c, cur_o) - cur_l + rej_str = "strong" if body_up > (cur_h - cur_l)*0.3 else "weak" + return { + "type": "bearish", + "level": round(level,6), + "level_idx": idx, + "wick_high": cur_h, + "close": cur_c, + "rejection": rej_str, + "liq_type": "swing_high", + } + + # ── Bullish sweep: price wicks below a swing low but closes above ────────── + for idx, level in sw_lows[-3:]: + if (cur_l < level - SWEEP_TOL + and (not REQ_REJECT or cur_c > level)): + body_dn = min(cur_c, cur_o) - cur_l + rej_str = "strong" if body_dn > (cur_h - cur_l)*0.3 else "weak" + return { + "type": "bullish", + "level": round(level,6), + "level_idx": idx, + "wick_low": cur_l, + "close": cur_c, + "rejection": rej_str, + "liq_type": "swing_low", + } + return None + +# ── Layer 2: Fair Value Gap Detection ───────────────────────────────────────── +def detect_fvg(highs, lows, closes, sweep_type: str, sym) -> dict | None: + """ + Layer 2: After a sweep candle (index -2), scan the 3 most recent candles + for a Fair Value Gap — 3-candle imbalance where candle 2 body doesn't + overlap candles 1 and 3's wicks. + + Bullish FVG (after bullish sweep): Candle3.low > Candle1.high → price void below + Bearish FVG (after bearish sweep): Candle3.high < Candle1.low → price void above + Min gap = min_gap_pips + """ + min_gap = MIN_GAP_P * pip(sym) + n = len(highs) + if n < 4: return None + + # Scan last FVG_AGE+3 bars for fresh FVGs + for i in range(n-4, max(n-FVG_AGE-4, 1), -1): + c1h, c1l = highs[i], lows[i] + c2h, c2l = highs[i+1], lows[i+1] + c3h, c3l = highs[i+2], lows[i+2] + + if sweep_type == "bullish": + # Bullish FVG: gap between candle1 high and candle3 low + gap = c3l - c1h + if gap > min_gap: + mitigation = min(closes[-2:]) + mitigated = mitigation <= c1h + gap/2 + return { + "type": "bullish", + "high": round(c3l, 6), + "low": round(c1h, 6), + "midpoint": round((c3l+c1h)/2, 6), + "gap_pips": round(gap/pip(sym), 1), + "bar_index": i+1, + "age_bars": n-2-i, + "mitigated": mitigated, + "strength": 3 if gap>min_gap*2 else 2 if gap>min_gap*1.5 else 1, + } + + elif sweep_type == "bearish": + # Bearish FVG: gap between candle3 high and candle1 low + gap = c1l - c3h + if gap > min_gap: + mitigation = max(closes[-2:]) + mitigated = mitigation >= c3h + gap/2 + return { + "type": "bearish", + "high": round(c1l, 6), + "low": round(c3h, 6), + "midpoint": round((c1l+c3h)/2, 6), + "gap_pips": round(gap/pip(sym), 1), + "bar_index": i+1, + "age_bars": n-2-i, + "mitigated": mitigated, + "strength": 3 if gap>min_gap*2 else 2 if gap>min_gap*1.5 else 1, + } + return None + +# ── Layer 3: Order Block Detection ──────────────────────────────────────────── +def detect_order_block(highs, lows, closes, opens, fvg: dict, sweep_type: str) -> dict | None: + """ + Layer 3: The Order Block is the LAST candle before the displacement move + that caused the FVG. + - Bullish OB: last down-close candle before the bullish displacement + - Bearish OB: last up-close candle before the bearish displacement + OB quality scored by body ratio, wick ratio, freshness. + """ + fvg_bar = fvg.get("bar_index", len(closes)-3) + search_start = max(0, fvg_bar - OB_AGE) + + if sweep_type == "bullish": + # Find last bearish (down-close) candle before fvg_bar + for i in range(fvg_bar, search_start, -1): + if i >= len(closes): continue + if closes[i] < opens[i]: # Bearish candle + total_range = highs[i] - lows[i] + if total_range <= 0: continue + body = abs(closes[i] - opens[i]) + wicks = total_range - body + body_r = body / total_range + wick_r = wicks / total_range + if body_r >= MIN_BODY and wick_r <= MAX_WICK: + qual = round(min(20, body_r*20 + (1-wick_r)*10 + max(0,10-(fvg_bar-i))), 1) + return { + "type": "bullish", + "high": round(highs[i],6), + "low": round(lows[i],6), + "open": round(opens[i],6), + "close": round(closes[i],6), + "body_ratio": round(body_r,2), + "wick_ratio": round(wick_r,2), + "quality": qual, + "bar_idx": i, + "age_bars": fvg_bar - i, + } + else: + # Find last bullish (up-close) candle before fvg_bar + for i in range(fvg_bar, search_start, -1): + if i >= len(closes): continue + if closes[i] > opens[i]: + total_range = highs[i] - lows[i] + if total_range <= 0: continue + body = abs(closes[i] - opens[i]) + wicks = total_range - body + body_r = body / total_range + wick_r = wicks / total_range + if body_r >= MIN_BODY and wick_r <= MAX_WICK: + qual = round(min(20, body_r*20 + (1-wick_r)*10 + max(0,10-(fvg_bar-i))), 1) + return { + "type": "bearish", + "high": round(highs[i],6), + "low": round(lows[i],6), + "open": round(opens[i],6), + "close": round(closes[i],6), + "body_ratio": round(body_r,2), + "wick_ratio": round(wick_r,2), + "quality": qual, + "bar_idx": i, + "age_bars": fvg_bar - i, + } + return None + +# ── Confluence Scoring (0-100) ───────────────────────────────────────────────── +def is_killzone() -> tuple[bool, str]: + now = datetime.now(timezone.utc) + hr = now.hour + now.minute/60 + if KZ_EN: + if KZ_LON["start"] <= hr <= KZ_LON["end"]: return True, "London" + if KZ_NY["start"] <= hr <= KZ_NY["end"]: return True, "New York" + return False, "" + +def score_confluence(sweep, fvg, ob, m15_aligns: bool) -> tuple[int, dict]: + SC = CFG["confluence"]["scoring"] + kz, kz_name = is_killzone() + + s_sweep = SC["sweep_quality"] if sweep.get("rejection")=="strong" else int(SC["sweep_quality"]*0.6) + s_fvg = SC["fvg_presence"] if fvg.get("strength",0)>=2 else int(SC["fvg_presence"]*0.6) + s_ob = min(SC["ob_quality"], int(ob.get("quality",0)/20*SC["ob_quality"])) if ob else 0 + s_bos = SC["bos_strength"] if m15_aligns else int(SC["bos_strength"]*0.6) + s_kz = SC["killzone"] if kz else 0 + s_mtf = SC["mtf_confluence"] if m15_aligns else 0 + s_fresh = SC["ob_freshness"] if ob and ob.get("age_bars",99)<5 else int(SC["ob_freshness"]*0.5) if ob and ob.get("age_bars",99)<10 else 0 + + total = s_sweep + s_fvg + s_ob + s_bos + s_kz + s_mtf + s_fresh + breakdown = { + "bos_strength": s_bos, "sweep_quality": s_sweep, + "fvg_presence": s_fvg, "ob_quality": s_ob, + "killzone": s_kz, "mtf_confluence": s_mtf, + "ob_freshness": s_fresh, + } + return min(100, total), breakdown + +# ── M15 context check ───────────────────────────────────────────────────────── +def get_m15_context(sym: str, direction: str) -> bool: + """Check if M15 trend aligns with intended trade direction.""" + try: + bars = get_bars(sym, "M15", 30) + if len(bars) < 20: return True + import pandas as pd, ta + df = pd.DataFrame(bars) + df.columns = [c.lower() for c in df.columns] + df["close"] = df["close"].astype(float) + ema20 = ta.trend.ema_indicator(df["close"], window=20) + last_close = float(df["close"].iloc[-2]) + last_ema = float(ema20.iloc[-2]) + if direction == "bullish": return last_close > last_ema + else: return last_close < last_ema + except: return True # Default allow if unavailable + +def is_trade_time(): + 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_H <= hr < END_H + +def check_daily(sym): + today = str(date.today()) + k = f"{sym}_{today}" + return _daily.get(k, 0) + +def inc_daily(sym): + today = str(date.today()) + k = f"{sym}_{today}" + _daily[k] = _daily.get(k, 0) + 1 + +def has_zeus_position(): + pos = bridge("/positions") + return isinstance(pos,list) and any("ZEUS" in str(p.get("comment","")).upper() for p in pos) + +# ── Main analysis (three-layer sequential) ───────────────────────────────────── +def run_analysis(symbol: str) -> dict: + symbol = symbol.upper() + if not symbol.endswith("XX"): symbol += "xx" + symbol = symbol[:-2] + "xx" + log.info(f"=== Zeus ICT Analysis: {symbol} ===") + + # ── Preflight ──────────────────────────────────────────────────── + acc = bridge("/balance") + if "error" in acc: return {"action":"wait","reason":f"Bridge: {acc['error']}"} + equity = float(acc.get("equity",0)) + if equity <= 0: return {"action":"wait","reason":"No equity."} + + if has_zeus_position(): return {"action":"wait","reason":"Zeus position already open."} + + if time.time() - _last_sig.get(symbol,0) < COOLDOWN: + rem = int(COOLDOWN-(time.time()-_last_sig.get(symbol,0))) + return {"action":"wait","reason":f"Cooldown: {rem}s"} + + daily_count = check_daily(symbol) + if daily_count >= MAX_DT: + return {"action":"wait","reason":f"Max daily trades ({MAX_DT}) reached."} + + if not is_trade_time(): + return {"action":"wait","reason":f"Outside session (GMT {START_H}–{END_H})."} + + quote = bridge(f"/quote?symbol={symbol}") + if "error" in quote or not quote.get("bid"): + return {"action":"wait","reason":f"No quote for {symbol}."} + bid = float(quote["bid"]); ask = float(quote["ask"]) + spread = to_pips(ask-bid, symbol) + if spread > MAX_SPREAD: + return {"action":"wait","reason":f"Spread {spread:.2f} > {MAX_SPREAD} pips."} + + bars = get_bars(symbol, "M5", 100) + if len(bars) < 30: + return {"action":"wait","reason":"Insufficient M5 data for ICT detection."} + + highs = [float(b.get("high",0)) for b in bars] + lows = [float(b.get("low",0)) for b in bars] + closes = [float(b.get("close",0)) for b in bars] + opens = [float(b.get("open",0)) for b in bars] + + # ════════════════════════════════════════════════════════════════ + # LAYER 1: LIQUIDITY SWEEP + # ════════════════════════════════════════════════════════════════ + sweep = detect_liquidity_sweep(highs, lows, closes, opens, symbol) + if not sweep: + return {"action":"wait","reason":"No liquidity sweep detected on M5.", + "layer":"1/3 — sweep not found"} + + sweep_type = sweep["type"] # "bullish" or "bearish" + log.info(f"Layer 1 PASS: {sweep_type} sweep at {sweep['level']}") + + # ════════════════════════════════════════════════════════════════ + # LAYER 2: FAIR VALUE GAP (must follow the sweep) + # ════════════════════════════════════════════════════════════════ + fvg = detect_fvg(highs, lows, closes, sweep_type, symbol) + if not fvg: + return {"action":"wait","reason":"Sweep found but no FVG after displacement.", + "layer":"2/3 — FVG not found", "sweep":sweep} + if fvg.get("mitigated") and CFG["ict"]["fvg"]["require_unmitigated"]: + return {"action":"wait","reason":"FVG found but already mitigated.", + "layer":"2/3 — FVG mitigated", "sweep":sweep, "fvg":fvg} + + log.info(f"Layer 2 PASS: {fvg['type']} FVG gap={fvg['gap_pips']}pips str={fvg['strength']}") + + # ════════════════════════════════════════════════════════════════ + # LAYER 3: ORDER BLOCK + # ════════════════════════════════════════════════════════════════ + ob = detect_order_block(highs, lows, closes, opens, fvg, sweep_type) + if not ob: + return {"action":"wait","reason":"Sweep+FVG found but no valid Order Block.", + "layer":"3/3 — OB not found", "sweep":sweep, "fvg":fvg} + + log.info(f"Layer 3 PASS: {ob['type']} OB quality={ob['quality']} age={ob['age_bars']}bars") + + # ════════════════════════════════════════════════════════════════ + # CONFLUENCE SCORING + # ════════════════════════════════════════════════════════════════ + m15_ok = get_m15_context(symbol, sweep_type) + score, breakdown = score_confluence(sweep, fvg, ob, m15_ok) + kz_active, kz_name = is_killzone() + + if score < MIN_SCORE: + return {"action":"wait","reason":f"All 3 layers passed but score {score} < {MIN_SCORE}.", + "score":score,"breakdown":breakdown,"sweep":sweep,"fvg":fvg,"ob":ob} + + # ════════════════════════════════════════════════════════════════ + # SIGNAL CONSTRUCTION + # ════════════════════════════════════════════════════════════════ + direction = "Buy" if sweep_type=="bullish" else "Sell" + entry = ask if direction=="Buy" else bid + + # SL: just below/above the Order Block + if direction == "Buy": + sl = round(ob["low"] - OB_BUF, 6) + tp = round(entry + abs(entry-sl)*TP_MULT, 6) + else: + sl = round(ob["high"] + OB_BUF, 6) + tp = round(entry - abs(sl-entry)*TP_MULT, 6) + + sl_pips = to_pips(entry-sl, symbol) + tp_pips = 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} < {MIN_RR}.", + "score":score,"sweep":sweep,"fvg":fvg,"ob":ob} + + volume = calc_lot(equity, sl_pips, symbol) + _last_sig[symbol] = time.time() + inc_daily(symbol) + + log.info(f"SIGNAL: {direction} {symbol} score={score} SL={sl} TP={tp} Vol={volume}") + + return { + "action": "trade", + "strategy": "zeus-ict-smartmoney", + "signal_type": f"ICT_{'BULLISH' if direction=='Buy' else 'BEARISH'}_SETUP", + "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 score>=80 else "medium", + "confidence_score": score, + "score_breakdown": breakdown, + "layers": { + "1_sweep": sweep, + "2_fvg": fvg, + "3_ob": ob, + }, + "killzone": kz_name if kz_active else "none", + "m15_aligned": m15_ok, + "signal_schema": { + "strategy_id": "ZEUS-v1", + "magic_number": CFG["strategy"]["magic_number"], + "risk_percent": RISK_PCT, + "confidence_score": score, + "metadata": { + "liquidity_sweep": {"level":sweep["level"],"type":sweep["liq_type"]}, + "fvg": {"type":fvg["type"],"high":fvg["high"],"low":fvg["low"],"strength":fvg["strength"]}, + "order_block": {"price":ob["low"] if direction=="Buy" else ob["high"], + "quality_score":ob["quality"]}, + "killzone_active": kz_name or "none", + "score_breakdown": breakdown, + } + }, + "analysed_at": datetime.now(timezone.utc).isoformat(), + } + +if __name__=="__main__": + import sys + sym = sys.argv[1] if len(sys.argv)>1 else "EURUSDxx" + print(json.dumps(run_analysis(sym), indent=2, default=str)) \ No newline at end of file diff --git a/strategies/zeus/zeus_tool.py b/strategies/zeus/zeus_tool.py new file mode 100644 index 0000000..90653d7 --- /dev/null +++ b/strategies/zeus/zeus_tool.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""GENESIS — Zeus Tool + Telegram Bot (Strategy G: ICT Smart Money) +CLI for Hermes autonomous use. Bot for monitoring — Hermes runs, you watch. +""" +import sys, os, json, time, logging, threading +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent)) +from pathlib import Path +from datetime import datetime, timezone +import yaml, requests + + + + +CONFIG_PATH = Path(__file__).parent / "zeus_config.yaml" +if not CONFIG_PATH.exists(): + CONFIG_PATH = Path(__file__).parents[2] / "configs" / "zeus_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(CFG["telegram"]["chat_id"]) +STRATEGY = CFG["strategy"]["name"] +COMMENT = CFG["strategy"]["comment"] +SYMBOLS = CFG["symbols"] +# 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" / "zeus" + local_log_dir.mkdir(parents=True, exist_ok=True) + JOURNAL = local_log_dir / "trade_journal.jsonl" + +# Resolve safe log path (fallback to local logs/ if system dir not writable) +default_log = "/var/log/zeus/zeus_bot.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" / "zeus" + local_log_dir.mkdir(parents=True, exist_ok=True) + log_file = str(local_log_dir / "zeus_bot.log") + +logging.basicConfig( + filename=log_file, + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s" +) +log = logging.getLogger(__name__) +_pending: dict = {} +_lock = threading.Lock() + +def tg_send(text): + try: + requests.post(f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", + json={"chat_id":TG_CHAT_ID,"text":text,"parse_mode":"Markdown"},timeout=10) + except: pass + +def tg_updates(offset=0): + try: + r=requests.get(f"https://api.telegram.org/bot{TG_TOKEN}/getUpdates", + params={"timeout":30,"offset":offset},timeout=40) + return r.json().get("result",[]) + except: return [] + +def bridge(path,method="GET",data=None): + sys.path.insert(0, str(Path(__file__).parents[2] / "core")) + from mt5_bridge import bridge as _b + return _b(path,method,data) + +def _load(): + import importlib.util + spec=importlib.util.spec_from_file_location("zeus_cycle",Path(__file__).parent/"zeus_cycle.py") + mod=importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); return mod + +def do_analyze(sym): + try: return _load().run_analysis(sym) + except Exception as e: return {"action":"wait","reason":f"Error: {str(e)[:200]}"} + +def do_execute(sym): + s=sym.upper(); s=(s+"xx") if not s.endswith("XX") else s + result=do_analyze(s) + if result.get("action")!="trade": + return {"executed":False,"reason":result.get("reason"),"layer":result.get("layer"), + "score":result.get("score")} + order=bridge("/market","POST",{"symbol":result["symbol"],"volume":result["volume"], + "type":result["direction"],"stop_loss":result["stop_loss"], + "take_profit":result["take_profit"],"comment":COMMENT}) + ticket=order.get("ticket") or order.get("Ticket") + if ticket: + + with open(JOURNAL,"a") as f: + f.write(json.dumps({"ticket":str(ticket),"symbol":result["symbol"], + "direction":result["direction"],"volume":result["volume"], + "sl":result["stop_loss"],"tp":result["take_profit"], + "entry":result["entry"],"rr":result["rr_ratio"], + "score":result["confidence_score"],"signal_type":result.get("signal_type"), + "killzone":result.get("killzone"),"opened":datetime.now(timezone.utc).isoformat(), + "result":None,"pnl":None,"strategy":"zeus-ict-smartmoney", + "triggered_by":"hermes-autonomous"})+"\n") + layers = result.get("layers",{}) + sweep = layers.get("1_sweep",{}); fvg = layers.get("2_fvg",{}); ob = layers.get("3_ob",{}) + tg_send( + f"⚡ *ZEUS TRADE — Hermes Triggered*\n" + f"`{result['symbol']}` {result['direction']} | {result.get('signal_type','').replace('_',' ')}\n" + f"Entry: `{result['entry']}` SL: `{result['stop_loss']}` TP: `{result['take_profit']}`\n" + f"R:R: `{result['rr_ratio']}` | Vol: `{result['volume']}`\n" + f"🎯 Score: `{result['confidence_score']}/100` | {result.get('killzone','no KZ')}\n" + f"Layer 1: {sweep.get('type','?')} sweep @ {sweep.get('level','?')}\n" + f"Layer 2: {fvg.get('type','?')} FVG {fvg.get('gap_pips','?')}pips\n" + f"Layer 3: OB quality {ob.get('quality','?')}/20\n" + f"🔖 Ticket: `{ticket}`" + ) + return {"executed":True,"ticket":str(ticket),"score":result["confidence_score"], + "direction":result["direction"],"rr":result["rr_ratio"]} + else: + err=order.get("message",str(order)) + tg_send(f"⚠️ *ZEUS*: Order FAILED — `{err}`") + return {"executed":False,"reason":f"Order failed: {err}"} + +def do_scan(): + best=None; best_sc=0; results={} + for sym in SYMBOLS: + r=do_analyze(sym) + results[sym]={"action":r.get("action"),"score":r.get("confidence_score",0), + "layer":r.get("layer",""),"reason":r.get("reason","")[:80]} + if r.get("action")=="trade": + sc=r.get("confidence_score",0) + if sc>best_sc: best_sc=sc; best=r + time.sleep(0.5) + return {"best_signal":best,"scan_results":results, + "signals_found":sum(1 for r in results.values() if r["action"]=="trade")} + +def do_status(): + acc=bridge("/balance"); pos=bridge("/positions") + zeus_pos=None; all_pos=[] + if isinstance(pos,list): + for p in pos: + c=str(p.get("comment","")) + all_pos.append({"ticket":p.get("ticket"),"symbol":p.get("symbol"), + "type":p.get("orderType"),"profit":p.get("profit"),"comment":c}) + if "ZEUS" in c.upper(): zeus_pos=p + w=l=0 + if JOURNAL.exists(): + for line in JOURNAL.read_text().strip().split("\n"): + if not line: continue + try: + t=json.loads(line) + if t.get("result")=="win": w+=1 + if t.get("result")=="loss": l+=1 + except: pass + return {"account":acc,"zeus_position":zeus_pos,"all_open":all_pos, + "journal":{"wins":w,"losses":l},"strategy":"ICT Smart Money M5"} + +def do_close(): + pos=bridge("/positions"); closed=[] + if isinstance(pos,list): + for p in pos: + if "ZEUS" in str(p.get("comment","")).upper(): + bridge("/close","POST",{"ticket":p["ticket"]}); closed.append(p["ticket"]) + tg_send(f"⚡ *ZEUS*: Position `{p['ticket']}` closed by Hermes.") + return {"closed":len(closed),"tickets":closed} if closed else {"closed":0,"reason":"No Zeus positions."} + +def send_result(result, scan=False): + if result.get("action")!="trade": + layer = result.get("layer","?") + tg_send(f"⚡ *{STRATEGY}* — `{result.get('symbol','?')}`\n\nSignal: *WAIT*\n" + f"Progress: {layer}\n💡 {result.get('reason','')[:300]}") + return + with _lock: _pending.clear(); _pending.update(result) + layers = result.get("layers",{}); sc=result.get("score_breakdown",{}) + sweep=layers.get("1_sweep",{}); fvg=layers.get("2_fvg",{}); ob=layers.get("3_ob",{}) + kz_name=result.get("killzone","none") + tg_send( + f"⚡ *{STRATEGY} SIGNAL*{'_(scan best)_' if scan else ''} — `{result.get('symbol')}`\n\n" + f"Signal: *{result.get('direction')}* | {result.get('signal_type','').replace('_',' ')}\n" + f"Entry: `{result.get('entry')}` SL: `{result.get('stop_loss')}` TP: `{result.get('take_profit')}`\n" + f"R:R: `{result.get('rr_ratio')}` | Vol: `{result.get('volume')}`\n" + f"🎯 *Score: {result.get('confidence_score',0)}/100* | {'🕐 '+kz_name if kz_name!='none' else 'No KZ'}\n\n" + f"✅ *Three-Layer Confirmation:*\n" + f" Layer 1 Sweep: {sweep.get('type','?').upper()} @ {sweep.get('level','?')} ({sweep.get('rejection','?')})\n" + f" Layer 2 FVG: {fvg.get('type','?').upper()} {fvg.get('gap_pips','?')}pips str={fvg.get('strength','?')}\n" + f" Layer 3 OB: Quality {ob.get('quality','?')}/20 | Age {ob.get('age_bars','?')}bars\n\n" + f"📊 *Score Breakdown:*\n" + f" BOS:{sc.get('bos_strength',0)} Sweep:{sc.get('sweep_quality',0)} " + f"FVG:{sc.get('fvg_presence',0)} OB:{sc.get('ob_quality',0)}\n" + f" KZ:{sc.get('killzone',0)} MTF:{sc.get('mtf_confluence',0)} Fresh:{sc.get('ob_freshness',0)}\n\n" + f"`/zeus_execute` to trade | `/zeus_skip` to cancel" + ) + +def dispatch(text, from_id): + if str(from_id)!=TG_CHAT_ID: return + lower=text.lower().strip() + def bg(fn,*args): threading.Thread(target=fn,args=args,daemon=True).start() + + if lower.startswith("/zeus_analyze"): + parts=text.split(maxsplit=1); sym=parts[1] if len(parts)>1 else "" + if not sym: tg_send("Usage: `/zeus_analyze EURUSD`"); return + def run(): + tg_send(f"⚡ *{STRATEGY}*: Running 3-layer ICT analysis on `{sym.upper()}`… (30–60s)") + send_result(do_analyze(sym)) + bg(run) + elif lower=="/zeus_scan": + def run(): + tg_send(f"⚡ *{STRATEGY}*: Scanning {len(SYMBOLS)} symbols (60–90s)…") + r=do_scan() + lines=[] + for s,v in r["scan_results"].items(): + if v["action"]=="trade": lines.append(f"⚡ `{s}`: Score {v['score']}/100") + else: lines.append(f"⚪ `{s}`: {v.get('layer','')} — {v['reason'][:50]}") + tg_send("⚡ *ZEUS SCAN*\n\n"+"\n".join(lines)) + if r["best_signal"]: send_result(r["best_signal"],scan=True) + else: tg_send(f"📊 No ICT setups found. Remember: 5–15 signals/month is normal.") + bg(run) + elif lower=="/zeus_execute": + def run(): + with _lock: + if not _pending: + tg_send(f"⚡ No pending. Run `/zeus_scan` or `/zeus_analyze SYMBOL` first."); return + sig=dict(_pending); _pending.clear() + tg_send("⚡ Placing Zeus order…") + r=do_execute(sig.get("symbol","").replace("xx","")) + if not r.get("executed"): tg_send(f"❌ Failed: {r.get('reason')}") + bg(run) + elif lower=="/zeus_skip": + with _lock: + if not _pending: tg_send("⚡ No pending signal."); return + sym=_pending.get("symbol"); _pending.clear() + tg_send(f"⏭ *{STRATEGY}*: Signal for `{sym}` cancelled.") + elif lower=="/zeus_status": + def run(): + r=do_status(); acc=r.get("account",{}); zp=r.get("zeus_position"); j=r.get("journal",{}) + all_s="\n".join(f"`{p['symbol']}` {p['type']} €{p.get('profit',0):.2f} [{p['comment']}]" + for p in r.get("all_open",[])) + tg_send(f"⚡ *{STRATEGY} — Status*\n\n" + f"💰 Balance: €{acc.get('balance',0):.2f} | Equity: €{acc.get('equity',0):.2f}\n" + f"📈 Zeus Position: {zp.get('symbol','None') if zp else 'None'}\n" + f"📒 Journal: {j.get('wins',0)}W / {j.get('losses',0)}L\n\n" + f"*All Open:*\n{all_s or 'None'}") + bg(run) + elif lower in ("/zeus_help","/zeus"): + tg_send( + f"⚡ *{STRATEGY} — Strategy G Commands*\n\n" + f"🤖 _Hermes runs Zeus autonomously._\n\n" + f"`/zeus_analyze [SYMBOL]` — 3-layer ICT analysis\n" + f"`/zeus_scan` — Scan all {len(SYMBOLS)} symbols\n" + f"`/zeus_execute` — Execute pending signal\n" + f"`/zeus_skip` — Cancel pending\n" + f"`/zeus_status` — Position + journal\n" + f"`/zeus_help` — This message\n\n" + f"📊 Three-Layer: Sweep → FVG → Order Block\n" + f"🕐 Killzones: London 08–11 | NY 13–16 GMT\n" + f"🔖 Tag: `{COMMENT}` | Risk: 0.75%\n" + f"⏱ Expected: 5–15 signals/month" + ) + +def cli(): + args=sys.argv[1:] + if not args: print(json.dumps({"error":"Usage: zeus_tool.py [analyze|execute|scan|status|close|symbols] [SYMBOL]"})); sys.exit(1) + cmd=args[0].lower() + sym=args[1] if len(args)>1 else "EURUSDxx" + if cmd=="analyze": print(json.dumps(do_analyze(sym),indent=2,default=str)) + elif cmd=="execute": print(json.dumps(do_execute(sym),indent=2,default=str)) + elif cmd=="scan": print(json.dumps(do_scan(),indent=2,default=str)) + elif cmd=="status": print(json.dumps(do_status(),indent=2,default=str)) + elif cmd=="close": print(json.dumps(do_close(),indent=2,default=str)) + elif cmd=="symbols": print(json.dumps({"symbols":SYMBOLS,"strategy":"ICT Smart Money","comment":COMMENT},indent=2)) + else: print(json.dumps({"error":f"Unknown: {cmd}"})) + +def bot(): + log.info(f"=== {STRATEGY} Telegram Bot started ===") + tg_send( + f"⚡ *{STRATEGY} Bot Online*\n" + f"Strategy G: ICT Smart Money (Sweep → FVG → Order Block)\n" + f"Entry: M5 | Context: M15 | Killzones: London + NY\n" + f"Min Score: {CFG['confluence']['min_score']}/100 | Risk: 0.75%\n\n" + f"🤖 _Hermes controls this bot autonomously._\n" + f"_Expected: 5–15 signals/month. Quality over quantity._\n" + f"Send `/zeus_help` for commands." + ) + offset=0 + while True: + try: + for upd in tg_updates(offset): + offset=upd["update_id"]+1 + msg=upd.get("message",{}); text=msg.get("text","") + chat_id=str(msg.get("chat",{}).get("id","")) + if text.startswith("/zeus"): dispatch(text,chat_id) + except Exception as e: log.error(f"Poll: {e}"); time.sleep(5) + time.sleep(1) + +if __name__=="__main__": + if len(sys.argv) > 1 and sys.argv[1] == "bot": + bot() + elif Path(sys.argv[0]).name.startswith("zeus_telegram"): + bot() + else: + cli() \ No newline at end of file