Files

296 lines
12 KiB
Python
Raw Permalink Normal View History

2025-10-05 07:52:39 +02:00
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Live trading runner (MT5) with verbose logs (ASCII-safe) + market-hours check.
"""
import os, sys, time, signal, argparse, logging
from logging.handlers import RotatingFileHandler
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional, Dict
import numpy as np
import pandas as pd
try:
from dotenv import load_dotenv
except Exception:
def load_dotenv(*args, **kwargs):
return False
try:
import MetaTrader5 as mt5
except Exception:
mt5 = None
from stable_baselines3 import PPO
import features
from adapters import broker
STOP = False
def handle_exit(signum, frame):
global STOP
STOP = True
logging.getLogger("live").info("Received signal %s, shutting down...", signum)
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, handle_exit)
except Exception:
pass
def setup_logging(log_path: Optional[Path] = None, level: int = logging.INFO) -> logging.Logger:
logger = logging.getLogger("live")
logger.setLevel(level)
fmt = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
sh = logging.StreamHandler(sys.stdout); sh.setFormatter(fmt); logger.addHandler(sh)
if log_path:
log_path.parent.mkdir(parents=True, exist_ok=True)
fh = RotatingFileHandler(log_path, maxBytes=2000000, backupCount=3, encoding="utf-8")
fh.setFormatter(fmt); logger.addHandler(fh)
return logger
def minutes_per_bar(tf: str) -> int:
tf = str(tf).upper()
if tf.startswith("M"): return int(tf[1:])
if tf.startswith("H"): return int(tf[1:]) * 60
if tf in ("D1", "1D"): return 1440
return 15
def seconds_to_next_bar(tf: str) -> float:
m = minutes_per_bar(tf)
now = datetime.now(timezone.utc)
base = now.replace(second=0, microsecond=0)
mins = now.minute
next_min = ((mins // m) + 1) * m
next_bar = base + timedelta(minutes=(next_min - mins))
delta = (next_bar - now).total_seconds()
if delta < 5: delta += m * 60
return float(delta)
def scalar_action(action) -> int:
return int(np.asarray(action).reshape(-1)[0])
def action_to_signals(a: int) -> Dict[str, bool]:
return {"buy": a == 2, "sell": a == 0, "hold": a == 1}
def print_header(logger: logging.Logger, symbol: str, a: int, sig: Dict[str, bool]) -> None:
logger.info("Model action: %s", a)
logger.info("-" * 66)
logger.info("Date: %s, SYMBOL: %s, BUY SIGNAL: %s, SELL SIGNAL: %s, HOLD SIGNAL: %s",
datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), symbol, sig["buy"], sig["sell"], sig["hold"])
def positions_snapshot(logger: logging.Logger, symbol: str) -> None:
try:
pos = broker.current_positions(symbol)
if pos is None or len(pos) == 0:
logger.info("No open positions.")
return
n_long = int((pos["type"] == 0).sum())
n_short = int((pos["type"] == 1).sum())
vol_total = float(pos["volume"].sum())
flt_pnl = float(pos["profit"].sum())
logger.info("Positions snapshot | longs=%d shorts=%d total_vol=%.3f floatingPnL=%.2f",
n_long, n_short, vol_total, flt_pnl)
except Exception as e:
logger.warning("Snapshot failed: %s", e)
def has_same_side_position(symbol: str, want_buy: bool, want_sell: bool) -> bool:
try:
pos_now = broker.current_positions(symbol)
if pos_now is None or len(pos_now) == 0: return False
if want_buy and (pos_now["type"] == 0).any(): return True
if want_sell and (pos_now["type"] == 1).any(): return True
except Exception:
return False
return False
def close_opposite_positions_if_any(logger: logging.Logger, symbol: str, sig: Dict[str, bool], dry_run: bool) -> bool:
try:
pos = broker.current_positions(symbol)
except Exception as e:
logger.warning("Could not fetch positions: %s", e)
return False
if pos is None or len(pos) == 0:
return False
if sig["buy"]:
to_close = pos[pos["type"] == 1]
side_text = "sell"
elif sig["sell"]:
to_close = pos[pos["type"] == 0]
side_text = "buy"
else:
return False
if len(to_close) == 0:
return False
if dry_run:
tickets = ", ".join(map(lambda x: str(int(x)), to_close["ticket"]))
logger.info("DRY_RUN=True -> Would close %d %s position(s): %s", len(to_close), side_text, tickets)
return False
logger.info("Existing %s positions found. Attempting to close...", side_text)
any_closed = False
for _, row in to_close.iterrows():
ticket = int(row["ticket"])
res = broker.close_position(ticket)
if res.get("ok"):
logger.info("Successfully closed position %s for %s", ticket, symbol); any_closed = True
else:
logger.warning("Failed to close position %s: %s", ticket, res)
return any_closed
def place_signal_order(logger: logging.Logger, symbol: str, sig: Dict[str, bool], vol: float, dry_run: bool, comment: str):
if sig["hold"]:
logger.info("Hold signal detected. No actions taken."); return None
side = "buy" if sig["buy"] else "sell"
if has_same_side_position(symbol, sig["buy"], sig["sell"]):
logger.info("Same-side position already exists. Skipping new %s order.", side)
return None
if dry_run:
logger.info("DRY_RUN=True -> Skipping order. Would place %s %.3f on %s", side.upper(), vol, symbol)
return {"ok": True, "dry_run": True, "side": side, "volume": vol}
logger.info("%s positions closed (if any). Placing new %s order.", "Buy" if side=="buy" else "Sell", side)
res = broker.place_market_order(symbol, side=side, volume=vol, comment=comment)
detail = res.get("result", {}) if isinstance(res, dict) else {}
price = detail.get("price") if isinstance(detail, dict) else None
order = detail.get("order") if isinstance(detail, dict) else None
deal = detail.get("deal") if isinstance(detail, dict) else None
logger.info("Order result: ok=%s, retcode=%s, order=%s, deal=%s, price=%s",
res.get("ok"), res.get("retcode"), order, deal, price)
return res
def print_bar_context(logger: logging.Logger, last_row: pd.Series):
fields = []
if "close" in last_row: fields.append("close=%.5f" % float(last_row["close"]))
if "ma_fast" in last_row: fields.append("ma_fast=%.5f" % float(last_row["ma_fast"]))
if "ma_slow" in last_row: fields.append("ma_slow=%.5f" % float(last_row["ma_slow"]))
if "rsi" in last_row: fields.append("rsi=%.1f" % float(last_row["rsi"]))
if fields:
logger.info("Context: " + " | ".join(fields))
def is_market_open(symbol: str, tf: str, logger: logging.Logger, allow_weekend: bool) -> bool:
try:
now = datetime.now(timezone.utc)
dow = now.weekday()
if not allow_weekend and dow in (5, 6):
logger.info("Market likely closed (weekend).")
return False
if mt5 is None:
return True
tick = mt5.symbol_info_tick(symbol)
if tick is None:
logger.info("No tick available for %s.", symbol)
return False
tick_ts = datetime.fromtimestamp(tick.time, tz=timezone.utc)
age_sec = (now - tick_ts).total_seconds()
bar_min = minutes_per_bar(tf)
thresh = max(bar_min * 120, 1800)
if age_sec > thresh:
logger.info("Market likely closed (last tick %.1f min ago).", age_sec / 60.0)
return False
return True
except Exception as e:
logger.warning("Market check failed: %s", e)
return True
def main():
load_dotenv()
p = argparse.ArgumentParser(description="Live DRL trading bot (MT5)")
p.add_argument("--symbol", default=os.getenv("TRAINING_SYMBOL","EURUSD"))
p.add_argument("--timeframe", default=os.getenv("TIMEFRAME","M15"))
p.add_argument("--model", default=None)
p.add_argument("--features", default="notebooks/models/selected_features.json")
p.add_argument("--volume", type=float, default=float(os.getenv("VOLUME","0.01")))
p.add_argument("--live", action="store_true")
p.add_argument("--dry-run", action="store_true")
p.add_argument("--order-comment", default=os.getenv("ORDER_COMMENT","DRL-Live"))
p.add_argument("--log-file", default="logs/live_bot.log")
p.add_argument("--skip-market-check", action="store_true", help="Disable market closed guard")
args = p.parse_args()
symbol = args.symbol; tf = args.timeframe
dry_run = not args.live or args.dry_run
vol = max(args.volume, 0.0); comment = args.order_comment
allow_weekend = os.getenv("WEEKEND_TRADING","false").lower() in ("1","true","yes")
logger = setup_logging(Path(args.log_file))
logger.info("Connecting to MT5...")
ok = broker.open_session(
login=int(os.getenv("MT5_LOGIN","0")) or None,
password=os.getenv("MT5_PASSWORD"),
server=os.getenv("MT5_SERVER"),
path=os.getenv("MT5_PATH"),
)
logger.info("MT5 connected: %s", ok)
model_path = args.model or f"notebooks/models/ppo_{symbol}_{tf}.zip"
mp = Path(model_path)
if not mp.exists():
alt = Path("notebooks") / "notebooks/models" / Path(f"ppo_{symbol}_{tf}.zip")
if alt.exists():
mp = alt
if not mp.exists():
logger.error("Model not found: %s", model_path)
logger.error("Also checked notebooks/models/ppo_%s_%s.zip", symbol, tf)
sys.exit(2)
try:
import json
with open(args.features, "r", encoding="utf-8") as f:
feature_cols = json.load(f)
except Exception as e:
logger.error("Failed to load features from %s: %s", args.features, e); sys.exit(2)
logger.info("Loading model: %s", mp)
model = PPO.load(mp.as_posix())
logger.info("Starting live loop | symbol=%s timeframe=%s dry_run=%s volume=%.3f", symbol, tf, dry_run, vol)
try:
while not STOP:
try:
open_ok = True if args.skip_market_check else is_market_open(symbol, tf, logger, allow_weekend)
if not open_ok:
secs = min(900, seconds_to_next_bar(tf))
logger.info("Sleeping %.0f seconds until next check (market closed).", secs)
time.sleep(secs)
continue
df = broker.fetch_last_n(symbol, tf, n=500)
if df is None or len(df) < 50:
logger.warning("Insufficient bars fetched; sleeping 5s"); time.sleep(5); continue
df_feat = features.add_indicators(df.copy())
last_row = df_feat.iloc[-1]
print_bar_context(logger, last_row)
obs = last_row[list(feature_cols)].astype(np.float32).values
if np.isnan(obs).any() or np.isinf(obs).any():
logger.warning("Obs has NaN/Inf; skipping this tick"); time.sleep(5); continue
action, _ = model.predict(obs, deterministic=True)
a = scalar_action(action); sig = action_to_signals(a)
print_header(logger, symbol, a, sig)
positions_snapshot(logger, symbol)
_ = close_opposite_positions_if_any(logger, symbol, sig, dry_run)
if sig["hold"]:
logger.info("Appropriate position already exists or HOLD signal. No new order.")
else:
_ = place_signal_order(logger, symbol, sig, vol, dry_run, comment)
secs = seconds_to_next_bar(tf)
logger.info("Waiting for new signals...")
logger.info("Calculated sleep time: %.3f seconds", secs)
time.sleep(secs)
except Exception as e:
logger.exception("Loop error: %s", e); time.sleep(5)
finally:
try: broker.close_session()
except Exception: pass
logger.info("Shutdown complete.")
if __name__ == "__main__":
main()