""" Main trading loop. Wakes on each M15 bar boundary, scans all configured symbols for signals, executes trades through the broker facade, and handles daily bookkeeping. """ import asyncio import logging from datetime import datetime, timezone, timedelta from . import broker, config, risk, alerts from .signals import check logger = logging.getLogger(__name__) # ── timing ─────────────────────────────────────────────────────────────────── def _next_bar_time(now: datetime) -> datetime: """Return the UTC datetime of the next M15 bar close.""" minutes = (now.minute // 15 + 1) * 15 base = now.replace(minute=0, second=0, microsecond=0) next_bar = base + timedelta(minutes=minutes) return next_bar + timedelta(seconds=5) # 5-s buffer after bar close async def _sleep_until(dt: datetime) -> None: delay = (dt - datetime.now(timezone.utc)).total_seconds() if delay > 0: logger.debug("Sleeping %.1f s until %s", delay, dt.strftime("%H:%M:%S")) await asyncio.sleep(delay) # ── daily reset ────────────────────────────────────────────────────────────── _last_day: str = "" def _daily_reset(): global _last_day today = datetime.now(timezone.utc).date().isoformat() if today != _last_day: _last_day = today logger.info("── New trading day: %s ──", today) # ── position tracker (for can_trade check across symbols) ──────────────────── async def _get_state() -> dict: """Read paper state for daily P&L. Returns empty dict for live MT5 mode.""" if config.PAPER_TRADING: from .paper_client import _state return _state return {} # ── trade execution ────────────────────────────────────────────────────────── async def _execute(signal, account_info: dict) -> bool: symbol_info = await broker.get_symbol_info(signal.symbol) if symbol_info is None: logger.warning("No symbol info for %s", signal.symbol) return False tick = await broker.get_tick(signal.symbol) if tick is None: logger.warning("No tick for %s", signal.symbol) return False fill_price = tick["ask"] if signal.direction == "long" else tick["bid"] volume = risk.calculate_lot_size( symbol_info, account_info["balance"], fill_price, signal.sl, ) if volume == 0.0: return False order_type = broker.ORDER_TYPE_BUY if signal.direction == "long" else broker.ORDER_TYPE_SELL result = await broker.place_order( symbol = signal.symbol, order_type = order_type, volume = volume, price = fill_price, sl = signal.sl, tp = signal.tp, comment = "RSITrend", ) if result is None: return False await alerts.notify_open(signal.symbol, signal.direction, fill_price, signal.sl, signal.tp, volume) return True # ── symbol already has open position? ──────────────────────────────────────── def _has_open(symbol: str, open_positions: list[dict]) -> bool: return any( p.get("symbol") == symbol or p.get("s") == symbol for p in open_positions ) # ── main loop ───────────────────────────────────────────────────────────────── async def run() -> None: mode = "PAPER" if config.PAPER_TRADING else "LIVE" logger.info("Bot started in %s mode — symbols: %s", mode, config.SYMBOLS) await alerts.notify_status( f"NewBot started ({mode}) — watching {', '.join(config.SYMBOLS)}" ) while True: now = datetime.now(timezone.utc) next_bar = _next_bar_time(now) await _sleep_until(next_bar) _daily_reset() try: await _scan() except Exception as exc: logger.exception("Scan error: %s", exc) await alerts.notify_error(str(exc)) async def _scan() -> None: account = await broker.get_account_info() if account is None: logger.error("Cannot fetch account info") return open_positions = await broker.get_open_positions() state = await _get_state() if not risk.can_trade(account, open_positions, state): return now_str = datetime.now(timezone.utc).strftime("%H:%M UTC") logger.info("[%s] Scanning %d symbols open=%d balance=$%.2f", now_str, len(config.SYMBOLS), len(open_positions), account["balance"]) for symbol in config.SYMBOLS: # don't add a second position on the same symbol if _has_open(symbol, open_positions): continue df = await broker.get_candles(symbol, config.TIMEFRAME, config.LOOKBACK) if df is None or len(df) < config.LOOKBACK: logger.debug("%s: not enough candles", symbol) continue signal = check(df, symbol) if signal is None: continue logger.info("Signal: %s", signal) success = await _execute(signal, account) if success: # re-check position count after each trade open_positions = await broker.get_open_positions() if not risk.can_trade(account, open_positions, state): break