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:
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Telegram alert system. All sends are fire-and-forget — a send failure
|
||||
never crashes the main loop.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_bot = None
|
||||
_chat_id = None
|
||||
|
||||
|
||||
def _get_bot():
|
||||
global _bot, _chat_id
|
||||
if _bot is None and config.TELEGRAM_BOT_TOKEN and config.TELEGRAM_CHAT_ID:
|
||||
try:
|
||||
from telegram import Bot
|
||||
_bot = Bot(token=config.TELEGRAM_BOT_TOKEN)
|
||||
_chat_id = config.TELEGRAM_CHAT_ID
|
||||
except ImportError:
|
||||
logger.warning("python-telegram-bot not installed — Telegram disabled")
|
||||
return _bot
|
||||
|
||||
|
||||
async def send(text: str) -> None:
|
||||
bot = _get_bot()
|
||||
if bot is None:
|
||||
return
|
||||
try:
|
||||
await bot.send_message(chat_id=_chat_id, text=text, parse_mode="HTML")
|
||||
except Exception as exc:
|
||||
logger.warning("Telegram send failed: %s", exc)
|
||||
|
||||
|
||||
async def notify_open(symbol: str, direction: str, entry: float,
|
||||
sl: float, tp: float, lots: float) -> None:
|
||||
arrow = "🟢 BUY" if direction == "long" else "🔴 SELL"
|
||||
mode = "PAPER" if config.PAPER_TRADING else "LIVE"
|
||||
text = (
|
||||
f"<b>[{mode}] {arrow} {symbol}</b>\n"
|
||||
f"Entry : <code>{entry:.5f}</code>\n"
|
||||
f"SL : <code>{sl:.5f}</code>\n"
|
||||
f"TP : <code>{tp:.5f}</code>\n"
|
||||
f"Size : {lots:.2f} lots\n"
|
||||
f"R:R : 1 : {config.TP_ATR / config.SL_ATR:.1f}"
|
||||
)
|
||||
logger.info("OPEN %s %s @ %.5f SL=%.5f TP=%.5f %.2f lots",
|
||||
symbol, direction.upper(), entry, sl, tp, lots)
|
||||
await send(text)
|
||||
|
||||
|
||||
async def notify_close(symbol: str, direction: str, entry: float,
|
||||
exit_price: float, pnl_pips: float, reason: str) -> None:
|
||||
sign = "✅" if pnl_pips > 0 else "❌"
|
||||
text = (
|
||||
f"<b>{sign} CLOSED {symbol}</b>\n"
|
||||
f"Dir : {direction.upper()}\n"
|
||||
f"Entry : <code>{entry:.5f}</code> → <code>{exit_price:.5f}</code>\n"
|
||||
f"P&L : <b>{pnl_pips:+.1f} pips</b>\n"
|
||||
f"Reason: {reason}"
|
||||
)
|
||||
logger.info("CLOSE %s %s %+.1f pips (%s)", symbol, direction.upper(),
|
||||
pnl_pips, reason)
|
||||
await send(text)
|
||||
|
||||
|
||||
async def notify_status(message: str) -> None:
|
||||
logger.info(message)
|
||||
await send(f"ℹ️ {message}")
|
||||
|
||||
|
||||
async def notify_error(message: str) -> None:
|
||||
logger.error(message)
|
||||
await send(f"🚨 <b>ERROR</b>: {message}")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Broker facade — routes all calls to either mt5_client or paper_client
|
||||
depending on config.PAPER_TRADING. Import this module everywhere; never
|
||||
import the underlying clients directly.
|
||||
"""
|
||||
|
||||
from . import config
|
||||
|
||||
if config.PAPER_TRADING:
|
||||
from .paper_client import ( # noqa: F401
|
||||
connect, disconnect,
|
||||
get_candles, get_tick, get_account_info,
|
||||
get_open_positions, get_symbol_info, place_order,
|
||||
ORDER_TYPE_BUY, ORDER_TYPE_SELL,
|
||||
)
|
||||
else:
|
||||
from .mt5_client import ( # noqa: F401
|
||||
connect, disconnect,
|
||||
get_candles, get_tick, get_account_info,
|
||||
get_open_positions, get_symbol_info, place_order,
|
||||
ORDER_TYPE_BUY, ORDER_TYPE_SELL,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Configuration — loaded once at import time.
|
||||
Values come from config.json (strategy params) and .env (secrets).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_root = Path(__file__).parent.parent
|
||||
with open(_root / "config.json") as f:
|
||||
_cfg = json.load(f)
|
||||
|
||||
# ── strategy ──────────────────────────────────────────────────────────────────
|
||||
SYMBOLS: list[str] = _cfg["symbols"]
|
||||
TIMEFRAME: str = _cfg["timeframe"] # "M15"
|
||||
LOOKBACK: int = _cfg["lookback_candles"] # bars for indicator warmup
|
||||
RSI_PERIOD: int = _cfg["rsi_period"]
|
||||
ATR_PERIOD: int = _cfg["atr_period"]
|
||||
OS_LEVEL: float = _cfg["os_level"]
|
||||
OB_LEVEL: float = _cfg["ob_level"]
|
||||
SL_ATR: float = _cfg["sl_atr"]
|
||||
TP_ATR: float = _cfg["tp_atr"]
|
||||
TREND_FAST: int = _cfg["trend_fast"]
|
||||
TREND_SLOW: int = _cfg["trend_slow"]
|
||||
SESSION_START: int = _cfg["session_start"]
|
||||
SESSION_END: int = _cfg["session_end"]
|
||||
|
||||
# ── risk ──────────────────────────────────────────────────────────────────────
|
||||
RISK_PER_TRADE: float = _cfg["risk_per_trade"] # fraction, e.g. 0.01
|
||||
MAX_POSITIONS: int = _cfg["max_positions"]
|
||||
MIN_SL_PIPS: float = _cfg["min_sl_pips"]
|
||||
MAX_DAILY_LOSS: float = _cfg["max_daily_loss"] # fraction of balance
|
||||
|
||||
# ── mode ──────────────────────────────────────────────────────────────────────
|
||||
PAPER_TRADING: bool = _cfg.get("paper_trading", True)
|
||||
PAPER_INITIAL_BALANCE: float = _cfg.get("paper_initial_balance", 10_000.0)
|
||||
REFRESH_INTERVAL: int = _cfg.get("refresh_interval_seconds", 15)
|
||||
|
||||
# ── broker secrets (from .env) ────────────────────────────────────────────────
|
||||
MT5_LOGIN: int = int(os.environ.get("MT5_LOGIN") or "0")
|
||||
MT5_PASSWORD: str = os.environ.get("MT5_PASSWORD", "")
|
||||
MT5_SERVER: str = os.environ.get("MT5_SERVER", "")
|
||||
|
||||
# ── telegram secrets ──────────────────────────────────────────────────────────
|
||||
TELEGRAM_BOT_TOKEN: str = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID: str = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
+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
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
MT5 broker client. Thin async wrapper around the synchronous MetaTrader5 API.
|
||||
All blocking calls run in the default executor so the event loop stays free.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import MetaTrader5 as mt5
|
||||
_MT5_AVAILABLE = True
|
||||
except ImportError:
|
||||
_MT5_AVAILABLE = False
|
||||
logger.warning("MetaTrader5 package not installed — MT5 client unavailable")
|
||||
|
||||
ORDER_TYPE_BUY = 0
|
||||
ORDER_TYPE_SELL = 1
|
||||
|
||||
_TF_MAP = {
|
||||
"M1": 1, "M5": 5, "M15": 15, "M30": 30,
|
||||
"H1": 16385, "H4": 16388, "D1": 16408,
|
||||
}
|
||||
|
||||
|
||||
def _run(fn):
|
||||
return asyncio.get_event_loop().run_in_executor(None, fn)
|
||||
|
||||
|
||||
def connect() -> bool:
|
||||
if not _MT5_AVAILABLE:
|
||||
logger.error("MetaTrader5 not installed")
|
||||
return False
|
||||
if not mt5.initialize(
|
||||
login=config.MT5_LOGIN,
|
||||
password=config.MT5_PASSWORD,
|
||||
server=config.MT5_SERVER,
|
||||
):
|
||||
logger.error("MT5 init failed: %s", mt5.last_error())
|
||||
return False
|
||||
info = mt5.terminal_info()
|
||||
logger.info("MT5 connected: %s", info.name if info else "unknown")
|
||||
return True
|
||||
|
||||
|
||||
def disconnect():
|
||||
if _MT5_AVAILABLE:
|
||||
mt5.shutdown()
|
||||
|
||||
|
||||
async def get_candles(symbol: str, timeframe: str, count: int) -> Optional[pd.DataFrame]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
tf = _TF_MAP[timeframe]
|
||||
rates = await _run(lambda: mt5.copy_rates_from_pos(symbol, tf, 0, count))
|
||||
if rates is None or len(rates) == 0:
|
||||
return None
|
||||
df = pd.DataFrame(rates)
|
||||
df["time"] = pd.to_datetime(df["time"], unit="s")
|
||||
df = df.rename(columns={"tick_volume": "tick_volume"})
|
||||
return df.set_index("time")
|
||||
|
||||
|
||||
async def get_tick(symbol: str) -> Optional[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
tick = await _run(lambda: mt5.symbol_info_tick(symbol))
|
||||
return tick._asdict() if tick else None
|
||||
|
||||
|
||||
async def get_account_info() -> Optional[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
info = await _run(mt5.account_info)
|
||||
return info._asdict() if info else None
|
||||
|
||||
|
||||
async def get_open_positions(magic: int = 234001) -> list[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return []
|
||||
positions = await _run(lambda: mt5.positions_get())
|
||||
if positions is None:
|
||||
return []
|
||||
return [p._asdict() for p in positions if p.magic == magic]
|
||||
|
||||
|
||||
async def get_symbol_info(symbol: str) -> Optional[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
info = await _run(lambda: mt5.symbol_info(symbol))
|
||||
return info._asdict() if info else None
|
||||
|
||||
|
||||
async def place_order(
|
||||
symbol: str,
|
||||
order_type: int,
|
||||
volume: float,
|
||||
price: float,
|
||||
sl: float,
|
||||
tp: float,
|
||||
comment: str = "",
|
||||
magic: int = 234001,
|
||||
) -> Optional[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": symbol,
|
||||
"volume": round(volume, 2),
|
||||
"type": order_type,
|
||||
"price": price,
|
||||
"sl": sl,
|
||||
"tp": tp,
|
||||
"deviation": 20,
|
||||
"magic": magic,
|
||||
"comment": comment,
|
||||
"type_time": mt5.ORDER_TIME_GTC,
|
||||
"type_filling": mt5.ORDER_FILLING_IOC,
|
||||
}
|
||||
result = await _run(lambda: mt5.order_send(request))
|
||||
if result is None or result.retcode != mt5.TRADE_RETCODE_DONE:
|
||||
logger.error("Order failed %s: %s", symbol, result)
|
||||
return None
|
||||
return result._asdict()
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Paper trading client — drop-in replacement for mt5_client.
|
||||
Simulates fills using real MT5 tick prices (if MT5 available) or midpoints
|
||||
from the local candles DB. State persists in paper_state.json.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ORDER_TYPE_BUY = 0
|
||||
ORDER_TYPE_SELL = 1
|
||||
|
||||
STATE_PATH = Path(__file__).parent.parent / "paper_state.json"
|
||||
|
||||
_state: dict = {}
|
||||
|
||||
# Try to use MT5 for live prices even in paper mode
|
||||
try:
|
||||
from . import mt5_client as _mt5
|
||||
_MT5_FOR_PRICES = True
|
||||
except Exception:
|
||||
_MT5_FOR_PRICES = False
|
||||
|
||||
# yfinance as live-price fallback when MT5 unavailable
|
||||
try:
|
||||
import yfinance as _yf
|
||||
import warnings as _warnings
|
||||
_warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
_YF_AVAILABLE = True
|
||||
except ImportError:
|
||||
_YF_AVAILABLE = False
|
||||
|
||||
# candle cache: key → (fetched_at, DataFrame)
|
||||
_candle_cache: dict[str, tuple[float, pd.DataFrame]] = {}
|
||||
_CACHE_TTL = 60.0 # seconds
|
||||
|
||||
_SPREADS_PIPS: dict[str, float] = {
|
||||
"EURUSD": 0.8, "GBPUSD": 0.9, "USDJPY": 0.7, "USDCHF": 1.0,
|
||||
"USDCAD": 1.0, "AUDUSD": 0.9, "NZDUSD": 1.2, "EURJPY": 1.0,
|
||||
"GBPJPY": 1.5, "EURGBP": 1.0, "EURAUD": 1.5, "EURCAD": 1.5,
|
||||
"AUDCAD": 1.5, "CADJPY": 1.5, "CHFJPY": 1.5, "EURCHF": 1.0,
|
||||
"AUDCHF": 1.5, "CADCHF": 1.5, "AUDJPY": 1.2, "GBPCAD": 2.0,
|
||||
"GBPAUD": 2.0,
|
||||
}
|
||||
|
||||
|
||||
def _pip_size(symbol: str) -> float:
|
||||
return 0.01 if symbol.endswith("JPY") else 0.0001
|
||||
|
||||
|
||||
def _half_spread(symbol: str) -> float:
|
||||
pips = _SPREADS_PIPS.get(symbol, 1.5)
|
||||
return pips * _pip_size(symbol) / 2
|
||||
|
||||
|
||||
# ── state ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_state() -> dict:
|
||||
if STATE_PATH.exists():
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {
|
||||
"balance": config.PAPER_INITIAL_BALANCE,
|
||||
"positions": [],
|
||||
"history": [],
|
||||
"daily_pnl": {},
|
||||
}
|
||||
|
||||
|
||||
def _save_state():
|
||||
with open(STATE_PATH, "w") as f:
|
||||
json.dump(_state, f, indent=2, default=str)
|
||||
|
||||
|
||||
def connect() -> bool:
|
||||
global _state
|
||||
_state = _load_state()
|
||||
logger.info("Paper trading — balance $%.2f open positions: %d",
|
||||
_state["balance"], len(_state["positions"]))
|
||||
return True
|
||||
|
||||
|
||||
def disconnect():
|
||||
_save_state()
|
||||
logger.info("Paper state saved → %s", STATE_PATH)
|
||||
|
||||
|
||||
# ── price helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _yf_ticker(symbol: str) -> str:
|
||||
return f"{symbol}=X"
|
||||
|
||||
|
||||
def _fetch_yf_candles(symbol: str, timeframe: str) -> Optional[pd.DataFrame]:
|
||||
"""Blocking yfinance fetch — run in executor."""
|
||||
if not _YF_AVAILABLE:
|
||||
return None
|
||||
interval_map = {"M15": "15m", "H1": "1h", "H4": "4h", "D1": "1d"}
|
||||
interval = interval_map.get(timeframe, "15m")
|
||||
# Request max available to give EMA(200) on H1 enough warmup
|
||||
period = "60d" if interval in ("15m", "30m") else "365d"
|
||||
try:
|
||||
df = _yf.download(
|
||||
_yf_ticker(symbol), interval=interval,
|
||||
period=period, auto_adjust=True, progress=False,
|
||||
)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
if isinstance(df.columns, pd.MultiIndex):
|
||||
df.columns = df.columns.get_level_values(0)
|
||||
df = df.rename(columns=str.lower)
|
||||
df.index = pd.to_datetime(df.index, utc=True).tz_localize(None)
|
||||
df.index.name = "time"
|
||||
df["tick_volume"] = df.get("volume", 0).fillna(0).astype(int)
|
||||
return df[["open", "high", "low", "close", "tick_volume"]].dropna()
|
||||
except Exception as exc:
|
||||
logger.debug("yfinance fetch failed for %s: %s", symbol, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_yf_cached(symbol: str, timeframe: str) -> Optional[pd.DataFrame]:
|
||||
import time
|
||||
key = f"{symbol}_{timeframe}"
|
||||
now = time.monotonic()
|
||||
if key in _candle_cache:
|
||||
fetched_at, df = _candle_cache[key]
|
||||
if now - fetched_at < _CACHE_TTL:
|
||||
return df
|
||||
loop = asyncio.get_event_loop()
|
||||
df = await loop.run_in_executor(None, _fetch_yf_candles, symbol, timeframe)
|
||||
if df is not None and not df.empty:
|
||||
_candle_cache[key] = (now, df)
|
||||
return df
|
||||
|
||||
|
||||
async def get_tick(symbol: str) -> Optional[dict]:
|
||||
# 1. prefer live MT5 tick
|
||||
if _MT5_FOR_PRICES:
|
||||
tick = await _mt5.get_tick(symbol)
|
||||
if tick:
|
||||
return tick
|
||||
# 2. yfinance last bar
|
||||
df = await _get_yf_cached(symbol, "M15")
|
||||
if df is not None and not df.empty:
|
||||
mid = float(df["close"].iloc[-1])
|
||||
hs = _half_spread(symbol)
|
||||
return {"bid": mid - hs, "ask": mid + hs}
|
||||
# 3. DB last close
|
||||
try:
|
||||
import sqlite3
|
||||
from engine.data import DB_PATH
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
row = conn.execute(
|
||||
"SELECT close FROM candles WHERE symbol=? AND timeframe='M15' "
|
||||
"ORDER BY time DESC LIMIT 1", (symbol,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
mid = row[0]
|
||||
hs = _half_spread(symbol)
|
||||
return {"bid": mid - hs, "ask": mid + hs}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def get_candles(symbol: str, timeframe: str, count: int) -> Optional[pd.DataFrame]:
|
||||
# 1. MT5
|
||||
if _MT5_FOR_PRICES:
|
||||
df = await _mt5.get_candles(symbol, timeframe, count)
|
||||
if df is not None:
|
||||
return df
|
||||
# 2. yfinance (returns all 60d; truncate to count)
|
||||
df = await _get_yf_cached(symbol, timeframe)
|
||||
if df is not None and not df.empty:
|
||||
return df.tail(count)
|
||||
# 3. DB fallback
|
||||
try:
|
||||
import sqlite3
|
||||
from engine.data import DB_PATH
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
rows = conn.execute(
|
||||
"SELECT time, open, high, low, close, tick_volume "
|
||||
"FROM candles WHERE symbol=? AND timeframe=? "
|
||||
"ORDER BY time DESC LIMIT ?",
|
||||
(symbol, timeframe, count)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
if not rows:
|
||||
return None
|
||||
df = pd.DataFrame(rows[::-1],
|
||||
columns=["time","open","high","low","close","tick_volume"])
|
||||
df["time"] = pd.to_datetime(df["time"])
|
||||
return df.set_index("time")
|
||||
except Exception as exc:
|
||||
logger.warning("DB candle fallback failed for %s: %s", symbol, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def get_account_info() -> Optional[dict]:
|
||||
return {"balance": _state["balance"], "equity": _state["balance"], "margin_level": 0.0}
|
||||
|
||||
|
||||
async def get_open_positions(**_) -> list[dict]:
|
||||
"""Settle any TP/SL hits, return surviving positions."""
|
||||
still_open = []
|
||||
|
||||
for pos in _state["positions"]:
|
||||
tick = await get_tick(pos["symbol"])
|
||||
if tick is None:
|
||||
still_open.append(pos)
|
||||
continue
|
||||
|
||||
mid = (tick["bid"] + tick["ask"]) / 2
|
||||
ps = _pip_size(pos["symbol"])
|
||||
pv = pos["pip_value_per_lot"] * pos["volume"]
|
||||
closed, pnl, reason, exit_px = False, 0.0, "", mid
|
||||
|
||||
if pos["direction"] == ORDER_TYPE_BUY:
|
||||
if mid <= pos["sl"]:
|
||||
pnl, reason, exit_px = (pos["sl"] - pos["entry"]) / ps * pv, "SL", pos["sl"]
|
||||
elif mid >= pos["tp"]:
|
||||
pnl, reason, exit_px = (pos["tp"] - pos["entry"]) / ps * pv, "TP", pos["tp"]
|
||||
else:
|
||||
if mid >= pos["sl"]:
|
||||
pnl, reason, exit_px = (pos["entry"] - pos["sl"]) / ps * pv, "SL", pos["sl"]
|
||||
elif mid <= pos["tp"]:
|
||||
pnl, reason, exit_px = (pos["entry"] - pos["tp"]) / ps * pv, "TP", pos["tp"]
|
||||
|
||||
if reason:
|
||||
_state["balance"] += pnl
|
||||
today = date.today().isoformat()
|
||||
_state.setdefault("daily_pnl", {})[today] = \
|
||||
_state["daily_pnl"].get(today, 0) + pnl
|
||||
pnl_pips = (exit_px - pos["entry"]) / ps * (1 if pos["direction"] == ORDER_TYPE_BUY else -1)
|
||||
_state["history"].append({
|
||||
**pos, "exit": exit_px, "pnl": round(pnl, 2),
|
||||
"pnl_pips": round(pnl_pips, 1),
|
||||
"reason": reason, "closed_at": datetime.now().isoformat(),
|
||||
})
|
||||
logger.info("%s %-8s %s pnl=$%+.2f (%.1f pips) balance=$%.2f",
|
||||
reason, pos["symbol"],
|
||||
"BUY" if pos["direction"] == ORDER_TYPE_BUY else "SELL",
|
||||
pnl, pnl_pips, _state["balance"])
|
||||
else:
|
||||
still_open.append(pos)
|
||||
|
||||
if len(still_open) != len(_state["positions"]):
|
||||
_state["positions"] = still_open
|
||||
_save_state()
|
||||
|
||||
return list(_state["positions"])
|
||||
|
||||
|
||||
async def get_symbol_info(symbol: str) -> Optional[dict]:
|
||||
"""Return pip-value per standard lot in USD."""
|
||||
ps = _pip_size(symbol)
|
||||
base, quote = symbol[:3], symbol[3:]
|
||||
|
||||
tick = await get_tick(symbol)
|
||||
if tick is None:
|
||||
return None
|
||||
mid = (tick["bid"] + tick["ask"]) / 2
|
||||
|
||||
async def cross_mid(pair: str) -> float:
|
||||
t = await get_tick(pair)
|
||||
return (t["bid"] + t["ask"]) / 2 if t else 1.0
|
||||
|
||||
if quote == "USD":
|
||||
pip_value = 100_000 * ps
|
||||
elif base == "USD":
|
||||
pip_value = 100_000 * ps / mid
|
||||
elif quote == "JPY":
|
||||
rate = await cross_mid("USDJPY")
|
||||
pip_value = 100_000 * ps / rate
|
||||
elif quote in ("CAD", "CHF"):
|
||||
rate = await cross_mid(f"USD{quote}")
|
||||
pip_value = 100_000 * ps / rate
|
||||
elif quote in ("AUD", "NZD", "GBP", "EUR"):
|
||||
rate = await cross_mid(f"{quote}USD")
|
||||
pip_value = 100_000 * ps * rate
|
||||
else:
|
||||
pip_value = 10.0
|
||||
|
||||
tick_size = ps / 10
|
||||
return {
|
||||
"name": symbol,
|
||||
"trade_tick_size": tick_size,
|
||||
"trade_tick_value": pip_value / 10,
|
||||
"volume_step": 0.01,
|
||||
"_pip_value_per_lot": pip_value,
|
||||
}
|
||||
|
||||
|
||||
async def place_order(
|
||||
symbol: str,
|
||||
order_type: int,
|
||||
volume: float,
|
||||
price: float,
|
||||
sl: float,
|
||||
tp: float,
|
||||
comment: str = "",
|
||||
**_,
|
||||
) -> Optional[dict]:
|
||||
tick = await get_tick(symbol)
|
||||
if tick is None:
|
||||
return None
|
||||
|
||||
fill = tick["ask"] if order_type == ORDER_TYPE_BUY else tick["bid"]
|
||||
info = await get_symbol_info(symbol)
|
||||
pv = info["_pip_value_per_lot"] if info else 10.0
|
||||
|
||||
trade_id = f"{symbol}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
_state["positions"].append({
|
||||
"id": trade_id,
|
||||
"symbol": symbol,
|
||||
"direction": order_type,
|
||||
"entry": fill,
|
||||
"sl": sl,
|
||||
"tp": tp,
|
||||
"volume": volume,
|
||||
"pip_value_per_lot": pv,
|
||||
"comment": comment,
|
||||
"opened_at": datetime.now().isoformat(),
|
||||
})
|
||||
_save_state()
|
||||
logger.info("PAPER %s %-8s %.2f lots @ %.5f SL=%.5f TP=%.5f",
|
||||
"BUY " if order_type == ORDER_TYPE_BUY else "SELL",
|
||||
symbol, volume, fill, sl, tp)
|
||||
return {"order": trade_id}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Position sizing and trade-permission checks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import date
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_LOT = 0.01
|
||||
MAX_LOT = 100.0
|
||||
|
||||
|
||||
def calculate_lot_size(
|
||||
symbol_info: dict,
|
||||
balance: float,
|
||||
entry_price: float,
|
||||
sl_price: float,
|
||||
) -> float:
|
||||
"""Risk `config.RISK_PER_TRADE` fraction of balance on a single trade."""
|
||||
risk_amount = balance * config.RISK_PER_TRADE
|
||||
|
||||
tick_value = symbol_info["trade_tick_value"]
|
||||
tick_size = symbol_info["trade_tick_size"]
|
||||
if tick_value == 0 or tick_size == 0:
|
||||
return MIN_LOT
|
||||
|
||||
pip_size = tick_size * 10
|
||||
pip_value = tick_value * (pip_size / tick_size)
|
||||
dist_pips = abs(entry_price - sl_price) / pip_size
|
||||
|
||||
if dist_pips < config.MIN_SL_PIPS:
|
||||
logger.warning("SL too tight for %s: %.1f pips — skipping",
|
||||
symbol_info.get("name"), dist_pips)
|
||||
return 0.0
|
||||
|
||||
lot = risk_amount / (pip_value * dist_pips)
|
||||
lot = max(MIN_LOT, min(MAX_LOT, lot))
|
||||
step = symbol_info.get("volume_step", 0.01)
|
||||
lot = math.floor(lot / step) * step
|
||||
return round(lot, 2)
|
||||
|
||||
|
||||
def can_trade(account_info: dict, open_positions: list[dict], state: dict) -> bool:
|
||||
if len(open_positions) >= config.MAX_POSITIONS:
|
||||
logger.debug("Max positions reached (%d)", config.MAX_POSITIONS)
|
||||
return False
|
||||
|
||||
today = date.today().isoformat()
|
||||
daily_pnl = state.get("daily_pnl", {}).get(today, 0.0)
|
||||
balance = account_info.get("balance", 1.0)
|
||||
if daily_pnl / balance < -config.MAX_DAILY_LOSS:
|
||||
logger.warning("Daily loss limit hit: %.1f%% of balance",
|
||||
-daily_pnl / balance * 100)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Live signal generation — applies the RSI Trend strategy to a freshly fetched
|
||||
candle window and returns a trade signal (or None).
|
||||
|
||||
The logic mirrors backtest.strategies.rsi_trend exactly so live and backtest
|
||||
behaviour stay in sync.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import config
|
||||
from engine import indicators as ind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── signal result ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Signal:
|
||||
__slots__ = ("symbol", "direction", "sl", "tp", "atr", "rsi")
|
||||
|
||||
def __init__(self, symbol: str, direction: str, sl: float, tp: float,
|
||||
atr: float, rsi: float):
|
||||
self.symbol = symbol
|
||||
self.direction = direction # 'long' | 'short'
|
||||
self.sl = sl
|
||||
self.tp = tp
|
||||
self.atr = atr
|
||||
self.rsi = rsi
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f"Signal({self.symbol} {self.direction.upper()} "
|
||||
f"sl={self.sl:.5f} tp={self.tp:.5f} rsi={self.rsi:.1f})")
|
||||
|
||||
|
||||
# ── signal check ─────────────────────────────────────────────────────────────
|
||||
|
||||
def check(df: pd.DataFrame, symbol: str) -> Optional[Signal]:
|
||||
"""
|
||||
Expects a DataFrame with columns [open, high, low, close] indexed by time,
|
||||
with at least (LOOKBACK) rows. Returns a Signal or None.
|
||||
|
||||
We check bar [-2] (the last completed bar) relative to bar [-3] (prev).
|
||||
Bar [-1] is the currently forming bar — we never use it for signals.
|
||||
"""
|
||||
if len(df) < config.LOOKBACK:
|
||||
logger.debug("%s: insufficient bars (%d < %d)", symbol, len(df), config.LOOKBACK)
|
||||
return None
|
||||
|
||||
# M15 indicators on full window
|
||||
close = df["close"]
|
||||
rsi_s = ind.rsi(close, config.RSI_PERIOD)
|
||||
atr_s = ind.atr(df, config.ATR_PERIOD)
|
||||
|
||||
# H1 trend from resampled M15
|
||||
h1 = close.resample("1h", label="left", closed="left").last().dropna()
|
||||
ema_f = ind.ema(h1, config.TREND_FAST)
|
||||
ema_s = ind.ema(h1, config.TREND_SLOW)
|
||||
trend = (ema_f - ema_s).reindex(df.index, method="ffill")
|
||||
|
||||
# Use second-to-last bar as signal bar (last fully closed bar)
|
||||
bar = df.iloc[-2]
|
||||
prev = df.iloc[-3]
|
||||
rsi_now = rsi_s.iloc[-2]
|
||||
rsi_prev = rsi_s.iloc[-3]
|
||||
atr_val = atr_s.iloc[-2]
|
||||
trend_val = trend.iloc[-2]
|
||||
|
||||
if any(pd.isna(v) for v in [rsi_now, rsi_prev, atr_val, trend_val]):
|
||||
return None
|
||||
|
||||
# session filter on signal bar
|
||||
hour = bar.name.hour
|
||||
if not (config.SESSION_START <= hour < config.SESSION_END):
|
||||
return None
|
||||
|
||||
ref = bar["close"]
|
||||
|
||||
if trend_val > 0 and rsi_prev < config.OS_LEVEL and rsi_now >= config.OS_LEVEL:
|
||||
sl = ref - config.SL_ATR * atr_val
|
||||
tp = ref + config.TP_ATR * atr_val
|
||||
return Signal(symbol, "long", sl, tp, atr_val, rsi_now)
|
||||
|
||||
if trend_val < 0 and rsi_prev > config.OB_LEVEL and rsi_now <= config.OB_LEVEL:
|
||||
sl = ref + config.SL_ATR * atr_val
|
||||
tp = ref - config.TP_ATR * atr_val
|
||||
return Signal(symbol, "short", sl, tp, atr_val, rsi_now)
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user