Files
2026-07-11 02:42:55 +08:00

543 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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))