diff --git a/tradelocker_bot/README.md b/tradelocker_bot/README.md new file mode 100644 index 00000000..2a1f0c4c --- /dev/null +++ b/tradelocker_bot/README.md @@ -0,0 +1,62 @@ +# DarkGPT TradeLocker Bot + +Automated trading bot for **TradeLocker** (Atlas Funded Accounts). + +## Strategy + +| Component | Detail | +|-----------|--------| +| Signal | EMA(9) crosses EMA(21) | +| Confirmation | RSI(14) momentum filter | +| Stop-Loss | 1.5 × ATR(14) | +| Take-Profit | 2.5 × ATR(14) — R:R ≈ 1.67 | +| Timeframe | 15-minute bars | +| Sessions | London (07-12 UTC) + New York (13-17 UTC) | + +## Atlas Risk Rules Built-In + +- Max **0.5% risk per trade** (configurable) +- Max **2% daily loss** — bot stops for the day if hit +- Max **4% total drawdown** — bot stops if hit +- Max **2 concurrent positions** + +## Setup + +```bash +pip install -r requirements.txt +``` + +Edit `config.py`: + +```python +TRADELOCKER_BASE_URL = "https://live.tradelocker.com" # live account +API_EMAIL = "you@email.com" +API_PASSWORD = "yourpassword" +API_SERVER = "YOUR-BROKER-SERVER" +``` + +## Run + +```bash +python bot.py +``` + +Logs are written to `bot.log` and stdout. + +## Files + +``` +bot.py — main loop +config.py — all settings in one place +tradelocker_api.py — TradeLocker REST wrapper (auth, orders, candles) +indicators.py — EMA, RSI, ATR, signal logic +risk_manager.py — position sizing, daily/total drawdown guards +session_filter.py — London/NY session gate +``` + +## Tips for Atlas + +- Keep `RISK_PER_TRADE_PCT` at **0.5 %** or lower +- Never widen the `MAX_DAILY_LOSS_PCT` beyond your Atlas daily limit +- Use the **demo** URL first to paper-trade and verify signals +- Monitor `bot.log` daily diff --git a/tradelocker_bot/bot.py b/tradelocker_bot/bot.py new file mode 100644 index 00000000..d8c275d9 --- /dev/null +++ b/tradelocker_bot/bot.py @@ -0,0 +1,143 @@ +""" +DarkGPT TradeLocker Bot — Main loop +Atlas Funded Account | EMA crossover + RSI + ATR + +Run: + python bot.py +""" + +import time +import logging +import sys + +import config +from tradelocker_api import TradeLockerAPI +from indicators import compute_signal +from risk_manager import RiskManager +from session_filter import in_trading_session + +# ─── Logging ───────────────────────────────────────────────────────────────── +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("bot.log"), + ], +) +log = logging.getLogger(__name__) + + +# ─── State ──────────────────────────────────────────────────────────────────── +# Track which symbols we already have an open position in (avoid re-entry) +open_symbol_ids: set[str] = set() + + +def run(): + api = TradeLockerAPI() + risk = RiskManager() + + log.info("=== DarkGPT TradeLocker Bot starting ===") + api.login() + + # Pre-resolve instrument ids once + instrument_map: dict[str, str] = {} + for sym in config.SYMBOLS: + iid = api.get_instrument_id(sym) + if iid: + instrument_map[sym] = iid + log.info("Resolved %s → id=%s", sym, iid) + else: + log.warning("Could not resolve %s — skipping.", sym) + + if not instrument_map: + log.error("No valid instruments. Check your symbol list and account server.") + return + + # Initialise risk manager with current balance + bal_data = api.get_balance() + balance = float(bal_data.get("balance", bal_data.get("equity", 10000))) + risk.initialise(balance) + + log.info("Starting main loop. Polling every %ds …", config.POLL_INTERVAL_SECONDS) + + while True: + try: + _cycle(api, risk, instrument_map) + except KeyboardInterrupt: + log.info("Bot stopped by user.") + break + except Exception as exc: + log.error("Cycle error: %s", exc, exc_info=True) + + time.sleep(config.POLL_INTERVAL_SECONDS) + + +def _cycle(api: TradeLockerAPI, risk: RiskManager, instrument_map: dict[str, str]): + """One polling cycle: fetch data → evaluate → act.""" + + # ── Get current state ──────────────────────────────────────────────────── + bal_data = api.get_balance() + balance = float(bal_data.get("balance", bal_data.get("equity", 0))) + open_positions = api.get_open_positions() + open_count = len(open_positions) + + # Update the set of instruments that already have a position + open_inst_ids = {str(p.get("tradableInstrumentId", "")) for p in open_positions} + + # ── Session guard ──────────────────────────────────────────────────────── + if not in_trading_session(): + log.debug("Outside trading session — skipping.") + return + + # ── Risk guard ─────────────────────────────────────────────────────────── + allowed, reason = risk.can_trade(balance, open_count) + if not allowed: + log.warning("Trading paused: %s", reason) + return + + # ── Evaluate each symbol ───────────────────────────────────────────────── + for sym, iid in instrument_map.items(): + if iid in open_inst_ids: + log.debug("%s: already in a position — skip.", sym) + continue + + candles = api.get_candles(iid, config.TIMEFRAME, config.CANDLES_NEEDED) + if not candles: + log.warning("%s: no candle data.", sym) + continue + + sig = compute_signal(candles, config) + + if sig["signal"] == "none": + log.debug("%s: no signal.", sym) + continue + + # ── Size position ──────────────────────────────────────────────────── + price = candles[-1]["close"] + sl_dist = abs(price - sig["sl"]) + lot = risk.calculate_lot_size(balance, sl_dist) + + side = "buy" if sig["signal"] == "long" else "sell" + + log.info( + "SIGNAL %s %s @%.5f SL=%.5f TP=%.5f lot=%.2f", + sym, side.upper(), price, sig["sl"], sig["tp"], lot, + ) + + # ── Place order ────────────────────────────────────────────────────── + try: + api.place_market_order( + instrument_id=iid, + side=side, + qty=lot, + sl=sig["sl"], + tp=sig["tp"], + ) + open_inst_ids.add(iid) # prevent double-entry this cycle + except Exception as exc: + log.error("Order failed for %s: %s", sym, exc) + + +if __name__ == "__main__": + run() diff --git a/tradelocker_bot/config.py b/tradelocker_bot/config.py new file mode 100644 index 00000000..954d29ab --- /dev/null +++ b/tradelocker_bot/config.py @@ -0,0 +1,45 @@ +""" +TradeLocker Bot Configuration +Atlas Funded Account - Conservative high win-rate settings +""" + +# ─── TradeLocker API ────────────────────────────────────────────────────────── +TRADELOCKER_BASE_URL = "https://demo.tradelocker.com" # change to live: https://live.tradelocker.com +API_EMAIL = "your_email@example.com" +API_PASSWORD = "your_password" +API_SERVER = "OSP-DEMO" # your broker server name + +# ─── Account / Risk ─────────────────────────────────────────────────────────── +ACCOUNT_ID = None # auto-fetched on login +RISK_PER_TRADE_PCT = 0.5 # 0.5 % of balance per trade (Atlas safe) +MAX_DAILY_LOSS_PCT = 2.0 # hard stop for the day (Atlas rule) +MAX_TOTAL_DRAWDOWN_PCT = 4.0 # absolute drawdown ceiling (Atlas rule) +MAX_OPEN_TRADES = 2 # never stack more than 2 positions + +# ─── Instruments to trade ───────────────────────────────────────────────────── +# Stick to the most liquid, tightest-spread pairs for best execution +SYMBOLS = ["EURUSD", "GBPUSD", "USDJPY"] + +# ─── Timeframe ──────────────────────────────────────────────────────────────── +TIMEFRAME = "15" # 15-minute bars (best signal/noise for intraday) +CANDLES_NEEDED = 100 # history bars to fetch per cycle + +# ─── Strategy Parameters ────────────────────────────────────────────────────── +EMA_FAST = 9 +EMA_SLOW = 21 +RSI_PERIOD = 14 +RSI_LONG = 55 # RSI must be > this to take a long (momentum filter) +RSI_SHORT = 45 # RSI must be < this to take a short +ATR_PERIOD = 14 +ATR_SL_MULT = 1.5 # stop-loss = ATR * this +ATR_TP_MULT = 2.5 # take-profit = ATR * this (R:R ≈ 1.67) + +# ─── Session Filter ─────────────────────────────────────────────────────────── +# Only trade during the two most liquid windows (UTC) +TRADE_SESSIONS = [ + (7, 12), # London open + (13, 17), # New York open +] + +# ─── Loop timing ───────────────────────────────────────────────────────────── +POLL_INTERVAL_SECONDS = 30 # check every 30 s diff --git a/tradelocker_bot/indicators.py b/tradelocker_bot/indicators.py new file mode 100644 index 00000000..61502608 --- /dev/null +++ b/tradelocker_bot/indicators.py @@ -0,0 +1,128 @@ +""" +Pure-Python technical indicators (no external libs needed). +All functions accept a list of floats and return a float or list. +""" + +from typing import Optional + + +# ─── EMA ────────────────────────────────────────────────────────────────────── + +def ema(values: list[float], period: int) -> list[float]: + """Exponential Moving Average — same as most platforms.""" + if len(values) < period: + return [] + k = 2.0 / (period + 1) + out = [sum(values[:period]) / period] # seed with SMA + for v in values[period:]: + out.append(v * k + out[-1] * (1 - k)) + return out + + +def last_ema(values: list[float], period: int) -> Optional[float]: + e = ema(values, period) + return e[-1] if e else None + + +# ─── RSI ────────────────────────────────────────────────────────────────────── + +def rsi(values: list[float], period: int = 14) -> Optional[float]: + """Wilder RSI — returns the latest value or None if not enough data.""" + if len(values) < period + 1: + return None + gains, losses = [], [] + for i in range(1, len(values)): + d = values[i] - values[i - 1] + gains.append(max(d, 0)) + losses.append(max(-d, 0)) + # Wilder smoothing + avg_g = sum(gains[:period]) / period + avg_l = sum(losses[:period]) / period + for i in range(period, len(gains)): + avg_g = (avg_g * (period - 1) + gains[i]) / period + avg_l = (avg_l * (period - 1) + losses[i]) / period + if avg_l == 0: + return 100.0 + rs = avg_g / avg_l + return round(100 - 100 / (1 + rs), 2) + + +# ─── ATR ────────────────────────────────────────────────────────────────────── + +def atr(highs: list[float], lows: list[float], closes: list[float], period: int = 14) -> Optional[float]: + """Average True Range (Wilder smoothing).""" + if len(highs) < period + 1: + return None + trs = [] + for i in range(1, len(highs)): + tr = max( + highs[i] - lows[i], + abs(highs[i] - closes[i - 1]), + abs(lows[i] - closes[i - 1]), + ) + trs.append(tr) + avg = sum(trs[:period]) / period + for t in trs[period:]: + avg = (avg * (period - 1) + t) / period + return avg + + +# ─── Signal generation ──────────────────────────────────────────────────────── + +def compute_signal(candles: list[dict], cfg) -> dict: + """ + Returns: + { + 'signal': 'long' | 'short' | 'none', + 'sl': float, + 'tp': float, + 'atr': float, + } + """ + if len(candles) < max(cfg.EMA_SLOW, cfg.RSI_PERIOD + 1, cfg.ATR_PERIOD + 1) + 5: + return {"signal": "none"} + + closes = [c["close"] for c in candles] + highs = [c["high"] for c in candles] + lows = [c["low"] for c in candles] + + ema_f = ema(closes, cfg.EMA_FAST) + ema_s = ema(closes, cfg.EMA_SLOW) + + if len(ema_f) < 2 or len(ema_s) < 2: + return {"signal": "none"} + + cur_f, cur_s = ema_f[-1], ema_s[-1] + prev_f, prev_s = ema_f[-2], ema_s[-2] + + cur_rsi = rsi(closes, cfg.RSI_PERIOD) + if cur_rsi is None: + return {"signal": "none"} + + cur_atr = atr(highs, lows, closes, cfg.ATR_PERIOD) + if cur_atr is None: + return {"signal": "none"} + + price = closes[-1] + sl_dist = cur_atr * cfg.ATR_SL_MULT + tp_dist = cur_atr * cfg.ATR_TP_MULT + + # Bullish crossover: fast crosses above slow + RSI confirms momentum + if prev_f <= prev_s and cur_f > cur_s and cur_rsi > cfg.RSI_LONG: + return { + "signal": "long", + "sl": round(price - sl_dist, 5), + "tp": round(price + tp_dist, 5), + "atr": cur_atr, + } + + # Bearish crossover: fast crosses below slow + RSI confirms + if prev_f >= prev_s and cur_f < cur_s and cur_rsi < cfg.RSI_SHORT: + return { + "signal": "short", + "sl": round(price + sl_dist, 5), + "tp": round(price - tp_dist, 5), + "atr": cur_atr, + } + + return {"signal": "none"} diff --git a/tradelocker_bot/requirements.txt b/tradelocker_bot/requirements.txt new file mode 100644 index 00000000..0eb8cae7 --- /dev/null +++ b/tradelocker_bot/requirements.txt @@ -0,0 +1 @@ +requests>=2.31.0 diff --git a/tradelocker_bot/risk_manager.py b/tradelocker_bot/risk_manager.py new file mode 100644 index 00000000..f0f8fe96 --- /dev/null +++ b/tradelocker_bot/risk_manager.py @@ -0,0 +1,90 @@ +""" +Risk manager for Atlas funded accounts. +Enforces: + - Per-trade risk % of balance + - Daily loss limit + - Total drawdown ceiling + - Max concurrent open trades +""" + +import logging +from datetime import date + +import config + +log = logging.getLogger(__name__) + + +class RiskManager: + def __init__(self): + self.starting_balance: float = 0.0 + self.daily_start_balance: float = 0.0 + self.daily_date: date = date.today() + self.initialised = False + + # ── Initialise ──────────────────────────────────────────────────────────── + + def initialise(self, balance: float): + self.starting_balance = balance + self.daily_start_balance = balance + self.daily_date = date.today() + self.initialised = True + log.info("RiskManager initialised. Starting balance: %.2f", balance) + + def _refresh_day(self, balance: float): + today = date.today() + if today != self.daily_date: + self.daily_start_balance = balance + self.daily_date = today + log.info("New trading day. Daily balance reset to %.2f", balance) + + # ── Guard checks ────────────────────────────────────────────────────────── + + def can_trade(self, balance: float, open_trade_count: int) -> tuple[bool, str]: + """Returns (True, '') if trading is allowed, else (False, reason).""" + if not self.initialised: + return False, "Risk manager not initialised." + + self._refresh_day(balance) + + # 1. Too many open positions + if open_trade_count >= config.MAX_OPEN_TRADES: + return False, f"Max open trades reached ({config.MAX_OPEN_TRADES})." + + # 2. Daily loss limit + daily_loss_pct = (self.daily_start_balance - balance) / self.daily_start_balance * 100 + if daily_loss_pct >= config.MAX_DAILY_LOSS_PCT: + return False, f"Daily loss limit hit ({daily_loss_pct:.2f}% >= {config.MAX_DAILY_LOSS_PCT}%)." + + # 3. Total drawdown + total_dd_pct = (self.starting_balance - balance) / self.starting_balance * 100 + if total_dd_pct >= config.MAX_TOTAL_DRAWDOWN_PCT: + return False, f"Total drawdown limit hit ({total_dd_pct:.2f}% >= {config.MAX_TOTAL_DRAWDOWN_PCT}%)." + + return True, "" + + # ── Position sizing ─────────────────────────────────────────────────────── + + def calculate_lot_size( + self, + balance: float, + sl_distance_price: float, + pip_value: float = 10.0, # approximate USD pip value for 1 lot on most majors + min_lot: float = 0.01, + max_lot: float = 1.0, + ) -> float: + """ + Risk $ = balance * RISK_PCT / 100 + Lot = Risk$ / (sl_distance_pips * pip_value_per_lot) + """ + risk_usd = balance * config.RISK_PER_TRADE_PCT / 100 + sl_pips = sl_distance_price / 0.0001 # 1 pip = 0.0001 for most pairs + if sl_pips <= 0: + return min_lot + lot = risk_usd / (sl_pips * pip_value) + lot = max(min_lot, min(max_lot, round(lot, 2))) + log.debug( + "Sizing: balance=%.2f risk=%.2f$ sl_pips=%.1f lot=%.2f", + balance, risk_usd, sl_pips, lot, + ) + return lot diff --git a/tradelocker_bot/session_filter.py b/tradelocker_bot/session_filter.py new file mode 100644 index 00000000..7f4c2021 --- /dev/null +++ b/tradelocker_bot/session_filter.py @@ -0,0 +1,15 @@ +""" +Trading session filter — only trade during high-liquidity windows. +""" + +from datetime import datetime, timezone +import config + + +def in_trading_session() -> bool: + """Returns True if current UTC hour falls inside a configured session.""" + now_hour = datetime.now(timezone.utc).hour + for start, end in config.TRADE_SESSIONS: + if start <= now_hour < end: + return True + return False diff --git a/tradelocker_bot/tradelocker_api.py b/tradelocker_bot/tradelocker_api.py new file mode 100644 index 00000000..baf2161d --- /dev/null +++ b/tradelocker_bot/tradelocker_api.py @@ -0,0 +1,179 @@ +""" +TradeLocker REST API wrapper +Handles auth, candle fetching, order placement, position management. +""" + +import time +import logging +import requests +from typing import Optional + +import config + +log = logging.getLogger(__name__) + + +class TradeLockerAPI: + """Thin wrapper around TradeLocker REST v1.""" + + # Refresh access token 5 min before it expires + TOKEN_REFRESH_BUFFER = 300 + + def __init__(self): + self.base = config.TRADELOCKER_BASE_URL.rstrip("/") + self.access_token = None + self.refresh_token = None + self.token_expiry = 0 + self.account_id = config.ACCOUNT_ID + self.acc_num = None # numeric account number used for trading routes + + # ── Auth ────────────────────────────────────────────────────────────────── + + def login(self): + """Authenticate and store tokens.""" + url = f"{self.base}/trade/auth/jwt/token" + body = { + "email": config.API_EMAIL, + "password": config.API_PASSWORD, + "server": config.API_SERVER, + } + r = requests.post(url, json=body, timeout=10) + r.raise_for_status() + data = r.json() + self.access_token = data["accessToken"] + self.refresh_token = data["refreshToken"] + self.token_expiry = time.time() + data.get("accessTokenExpiresIn", 3600) + log.info("Login successful.") + self._fetch_account() + + def _refresh(self): + url = f"{self.base}/trade/auth/jwt/refresh" + body = {"refreshToken": self.refresh_token} + r = requests.post(url, json=body, timeout=10) + r.raise_for_status() + data = r.json() + self.access_token = data["accessToken"] + self.token_expiry = time.time() + data.get("accessTokenExpiresIn", 3600) + log.debug("Token refreshed.") + + def _ensure_token(self): + if time.time() >= self.token_expiry - self.TOKEN_REFRESH_BUFFER: + self._refresh() + + def _headers(self) -> dict: + self._ensure_token() + return { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + + # ── Account ─────────────────────────────────────────────────────────────── + + def _fetch_account(self): + url = f"{self.base}/trade/accounts" + r = requests.get(url, headers=self._headers(), timeout=10) + r.raise_for_status() + accounts = r.json().get("accounts", []) + if not accounts: + raise RuntimeError("No accounts found.") + acc = accounts[0] + self.account_id = acc.get("id") or acc.get("accNum") + self.acc_num = acc.get("accNum") or self.account_id + log.info("Account: id=%s accNum=%s", self.account_id, self.acc_num) + + def get_balance(self) -> dict: + """Returns dict with 'balance', 'equity', 'usedMargin'.""" + url = f"{self.base}/trade/accounts/{self.acc_num}" + r = requests.get(url, headers=self._headers(), timeout=10) + r.raise_for_status() + return r.json() + + # ── Market data ─────────────────────────────────────────────────────────── + + def get_instrument_id(self, symbol: str) -> Optional[str]: + """Resolve a symbol like 'EURUSD' to its TradeLocker instrument id.""" + url = f"{self.base}/trade/instruments" + params = {"locale": "en", "routeId": self.acc_num} + r = requests.get(url, headers=self._headers(), params=params, timeout=10) + r.raise_for_status() + for inst in r.json().get("d", {}).get("instruments", []): + if inst.get("name", "").upper() == symbol.upper(): + return str(inst["tradableInstrumentId"]) + log.warning("Instrument not found: %s", symbol) + return None + + def get_candles(self, instrument_id: str, timeframe: str, count: int) -> list[dict]: + """ + Fetch the last `count` OHLCV bars. + Returns list of dicts: {time, open, high, low, close, volume} + """ + url = f"{self.base}/trade/history" + params = { + "tradableInstrumentId": instrument_id, + "routeId": self.acc_num, + "resolution": timeframe, + "limit": count, + } + r = requests.get(url, headers=self._headers(), params=params, timeout=15) + r.raise_for_status() + raw = r.json().get("d", {}).get("bars", []) + candles = [ + { + "time": bar[0], + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]) if len(bar) > 5 else 0, + } + for bar in raw + ] + return candles + + def get_price(self, instrument_id: str) -> dict: + """Get current bid/ask.""" + url = f"{self.base}/trade/quotes" + params = {"tradableInstrumentId": instrument_id, "routeId": self.acc_num} + r = requests.get(url, headers=self._headers(), params=params, timeout=10) + r.raise_for_status() + q = r.json().get("d", {}) + return {"bid": float(q.get("bp", 0)), "ask": float(q.get("ap", 0))} + + # ── Orders / Positions ──────────────────────────────────────────────────── + + def place_market_order( + self, + instrument_id: str, + side: str, # "buy" or "sell" + qty: float, + sl: float, + tp: float, + ) -> dict: + url = f"{self.base}/trade/orders" + body = { + "tradableInstrumentId": instrument_id, + "routeId": self.acc_num, + "type": "market", + "side": side, + "qty": qty, + "stopLoss": round(sl, 5), + "takeProfit": round(tp, 5), + } + r = requests.post(url, json=body, headers=self._headers(), timeout=10) + r.raise_for_status() + log.info("Order placed: %s %s qty=%.4f SL=%.5f TP=%.5f", side, instrument_id, qty, sl, tp) + return r.json() + + def get_open_positions(self) -> list[dict]: + url = f"{self.base}/trade/positions" + params = {"routeId": self.acc_num} + r = requests.get(url, headers=self._headers(), params=params, timeout=10) + r.raise_for_status() + return r.json().get("d", {}).get("positions", []) + + def close_position(self, position_id: str, qty: float): + url = f"{self.base}/trade/positions/{position_id}" + body = {"routeId": self.acc_num, "qty": qty} + r = requests.delete(url, json=body, headers=self._headers(), timeout=10) + r.raise_for_status() + log.info("Closed position %s", position_id)