Files

169 lines
5.7 KiB
Python

"""
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