Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 121 additions & 2 deletions tools/tide-chart/gtrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"""

import time
from datetime import datetime, date
from zoneinfo import ZoneInfo

import requests
from typing import Optional

Expand All @@ -24,18 +27,20 @@
USDC_COLLATERAL_INDEX = 3

# Per-group protocol limits enforced by gTrade smart contracts
# Source: https://docs.gains.trade/gtrade-leveraged-trading/asset-classes/
GROUP_LIMITS = {
"crypto": {"min_leverage": 2, "max_leverage": 150, "min_position_usd": 1500, "max_collateral_usd": 100_000},
"stocks": {"min_leverage": 2, "max_leverage": 150, "min_position_usd": 1500, "max_collateral_usd": 100_000},
"stocks": {"min_leverage": 1.1, "max_leverage": 50, "min_position_usd": 1500, "max_collateral_usd": 100_000},
"commodities": {"min_leverage": 2, "max_leverage": 150, "min_position_usd": 1500, "max_collateral_usd": 100_000},
"commodities_t1": {"min_leverage": 2, "max_leverage": 250, "min_position_usd": 1500, "max_collateral_usd": 100_000},
}

# Tide Chart ticker -> gTrade pair mapping (all Synth API assets)
GTRADE_PAIRS = {
"BTC": {"name": "BTC/USD", "group_index": 0, "group": "crypto"},
"ETH": {"name": "ETH/USD", "group_index": 0, "group": "crypto"},
"SOL": {"name": "SOL/USD", "group_index": 0, "group": "crypto"},
"XAU": {"name": "XAU/USD", "group_index": 4, "group": "commodities"},
"XAU": {"name": "XAU/USD", "group_index": 4, "group": "commodities_t1"},
"SPY": {"name": "SPY/USD", "group_index": 3, "group": "stocks"},
"NVDA": {"name": "NVDA/USD", "group_index": 3, "group": "stocks"},
"TSLA": {"name": "TSLA/USD", "group_index": 3, "group": "stocks"},
Expand All @@ -47,6 +52,34 @@

MIN_COLLATERAL_USD = 5

# Liquidation threshold: gTrade liquidates when collateral loss reaches this %
LIQ_THRESHOLD_PCT = 90

# Approximate trading fees by group (% of position size)
GROUP_FEES = {
"crypto": {"open_fee_pct": 0.06, "close_fee_pct": 0.06},
"stocks": {"open_fee_pct": 0.01, "close_fee_pct": 0.01},
"commodities": {"open_fee_pct": 0.01, "close_fee_pct": 0.01},
"commodities_t1": {"open_fee_pct": 0.01, "close_fee_pct": 0.01},
}

# US stock market hours (Eastern Time)
_ET = ZoneInfo("America/New_York")
_MARKET_OPEN_H, _MARKET_OPEN_M = 9, 30
_MARKET_CLOSE_H, _MARKET_CLOSE_M = 16, 0

# US market holidays for 2025-2026 (federal holidays when NYSE is closed)
_MARKET_HOLIDAYS: set[date] = {
# 2025
date(2025, 1, 1), date(2025, 1, 20), date(2025, 2, 17),
date(2025, 4, 18), date(2025, 5, 26), date(2025, 6, 19),
date(2025, 7, 4), date(2025, 9, 1), date(2025, 11, 27), date(2025, 12, 25),
# 2026
date(2026, 1, 1), date(2026, 1, 19), date(2026, 2, 16),
date(2026, 4, 3), date(2026, 5, 25), date(2026, 6, 19),
date(2026, 7, 3), date(2026, 9, 7), date(2026, 11, 26), date(2026, 12, 25),
}

_trading_vars_cache: Optional[dict] = None
_trading_vars_ts: float = 0

Expand Down Expand Up @@ -219,13 +252,37 @@ def fetch_open_trades(address: str) -> list[dict]:
return []


GTRADE_GLOBAL_BACKEND_URL = "https://backend-global.gains.trade"


def fetch_trade_history(address: str) -> list[dict]:
"""Fetch historical trades (open & closed) for a wallet address.

Uses the new backend-global paginated endpoint (cursor-based) which
reliably includes liquidations, TP/SL hits, and partial closes.
Falls back to the legacy per-network endpoint on failure.

Returns a list of trade history dicts, or empty list on failure.
"""
if not address:
return []
# Primary: backend-global endpoint (paginated, includes all close types)
try:
resp = requests.get(
f"{GTRADE_GLOBAL_BACKEND_URL}/api/personal-trading-history/{address.lower()}",
params={"chainId": ARBITRUM_CHAIN_ID, "limit": 50},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
# New endpoint wraps trades in a "data" key with cursor pagination
if isinstance(data, dict) and "data" in data:
return data["data"] if isinstance(data["data"], list) else []
if isinstance(data, list):
return data
except (requests.RequestException, ValueError, KeyError):
pass
# Fallback: legacy per-network endpoint (deprecated, may miss liquidations)
try:
resp = requests.get(
f"{GTRADE_BACKEND_URL}/personal-trading-history-table/{address.lower()}",
Expand All @@ -238,6 +295,68 @@ def fetch_trade_history(address: str) -> list[dict]:
return []


def is_market_open() -> tuple[bool, str]:
"""Check if the US stock market is currently open.

Returns (is_open, reason). Stocks on gTrade can only be traded
during NYSE regular hours: Mon-Fri 9:30 AM - 4:00 PM ET,
excluding federal holidays.
"""
now = datetime.now(_ET)
if now.date() in _MARKET_HOLIDAYS:
return False, "Market closed (holiday)"
wd = now.weekday()
if wd >= 5:
day_name = "Saturday" if wd == 5 else "Sunday"
return False, f"Market closed ({day_name})"
market_open = now.replace(hour=_MARKET_OPEN_H, minute=_MARKET_OPEN_M, second=0, microsecond=0)
market_close = now.replace(hour=_MARKET_CLOSE_H, minute=_MARKET_CLOSE_M, second=0, microsecond=0)
if now < market_open:
return False, f"Market opens at 9:30 AM ET (currently {now.strftime('%I:%M %p')} ET)"
if now >= market_close:
return False, "Market closed (after 4:00 PM ET)"
return True, "Market open"


def estimate_trade_fees(asset: str, collateral_usd: float, leverage: float) -> dict:
"""Estimate opening and closing fees for a trade.

Returns a dict with open_fee, close_fee, total_fee (all in USD),
and the fee_pct used. Fees are a percentage of position size.
"""
pair = GTRADE_PAIRS.get(asset)
if not pair:
return {"open_fee": 0, "close_fee": 0, "total_fee": 0, "fee_pct": 0}
group = pair["group"]
fees = GROUP_FEES.get(group, GROUP_FEES["crypto"])
position_usd = collateral_usd * leverage
open_fee = position_usd * fees["open_fee_pct"] / 100
close_fee = position_usd * fees["close_fee_pct"] / 100
return {
"open_fee": round(open_fee, 4),
"close_fee": round(close_fee, 4),
"total_fee": round(open_fee + close_fee, 4),
"fee_pct": fees["open_fee_pct"],
"position_usd": round(position_usd, 2),
}


def calculate_liquidation_price(
entry_price: float, is_long: bool, leverage: float,
) -> float:
"""Calculate the liquidation price for a leveraged position.

gTrade liquidates when collateral loss reaches ~90%. The remaining
~10% covers the liquidator incentive.
"""
if leverage <= 0 or entry_price <= 0:
return 0.0
threshold = LIQ_THRESHOLD_PCT / 100
if is_long:
return entry_price * (1 - threshold / leverage)
return entry_price * (1 + threshold / leverage)


def resolve_pair_index(asset: str, trading_vars: Optional[dict] = None, skip_fetch: bool = False) -> Optional[int]:
"""Resolve a Tide Chart ticker to its gTrade pair index.

Expand Down
74 changes: 74 additions & 0 deletions tools/tide-chart/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@
fetch_open_trades,
fetch_trade_history,
get_pair_name_map,
is_market_open,
estimate_trade_fees,
calculate_liquidation_price,
GTRADE_PAIRS,
LIQ_THRESHOLD_PCT,
GROUP_FEES,
)

ASSET_COLORS = {
Expand Down Expand Up @@ -490,12 +496,55 @@ def generate_dashboard_html(client) -> str:
" .history-badge { font-size: 9px; font-weight: 600; padding: 2px 6px; border-radius: 3px;\n"
" background: rgba(100,116,139,0.2); color: var(--text-muted); border: 1px solid rgba(100,116,139,0.3);\n"
" white-space: nowrap; flex-shrink: 0; }\n"
" .history-badge.liq-badge { background: rgba(240,96,112,0.15); color: var(--negative);\n"
" border-color: rgba(240,96,112,0.3); }\n"
" .history-badge.tp-badge { background: rgba(52,211,153,0.15); color: var(--positive);\n"
" border-color: rgba(52,211,153,0.3); }\n"
" .history-badge.sl-badge { background: rgba(251,191,36,0.15); color: #fbbf24;\n"
" border-color: rgba(251,191,36,0.3); }\n"
" .history-row { opacity: 0.8; }\n"
" .close-trade-btn { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3);\n"
" border-radius: 4px; padding: 2px 6px; font-size: 10px; cursor: pointer;\n"
" font-family: 'IBM Plex Mono', monospace; transition: all 0.2s; }\n"
" .close-trade-btn:hover { background: rgba(239,68,68,0.3); border-color: #ef4444; }\n"
" .no-trades { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--text-muted); }\n"
" .manage-btn { background: rgba(232,212,77,0.12); color: var(--accent); border: 1px solid rgba(232,212,77,0.25);\n"
" border-radius: 4px; padding: 2px 8px; font-family: 'IBM Plex Mono', monospace; font-size: 9px;\n"
" cursor: pointer; transition: all 0.2s; letter-spacing: 0.5px; }\n"
" .manage-btn:hover { background: rgba(232,212,77,0.25); }\n"
" .manage-panel { display: none; width: 100%; padding: 10px 0 6px; margin-top: 6px;\n"
" border-top: 1px dashed rgba(30,42,64,0.8); }\n"
" .manage-panel.open { display: block; }\n"
" .manage-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }\n"
" .manage-row label { font-family: 'IBM Plex Mono', monospace; font-size: 9px; color: var(--text-muted);\n"
" text-transform: uppercase; letter-spacing: 0.5px; min-width: 70px; }\n"
" .manage-row input { font-family: 'IBM Plex Mono', monospace; font-size: 11px; padding: 4px 8px;\n"
" background: var(--bg-deep); border: 1px solid var(--border); border-radius: 4px;\n"
" color: var(--text-primary); outline: none; width: 100px; }\n"
" .manage-row input:focus { border-color: rgba(232,212,77,0.4); }\n"
" .manage-action-btn { font-family: 'IBM Plex Mono', monospace; font-size: 9px; padding: 4px 10px;\n"
" border-radius: 4px; cursor: pointer; transition: all 0.15s; border: 1px solid; }\n"
" .manage-action-btn.update { background: rgba(52,211,153,0.12); color: var(--positive);\n"
" border-color: rgba(52,211,153,0.25); }\n"
" .manage-action-btn.update:hover { background: rgba(52,211,153,0.25); }\n"
" .manage-action-btn.remove { background: rgba(240,96,112,0.1); color: var(--negative);\n"
" border-color: rgba(240,96,112,0.2); }\n"
" .manage-action-btn.remove:hover { background: rgba(240,96,112,0.25); }\n"
" .manage-action-btn.decrease { background: rgba(232,212,77,0.1); color: var(--accent);\n"
" border-color: rgba(232,212,77,0.25); }\n"
" .manage-action-btn.decrease:hover { background: rgba(232,212,77,0.2); }\n"
" .liq-price { font-size: 10px; color: var(--negative); opacity: 0.8; }\n"
" .fee-estimate { margin-top: 6px; padding: 8px 12px; background: rgba(232,212,77,0.04);\n"
" border: 1px solid rgba(232,212,77,0.1); border-radius: 4px;\n"
" font-family: 'IBM Plex Mono', monospace; font-size: 10px; }\n"
" .fee-estimate .fee-row { display: flex; justify-content: space-between;\n"
" padding: 2px 0; color: var(--text-secondary); }\n"
" .fee-estimate .fee-row span:last-child { color: var(--text-primary); }\n"
" .fee-estimate .fee-total { border-top: 1px solid rgba(232,212,77,0.15);\n"
" margin-top: 2px; padding-top: 4px; color: var(--accent); font-weight: 500; }\n"
" .market-warning { margin-top: 6px; padding: 8px 12px; background: rgba(240,96,112,0.08);\n"
" border: 1px solid rgba(240,96,112,0.2); border-radius: 4px;\n"
" font-family: 'IBM Plex Mono', monospace; font-size: 10px; color: var(--negative); }\n"
" .trade-pos-size { font-family: 'IBM Plex Mono', monospace; font-size: 16px;\n"
" font-weight: 600; color: var(--text-primary); text-align: center;\n"
" padding: 10px; background: var(--bg-deep); border-radius: 6px;\n"
Expand Down Expand Up @@ -939,6 +988,22 @@ def api_probability():
def gtrade_config_route():
return jsonify(get_contract_config())

@app.route("/api/gtrade/market-status")
def gtrade_market_status():
open_now, reason = is_market_open()
return jsonify({"open": open_now, "reason": reason})

@app.route("/api/gtrade/estimate-fees", methods=["POST"])
def gtrade_estimate_fees():
body = request.get_json(silent=True) or {}
asset = body.get("asset", "")
collateral_usd = body.get("collateral_usd", 0)
leverage = body.get("leverage", 0)
if not asset or collateral_usd <= 0 or leverage <= 0:
return jsonify({"error": "Missing or invalid parameters"}), 400
fees = estimate_trade_fees(asset, collateral_usd, leverage)
return jsonify(fees)

@app.route("/api/gtrade/validate-trade", methods=["POST"])
def gtrade_validate_trade():
body = request.get_json(silent=True) or {}
Expand All @@ -951,14 +1016,23 @@ def gtrade_validate_trade():
if not valid:
return jsonify({"valid": False, "error": error}), 400

# Block stock trades when market is closed
pair = GTRADE_PAIRS.get(asset, {})
if pair.get("group") == "stocks":
mkt_open, mkt_reason = is_market_open()
if not mkt_open:
return jsonify({"valid": False, "error": mkt_reason}), 400

current_price = 0.0
try:
forecast = client.get_prediction_percentiles(asset, horizon="24h")
current_price = forecast["current_price"]
except Exception:
pass

fees = estimate_trade_fees(asset, collateral_usd, leverage)
summary = build_trade_summary(asset, current_price, direction, leverage, collateral_usd)
summary["fees"] = fees
return jsonify({"valid": True, "summary": summary})

@app.route("/api/gtrade/resolve-pair")
Expand Down
Loading
Loading