| import os |
| import json |
| import socket |
| import ssl |
| import struct |
| import logging |
| import time |
| import asyncio |
| import threading |
| from datetime import datetime |
| import requests |
| from fastapi import FastAPI, Request, Form, HTTPException |
| from fastapi.responses import HTMLResponse, RedirectResponse |
|
|
| |
| from ctrader_open_api.messages.OpenApiCommonMessages_pb2 import ProtoMessage |
| from ctrader_open_api.messages.OpenApiMessages_pb2 import * |
| from ctrader_open_api.messages.OpenApiModelMessages_pb2 import * |
|
|
| |
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger("CloudBot") |
|
|
| app = FastAPI(title="cTra Webhook Bot") |
|
|
| |
| CLIENT_ID = os.environ.get("CTRADER_CLIENT_ID", "").strip() |
| CLIENT_SECRET = os.environ.get("CTRADER_CLIENT_SECRET", "").strip() |
| CTRADER_ENV = os.environ.get("CTRADER_ENV", "demo").lower().strip() |
|
|
| |
| |
| DEFAULT_VOLUME_LOTS = 0.01 |
| symbol_volumes = { |
| "NAS100": 0.05, |
| "NAS100m": 0.05, |
| "USTEC": 0.05, |
| "US500": 0.05, |
| "US500m": 0.05, |
| "US30": 0.05, |
| "US30m": 0.05, |
| "BTCUSD": 0.01, |
| "ETHUSD": 0.01, |
| "EURUSD": 0.01, |
| "_DEFAULT_FOREX": 0.01, |
| "_DEFAULT_CRYPTO": 0.01, |
| } |
|
|
| |
| symbol_status = { |
| "NAS100": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "NAS100m": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "USTEC": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "US500": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "US500m": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "US30": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "US30m": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "BTCUSD": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "ETHUSD": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "EURUSD": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "_DEFAULT_FOREX": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| "_DEFAULT_CRYPTO": {"buy": True, "sell": True, "stop-trade": True, "anchor": True}, |
| } |
|
|
| CONFIG_FILE = "bot_config.json" |
|
|
| def load_config(): |
| """Load persisted volumes + status toggles from disk.""" |
| global symbol_volumes, symbol_status |
| if os.path.exists(CONFIG_FILE): |
| try: |
| with open(CONFIG_FILE, "r") as f_conf: |
| config = json.load(f_conf) |
| if "volumes" in config: |
| symbol_volumes.update(config["volumes"]) |
| if "status" in config: |
| for k, v in config["status"].items(): |
| if k in symbol_status: |
| symbol_status[k].update(v) |
| else: |
| symbol_status[k] = v |
| logger.info("Configuration loaded from bot_config.json") |
| except Exception as e: |
| logger.error(f"Error loading bot_config.json: {e}") |
|
|
| def save_config(): |
| """Persist current volumes + status toggles to disk.""" |
| try: |
| config = {"volumes": symbol_volumes, "status": symbol_status} |
| with open(CONFIG_FILE, "w") as f_conf: |
| json.dump(config, f_conf, indent=4) |
| logger.info("Configuration saved to bot_config.json") |
| except Exception as e: |
| logger.error(f"Error saving bot_config.json: {e}") |
|
|
| load_config() |
| logger.info(f"Per-symbol volumes initialized: {symbol_volumes}") |
| logger.info(f"Per-symbol statuses initialized: {symbol_status}") |
|
|
| |
| SYMBOL_ID_CACHE = {} |
| SYMBOL_LIMITS_CACHE = {} |
|
|
| |
| |
| |
| _persistent_socket = None |
| _persistent_socket_lock = threading.Lock() |
| _heartbeat_thread = None |
| _heartbeat_stop = threading.Event() |
|
|
| |
| WEBHOOK_LOGS = [] |
| TOKEN_FILE = "/tmp/tokens.json" if os.environ.get("SPACE_ID") else "tokens.json" |
|
|
| |
| def save_tokens(tokens): |
| try: |
| with open(TOKEN_FILE, "w") as f: |
| json.dump(tokens, f) |
| except Exception as e: |
| logger.error(f"Failed to save tokens: {e}") |
|
|
| |
| def load_tokens(): |
| if os.path.exists(TOKEN_FILE): |
| try: |
| with open(TOKEN_FILE, "r") as f: |
| return json.load(f) |
| except Exception as e: |
| logger.error(f"Failed to load tokens: {e}") |
| |
| |
| env_refresh = os.environ.get("CTRADER_REFRESH_TOKEN") |
| env_access = os.environ.get("CTRADER_ACCESS_TOKEN") |
| env_acc_id = os.environ.get("CTRADER_ACCOUNT_ID") |
| if env_refresh or env_access: |
| logger.info("Using tokens from environment secrets") |
| return { |
| "refresh_token": env_refresh.strip() if env_refresh else "", |
| "access_token": env_access.strip() if env_access else "", |
| "account_id": env_acc_id.strip() if env_acc_id else "", |
| "updated_at": time.time() |
| } |
| return None |
|
|
| |
| def fetch_token_from_api(payload): |
| url = "https://openapi.ctrader.com/apps/token" |
| headers = { |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", |
| "Accept": "application/json, text/plain, */*" |
| } |
| |
| |
| try: |
| res = requests.get(url, params=payload, headers=headers, timeout=10) |
| if res.status_code == 200: |
| data = res.json() |
| logger.info("Token request successful (GET)") |
| return data |
| except Exception as e: |
| logger.warning(f"GET token request exception: {e}") |
|
|
| |
| try: |
| res = requests.post(url, data=payload, headers=headers, timeout=10) |
| if res.status_code == 200: |
| data = res.json() |
| logger.info("Token request successful (POST)") |
| return data |
| elif res.status_code == 429: |
| logger.warning("cTrader API rate limited (HTTP 429). Cloudflare/WAF blocked request.") |
| return None |
| else: |
| logger.error(f"Token exchange failed (HTTP {res.status_code}): {res.text[:200]}") |
| return None |
| except Exception as e: |
| logger.error(f"POST token request exception: {e}") |
| return None |
|
|
| |
| def send_proto_message(ssl_socket, message, payload_type, client_msg_id=None): |
| inner_payload = message.SerializeToString() |
| |
| proto_msg = ProtoMessage() |
| proto_msg.payloadType = payload_type |
| proto_msg.payload = inner_payload |
| if client_msg_id is not None: |
| proto_msg.clientMsgId = str(client_msg_id) |
| |
| serialized = proto_msg.SerializeToString() |
| length = len(serialized) |
| header = struct.pack(">I", length) |
| |
| ssl_socket.sendall(header + serialized) |
|
|
| |
| def receive_proto_message(ssl_socket): |
| try: |
| header = ssl_socket.recv(4) |
| if not header or len(header) < 4: |
| return None |
| length = struct.unpack(">I", header)[0] |
| |
| data = b"" |
| while len(data) < length: |
| chunk = ssl_socket.recv(length - len(data)) |
| if not chunk: |
| break |
| data += chunk |
| |
| if len(data) < length: |
| return None |
| |
| proto_msg = ProtoMessage() |
| proto_msg.ParseFromString(data) |
| return proto_msg |
| except Exception as e: |
| logger.error(f"Socket receive error: {e}") |
| return None |
|
|
| |
| def get_authenticated_socket(account_id, access_token): |
| tokens = load_tokens() |
| env = tokens.get("environment", CTRADER_ENV) if tokens else CTRADER_ENV |
| |
| |
| envs_to_try = [env, "demo" if env == "live" else "live"] |
| last_error = "" |
| |
| for try_env in envs_to_try: |
| host = "live.ctraderapi.com" if try_env == "live" else "demo.ctraderapi.com" |
| logger.info(f"Attempting connection to {host} for account {account_id}...") |
| |
| try: |
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| sock.settimeout(10.0) |
| ssl_sock = ssl.wrap_socket(sock) |
| ssl_sock.connect((host, 5035)) |
| |
| |
| app_req = ProtoOAApplicationAuthReq() |
| app_req.clientId = CLIENT_ID |
| app_req.clientSecret = CLIENT_SECRET |
| send_proto_message(ssl_sock, app_req, 2100) |
| |
| proto_msg = receive_proto_message(ssl_sock) |
| if not proto_msg or proto_msg.payloadType != 2101: |
| error_detail = "" |
| if proto_msg: |
| try: |
| err = ProtoOAErrorRes() |
| err.ParseFromString(proto_msg.payload) |
| error_detail = f" (payloadType={proto_msg.payloadType}, error={err.errorCode}: {err.description})" |
| except Exception: |
| error_detail = f" (payloadType={proto_msg.payloadType})" |
| last_error = f"App auth rejected on {host}{error_detail}" |
| logger.warning(last_error) |
| ssl_sock.close() |
| continue |
| |
| logger.info(f"App auth successful on {host}") |
| |
| |
| acc_req = ProtoOAAccountAuthReq() |
| acc_req.ctidTraderAccountId = int(account_id) |
| acc_req.accessToken = access_token |
| send_proto_message(ssl_sock, acc_req, 2102) |
| |
| proto_msg = receive_proto_message(ssl_sock) |
| if not proto_msg or proto_msg.payloadType != 2103: |
| error_detail = "" |
| if proto_msg: |
| try: |
| err = ProtoOAErrorRes() |
| err.ParseFromString(proto_msg.payload) |
| error_detail = f" (payloadType={proto_msg.payloadType}, error={err.errorCode}: {err.description})" |
| except Exception: |
| error_detail = f" (payloadType={proto_msg.payloadType})" |
| last_error = f"Account auth rejected on {host} for account {account_id}{error_detail}" |
| logger.warning(last_error) |
| ssl_sock.close() |
| continue |
| |
| logger.info(f"Account auth successful on {host} for account {account_id}") |
| |
| |
| if try_env != env: |
| logger.info(f"Updating saved environment from '{env}' to '{try_env}' (fallback succeeded)") |
| if tokens: |
| tokens["environment"] = try_env |
| save_tokens(tokens) |
| |
| return ssl_sock |
| |
| except Exception as e: |
| last_error = f"Connection failed to {host}: {e}" |
| logger.warning(last_error) |
| continue |
| |
| raise Exception(f"Could not authenticate on any server. Last error: {last_error}") |
|
|
| |
|
|
| def _drain_socket(ssl_sock, timeout=0.05): |
| """Drain any pending messages (heartbeat responses, spot events, etc.) from the socket. |
| This prevents stale data from interfering with the next request/response pair.""" |
| ssl_sock.settimeout(timeout) |
| drained = 0 |
| try: |
| while True: |
| header = ssl_sock.recv(4) |
| if not header or len(header) < 4: |
| break |
| length = struct.unpack(">I", header)[0] |
| data = b"" |
| while len(data) < length: |
| chunk = ssl_sock.recv(length - len(data)) |
| if not chunk: |
| break |
| data += chunk |
| drained += 1 |
| except (socket.timeout, ssl.SSLError, OSError): |
| pass |
| finally: |
| ssl_sock.settimeout(10.0) |
| if drained > 0: |
| logger.debug(f"Drained {drained} stale message(s) from persistent socket.") |
|
|
| def _heartbeat_worker(): |
| """Background thread that sends heartbeat pings every 10 seconds to keep |
| the persistent socket alive. cTrader disconnects idle sockets after ~30s.""" |
| global _persistent_socket |
| logger.info("Heartbeat worker started.") |
| while not _heartbeat_stop.is_set(): |
| _heartbeat_stop.wait(10) |
| if _heartbeat_stop.is_set(): |
| break |
| with _persistent_socket_lock: |
| sock = _persistent_socket |
| if sock is None: |
| continue |
| try: |
| hb = ProtoMessage() |
| hb.payloadType = 51 |
| serialized = hb.SerializeToString() |
| header = struct.pack(">I", len(serialized)) |
| sock.sendall(header + serialized) |
| logger.debug("Heartbeat sent on persistent socket.") |
| except Exception as e: |
| logger.warning(f"Heartbeat failed — marking socket as dead: {e}") |
| _invalidate_persistent_socket() |
| logger.info("Heartbeat worker stopped.") |
|
|
| def _invalidate_persistent_socket(): |
| """Close and discard the persistent socket so the next call creates a fresh one.""" |
| global _persistent_socket |
| with _persistent_socket_lock: |
| if _persistent_socket is not None: |
| try: |
| _persistent_socket.close() |
| except Exception: |
| pass |
| _persistent_socket = None |
| logger.info("Persistent socket invalidated.") |
|
|
| def get_or_create_socket(account_id, access_token): |
| """Return the persistent authenticated socket, creating one if needed. |
| This eliminates ~500-1000ms of TCP+SSL+Auth overhead on every webhook.""" |
| global _persistent_socket, _heartbeat_thread |
| |
| with _persistent_socket_lock: |
| |
| if _persistent_socket is not None: |
| try: |
| |
| _persistent_socket.settimeout(0.01) |
| |
| _drain_socket(_persistent_socket, timeout=0.02) |
| _persistent_socket.settimeout(10.0) |
| logger.info("Reusing persistent socket (0ms connection overhead).") |
| return _persistent_socket |
| except Exception as e: |
| logger.warning(f"Persistent socket dead ({e}), creating new one...") |
| try: |
| _persistent_socket.close() |
| except Exception: |
| pass |
| _persistent_socket = None |
| |
| |
| logger.info("Creating new persistent socket...") |
| new_sock = get_authenticated_socket(account_id, access_token) |
| _persistent_socket = new_sock |
| |
| |
| if _heartbeat_thread is None or not _heartbeat_thread.is_alive(): |
| _heartbeat_stop.clear() |
| _heartbeat_thread = threading.Thread(target=_heartbeat_worker, daemon=True) |
| _heartbeat_thread.start() |
| |
| return _persistent_socket |
|
|
|
|
| |
| def resolve_symbol_id(ssl_sock, account_id, symbol_name): |
| symbol_name_upper = symbol_name.upper() |
| if symbol_name_upper in SYMBOL_ID_CACHE: |
| logger.info(f"Resolved symbol ID for {symbol_name_upper} from cache: {SYMBOL_ID_CACHE[symbol_name_upper]}") |
| return SYMBOL_ID_CACHE[symbol_name_upper] |
| |
| logger.info(f"Symbol ID for {symbol_name_upper} not in cache. Fetching list from broker...") |
| req = ProtoOASymbolsListReq() |
| req.ctidTraderAccountId = int(account_id) |
| send_proto_message(ssl_sock, req, 2114) |
| |
| while True: |
| proto_msg = receive_proto_message(ssl_sock) |
| if not proto_msg: |
| break |
| if proto_msg.payloadType == 2115: |
| res = ProtoOASymbolsListRes() |
| res.ParseFromString(proto_msg.payload) |
| for sym in res.symbol: |
| SYMBOL_ID_CACHE[sym.symbolName.upper()] = sym.symbolId |
| logger.info(f"Cached {len(res.symbol)} symbols from list response.") |
| break |
| return SYMBOL_ID_CACHE.get(symbol_name_upper) |
|
|
| |
| def get_symbol_volume_limits(ssl_sock, account_id, symbol_id): |
| """Query the broker for the symbol's volume limits and lotSize. |
| Returns dict with maxVolume, minVolume, stepVolume (protocol units) and lotSize (units per 1 lot).""" |
| if symbol_id in SYMBOL_LIMITS_CACHE: |
| logger.info(f"Resolved volume limits for symbol ID {symbol_id} from cache: {SYMBOL_LIMITS_CACHE[symbol_id]}") |
| return SYMBOL_LIMITS_CACHE[symbol_id] |
| |
| result = {} |
| try: |
| logger.info(f"Volume limits for symbol ID {symbol_id} not in cache. Fetching details from broker...") |
| req = ProtoOASymbolByIdReq() |
| req.ctidTraderAccountId = int(account_id) |
| req.symbolId.append(int(symbol_id)) |
| send_proto_message(ssl_sock, req, 2116) |
| |
| while True: |
| proto_msg = receive_proto_message(ssl_sock) |
| if not proto_msg: |
| break |
| if proto_msg.payloadType == 2117: |
| res = ProtoOASymbolByIdRes() |
| res.ParseFromString(proto_msg.payload) |
| for sym in res.symbol: |
| if hasattr(sym, 'maxVolume') and sym.maxVolume: |
| result['maxVolume'] = sym.maxVolume |
| if hasattr(sym, 'minVolume') and sym.minVolume: |
| result['minVolume'] = sym.minVolume |
| if hasattr(sym, 'stepVolume') and sym.stepVolume: |
| result['stepVolume'] = sym.stepVolume |
| |
| if hasattr(sym, 'lotSize') and sym.lotSize: |
| result['lotSize'] = sym.lotSize |
| SYMBOL_LIMITS_CACHE[symbol_id] = result |
| logger.info(f"Symbol {symbol_id} info cached: lotSize={result.get('lotSize', 'N/A')}, max={result.get('maxVolume', 'N/A')}, min={result.get('minVolume', 'N/A')}, step={result.get('stepVolume', 'N/A')}") |
| break |
| except Exception as e: |
| logger.warning(f"Could not fetch volume limits for symbol {symbol_id}: {e}") |
| return result |
|
|
| |
| def lots_to_protocol_volume(lots, lot_size): |
| """Convert a lot value to cTrader protocol units. |
| Protocol volume = lots × lotSize |
| e.g. EURUSD: 0.01 lots × 100000 lotSize = 1,000 |
| e.g. BTCUSD: 0.01 lots × 100 lotSize = 1 |
| e.g. NAS100: 0.05 lots × 100 lotSize = 5""" |
| result = int(lots * lot_size) |
| logger.info(f"lots_to_protocol_volume: {lots} lots × {lot_size} lotSize = {result} protocol units") |
| return result |
|
|
| |
| def get_volume_for_symbol(ticker): |
| """Look up the per-symbol volume (in lots). Falls back to _DEFAULT_CRYPTO or _DEFAULT_FOREX, then DEFAULT_VOLUME_LOTS.""" |
| ticker_upper = ticker.upper() |
| if ticker_upper in symbol_volumes: |
| return symbol_volumes[ticker_upper] |
| |
| CRYPTO_BASES = ["BTC", "ETH", "XLM", "BCH", "SOL", "DOGE", "BNB", "LTC", "ADA", "AVAX", "LNK", "MATIC", "EOS", "DOT", "XRP", "SHIB", "UNI", "LINK"] |
| if any(ticker_upper.startswith(base) for base in CRYPTO_BASES) and ticker_upper.endswith("USD"): |
| return symbol_volumes.get('_DEFAULT_CRYPTO', DEFAULT_VOLUME_LOTS) |
| |
| if len(ticker_upper) == 6 and ticker_upper.isalpha(): |
| return symbol_volumes.get('_DEFAULT_FOREX', DEFAULT_VOLUME_LOTS) |
| return DEFAULT_VOLUME_LOTS |
|
|
| |
| def place_market_order_smart(ssl_sock, account_id, symbol_id, trade_side, volume, min_volume=100, step_volume=100): |
| """Try placing an order. If 'Not enough funds', halve volume and retry until min_volume.""" |
| attempt = 0 |
| try_volume = volume |
| |
| while try_volume >= min_volume: |
| attempt += 1 |
| |
| if step_volume > 0: |
| try_volume = (try_volume // step_volume) * step_volume |
| if try_volume < min_volume: |
| break |
| |
| logger.info(f"Order attempt {attempt}: volume={try_volume} protocol units") |
| res = place_market_order(ssl_sock, account_id, symbol_id, trade_side, int(try_volume)) |
| |
| if res.get('status') == 'success': |
| if attempt > 1: |
| logger.info(f"Order succeeded on attempt {attempt} with reduced volume {try_volume}") |
| return res |
| |
| error_msg = res.get('message', '') |
| if 'not enough funds' in error_msg.lower() or 'Not enough' in error_msg: |
| |
| try_volume = try_volume // 2 |
| logger.info(f"Not enough funds — reducing volume to {try_volume} and retrying...") |
| else: |
| |
| return res |
| |
| return {"status": "error", "message": f"Not enough funds even at minimum volume ({min_volume} protocol units)"} |
|
|
|
|
| |
| def get_trader_details(ssl_sock, account_id): |
| req = ProtoOATraderReq() |
| req.ctidTraderAccountId = int(account_id) |
| send_proto_message(ssl_sock, req, 2118) |
| |
| while True: |
| proto_msg = receive_proto_message(ssl_sock) |
| if not proto_msg: |
| break |
| if proto_msg.payloadType == 2119: |
| res = ProtoOATraderRes() |
| res.ParseFromString(proto_msg.payload) |
| return res.trader |
| return None |
|
|
| |
| def get_open_positions(ssl_sock, account_id): |
| req = ProtoOAReconcileReq() |
| req.ctidTraderAccountId = int(account_id) |
| send_proto_message(ssl_sock, req, 2124) |
| |
| while True: |
| proto_msg = receive_proto_message(ssl_sock) |
| if not proto_msg: |
| break |
| if proto_msg.payloadType == 2125: |
| res = ProtoOAReconcileRes() |
| res.ParseFromString(proto_msg.payload) |
| return res.position |
| return [] |
|
|
| |
| MAX_LOSING_POSITIONS = 2 |
|
|
| |
| def get_spot_price(ssl_sock, account_id, symbol_id): |
| current_bid = None |
| current_ask = None |
| try: |
| spot_req = ProtoOASubscribeSpotsReq() |
| spot_req.ctidTraderAccountId = int(account_id) |
| spot_req.symbolId.append(int(symbol_id)) |
| send_proto_message(ssl_sock, spot_req, 2127) |
| |
| |
| for _ in range(10): |
| proto_msg = receive_proto_message(ssl_sock) |
| if not proto_msg: |
| break |
| if proto_msg.payloadType == 2131: |
| spot = ProtoOASpotEvent() |
| spot.ParseFromString(proto_msg.payload) |
| if hasattr(spot, 'bid') and spot.bid: |
| current_bid = spot.bid / 100000.0 |
| if hasattr(spot, 'ask') and spot.ask: |
| current_ask = spot.ask / 100000.0 |
| if current_bid and current_ask: |
| break |
| elif proto_msg.payloadType == 2128: |
| continue |
| |
| |
| try: |
| unsub_req = ProtoOAUnsubscribeSpotsReq() |
| unsub_req.ctidTraderAccountId = int(account_id) |
| unsub_req.symbolId.append(int(symbol_id)) |
| send_proto_message(ssl_sock, unsub_req, 2129) |
| receive_proto_message(ssl_sock) |
| except Exception: |
| pass |
| except Exception as e: |
| logger.warning(f"Could not fetch spot price: {e}") |
| |
| return current_bid, current_ask |
|
|
| |
| def count_losing_positions(positions, trade_side, current_bid, current_ask): |
| """Count positions that are currently in loss. |
| trade_side: 1 = BUY positions, 2 = SELL positions""" |
| count = 0 |
| for p in positions: |
| if p.tradeData.tradeSide != trade_side: |
| continue |
| entry_price = p.price |
| if trade_side == 1: |
| if current_bid and current_bid <= entry_price: |
| count += 1 |
| elif not current_bid: |
| count += 1 |
| else: |
| if current_ask and current_ask >= entry_price: |
| count += 1 |
| elif not current_ask: |
| count += 1 |
| return count |
|
|
| |
| def place_market_order(ssl_sock, account_id, symbol_id, trade_side, volume, position_id=None): |
| if position_id is not None: |
| req = ProtoOAClosePositionReq() |
| req.ctidTraderAccountId = int(account_id) |
| req.positionId = int(position_id) |
| req.volume = int(volume) |
| send_proto_message(ssl_sock, req, 2111) |
| else: |
| req = ProtoOANewOrderReq() |
| req.ctidTraderAccountId = int(account_id) |
| req.symbolId = int(symbol_id) |
| req.orderType = ProtoOAOrderType.MARKET |
| req.tradeSide = trade_side |
| req.volume = int(volume) |
| send_proto_message(ssl_sock, req, 2106) |
| |
| while True: |
| proto_msg = receive_proto_message(ssl_sock) |
| if not proto_msg: |
| break |
| if proto_msg.payloadType == 2126: |
| res = ProtoOAExecutionEvent() |
| res.ParseFromString(proto_msg.payload) |
| return {"status": "success", "orderId": res.order.orderId} |
| elif proto_msg.payloadType == 2132: |
| res = ProtoOAOrderErrorEvent() |
| res.ParseFromString(proto_msg.payload) |
| return {"status": "error", "message": res.description} |
| elif proto_msg.payloadType == 2142: |
| res = ProtoOAErrorRes() |
| res.ParseFromString(proto_msg.payload) |
| return {"status": "error", "message": f"cTra Error: {res.errorCode} - {res.description}"} |
| elif proto_msg.payloadType == 50: |
| res = ProtoErrorRes() |
| res.ParseFromString(proto_msg.payload) |
| return {"status": "error", "message": f"cTra Generic Error: {res.errorCode} - {res.description}"} |
| else: |
| logger.info(f"place_market_order: Received other payloadType: {proto_msg.payloadType}") |
| return {"status": "error", "message": "No execution response received"} |
|
|
| |
| _last_refresh_attempt = 0 |
| REFRESH_COOLDOWN = 3600 |
|
|
| |
| def get_valid_tokens(): |
| global _last_refresh_attempt |
| tokens = load_tokens() |
| if not tokens: |
| return None |
| |
| access_token = tokens.get("access_token") |
| refresh_token = tokens.get("refresh_token") |
| updated_at = tokens.get("updated_at", 0) |
| |
| |
| |
| if not access_token or (time.time() - updated_at > 86400): |
| if time.time() - _last_refresh_attempt < REFRESH_COOLDOWN: |
| logger.info(f"Skipping token refresh (cooldown: {REFRESH_COOLDOWN - (time.time() - _last_refresh_attempt):.0f}s remaining)") |
| if access_token: |
| return tokens |
| return None |
| _last_refresh_attempt = time.time() |
| if refresh_token: |
| payload = { |
| "grant_type": "refresh_token", |
| "refresh_token": refresh_token, |
| "client_id": CLIENT_ID, |
| "client_secret": CLIENT_SECRET |
| } |
| res_data = fetch_token_from_api(payload) |
| if res_data and "accessToken" in res_data: |
| tokens["access_token"] = res_data["accessToken"] |
| tokens["refresh_token"] = res_data["refreshToken"] |
| tokens["updated_at"] = time.time() |
| |
| |
| |
| try: |
| for try_env in ["demo", "live"]: |
| try: |
| disc_host = "live.ctraderapi.com" if try_env == "live" else "demo.ctraderapi.com" |
| disc_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| disc_sock.settimeout(10.0) |
| disc_ssl = ssl.wrap_socket(disc_sock) |
| disc_ssl.connect((disc_host, 5035)) |
| |
| app_req = ProtoOAApplicationAuthReq() |
| app_req.clientId = CLIENT_ID |
| app_req.clientSecret = CLIENT_SECRET |
| send_proto_message(disc_ssl, app_req, 2100) |
| app_resp = receive_proto_message(disc_ssl) |
| if not app_resp or app_resp.payloadType != 2101: |
| disc_ssl.close() |
| continue |
| |
| acc_list_req = ProtoOAGetAccountListByAccessTokenReq() |
| acc_list_req.accessToken = res_data["accessToken"] |
| send_proto_message(disc_ssl, acc_list_req, 2149) |
| |
| proto_msg = receive_proto_message(disc_ssl) |
| if proto_msg and proto_msg.payloadType == 2150: |
| acc_list_res = ProtoOAGetAccountListByAccessTokenRes() |
| acc_list_res.ParseFromString(proto_msg.payload) |
| if acc_list_res.ctidTraderAccount: |
| |
| selected = None |
| for a in acc_list_res.ctidTraderAccount: |
| is_live = getattr(a, "isLive", False) |
| if (CTRADER_ENV == "live" and is_live) or (CTRADER_ENV == "demo" and not is_live): |
| selected = a |
| break |
| if not selected: |
| selected = acc_list_res.ctidTraderAccount[0] |
| |
| tokens["account_id"] = str(selected.ctidTraderAccountId) |
| tokens["environment"] = "live" if getattr(selected, "isLive", False) else "demo" |
| logger.info(f"Auto-discovered account {tokens['account_id']} ({tokens['environment']}) after token refresh") |
| |
| disc_ssl.close() |
| if tokens.get("account_id"): |
| break |
| except Exception as inner_e: |
| logger.warning(f"Account discovery failed on {try_env}: {inner_e}") |
| continue |
| except Exception as e: |
| logger.warning(f"Account auto-discovery failed: {e}") |
| |
| save_tokens(tokens) |
| return tokens |
| else: |
| logger.error(f"Failed to refresh token: {res_data}") |
| |
| |
| if access_token: |
| return tokens |
| return None |
|
|
|
|
| |
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def dashboard(request: Request): |
| |
| |
| tokens = get_valid_tokens() |
| is_authenticated = tokens is not None and bool(tokens.get("access_token")) |
| account_id = tokens.get("account_id", "") if is_authenticated else "" |
| access_token = tokens.get("access_token", "") if is_authenticated else "" |
| |
| balance = "N/A" |
| positions = [] |
| error_msg = "" |
| |
| if is_authenticated and account_id: |
| try: |
| |
| ssl_sock = get_authenticated_socket(account_id, access_token) |
| trader = get_trader_details(ssl_sock, account_id) |
| if trader: |
| balance = f"${trader.balance / 100:.2f}" |
| positions_raw = get_open_positions(ssl_sock, account_id) |
| for p in positions_raw: |
| positions.append({ |
| "id": p.positionId, |
| "symbol_id": p.tradeData.symbolId, |
| "side": "BUY" if p.tradeData.tradeSide == 1 else "SELL", |
| "volume": f"{p.tradeData.volume / 10000000:.2f} lots", |
| "entry_price": p.price, |
| "pnl": f"${p.commission / 100:.2f}" |
| }) |
| ssl_sock.close() |
| except Exception as e: |
| error_msg = f"Failed to connect to cTra: {e}" |
| logger.error(error_msg) |
|
|
| |
| positions_html = "" |
| if positions: |
| rows = "".join( |
| f"<tr><td>{p['id']}</td><td>{p['symbol_id']}</td>" |
| f"<td style='color: {'#34d399' if p['side']=='BUY' else '#f87171'}'>{p['side']}</td>" |
| f"<td>{p['volume']}</td><td>{p['entry_price']}</td></tr>" |
| for p in positions |
| ) |
| positions_html = f""" |
| <table> |
| <thead> |
| <tr> |
| <th>Position ID</th> |
| <th>Symbol ID</th> |
| <th>Side</th> |
| <th>Volume</th> |
| <th>Entry Price</th> |
| </tr> |
| </thead> |
| <tbody> |
| {rows} |
| </tbody> |
| </table> |
| """ |
| else: |
| positions_html = '<p style="color: #64748b; margin: 10px 0 0 0;">No active positions found.</p>' |
|
|
| |
| display_order = ["NAS100", "NAS100m", "USTEC", "US500", "US500m", "US30", "US30m", "BTCUSD", "ETHUSD", "EURUSD", "_DEFAULT_FOREX", "_DEFAULT_CRYPTO"] |
| volume_rows_html = "" |
| for sym in display_order: |
| if sym not in symbol_volumes: |
| continue |
| vol = symbol_volumes[sym] |
| status = symbol_status.get(sym, {"buy": True, "sell": True, "stop-trade": True, "anchor": True}) |
| buy_on = status.get("buy", True) |
| sell_on = status.get("sell", True) |
| stop_on = status.get("stop-trade", True) |
| anchor_on = status.get("anchor", True) |
| display_name = "Default Forex" if sym == "_DEFAULT_FOREX" else "Default Crypto" if sym == "_DEFAULT_CRYPTO" else sym |
| row_bg = "#0f172a" if display_order.index(sym) % 2 == 0 else "#111827" |
| |
| def _btn(label, key, is_on, on_color): |
| color = on_color if is_on else "#475569" |
| text = f"{label} \u2713" if is_on else f"{label} \u2717" |
| val = "false" if is_on else "true" |
| return f'''<form action="/toggle-status" method="post" style="display: inline-flex; margin: 2px;"> |
| <input type="hidden" name="symbol" value="{sym}"> |
| <input type="hidden" name="direction" value="{key}"> |
| <input type="hidden" name="new_value" value="{val}"> |
| <button type="submit" style="background: {color}; color: white; border: none; border-radius: 6px; padding: 5px 10px; font-size: 11px; font-weight: 700; cursor: pointer;">{text}</button> |
| </form>''' |
| buy_btn = _btn("BUY", "buy", buy_on, "#00E676") |
| sell_btn = _btn("SELL", "sell", sell_on, "#FF5252") |
| stop_btn = _btn("STOP", "stop-trade", stop_on, "#FF9800") |
| anchor_btn = _btn("ANCHOR", "anchor", anchor_on, "#FF9800") |
| volume_rows_html += f''' |
| <tr style="border-bottom: 1px solid #1e293b; background: {row_bg};"> |
| <td style="padding: 8px 12px; color: #e2e8f0; font-size: 14px; font-weight: 600;">{display_name}</td> |
| <td style="padding: 8px 12px;"> |
| <form action="/set-volume" method="post" style="display: inline-flex; gap: 8px; align-items: center;"> |
| <input type="hidden" name="symbol" value="{sym}"> |
| <input type="text" name="volume" value="{vol:.2f}" |
| style="background: #1e293b; border: 1px solid #334155; border-radius: 6px; padding: 6px 10px; color: #e2e8f0; font-size: 14px; width: 80px;"> |
| <button type="submit" class="btn" style="padding: 6px 14px; font-size: 12px;">Update</button> |
| </form> |
| </td> |
| <td style="padding: 8px 12px;"> |
| {buy_btn} |
| {sell_btn} |
| {stop_btn} |
| {anchor_btn} |
| </td> |
| </tr>''' |
|
|
| |
| auth_card_html = "" |
| if not is_authenticated: |
| auth_card_html = f""" |
| <div class="card" style="text-align: center; padding: 40px;"> |
| <h2>Authentication Required</h2> |
| <p style="color: #94a3b8; margin-bottom: 24px;">Please connect your cTra account to authorize the bot to execute trades on your behalf.</p> |
| <a href="/login" target="_blank" class="btn">Authorize Bot with cTra</a> |
| </div> |
| """ |
| else: |
| auth_card_html = f""" |
| <div class="grid"> |
| <div class="card"> |
| <h3>Account ID</h3> |
| <div class="value">{account_id}</div> |
| </div> |
| <div class="card"> |
| <h3>Current Balance</h3> |
| <div class="value">{balance}</div> |
| </div> |
| </div> |
| |
| <div class="card" style="margin-bottom: 30px;"> |
| <h3>Active Open Positions</h3> |
| {positions_html} |
| </div> |
| |
| <div class="card" style="margin-bottom: 30px;"> |
| <h3>Trade Volume Settings (Per Symbol)</h3> |
| <p style="color: #64748b; font-size: 12px; margin-bottom: 16px;">Set the lot size for each instrument. Conversion to broker units happens automatically using the broker's lotSize.</p> |
| <table style="width: 100%; border-collapse: collapse;"> |
| <thead> |
| <tr style="border-bottom: 1px solid #334155;"> |
| <th style="text-align: left; padding: 8px 12px; color: #94a3b8; font-size: 13px;">Symbol</th> |
| <th style="text-align: left; padding: 8px 12px; color: #94a3b8; font-size: 13px;">Lot Size</th> |
| <th style="text-align: left; padding: 8px 12px; color: #94a3b8; font-size: 13px;">Allow Trade</th> |
| </tr> |
| </thead> |
| <tbody> |
| {volume_rows_html} |
| </tbody> |
| </table> |
| </div> |
| """ |
|
|
| |
| logs_html = "" |
| if WEBHOOK_LOGS: |
| logs_html = "".join( |
| f'<div class="log-item"><span class="log-time">[{l["time"]}]</span> ' |
| f'<span class="log-action" style="color: {l["color"]}">{l["action"]}</span> {l["msg"]}</div>' |
| for l in reversed(WEBHOOK_LOGS) |
| ) |
| else: |
| logs_html = '<div style="color: #64748b;">Waiting for incoming data stream...</div>' |
|
|
| status_badge_html = ( |
| '<span class="status-badge status-connected">✓ Connected</span>' |
| if is_authenticated |
| else '<span class="status-badge status-disconnected">✗ Disconnected</span>' |
| ) |
| |
| error_banner_html = f'<div class="alert-danger">{error_msg}</div>' if error_msg else '' |
|
|
| |
| html_content = f""" |
| <!DOCTYPE html> |
| <html> |
| <head> |
| <title>cTra Webhook Dashboard</title> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet"> |
| <style> |
| body {{ |
| font-family: 'Inter', sans-serif; |
| background-color: #0b0f19; |
| color: #e2e8f0; |
| margin: 0; |
| padding: 40px; |
| }} |
| .container {{ |
| max-width: 1000px; |
| margin: 0 auto; |
| }} |
| .header {{ |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| border-bottom: 1px solid #1e293b; |
| padding-bottom: 20px; |
| margin-bottom: 30px; |
| }} |
| h1 {{ margin: 0; font-size: 28px; font-weight: 700; color: #60a5fa; }} |
| .status-badge {{ |
| padding: 6px 12px; |
| border-radius: 9999px; |
| font-size: 14px; |
| font-weight: 600; |
| display: inline-block; |
| }} |
| .status-connected {{ background-color: #064e3b; color: #34d399; }} |
| .status-disconnected {{ background-color: #7f1d1d; color: #f87171; }} |
| .grid {{ |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 20px; |
| margin-bottom: 30px; |
| }} |
| .card {{ |
| background: rgba(30, 41, 59, 0.4); |
| backdrop-filter: blur(10px); |
| border: 1px solid #334155; |
| border-radius: 12px; |
| padding: 24px; |
| }} |
| .card h3 {{ margin-top: 0; margin-bottom: 15px; color: #94a3b8; font-size: 16px; text-transform: uppercase; }} |
| .value {{ font-size: 32px; font-weight: 700; color: #ffffff; }} |
| .btn {{ |
| background-color: #2563eb; |
| color: #ffffff; |
| border: none; |
| padding: 12px 24px; |
| border-radius: 8px; |
| font-size: 16px; |
| font-weight: 600; |
| cursor: pointer; |
| text-decoration: none; |
| display: inline-block; |
| transition: background 0.2s; |
| }} |
| .btn:hover {{ background-color: #1d4ed8; }} |
| table {{ |
| width: 100%; |
| border-collapse: collapse; |
| margin-top: 15px; |
| }} |
| th, td {{ |
| padding: 12px; |
| text-align: left; |
| border-bottom: 1px solid #334155; |
| }} |
| th {{ color: #94a3b8; font-weight: 600; }} |
| .log-feed {{ |
| max-height: 250px; |
| overflow-y: auto; |
| background-color: #020617; |
| border-radius: 8px; |
| padding: 15px; |
| font-family: monospace; |
| font-size: 14px; |
| }} |
| .log-item {{ margin-bottom: 8px; line-height: 1.4; }} |
| .log-time {{ color: #64748b; }} |
| .log-action {{ font-weight: bold; }} |
| .alert-danger {{ |
| background-color: #7f1d1d; |
| color: #f87171; |
| padding: 15px; |
| border-radius: 8px; |
| margin-bottom: 20px; |
| border: 1px solid #ef4444; |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <div class="header"> |
| <div> |
| <h1>cTra Webhook Controller</h1> |
| <p style="margin: 5px 0 0 0; color: #64748b;">Hugging Face Cloud Server ({CTRADER_ENV} environment)</p> |
| </div> |
| <div> |
| {status_badge_html} |
| </div> |
| </div> |
| |
| {error_banner_html} |
| |
| {auth_card_html} |
| |
| <div class="card"> |
| <h3>Webhook Signal History</h3> |
| <div class="log-feed"> |
| {logs_html} |
| </div> |
| </div> |
| </div> |
| </body> |
| </html> |
| """ |
| return HTMLResponse(content=html_content) |
|
|
| @app.get("/login") |
| async def login(request: Request): |
| import urllib.parse |
| |
| space_host = os.environ.get("SPACE_HOST") |
| if space_host: |
| redirect_uri = f"https://{space_host}/callback" |
| else: |
| host = request.headers.get("host", "") |
| protocol = "https" if "hf.space" in host or request.headers.get("x-forwarded-proto") == "https" else "http" |
| redirect_uri = f"{protocol}://{host}/callback" |
| |
| logger.info(f"Login URL redirect_uri generated: {redirect_uri}") |
| |
| encoded_redirect_uri = urllib.parse.quote(redirect_uri) |
| auth_url = ( |
| f"https://id.ctrader.com/my/settings/openapi/grantingaccess" |
| f"?client_id={CLIENT_ID}" |
| f"&scope=trading" |
| f"&redirect_uri={encoded_redirect_uri}" |
| ) |
| return RedirectResponse(auth_url) |
|
|
| @app.get("/callback") |
| async def callback(request: Request, code: str = None): |
| if not code: |
| raise HTTPException(status_code=400, detail="Authorization code missing") |
| |
| space_host = os.environ.get("SPACE_HOST") |
| if space_host: |
| redirect_uri = f"https://{space_host}/callback" |
| else: |
| host = request.headers.get("host", "") |
| protocol = "https" if "hf.space" in host or request.headers.get("x-forwarded-proto") == "https" else "http" |
| redirect_uri = f"{protocol}://{host}/callback" |
| |
| logger.info(f"Callback redirect_uri generated: {redirect_uri}") |
| |
| payload = { |
| "grant_type": "authorization_code", |
| "code": code, |
| "client_id": CLIENT_ID, |
| "client_secret": CLIENT_SECRET, |
| "redirect_uri": redirect_uri |
| } |
| |
| res_data = fetch_token_from_api(payload) |
| if res_data and res_data.get("accessToken"): |
| tokens = { |
| "access_token": res_data["accessToken"], |
| "refresh_token": res_data["refreshToken"], |
| "updated_at": time.time() |
| } |
| |
| |
| account_id = "" |
| detected_env = CTRADER_ENV |
| try: |
| |
| for try_env in ["demo", "live"]: |
| try: |
| host = "live.ctraderapi.com" if try_env == "live" else "demo.ctraderapi.com" |
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| sock.settimeout(10.0) |
| ssl_sock = ssl.wrap_socket(sock) |
| ssl_sock.connect((host, 5035)) |
| |
| |
| app_req = ProtoOAApplicationAuthReq() |
| app_req.clientId = CLIENT_ID |
| app_req.clientSecret = CLIENT_SECRET |
| send_proto_message(ssl_sock, app_req, 2100) |
| app_resp = receive_proto_message(ssl_sock) |
| if not app_resp or app_resp.payloadType != 2101: |
| ssl_sock.close() |
| continue |
| |
| |
| acc_list_req = ProtoOAGetAccountListByAccessTokenReq() |
| acc_list_req.accessToken = res_data["accessToken"] |
| send_proto_message(ssl_sock, acc_list_req, 2149) |
| |
| proto_msg = receive_proto_message(ssl_sock) |
| if proto_msg and proto_msg.payloadType == 2150: |
| acc_list_res = ProtoOAGetAccountListByAccessTokenRes() |
| acc_list_res.ParseFromString(proto_msg.payload) |
| if acc_list_res.ctidTraderAccount: |
| logger.info(f"Accounts returned from token ({len(acc_list_res.ctidTraderAccount)} accounts):") |
| for a in acc_list_res.ctidTraderAccount: |
| is_live = getattr(a, "isLive", False) |
| logger.info(f" - Account ID: {a.ctidTraderAccountId}, isLive: {is_live}, broker: {getattr(a, 'brokerName', 'N/A')}") |
| |
| |
| matched_acc = None |
| for a in acc_list_res.ctidTraderAccount: |
| is_live = getattr(a, "isLive", False) |
| env_type = "live" if is_live else "demo" |
| if env_type == CTRADER_ENV: |
| matched_acc = a |
| break |
| |
| if matched_acc: |
| selected_acc = matched_acc |
| logger.info(f"Successfully matched account matching CTRADER_ENV={CTRADER_ENV}: {selected_acc.ctidTraderAccountId}") |
| else: |
| selected_acc = acc_list_res.ctidTraderAccount[0] |
| logger.info(f"No account matching CTRADER_ENV={CTRADER_ENV} found. Using first available: {selected_acc.ctidTraderAccountId}") |
| |
| account_id = str(selected_acc.ctidTraderAccountId) |
| detected_env = "live" if getattr(selected_acc, "isLive", False) else "demo" |
| |
| ssl_sock.close() |
| if account_id: |
| break |
| except Exception as inner_e: |
| logger.warning(f"Failed to check {try_env} server: {inner_e}") |
| continue |
| except Exception as e: |
| logger.error(f"Failed to fetch account list: {e}") |
| |
| if account_id: |
| tokens["account_id"] = account_id |
| tokens["environment"] = detected_env |
| |
| save_tokens(tokens) |
| |
| |
| logger.info("=" * 60) |
| logger.info(f"AUTHORIZED SUCCESSFULLY!") |
| logger.info(f"Your CTRADER_ACCESS_TOKEN is: {res_data['accessToken']}") |
| logger.info(f"Your CTRADER_REFRESH_TOKEN is: {res_data['refreshToken']}") |
| logger.info(f"Target Account ID: {tokens.get('account_id', 'NOT FOUND - set CTRADER_ACCOUNT_ID env var')}") |
| logger.info(f"") |
| logger.info(f">>> Save these 3 values as HF Secrets to persist across restarts:") |
| logger.info(f" CTRADER_ACCESS_TOKEN = {res_data['accessToken']}") |
| logger.info(f" CTRADER_REFRESH_TOKEN = {res_data['refreshToken']}") |
| logger.info(f" CTRADER_ACCOUNT_ID = {tokens.get('account_id', '')}") |
| logger.info("=" * 60) |
| |
| |
| space_host = os.environ.get("SPACE_HOST") |
| if space_host: |
| redirect_url = f"https://{space_host}/" |
| else: |
| host = request.headers.get("host", "") |
| protocol = "https" if "hf.space" in host or request.headers.get("x-forwarded-proto") == "https" else "http" |
| redirect_url = f"{protocol}://{host}/" |
| return RedirectResponse(redirect_url) |
| else: |
| error_detail = "" |
| if res_data: |
| error_detail = f" Error: {res_data.get('errorCode', '')} - {res_data.get('description', '')}" |
| return HTMLResponse(f"Authorization failed. Please check your Client ID/Secret and logs.{error_detail}", status_code=400) |
|
|
| @app.post("/set-volume") |
| async def set_volume(request: Request, symbol: str = Form(...), volume: str = Form(...)): |
| global symbol_volumes |
| try: |
| vol_float = float(volume.strip()) |
| symbol_upper = symbol.strip().upper() |
| symbol_volumes[symbol_upper] = vol_float |
| save_config() |
| logger.info(f"Trade volume for symbol '{symbol_upper}' updated to {vol_float} lots.") |
| except Exception as e: |
| logger.error(f"Invalid volume update for symbol '{symbol}': {volume}. Error: {e}") |
| |
| space_host = os.environ.get("SPACE_HOST") |
| if space_host: |
| redirect_url = f"https://{space_host}/" |
| else: |
| host = request.headers.get("host", "") |
| protocol = "https" if "hf.space" in host or request.headers.get("x-forwarded-proto") == "https" else "http" |
| redirect_url = f"{protocol}://{host}/" |
| return RedirectResponse(redirect_url, status_code=303) |
|
|
| @app.post("/toggle-status") |
| async def toggle_status(request: Request, symbol: str = Form(...), direction: str = Form(...), new_value: str = Form(...)): |
| """Instantly toggle Allow BUY or Allow SELL for a symbol from the dashboard.""" |
| global symbol_status |
| symbol_upper = symbol.strip().upper() |
| dir_lower = direction.strip().lower() |
| new_bool = new_value.strip().lower() == "true" |
| |
| if symbol_upper not in symbol_status: |
| symbol_status[symbol_upper] = {"buy": True, "sell": True, "stop-trade": True, "anchor": True} |
| symbol_status[symbol_upper][dir_lower] = new_bool |
| save_config() |
| |
| state_str = "ENABLED" if new_bool else "DISABLED" |
| logger.info(f"{dir_lower.upper()} {state_str} for {symbol_upper}") |
| |
| space_host = os.environ.get("SPACE_HOST") |
| if space_host: |
| redirect_url = f"https://{space_host}/" |
| else: |
| host = request.headers.get("host", "") |
| protocol = "https" if "hf.space" in host or request.headers.get("x-forwarded-proto") == "https" else "http" |
| redirect_url = f"{protocol}://{host}/" |
| return RedirectResponse(redirect_url, status_code=303) |
|
|
| @app.post("/webhook") |
| async def webhook(request: Request): |
| |
| try: |
| data = await request.json() |
| except Exception: |
| raise HTTPException(status_code=400, detail="Invalid JSON payload") |
| |
| action = data.get("action", "").upper() |
| ticker = data.get("ticker", "").upper() |
| price = data.get("price") |
| |
| if action not in ["BUY", "SELL", "STOP-TRADE", "CLOSE", "ANCHOR"]: |
| raise HTTPException(status_code=400, detail="Invalid action. Must be BUY, SELL, STOP-TRADE, CLOSE, or ANCHOR") |
| |
| |
| |
| if action in ["BUY", "SELL", "STOP-TRADE", "ANCHOR"]: |
| status = symbol_status.get(ticker, symbol_status.get("_DEFAULT_CRYPTO" if any(ticker.startswith(b) for b in ["BTC","ETH","XLM","BCH","SOL","DOGE","BNB","LTC","ADA","AVAX","LNK","MATIC","EOS","DOT"]) else "_DEFAULT_FOREX", {"buy": True, "sell": True, "stop-trade": True, "anchor": True})) |
| is_allowed = status.get(action.lower(), True) |
| if not is_allowed: |
| ignore_msg = f"{action} for {ticker} IGNORED — disabled on dashboard" |
| logger.info(ignore_msg) |
| WEBHOOK_LOGS.append({"time": datetime.now().strftime('%H:%M:%S'), "action": f"{action} BLOCKED", "color": "#94a3b8", "msg": ignore_msg}) |
| if len(WEBHOOK_LOGS) > 50: |
| WEBHOOK_LOGS.pop(0) |
| return {"status": "ignored", "message": ignore_msg} |
| |
| |
| tokens = get_valid_tokens() |
| if not tokens or not tokens.get("account_id") or not tokens.get("access_token"): |
| raise HTTPException(status_code=503, detail="Bot is not authenticated with cTra") |
| |
| account_id = tokens["account_id"] |
| access_token = tokens["access_token"] |
| |
| log_time = datetime.now().strftime("%H:%M:%S") |
| color = "#60a5fa" if action == "BUY" else "#f87171" if action == "SELL" else "#fb923c" if action == "CLOSE" else "#FF9800" if action == "ANCHOR" else "#94a3b8" |
| |
| |
| def _execute_trade(ssl_sock): |
| """Core trade logic using the given socket. Returns execution_msg string.""" |
| |
| symbol_id = resolve_symbol_id(ssl_sock, account_id, ticker) |
| if not symbol_id: |
| raise Exception(f"Symbol {ticker} not found on this cTra account") |
| |
| |
| positions = get_open_positions(ssl_sock, account_id) |
| symbol_positions = [p for p in positions if p.tradeData.symbolId == symbol_id] |
| |
| |
| vol_limits = get_symbol_volume_limits(ssl_sock, account_id, symbol_id) |
| max_vol = vol_limits.get('maxVolume') |
| min_vol = vol_limits.get('minVolume', 100) |
| step_vol = vol_limits.get('stepVolume', 100) |
| lot_size = vol_limits.get('lotSize', 100000) |
| |
| |
| lots = get_volume_for_symbol(ticker) |
| |
| |
| effective_volume = lots_to_protocol_volume(lots, lot_size) |
| logger.info(f"Target lots for {ticker}: {lots}. Resolved lotSize: {lot_size}. Effective protocol volume: {effective_volume}") |
| |
| if max_vol and effective_volume > max_vol: |
| logger.info(f"Clamping volume from {effective_volume} to {max_vol} (broker max for {ticker})") |
| effective_volume = max_vol |
| |
| execution_msg = "" |
| |
| |
| current_bid = None |
| current_ask = None |
| if price is not None: |
| try: |
| current_bid = float(price) |
| current_ask = float(price) |
| logger.info(f"Using spot price directly from TradingView payload: {current_bid}") |
| except Exception as e: |
| logger.warning(f"Error parsing price payload '{price}': {e}") |
| |
| |
| if current_bid is None or current_ask is None: |
| logger.info("Spot price not found in payload. Subscribing to live broker spots...") |
| current_bid, current_ask = get_spot_price(ssl_sock, account_id, symbol_id) |
| |
| if action == "BUY": |
| |
| losing_count = count_losing_positions(symbol_positions, 1, current_bid, current_ask) |
| |
| if losing_count >= MAX_LOSING_POSITIONS: |
| execution_msg = f"BUY BLOCKED: Already {losing_count} losing BUY position(s) on {ticker}. Max {MAX_LOSING_POSITIONS} allowed." |
| logger.info(execution_msg) |
| else: |
| |
| logger.info(f"Opening new Long position... ({losing_count} losing BUY(s) open, limit {MAX_LOSING_POSITIONS})") |
| res = place_market_order_smart(ssl_sock, account_id, symbol_id, ProtoOATradeSide.BUY, effective_volume, min_vol, step_vol) |
| execution_msg = f"Opened BUY position for {ticker}. Order Result: {res.get('status')} {res.get('message', '')}" |
| |
| elif action == "SELL": |
| |
| losing_count = count_losing_positions(symbol_positions, 2, current_bid, current_ask) |
| |
| if losing_count >= MAX_LOSING_POSITIONS: |
| execution_msg = f"SELL BLOCKED: Already {losing_count} losing SELL position(s) on {ticker}. Max {MAX_LOSING_POSITIONS} allowed." |
| logger.info(execution_msg) |
| else: |
| |
| logger.info(f"Opening new Short position... ({losing_count} losing SELL(s) open, limit {MAX_LOSING_POSITIONS})") |
| res = place_market_order_smart(ssl_sock, account_id, symbol_id, ProtoOATradeSide.SELL, effective_volume, min_vol, step_vol) |
| execution_msg = f"Opened SELL position for {ticker}. Order Result: {res.get('status')} {res.get('message', '')}" |
| |
| elif action == "CLOSE": |
| |
| if len(symbol_positions) > 0: |
| for p in symbol_positions: |
| side = ProtoOATradeSide.SELL if p.tradeData.tradeSide == 1 else ProtoOATradeSide.BUY |
| place_market_order(ssl_sock, account_id, symbol_id, side, p.tradeData.volume, position_id=p.positionId) |
| execution_msg = f"CLOSE: Force-closed {len(symbol_positions)} positions on {ticker}." |
| else: |
| execution_msg = f"No open positions on {ticker} to close." |
| |
| elif action == "STOP-TRADE": |
| |
| if len(symbol_positions) > 0: |
| closed_count = 0 |
| skipped_count = 0 |
| |
| for p in symbol_positions: |
| entry_price = p.price |
| is_buy = p.tradeData.tradeSide == 1 |
| |
| |
| in_profit = False |
| if current_bid and current_ask: |
| if is_buy: |
| |
| in_profit = current_bid > entry_price |
| else: |
| |
| in_profit = entry_price > current_ask |
| else: |
| |
| in_profit = getattr(p, 'swap', 0) > 0 |
| logger.warning(f"No spot price available, using swap as fallback for position {p.positionId}") |
| |
| if in_profit: |
| side = ProtoOATradeSide.SELL if is_buy else ProtoOATradeSide.BUY |
| logger.info(f"Closing PROFITABLE {'BUY' if is_buy else 'SELL'} position {p.positionId} (entry: {entry_price})") |
| place_market_order(ssl_sock, account_id, symbol_id, side, p.tradeData.volume, position_id=p.positionId) |
| closed_count += 1 |
| else: |
| logger.info(f"KEEPING losing {'BUY' if is_buy else 'SELL'} position {p.positionId} (entry: {entry_price})") |
| skipped_count += 1 |
| |
| execution_msg = f"STOP-TRADE: Closed {closed_count} profitable position(s), kept {skipped_count} losing position(s) on {ticker}." |
| else: |
| execution_msg = f"No open positions on {ticker} to close." |
| |
| elif action == "ANCHOR": |
| |
| if len(symbol_positions) > 0: |
| closed_count = 0 |
| for p in symbol_positions: |
| side = ProtoOATradeSide.SELL if p.tradeData.tradeSide == 1 else ProtoOATradeSide.BUY |
| logger.info(f"ANCHOR: Closing {'BUY' if p.tradeData.tradeSide == 1 else 'SELL'} position {p.positionId}") |
| place_market_order(ssl_sock, account_id, symbol_id, side, p.tradeData.volume, position_id=p.positionId) |
| closed_count += 1 |
| execution_msg = f"ANCHOR: Closed {closed_count} active position(s) on {ticker}." |
| else: |
| execution_msg = f"ANCHOR: No active positions on {ticker} to close." |
| |
| return execution_msg |
| |
| try: |
| |
| ssl_sock = get_or_create_socket(account_id, access_token) |
| |
| try: |
| execution_msg = _execute_trade(ssl_sock) |
| except Exception as first_err: |
| |
| logger.warning(f"Trade failed (likely dead socket): {first_err}. Reconnecting and retrying...") |
| _invalidate_persistent_socket() |
| ssl_sock = get_or_create_socket(account_id, access_token) |
| execution_msg = _execute_trade(ssl_sock) |
| |
| |
| logger.info(execution_msg) |
| WEBHOOK_LOGS.append({ |
| "time": log_time, |
| "action": action, |
| "color": color, |
| "msg": f"processed for {ticker} at {price}. {execution_msg}" |
| }) |
| if len(WEBHOOK_LOGS) > 50: |
| WEBHOOK_LOGS.pop(0) |
| |
| return {"status": "success", "message": execution_msg} |
| |
| except Exception as e: |
| error_msg = f"Trading execution failed: {e}" |
| logger.error(error_msg) |
| WEBHOOK_LOGS.append({ |
| "time": log_time, |
| "action": f"{action} ERROR", |
| "color": "#ef4444", |
| "msg": f"for {ticker}. Reason: {e}" |
| }) |
| return {"status": "error", "message": error_msg} |
|
|