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

443 lines
19 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 — 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))