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:
2026-04-28 21:09:12 +02:00
commit ad8dfa27d7
32 changed files with 2644 additions and 0 deletions
View File
+77
View File
@@ -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}")
+22
View File
@@ -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,
)
+51
View File
@@ -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
View File
@@ -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
+130
View File
@@ -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()
+341
View File
@@ -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
View File
@@ -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
+91
View File
@@ -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