import shutil
import os
import re
import time
import threading
import urllib.parse
from datetime import datetime, timezone
import requests
import socketio
from cryptography.hazmat.primitives.asymmetric import ed25519
# ============================================================
# CONFIGURATION
# ============================================================
INTERVAL = "5" # 5-minute candles
# ================================================================
# MASTER SAFETY SWITCH
#
# False -> paper trading only. Signals, SL, exits, PnL are all
# simulated in memory. NOTHING is sent to CoinSwitch.
# True -> REAL orders are placed with REAL money on your
# CoinSwitch PRO futures account (fixed 10x leverage,
# MARKET orders). Do not flip this to True until you
# have watched the bot run in paper mode and are
# comfortable with its behaviour.
# ================================================================
ORDERS_ENABLED = False
# Leverage applied to every symbol before its first trade (fixed,
# per your instruction). Must be <= the symbol's max_leverage -
# since the watchlist only includes symbols with max_leverage
# above MIN_LEVERAGE_FILTER (10), this fixed value is always valid.
TRADE_LEVERAGE = 10
# Maximum allowed loss on trading capital when the fixed SL is hit.
# Raw price risk is multiplied by TRADE_LEVERAGE. >30% is rejected.
MAX_SL_RISK_PCT = 30.0
PRINT_INTERVAL = 2.0
# --------------------------------------------------------------
# WATCHLIST (Top Gainers / Top Losers)
# --------------------------------------------------------------
TOP_N_GAINERS = 20
TOP_N_LOSERS = 20
# Only trade instruments whose max allowed leverage is ABOVE this.
# (Read from Get Instrument Info's max_leverage field per symbol.)
MIN_LEVERAGE_FILTER = 10.0
# How often (seconds) the gainers/losers watchlist is re-scanned.
WATCHLIST_REFRESH_SECONDS = 4 * 60 * 60 # 4 hours
# --------------------------------------------------------------
# CAPITAL ALLOCATION (paper-trading sizing - ORDERS_ENABLED is
# False, so no real orders are placed, but position sizing is
# simulated so PnL in USDT terms makes sense).
#
# Quantity per trade is NOT a fixed USDT slice anymore - each
# trade uses the exchange's MINIMUM allowed order quantity for
# that symbol (Get Instrument Info -> min_base_quantity). The
# capital numbers below only act as a safety cap: total notional
# value across all open positions is never allowed to exceed
# MAX_ALLOCATION_PCT of TOTAL_CAPITAL, and no more than
# MAX_CONCURRENT_POSITIONS trades run at once.
# --------------------------------------------------------------
TOTAL_CAPITAL = 10000.0 # <-- set this to your real capital
MAX_ALLOCATION_PCT = 50.0 # never deploy more than this % of capital at once
MAX_CONCURRENT_POSITIONS = 1 # only ONE trade open at a time (for now)
MAX_DEPLOYABLE_CAPITAL = TOTAL_CAPITAL * (MAX_ALLOCATION_PCT / 100.0)
# --------------------------------------------------------------
# WEBSOCKET (candles only - ticker/live price comes from REST)
# --------------------------------------------------------------
WS_URL = "wss://ws.coinswitch.co/"
NAMESPACE = "/exchange_2"
SOCKETIO_PATH = "/pro/realtime-rates-socket/futures/exchange_2"
# --------------------------------------------------------------
# REST API
# --------------------------------------------------------------
BASE_URL = "https://coinswitch.co"
# Fill these in with your own CoinSwitch PRO API key pair
# (Profile -> API Trading on CoinSwitch PRO). Both are hex strings.
API_KEY = os.environ.get("COINSWITCH_API_KEY", "")
SECRET_KEY = os.environ.get("COINSWITCH_SECRET_KEY", "")
# How often (seconds) to poll the REST all-pairs ticker for live
# prices of every symbol in the watchlist.
TICKER_POLL_SECONDS = 2.0
# ============================================================
# WEBSOCKET CLIENT
# ============================================================
sio = socketio.Client(
reconnection=True,
reconnection_attempts=0,
reconnection_delay=2,
reconnection_delay_max=10,
)
# ============================================================
# SHARED STATE
# ============================================================
state_lock = threading.Lock()
ws_connected = False
# The symbols we currently want NEW entries on (refreshed every
# WATCHLIST_REFRESH_SECONDS).
watchlist = set()
# Every symbol we've ever subscribed a KLine stream for. This is a
# superset of `watchlist` - a symbol stays here (and keeps getting
# its candles/price updated) even after it drops out of the
# watchlist, for as long as it still has an open position, so an
# open trade is never abandoned mid-flight.
subscribed_symbols = set()
# Per-symbol state. Keys are symbol strings (e.g. "BTCUSDT").
# See make_symbol_state() for the shape of each value.
symbol_states = {}
# Simple trade log (kept in memory since orders are disabled).
# Each entry also carries a "symbol" key.
trade_log = []
last_watchlist_refresh = None
# Per-symbol trading rules from Get Instrument Info: max_leverage
# and min_base_quantity. Refreshed alongside the watchlist.
instrument_info = {}
# Symbols for which we've successfully set TRADE_LEVERAGE (only
# relevant when ORDERS_ENABLED is True - leverage must be set
# before the first order on a symbol).
leverage_set_symbols = set()
# ============================================================
# HELPERS
# ============================================================
def to_float(value):
try:
if value is None:
return None
return float(value)
except Exception:
return None
def to_int(value):
try:
if value is None:
return None
return int(value)
except Exception:
return None
def utc_string(timestamp_ms):
if timestamp_ms is None:
return "N/A"
try:
dt = datetime.fromtimestamp(
timestamp_ms / 1000.0,
tz=timezone.utc,
)
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
except Exception:
return "N/A"
def now_string():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
def make_symbol_state():
"""
Fresh per-symbol state dict. Every symbol we track gets one
of these (candle history, signal search state, position).
"""
return {
"current_candle": None,
"previous_closed_candle": None,
"live_price": None,
"previous_live_price": None,
"signal": "WAIT",
"entry_price": None,
"stop_loss": None,
"signal_candle_start": None,
"signal_triggered": False,
"in_position": False,
"position_side": None, # "BUY" or "SELL"
"position_entry": None,
"position_sl": None,
"position_size": None, # USDT notional (qty * entry price)
"position_qty": None, # base-asset quantity (min_qty for the symbol)
"position_open_time": None,
}
def ensure_symbol_state(symbol):
"""
Must be called under state_lock. Creates a fresh state entry
for `symbol` if one doesn't already exist.
"""
if symbol not in symbol_states:
symbol_states[symbol] = make_symbol_state()
# ============================================================
# REST API (Ed25519-signed requests)
# ============================================================
def sign_request(method, path, params=None):
"""
Build the headers and final URL path for an authenticated
CoinSwitch request (Ed25519 signing), per CoinSwitch PRO's
API Trading docs.
"""
method = method.upper()
if params:
sep = "&" if "?" in path else "?"
path = path + sep + urllib.parse.urlencode(params)
decoded_path = urllib.parse.unquote_plus(path)
epoch = str(int(time.time() * 1000))
message = method + decoded_path + epoch
secret = ed25519.Ed25519PrivateKey.from_private_bytes(
bytes.fromhex(SECRET_KEY)
)
signature = secret.sign(message.encode("utf-8")).hex()
headers = {
"Content-Type": "application/json",
"X-AUTH-APIKEY": API_KEY,
"X-AUTH-SIGNATURE": signature,
"X-AUTH-EPOCH": epoch,
}
return headers, decoded_path
def fetch_all_pairs_ticker():
"""
GET /trade/api/v2/futures/all-pairs/ticker
Returns a dict: { "BTCUSDT": {"price": .., "bid": .., "ask": ..,
"mark": .., "timestamp": .., "pct24h": ..}, ... } on success,
or None on any failure. Never raises.
"""
try:
headers, path = sign_request(
"GET",
"/trade/api/v2/futures/all-pairs/ticker",
params={"exchange": "EXCHANGE_2"},
)
response = requests.get(
BASE_URL + path,
headers=headers,
timeout=10,
)
response.raise_for_status()
payload = response.json()
raw = payload.get("data")
if not isinstance(raw, dict):
return None
result = {}
for symbol, ticker in raw.items():
if not isinstance(ticker, dict):
continue
price = to_float(ticker.get("last_price"))
if price is None:
continue
result[symbol] = {
"price": price,
"bid": to_float(ticker.get("best_bid_price")),
"ask": to_float(ticker.get("best_ask_price")),
"mark": to_float(ticker.get("mark_price")),
"timestamp": to_int(ticker.get("timestamp")),
"pct24h": to_float(ticker.get("price_24h_pcnt")),
}
return result
except Exception as e:
print(f"[REST All-Pairs Ticker Error] {e}")
return None
def fetch_futures_balance():
"""
GET /trade/api/v2/futures/wallet_balance
Returns total available USDT futures balance, or None on failure.
Read-only request. Never places or modifies any order.
"""
try:
headers, path = sign_request(
"GET",
"/trade/api/v2/futures/wallet_balance",
)
response = requests.get(
BASE_URL + path,
headers=headers,
timeout=10,
)
response.raise_for_status()
payload = response.json()
data = payload.get("data", {})
balances = data.get("base_asset_balances", [])
if not isinstance(balances, list):
return None
for asset in balances:
if not isinstance(asset, dict):
continue
if asset.get("base_asset") != "USDT":
continue
balance_data = asset.get("balances", {})
if not isinstance(balance_data, dict):
return None
return to_float(
balance_data.get("total_available_balance")
)
return None
except Exception as e:
print(f"[REST Futures Balance Error] {e}")
return None
def fetch_instrument_info():
"""
GET /trade/api/v2/futures/instrument_info
Returns a dict: { "BTCUSDT": {"max_leverage": .., "min_qty": ..}, ... }
on success, or None on any failure. Never raises.
"""
try:
headers, path = sign_request(
"GET",
"/trade/api/v2/futures/instrument_info",
params={"exchange": "EXCHANGE_2"},
)
response = requests.get(
BASE_URL + path,
headers=headers,
timeout=10,
)
response.raise_for_status()
payload = response.json()
raw = payload.get("data")
if not isinstance(raw, dict):
return None
result = {}
for symbol, info in raw.items():
if not isinstance(info, dict):
continue
max_leverage = to_float(info.get("max_leverage"))
min_qty = to_float(info.get("min_base_quantity"))
if max_leverage is None or min_qty is None:
continue
result[symbol] = {
"max_leverage": max_leverage,
"min_qty": min_qty,
}
return result
except Exception as e:
print(f"[REST Instrument Info Error] {e}")
return None
# ============================================================
# REAL ORDER PLACEMENT (only actually called when ORDERS_ENABLED)
# ============================================================
def set_symbol_leverage(symbol):
"""
POST /trade/api/v2/futures/leverage
Sets TRADE_LEVERAGE for `symbol`. Must be called BEFORE the
first order on that symbol (leverage can't be changed while
there's an open position or open order on it). Returns True
on success, False on failure. Never raises.
"""
try:
headers, path = sign_request(
"POST", "/trade/api/v2/futures/leverage"
)
body = {
"symbol": symbol,
"exchange": "EXCHANGE_2",
"leverage": TRADE_LEVERAGE,
}
response = requests.post(
BASE_URL + path,
headers=headers,
json=body,
timeout=10,
)
response.raise_for_status()
print(f"[Leverage] {symbol} set to {TRADE_LEVERAGE}x")
return True
except Exception as e:
print(f"[Leverage Error] ({symbol}) {e}")
return False
def place_market_order(symbol, side, quantity, reduce_only=False):
"""
POST /trade/api/v2/futures/order (MARKET order).
side — "BUY" or "SELL"
quantity — base-asset quantity
reduce_only — True when this order is meant to CLOSE an
existing position (never opens a new/opposite one)
Returns the parsed response dict on success, or None on any
failure. Never raises.
"""
try:
headers, path = sign_request(
"POST", "/trade/api/v2/futures/order"
)
body = {
"exchange": "EXCHANGE_2",
"symbol": symbol,
"side": side,
"order_type": "MARKET",
"quantity": quantity,
}
if reduce_only:
body["reduce_only"] = True
response = requests.post(
BASE_URL + path,
headers=headers,
json=body,
timeout=10,
)
response.raise_for_status()
data = response.json()
print(f"[Order] {symbol} {side} qty={quantity} reduce_only={reduce_only} -> {data}")
return data
except Exception as e:
print(f"[Order Error] ({symbol} {side} qty={quantity}) {e}")
return None
# ============================================================
# HISTORICAL KLINES (REST) - used to seed a newly-added symbol's
# "previous closed candle" immediately, instead of waiting for
# it to arrive organically over the live WebSocket stream.
# ============================================================
def fetch_recent_klines(symbol, limit=3):
"""
GET /trade/api/v2/futures/klines
Returns a list of candle dicts (sorted oldest -> newest), each
with: start_time, close_time, open, high, low, close, volume.
Returns None on any failure. Never raises.
"""
try:
headers, path = sign_request(
"GET",
"/trade/api/v2/futures/klines",
params={
"symbol": symbol.lower(),
"exchange": "EXCHANGE_2",
"interval": INTERVAL,
"limit": limit,
},
)
response = requests.get(
BASE_URL + path,
headers=headers,
timeout=10,
)
response.raise_for_status()
payload = response.json()
raw = payload.get("data")
if not isinstance(raw, list):
return None
candles = []
for row in raw:
start_time = to_int(row.get("start_time"))
close_time = to_int(row.get("close_time"))
o = to_float(row.get("o"))
h = to_float(row.get("h"))
l = to_float(row.get("l"))
c = to_float(row.get("c"))
v = to_float(row.get("volume"))
if start_time is None or o is None or h is None or l is None or c is None:
continue
candles.append(
{
"start_time": start_time,
"close_time": close_time,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": v,
}
)
candles.sort(key=lambda x: x["start_time"])
return candles
except Exception as e:
print(f"[REST Klines Error] ({symbol}) {e}")
return None
def seed_symbol_history(symbol):
"""
Fetches the last few candles for `symbol` via REST and, if the
symbol doesn't already have candle data (from the live
WebSocket stream), seeds its previous_closed_candle (and, if
available, current_candle too) so the strategy can evaluate
signals immediately instead of waiting 5-10 minutes for two
live candles to pass.
"""
candles = fetch_recent_klines(symbol, limit=3)
if not candles:
return
now_ms = int(time.time() * 1000)
# A candle counts as fully closed only if its close_time has
# actually passed - never trust the last row blindly, since
# some APIs include the still-forming candle as the last row.
closed = [
c for c in candles
if c["close_time"] is not None and c["close_time"] <= now_ms
]
if not closed:
return
previous = closed[-1]
# Whatever candle (if any) starts after `previous` is the
# current, still-forming candle.
current_candidate = None
for c in candles:
if c["start_time"] > previous["start_time"]:
current_candidate = c
break
def strip(c):
return {
"start_time": c["start_time"],
"open": c["open"],
"high": c["high"],
"low": c["low"],
"close": c["close"],
"volume": c["volume"],
}
with state_lock:
if symbol not in symbol_states:
return
state = symbol_states[symbol]
# Only seed if the live WebSocket hasn't already populated
# this - never overwrite live data with a stale REST call.
if state["previous_closed_candle"] is None:
state["previous_closed_candle"] = strip(previous)
if state["current_candle"] is None and current_candidate is not None:
state["current_candle"] = strip(current_candidate)
print(
f"[Seed] {symbol}: previous candle loaded from REST "
f"(O:{previous['open']:.6f} H:{previous['high']:.6f} "
f"L:{previous['low']:.6f} C:{previous['close']:.6f})"
)
# ============================================================
# WATCHLIST (Top Gainers / Top Losers)
# ============================================================
def compute_watchlist(all_ticker_data, instrument_data):
"""
Given the dicts returned by fetch_all_pairs_ticker() and
fetch_instrument_info(), first filters to symbols whose
max_leverage is ABOVE MIN_LEVERAGE_FILTER, then picks the
top TOP_N_GAINERS by 24h % change and the top TOP_N_LOSERS by
(most negative) 24h % change from that eligible set. Returns a list of symbols
(up to TOP_N_GAINERS + TOP_N_LOSERS, no duplicates).
"""
ranked = [
(symbol, data["pct24h"])
for symbol, data in all_ticker_data.items()
if data.get("pct24h") is not None
and instrument_data.get(symbol, {}).get("max_leverage", 0)
> MIN_LEVERAGE_FILTER
]
if not ranked:
return [], [], []
ga⚠️Content was pasted as plain text and auto-formatted as a code block. Use the Code Block button in the editor for proper formatting.