diff --git a/tools/tide-chart/gtrade.py b/tools/tide-chart/gtrade.py index 1ffc638..cc39b7f 100644 --- a/tools/tide-chart/gtrade.py +++ b/tools/tide-chart/gtrade.py @@ -6,6 +6,9 @@ """ import time +from datetime import datetime, date +from zoneinfo import ZoneInfo + import requests from typing import Optional @@ -24,10 +27,12 @@ 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) @@ -35,7 +40,7 @@ "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"}, @@ -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 @@ -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()}", @@ -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. diff --git a/tools/tide-chart/main.py b/tools/tide-chart/main.py index ec18f0d..6d14459 100644 --- a/tools/tide-chart/main.py +++ b/tools/tide-chart/main.py @@ -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 = { @@ -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" @@ -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 {} @@ -951,6 +1016,13 @@ 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") @@ -958,7 +1030,9 @@ def gtrade_validate_trade(): 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") diff --git a/tools/tide-chart/static/trading.js b/tools/tide-chart/static/trading.js index 8f6d22f..acc62eb 100644 --- a/tools/tide-chart/static/trading.js +++ b/tools/tide-chart/static/trading.js @@ -22,8 +22,17 @@ var CHAINLINK_FEEDS = { /* Cached mapping from pairIndex -> asset ticker, populated by loadOpenTrades */ var pairIndexToTicker = {}; -/* Cache of open trade metadata keyed by tradeIndex, used to record history on close */ -var _openTradesCache = {}; +/* Cache of open trade metadata keyed by tradeIndex, used to record history on close. + Persisted to sessionStorage so liquidation detection survives page refreshes. */ +var _OPEN_CACHE_KEY = 'tidechart_open_cache'; +var _openTradesCache = (function() { + try { var r = sessionStorage.getItem(_OPEN_CACHE_KEY); return r ? JSON.parse(r) : {}; } catch (_) { return {}; } +})(); +function _persistOpenCache() { + try { sessionStorage.setItem(_OPEN_CACHE_KEY, JSON.stringify(_openTradesCache)); } catch (_) {} +} +/* Set of trade indices currently being closed by the user (to avoid false liquidation detection) */ +var _closingTradeIndices = {}; var TRADE_HISTORY_KEY = 'tidechart_trade_history'; function getTradeHistory() { @@ -40,6 +49,247 @@ function saveTradeToHistory(entry) { try { localStorage.setItem(TRADE_HISTORY_KEY, JSON.stringify(history)); } catch (_) {} } +/* ========== Liquidation Price (client-side) ========== */ +function calculateLiquidationPrice(entryPrice, isLong, leverage) { + if (leverage <= 0 || entryPrice <= 0) return 0; + var threshold = 0.9; // 90% loss triggers liquidation + if (isLong) return entryPrice * (1 - threshold / leverage); + return entryPrice * (1 + threshold / leverage); +} + +/* ========== Trade Management Panel ========== */ +function toggleManagePanel(tradeIndex) { + var panel = document.getElementById('manage-panel-' + tradeIndex); + if (!panel) return; + panel.classList.toggle('open'); +} + +async function updateTradeTP(tradeIndex, remove) { + if (!walletState.connected || !walletState.signer || tradePending) return; + if (walletState.chainId !== 42161) { showToast('Switch to Arbitrum', 'error'); return; } + var cached = _openTradesCache[tradeIndex]; + // Warn if stock pair and market is closed (oracle callbacks may fail) + if (cached) { + var ticker = pairIndexToTicker[cached.pairIdx] || pairIndexToTicker[String(cached.pairIdx)]; + if (ticker && gtradeConfig && gtradeConfig.pairs && gtradeConfig.pairs[ticker] && gtradeConfig.pairs[ticker].group === 'stocks') { + var mkt = await checkMarketStatus(); + if (!mkt.open) { showToast(mkt.reason + '. TP/SL updates on stocks may fail.', 'error', 6000); } + } + } + var newTp; + if (remove) { + newTp = BigInt(0); + } else { + var input = document.getElementById('manage-tp-' + tradeIndex); + var tpPrice = parseFloat(input ? input.value : ''); + if (isNaN(tpPrice) || tpPrice <= 0) { showToast('Enter a valid TP price', 'error'); return; } + // Skip if value hasn't changed + if (cached) { + var currentTp = cached.tp ? parseFloat(cached.tp) / 1e10 : 0; + if (Math.abs(tpPrice - currentTp) < 0.005) { showToast('TP is already set to $' + currentTp.toFixed(2), 'info'); return; } + } + // Validate TP direction and max distance + if (cached) { + var entryPrice = cached.openPrice ? parseFloat(cached.openPrice) / 1e10 : 0; + if (entryPrice > 0) { + if (cached.long && tpPrice <= entryPrice) { showToast('TP must be above entry price ($' + entryPrice.toFixed(2) + ') for longs', 'error'); return; } + if (!cached.long && tpPrice >= entryPrice) { showToast('TP must be below entry price ($' + entryPrice.toFixed(2) + ') for shorts', 'error'); return; } + var tpPct = Math.abs(tpPrice - entryPrice) / entryPrice * 100; + if (tpPct > 900) { showToast('TP cannot exceed 900% from entry price', 'error'); return; } + } + } + newTp = BigInt(Math.round(tpPrice * 1e10)); + } + tradePending = true; + try { + var abi = ['function updateTp(uint32 _index, uint64 _newTp)']; + var diamond = new ethers.Contract(gtradeConfig.trading_contract, abi, walletState.signer); + showToast(remove ? 'Removing TP...' : 'Updating TP...', 'info', 15000); + var tx = await diamond.updateTp(tradeIndex, newTp, { gasLimit: 1500000 }); + await tx.wait(); + showToast(remove ? 'TP removed' : 'TP updated to $' + (Number(newTp) / 1e10).toFixed(2), 'success'); + // Optimistic UI + cache update: patch displayed TP and cache immediately (backend API has indexing delay) + var tpDisplay = Number(newTp) / 1e10; + var tpSpan = document.querySelector('[data-tp-trade="' + tradeIndex + '"]'); + if (tpSpan) tpSpan.textContent = remove ? '' : 'TP: $' + tpDisplay.toFixed(2); + var tpInput = document.getElementById('manage-tp-' + tradeIndex); + if (tpInput) tpInput.value = remove ? '' : tpDisplay.toFixed(2); + if (_openTradesCache[tradeIndex]) { _openTradesCache[tradeIndex].tp = String(newTp); _persistOpenCache(); } + pollOpenTrades(5, 6000); + } catch (e) { + var msg = e.reason || e.shortMessage || e.message || 'Update TP failed'; + if (e.code === 4001 || (e.info && e.info.error && e.info.error.code === 4001)) msg = 'Transaction rejected'; + showToast(msg.length > 120 ? msg.slice(0, 120) + '...' : msg, 'error'); + } finally { tradePending = false; } +} + +async function updateTradeSL(tradeIndex, remove) { + if (!walletState.connected || !walletState.signer || tradePending) return; + if (walletState.chainId !== 42161) { showToast('Switch to Arbitrum', 'error'); return; } + var cached = _openTradesCache[tradeIndex]; + // Warn if stock pair and market is closed + if (cached) { + var ticker = pairIndexToTicker[cached.pairIdx] || pairIndexToTicker[String(cached.pairIdx)]; + if (ticker && gtradeConfig && gtradeConfig.pairs && gtradeConfig.pairs[ticker] && gtradeConfig.pairs[ticker].group === 'stocks') { + var mkt = await checkMarketStatus(); + if (!mkt.open) { showToast(mkt.reason + '. TP/SL updates on stocks may fail.', 'error', 6000); } + } + } + var newSl; + if (remove) { + newSl = BigInt(0); + } else { + var input = document.getElementById('manage-sl-' + tradeIndex); + var slPrice = parseFloat(input ? input.value : ''); + if (isNaN(slPrice) || slPrice <= 0) { showToast('Enter a valid SL price', 'error'); return; } + // Skip if value hasn't changed + if (cached) { + var currentSl = cached.sl ? parseFloat(cached.sl) / 1e10 : 0; + if (Math.abs(slPrice - currentSl) < 0.005) { showToast('SL is already set to $' + currentSl.toFixed(2), 'info'); return; } + } + // Validate SL direction and max distance (MAX_SL_P = 75, so max SL % = 75 / leverage) + if (cached) { + var entryPrice = cached.openPrice ? parseFloat(cached.openPrice) / 1e10 : 0; + var levNum = cached.leverage ? parseFloat(cached.leverage) / 1000 : 0; + if (entryPrice > 0) { + if (cached.long && slPrice >= entryPrice) { showToast('SL must be below entry price ($' + entryPrice.toFixed(2) + ') for longs', 'error'); return; } + if (!cached.long && slPrice <= entryPrice) { showToast('SL must be above entry price ($' + entryPrice.toFixed(2) + ') for shorts', 'error'); return; } + if (levNum > 0) { + var maxSlPct = 75 / levNum; + var slPct = Math.abs(slPrice - entryPrice) / entryPrice * 100; + if (slPct > maxSlPct) { showToast('SL too far from entry. Max distance at ' + levNum.toFixed(0) + 'x leverage: ' + maxSlPct.toFixed(2) + '%', 'error', 6000); return; } + } + } + } + newSl = BigInt(Math.round(slPrice * 1e10)); + } + tradePending = true; + try { + var abi = ['function updateSl(uint32 _index, uint64 _newSl)']; + var diamond = new ethers.Contract(gtradeConfig.trading_contract, abi, walletState.signer); + showToast(remove ? 'Removing SL...' : 'Updating SL...', 'info', 15000); + var tx = await diamond.updateSl(tradeIndex, newSl, { gasLimit: 1500000 }); + await tx.wait(); + showToast(remove ? 'SL removed' : 'SL updated to $' + (Number(newSl) / 1e10).toFixed(2), 'success'); + // Optimistic UI + cache update: patch displayed SL and cache immediately (backend API has indexing delay) + var slDisplay = Number(newSl) / 1e10; + var slSpan = document.querySelector('[data-sl-trade="' + tradeIndex + '"]'); + if (slSpan) slSpan.textContent = remove ? '' : 'SL: $' + slDisplay.toFixed(2); + var slInput = document.getElementById('manage-sl-' + tradeIndex); + if (slInput) slInput.value = remove ? '' : slDisplay.toFixed(2); + if (_openTradesCache[tradeIndex]) { _openTradesCache[tradeIndex].sl = String(newSl); _persistOpenCache(); } + pollOpenTrades(5, 6000); + } catch (e) { + var msg = e.reason || e.shortMessage || e.message || 'Update SL failed'; + if (e.code === 4001 || (e.info && e.info.error && e.info.error.code === 4001)) msg = 'Transaction rejected'; + showToast(msg.length > 120 ? msg.slice(0, 120) + '...' : msg, 'error'); + } finally { tradePending = false; } +} + +async function decreasePosition(tradeIndex) { + if (!walletState.connected || !walletState.signer || tradePending) return; + if (walletState.chainId !== 42161) { showToast('Switch to Arbitrum', 'error'); return; } + var input = document.getElementById('manage-decrease-' + tradeIndex); + var amount = parseFloat(input ? input.value : ''); + if (isNaN(amount) || amount <= 0) { showToast('Enter a valid USDC amount', 'error'); return; } + + // Validate remaining position stays above protocol minimum ($1,500) + var cached = _openTradesCache[tradeIndex]; + // Warn if stock pair and market is closed (oracle callback required for partial close) + if (cached) { + var _ticker = pairIndexToTicker[cached.pairIdx] || pairIndexToTicker[String(cached.pairIdx)]; + if (_ticker && gtradeConfig && gtradeConfig.pairs && gtradeConfig.pairs[_ticker] && gtradeConfig.pairs[_ticker].group === 'stocks') { + var mkt = await checkMarketStatus(); + if (!mkt.open) { showToast(mkt.reason + '. Partial closes on stocks may fail.', 'error', 6000); return; } + } + } + if (cached) { + var colIdx = parseInt(cached.collateralIndex || '3'); + var colDecimals = (colIdx === 3) ? 6 : 18; + var currentCol = Number(BigInt(cached.collateralAmount || '0')) / Math.pow(10, colDecimals); + var levNum = cached.leverage ? parseFloat(cached.leverage) / 1000 : 0; + var remainingCol = currentCol - amount; + if (remainingCol < 0) { showToast('Amount exceeds position collateral (' + currentCol.toFixed(2) + ' USDC)', 'error'); return; } + var remainingPosition = remainingCol * levNum; + var minPosition = (gtradeConfig && gtradeConfig.group_limits) ? 1500 : 1500; + if (remainingPosition < minPosition && remainingCol > 0) { + showToast('Remaining position $' + remainingPosition.toFixed(0) + ' would be below $' + minPosition + ' minimum. Max decrease: ' + + Math.max(0, currentCol - (minPosition / levNum)).toFixed(2) + ' USDC', 'error', 8000); + return; + } + } + + var collateralDelta = ethers.parseUnits(amount.toString(), gtradeConfig.usdc_decimals); + + // Fetch current price for _expectedPrice (required by gTrade v9) + var expectedPrice = BigInt(0); + var ticker = null; + if (cached) { + ticker = pairIndexToTicker[cached.pairIdx] || pairIndexToTicker[String(cached.pairIdx)]; + var feedAddr = ticker ? CHAINLINK_FEEDS[ticker] : null; + if (feedAddr && walletState.provider) { + var livePrice = await fetchChainlinkPrice(feedAddr, walletState.provider); + if (livePrice) expectedPrice = BigInt(Math.round(livePrice * 1e10)); + } + } + // Fallback: use Synth API cached price + if (expectedPrice === BigInt(0) && ticker && typeof currentAssets !== 'undefined' && + currentAssets[ticker] && currentAssets[ticker].current_price) { + expectedPrice = BigInt(Math.round(currentAssets[ticker].current_price * 1e10)); + } + if (expectedPrice === BigInt(0)) { + showToast('Could not fetch current price for partial close', 'error'); + return; + } + + tradePending = true; + try { + var abi = ['function decreasePositionSize(uint32 _index, uint120 _collateralDelta, uint24 _leverageDelta, uint64 _expectedPrice)']; + var diamond = new ethers.Contract(gtradeConfig.trading_contract, abi, walletState.signer); + showToast('Decreasing position by ' + amount.toFixed(2) + ' USDC...', 'info', 15000); + var tx = await diamond.decreasePositionSize(tradeIndex, collateralDelta, 0, expectedPrice, { gasLimit: 3000000 }); + showToast('Partial close submitted...', 'info', 20000); + await tx.wait(); + showToast('Position decreased by ' + amount.toFixed(2) + ' USDC', 'success'); + await refreshUSDCBalance(); + // Optimistic UI + cache update for collateral + if (cached) { + var colIdx = parseInt(cached.collateralIndex || '3'); + var colDecimals = (colIdx === 3) ? 6 : 18; + var oldCol = Number(BigInt(cached.collateralAmount || '0')) / Math.pow(10, colDecimals); + var newCol = oldCol - amount; + var colSpan = document.querySelector('[data-col-trade="' + tradeIndex + '"]'); + if (colSpan) colSpan.textContent = newCol.toFixed(2) + ' USDC'; + var newColRaw = BigInt(cached.collateralAmount || '0') - BigInt(collateralDelta); + _openTradesCache[tradeIndex].collateralAmount = String(newColRaw); + _persistOpenCache(); + } + pollOpenTrades(5, 6000); + } catch (e) { + var msg = e.reason || e.shortMessage || e.message || 'Decrease position failed'; + if (e.code === 4001 || (e.info && e.info.error && e.info.error.code === 4001)) msg = 'Transaction rejected'; + showToast(msg.length > 120 ? msg.slice(0, 120) + '...' : msg, 'error'); + } finally { tradePending = false; } +} + +/* ========== Market Hours Check ========== */ +var _marketStatusCache = { open: null, reason: '', ts: 0 }; + +async function checkMarketStatus() { + var now = Date.now(); + if (_marketStatusCache.open !== null && (now - _marketStatusCache.ts) < 60000) { + return _marketStatusCache; + } + try { + var resp = await fetch('/api/gtrade/market-status'); + var data = await resp.json(); + _marketStatusCache = { open: data.open, reason: data.reason, ts: now }; + return _marketStatusCache; + } catch (_) { + return { open: true, reason: '' }; + } +} + function resolveFeedForPairIndex(pairIndex, pairNames) { if (pairIndexToTicker[pairIndex]) return CHAINLINK_FEEDS[pairIndexToTicker[pairIndex]] || null; var name = pairNames[pairIndex]; @@ -398,11 +648,57 @@ function updateTradePreview() { var tp = parseFloat(document.getElementById('trade-tp').value); var sl = parseFloat(document.getElementById('trade-sl').value); var slippage = parseFloat(document.getElementById('trade-slippage').value) || 1; - if (tp > 0) html += '