Initial commit: multi-symbol bot with backtest engine and RSI trend strategy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+168
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user