ad8dfa27d7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
169 lines
5.7 KiB
Python
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
|