From f5b9398333ec54dde248dd57548b9f92e66e2551 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Tue, 10 Mar 2026 22:30:27 +0100 Subject: [PATCH 1/2] feat(tide-chart): trade management enhancements (issue #29) - Add TP/SL management on open positions (updateTp/updateSl contract calls) - Add partial close via decreasePositionSize with inline manage panel - Add market hours validation for stock trades (NYSE hours + holidays) - Add real-time fee estimation display in trade preview - Add liquidation price display on open positions and trade preview - Add /api/gtrade/market-status and /api/gtrade/estimate-fees endpoints - Include fee data in validate-trade response summary - Block stock trades when US market is closed (server + client) - Add 17 new tests covering all new features (69 total pass) --- tools/tide-chart/gtrade.py | 92 +++++ tools/tide-chart/main.py | 70 ++++ tools/tide-chart/static/trading.js | 499 +++++++++++++++++++++++++++- tools/tide-chart/tests/test_tool.py | 176 +++++++++- 4 files changed, 818 insertions(+), 19 deletions(-) diff --git a/tools/tide-chart/gtrade.py b/tools/tide-chart/gtrade.py index 1ffc638..7110fa6 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 @@ -47,6 +50,33 @@ 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}, +} + +# 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 @@ -238,6 +268,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..5106d19 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,51 @@ 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-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 +984,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 +1012,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 +1026,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..1863b35 100644 --- a/tools/tide-chart/static/trading.js +++ b/tools/tide-chart/static/trading.js @@ -40,6 +40,246 @@ 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); + 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); + 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); + } + 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 +638,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 += '
Take Profit+' + tp + '%
'; - if (sl > 0) html += '
Stop Loss-' + sl + '%
'; + if (tp > 0) { + var tpTarget = direction === 'long' ? price * (1 + tp / 100) : price * (1 - tp / 100); + html += '
Take Profit$' + fmt(tpTarget) + ' (+' + tp + '%)
'; + } + if (sl > 0) { + var slTarget = direction === 'long' ? price * (1 - sl / 100) : price * (1 + sl / 100); + html += '
Stop Loss$' + fmt(slTarget) + ' (-' + sl + '%)
'; + } html += '
Max Slippage' + slippage.toFixed(1) + '%
'; + + // Liquidation price estimate + if (price > 0 && leverage > 0) { + var liqLong = calculateLiquidationPrice(price, direction === 'long', leverage); + html += '
Est. Liq. Price$' + fmt(liqLong) + '
'; + } + html += '
ProtocolgTrade · Arbitrum
'; preview.innerHTML = html; + + // Fee estimation (async) + if (asset && collateral > 0 && leverage > 0) { + fetch('/api/gtrade/estimate-fees', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ asset: asset, collateral_usd: collateral, leverage: leverage }) + }).then(function(r) { return r.json(); }).then(function(fees) { + if (fees.error) return; + var existing = preview.querySelector('.fee-estimate'); + if (existing) existing.remove(); + var feeHtml = '
' + + '
Open Fee (' + fees.fee_pct + '%)$' + fees.open_fee.toFixed(2) + '
' + + '
Close Fee (est.)$' + fees.close_fee.toFixed(2) + '
' + + '
Total Fees$' + fees.total_fee.toFixed(2) + '
' + + '
'; + preview.innerHTML += feeHtml; + }).catch(function() {}); + } + + // Market hours warning for stocks (async) + if (gtradeConfig && gtradeConfig.pairs && gtradeConfig.pairs[asset]) { + var pairGroup = gtradeConfig.pairs[asset].group; + if (pairGroup === 'stocks') { + checkMarketStatus().then(function(status) { + var existing = preview.querySelector('.market-warning'); + if (existing) existing.remove(); + if (!status.open) { + preview.innerHTML += '
\u26A0 ' + status.reason + + '. Stock trades will revert on-chain.
'; + } + }); + } + } } async function executeTrade() { @@ -429,6 +715,15 @@ async function executeTrade() { var tpPct = parseFloat(document.getElementById('trade-tp').value) || 0; var slPct = parseFloat(document.getElementById('trade-sl').value) || 0; + // Block stock trades when market is closed + if (gtradeConfig && gtradeConfig.pairs && gtradeConfig.pairs[asset] && gtradeConfig.pairs[asset].group === 'stocks') { + var mktStatus = await checkMarketStatus(); + if (!mktStatus.open) { + showToast(mktStatus.reason + '. Stock trades will revert.', 'error', 8000); + return; + } + } + // Fetch live price from Chainlink on-chain feed (same oracle gTrade uses) var currentPrice = 0; var feedAddr = CHAINLINK_FEEDS[asset]; @@ -448,13 +743,15 @@ async function executeTrade() { var openPriceScaled = BigInt(Math.round(currentPrice * 1e10)); // TP/SL as absolute prices in 1e10 precision + // Long: TP above entry, SL below. Short: TP below entry, SL above. var tpScaled = 0; var slScaled = 0; - if (tpPct > 0) { - tpScaled = BigInt(Math.round(currentPrice * (1 + tpPct / 100) * 1e10)); - } - if (slPct > 0) { - slScaled = BigInt(Math.round(currentPrice * (1 - slPct / 100) * 1e10)); + if (direction === 'long') { + if (tpPct > 0) tpScaled = BigInt(Math.round(currentPrice * (1 + tpPct / 100) * 1e10)); + if (slPct > 0) slScaled = BigInt(Math.round(currentPrice * (1 - slPct / 100) * 1e10)); + } else { + if (tpPct > 0) tpScaled = BigInt(Math.round(currentPrice * (1 - tpPct / 100) * 1e10)); + if (slPct > 0) slScaled = BigInt(Math.round(currentPrice * (1 + slPct / 100) * 1e10)); } // Server-side validation (mirrors client-side guards) @@ -675,7 +972,8 @@ async function loadOpenTrades() { var levNum = t.leverage ? parseFloat(t.leverage) / 1000 : 0; // Cache for trade history recording _openTradesCache[tradeIdx] = { pairIdx: pairIdx, pairLabel: pairLabel, dir: dir, lev: lev, long: t.long, - leverage: t.leverage, collateralAmount: t.collateralAmount, collateralIndex: t.collateralIndex, openPrice: t.openPrice }; + leverage: t.leverage, collateralAmount: t.collateralAmount, collateralIndex: t.collateralIndex, openPrice: t.openPrice, + tp: t.tp || '0', sl: t.sl || '0' }; var colRaw = BigInt(t.collateralAmount || '0'); var colIdx = parseInt(t.collateralIndex || '3'); var colDecimals = (colIdx === 3) ? 6 : 18; @@ -694,40 +992,207 @@ async function loadOpenTrades() { var pnlUsd = col * (pnlPct / 100); var pnlClass = pnlUsd >= 0 ? 'positive' : 'negative'; var pnlSign = pnlUsd >= 0 ? '+' : ''; - pnlHtml = '' + + pnlHtml = '' + 'Est. ' + pnlSign + pnlUsd.toFixed(2) + ' USDC (' + pnlSign + pnlPct.toFixed(2) + '%)' + ''; } - html += '
' + + // Liquidation price + var liqPrice = calculateLiquidationPrice(entryPrice, !!t.long, levNum); + var liqHtml = liqPrice > 0 ? 'Liq: $' + liqPrice.toFixed(2) + '' : ''; + + // Current TP/SL from trade data + var tpRaw = t.tp ? parseFloat(t.tp) / 1e10 : 0; + var slRaw = t.sl ? parseFloat(t.sl) / 1e10 : 0; + var tpSlHtml = ''; + if (tpRaw > 0) tpSlHtml += 'TP: $' + tpRaw.toFixed(2) + ' '; + if (slRaw > 0) tpSlHtml += 'SL: $' + slRaw.toFixed(2) + ''; + + // Manage panel HTML + var manageHtml = + '
' + + '
' + + '' + + ' 0 ? ' value="' + tpRaw.toFixed(2) + '"' : '') + '>' + + '' + + (tpRaw > 0 ? '' : '') + + '
' + + '
' + + '' + + ' 0 ? ' value="' + slRaw.toFixed(2) + '"' : '') + '>' + + '' + + (slRaw > 0 ? '' : '') + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + + html += '
' + '
' + '
' + '' + dir + ' ' + lev + '' + '' + pairLabel + '' + 'Entry: ' + entryFmt + (curPrice ? ' / Now: $' + curPrice.toFixed(2) : '') + '' + - '' + colFmt + ' USDC' + + '' + colFmt + ' USDC' + + liqHtml + '
' + + (tpSlHtml ? '
' + tpSlHtml + '
' : '') + (pnlHtml ? '
' + pnlHtml + '
' : '') + '
' + + '
' + + '' + '' + + '
' + + manageHtml + '
'; }); + // Skip full re-render if a manage panel is open (prevents input flicker) + var hasOpenPanel = container.querySelector('.manage-panel.open'); + if (hasOpenPanel) { + // Still update P&L and price text in-place without replacing HTML + trades.forEach(function(item) { + var t = item.trade || item; + var tradeIdx = parseInt(t.index || '0'); + var pairIdx = parseInt(t.pairIndex || '0'); + var curPrice = livePrices[pairIdx]; + var pnlEl = container.querySelector('[data-pnl-trade="' + tradeIdx + '"]'); + if (pnlEl && curPrice) { + var entryP = t.openPrice ? parseFloat(t.openPrice) / 1e10 : 0; + var levNum = t.leverage ? parseFloat(t.leverage) / 1000 : 0; + var colRaw = BigInt(t.collateralAmount || '0'); + var colIdx2 = parseInt(t.collateralIndex || '3'); + var colDec = (colIdx2 === 3) ? 6 : 18; + var col2 = Number(colRaw) / Math.pow(10, colDec); + if (entryP > 0 && levNum > 0) { + var pct = t.long ? ((curPrice - entryP) / entryP) * levNum * 100 : ((entryP - curPrice) / entryP) * levNum * 100; + var usd = col2 * (pct / 100); + var cls = usd >= 0 ? 'positive' : 'negative'; + var sgn = usd >= 0 ? '+' : ''; + pnlEl.className = 'trade-pnl ' + cls; + pnlEl.textContent = 'Est. ' + sgn + usd.toFixed(2) + ' USDC (' + sgn + pct.toFixed(2) + '%)'; + } + } + }); + return; + } container.innerHTML = html; } catch (e) { container.innerHTML = '
Could not load trades
'; } } -function loadTradeHistory() { +async function loadTradeHistory() { var container = document.getElementById('trade-history-list'); if (!container) return; - var history = getTradeHistory(); - if (history.length === 0) { + if (!walletState.connected) return; + + // Fetch from gTrade backend (includes liquidations, TP/SL hits, all protocol-closed trades) + var backendTrades = []; + var pairNames = {}; + try { + var resp = await fetch('/api/gtrade/trade-history?address=' + walletState.address); + var data = await resp.json(); + backendTrades = data.history || []; + pairNames = data.pair_names || {}; + } catch (_) {} + + // Build merged history: backend trades + localStorage-only entries + var localHistory = getTradeHistory(); + var localByTx = {}; + localHistory.forEach(function(h) { if (h.txHash) localByTx[h.txHash.toLowerCase()] = h; }); + + var merged = []; + + // Process backend trades (authoritative source for all closed trades including liquidations) + backendTrades.forEach(function(item) { + var t = item.trade || item; + if (t.isOpen) return; // skip still-open trades + var pairIdx = parseInt(t.pairIndex || '0'); + var dir = t.long ? 'LONG' : 'SHORT'; + var lev = t.leverage ? (parseFloat(t.leverage) / 1000).toFixed(0) + 'x' : '?x'; + var levNum = t.leverage ? parseFloat(t.leverage) / 1000 : 0; + var colRaw = BigInt(t.collateralAmount || '0'); + var colIdx = parseInt(t.collateralIndex || '3'); + var colDecimals = (colIdx === 3) ? 6 : 18; + var col = Number(colRaw) / Math.pow(10, colDecimals); + var entryPrice = t.openPrice ? (parseFloat(t.openPrice) / 1e10) : 0; + var pairLabel = pairNames[pairIdx] || ('Pair #' + pairIdx); + + // Extract close data + var closeData = item.closeTradeData || item.close_trade_data || {}; + var closePrice = closeData.closePrice ? parseFloat(closeData.closePrice) / 1e10 : 0; + var pnlUsd = null; + var pnlPct = null; + + if (closePrice > 0 && entryPrice > 0 && levNum > 0) { + var pctRaw = t.long + ? ((closePrice - entryPrice) / entryPrice) * levNum * 100 + : ((entryPrice - closePrice) / entryPrice) * levNum * 100; + pnlUsd = col * (pctRaw / 100); + pnlPct = pctRaw; + } + + // Detect liquidation: close type or -90%+ loss + var isLiquidation = false; + if (closeData.closeType !== undefined) { + // gTrade closeType: 0=market, 1=TP, 2=SL, 3=LIQ + isLiquidation = parseInt(closeData.closeType) === 3; + } else if (pnlPct !== null && pnlPct <= -89) { + isLiquidation = true; + } + + merged.push({ + dir: dir, lev: lev, long: !!t.long, + pairLabel: pairLabel, + collateral: col.toFixed(2), + entryPrice: entryPrice.toFixed(2), + closePrice: closePrice > 0 ? closePrice.toFixed(2) : '?', + pnlUsd: pnlUsd !== null ? pnlUsd.toFixed(2) : 'pending', + pnlPct: pnlPct !== null ? pnlPct.toFixed(1) : 'pending', + txHash: null, + closedAt: null, + isLiquidation: isLiquidation, + _source: 'backend' + }); + }); + + // Add localStorage-only entries (recent closes that backend hasn't indexed yet) + localHistory.forEach(function(h) { + // Check if this local entry is already covered by a backend entry + // (match by entry price + collateral + direction as a rough dedup) + var dominated = merged.some(function(m) { + return m.entryPrice === h.entryPrice && m.collateral === h.collateral && m.dir === h.dir; + }); + if (!dominated) { + h._source = 'local'; + merged.push(h); + } else if (h.txHash) { + // Enrich the backend entry with the local tx hash + for (var i = 0; i < merged.length; i++) { + if (merged[i].entryPrice === h.entryPrice && merged[i].collateral === h.collateral && merged[i].dir === h.dir) { + if (!merged[i].txHash) merged[i].txHash = h.txHash; + if (!merged[i].closedAt && h.closedAt) merged[i].closedAt = h.closedAt; + break; + } + } + } + }); + + // Limit to 20 most recent + merged = merged.slice(0, 20); + + if (merged.length === 0) { container.innerHTML = '
No trade history
'; return; } + var html = ''; - history.forEach(function(h) { + merged.forEach(function(h) { var dirClass = h.long ? 'positive' : 'negative'; var pnlHtml = ''; if (h.pnlUsd === 'pending' || h.pnlPct === 'pending') { @@ -743,6 +1208,8 @@ function loadTradeHistory() { var txLink = h.txHash ? ' tx' : ''; + var badge = h.isLiquidation ? 'LIQUIDATED' : 'CLOSED'; + var badgeClass = h.isLiquidation ? 'history-badge liq-badge' : 'history-badge'; // Format timestamp var timeStr = ''; if (h.closedAt) { @@ -764,7 +1231,7 @@ function loadTradeHistory() { '
' + '
' + pnlHtml + txLink + '
' + '' + - 'CLOSED' + + '' + badge + '' + ''; }); container.innerHTML = html; diff --git a/tools/tide-chart/tests/test_tool.py b/tools/tide-chart/tests/test_tool.py index 92bd8aa..fc88bc3 100644 --- a/tools/tide-chart/tests/test_tool.py +++ b/tools/tide-chart/tests/test_tool.py @@ -44,10 +44,15 @@ get_contract_config, get_asset_limits, fetch_open_trades, + is_market_open, + estimate_trade_fees, + calculate_liquidation_price, TRADEABLE_ASSETS, GROUP_LIMITS, + GROUP_FEES, MIN_COLLATERAL_USD, ARBITRUM_CHAIN_ID, + LIQ_THRESHOLD_PCT, ) @@ -670,14 +675,14 @@ def test_flask_gtrade_validate_trade_valid(): app = create_app(client) with app.test_client() as tc: resp = tc.post("/api/gtrade/validate-trade", - data=json.dumps({"asset": "SPY", "direction": "long", - "leverage": 10, "collateral_usd": 200}), + data=json.dumps({"asset": "BTC", "direction": "long", + "leverage": 50, "collateral_usd": 100}), content_type="application/json") assert resp.status_code == 200 data = json.loads(resp.data) assert data["valid"] is True assert "summary" in data - assert data["summary"]["position_size_usd"] == 2000 + assert data["summary"]["position_size_usd"] == 5000 def test_flask_gtrade_validate_trade_invalid(): @@ -771,6 +776,156 @@ def test_fetch_open_trades_empty_address(): assert result == [] +# --- Issue #29: Trade Management Enhancement tests --- + + +def test_is_market_open_returns_tuple(): + """Verify is_market_open returns (bool, str) tuple.""" + result = is_market_open() + assert isinstance(result, tuple) + assert len(result) == 2 + assert isinstance(result[0], bool) + assert isinstance(result[1], str) + assert len(result[1]) > 0 + + +def test_estimate_trade_fees_crypto(): + """Verify fee estimation for crypto asset.""" + fees = estimate_trade_fees("BTC", 100, 50) + assert fees["open_fee"] > 0 + assert fees["close_fee"] > 0 + assert fees["total_fee"] == fees["open_fee"] + fees["close_fee"] + assert fees["position_usd"] == 5000.0 + # Crypto: 0.06% of 5000 = 3.0 + assert abs(fees["open_fee"] - 3.0) < 0.01 + + +def test_estimate_trade_fees_stocks(): + """Verify fee estimation for stock asset.""" + fees = estimate_trade_fees("SPY", 200, 10) + assert fees["position_usd"] == 2000.0 + # Stocks: 0.01% of 2000 = 0.2 + assert abs(fees["open_fee"] - 0.2) < 0.01 + assert fees["fee_pct"] == 0.01 + + +def test_estimate_trade_fees_unknown_asset(): + """Verify fee estimation returns zeros for unknown asset.""" + fees = estimate_trade_fees("DOGE", 100, 10) + assert fees["open_fee"] == 0 + assert fees["total_fee"] == 0 + + +def test_calculate_liquidation_price_long(): + """Verify liquidation price for long position.""" + # Long at $100, 10x: liq = 100 * (1 - 0.9/10) = 100 * 0.91 = $91 + liq = calculate_liquidation_price(100.0, True, 10) + assert abs(liq - 91.0) < 0.01 + + +def test_calculate_liquidation_price_short(): + """Verify liquidation price for short position.""" + # Short at $100, 10x: liq = 100 * (1 + 0.9/10) = 100 * 1.09 = $109 + liq = calculate_liquidation_price(100.0, False, 10) + assert abs(liq - 109.0) < 0.01 + + +def test_calculate_liquidation_price_high_leverage(): + """Verify liq price moves closer to entry at high leverage.""" + liq_low = calculate_liquidation_price(100.0, True, 5) + liq_high = calculate_liquidation_price(100.0, True, 100) + # Higher leverage = liq price closer to entry + assert liq_high > liq_low + + +def test_calculate_liquidation_price_edge_cases(): + """Verify edge cases return 0.""" + assert calculate_liquidation_price(0, True, 10) == 0.0 + assert calculate_liquidation_price(100, True, 0) == 0.0 + assert calculate_liquidation_price(100, True, -5) == 0.0 + + +def test_flask_gtrade_market_status(): + """Verify /api/gtrade/market-status returns open/reason.""" + client = _make_client() + app = create_app(client) + with app.test_client() as tc: + resp = tc.get("/api/gtrade/market-status") + assert resp.status_code == 200 + data = json.loads(resp.data) + assert "open" in data + assert "reason" in data + assert isinstance(data["open"], bool) + + +def test_flask_gtrade_estimate_fees_valid(): + """Verify /api/gtrade/estimate-fees returns fee breakdown.""" + client = _make_client() + app = create_app(client) + with app.test_client() as tc: + resp = tc.post("/api/gtrade/estimate-fees", + data=json.dumps({"asset": "BTC", "collateral_usd": 100, "leverage": 50}), + content_type="application/json") + assert resp.status_code == 200 + data = json.loads(resp.data) + assert "open_fee" in data + assert "close_fee" in data + assert "total_fee" in data + assert data["total_fee"] > 0 + + +def test_flask_gtrade_estimate_fees_invalid(): + """Verify /api/gtrade/estimate-fees rejects invalid params.""" + client = _make_client() + app = create_app(client) + with app.test_client() as tc: + resp = tc.post("/api/gtrade/estimate-fees", + data=json.dumps({"asset": "", "collateral_usd": 0, "leverage": 0}), + content_type="application/json") + assert resp.status_code == 400 + + +def test_flask_validate_trade_includes_fees(): + """Verify validate-trade response includes fee data in summary.""" + client = _make_client() + app = create_app(client) + with app.test_client() as tc: + resp = tc.post("/api/gtrade/validate-trade", + data=json.dumps({"asset": "BTC", "direction": "long", + "leverage": 50, "collateral_usd": 100}), + content_type="application/json") + assert resp.status_code == 200 + data = json.loads(resp.data) + assert data["valid"] is True + assert "fees" in data["summary"] + assert data["summary"]["fees"]["total_fee"] > 0 + + +def test_group_fees_structure(): + """Verify GROUP_FEES has expected groups and keys.""" + for group in ["crypto", "stocks", "commodities"]: + assert group in GROUP_FEES + assert "open_fee_pct" in GROUP_FEES[group] + assert "close_fee_pct" in GROUP_FEES[group] + assert GROUP_FEES[group]["open_fee_pct"] > 0 + + +def test_liq_threshold_constant(): + """Verify LIQ_THRESHOLD_PCT is 90.""" + assert LIQ_THRESHOLD_PCT == 90 + + +def test_dashboard_html_contains_new_css(): + """Verify dashboard HTML includes CSS for new trade management features.""" + client = _make_client() + html = generate_dashboard_html(client) + assert 'manage-btn' in html + assert 'manage-panel' in html + assert 'liq-price' in html + assert 'fee-estimate' in html + assert 'market-warning' in html + + if __name__ == "__main__": test_client_loads_in_mock_mode() test_fetch_all_equities_data() @@ -827,4 +982,19 @@ def test_fetch_open_trades_empty_address(): test_dashboard_html_contains_wallet_ui() test_flask_gtrade_open_trades_invalid_address() test_fetch_open_trades_empty_address() + test_is_market_open_returns_tuple() + test_estimate_trade_fees_crypto() + test_estimate_trade_fees_stocks() + test_estimate_trade_fees_unknown_asset() + test_calculate_liquidation_price_long() + test_calculate_liquidation_price_short() + test_calculate_liquidation_price_high_leverage() + test_calculate_liquidation_price_edge_cases() + test_flask_gtrade_market_status() + test_flask_gtrade_estimate_fees_valid() + test_flask_gtrade_estimate_fees_invalid() + test_flask_validate_trade_includes_fees() + test_group_fees_structure() + test_liq_threshold_constant() + test_dashboard_html_contains_new_css() print("All tests passed!") From 7c352214ffc42aa95795b1084d3fc8714c2ccd50 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 13 Mar 2026 01:06:50 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20#33=20review=20commen?= =?UTF-8?q?ts=20=E2=80=94=20liquidation=20history,=20GROUP=5FLIMITS,=20XAU?= =?UTF-8?q?=20fees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix GROUP_LIMITS: stocks max_leverage 150→50, min_leverage 2→1.1 - Add commodities_t1 group (XAU max_leverage 250) + fee tier in GROUP_FEES - Detect liquidations/TP/SL hits via disappeared-trade polling in loadOpenTrades - Persist _openTradesCache to sessionStorage for cross-refresh detection - Guard against false positives with _closingTradeIndices for user-initiated closes - Migrate fetch_trade_history to backend-global endpoint with legacy fallback - Add TP HIT / SL HIT / LIQUIDATED badge CSS styling - Add regression tests: leverage limits, XAU fees, badge CSS, backend URL (73/73 pass) --- tools/tide-chart/gtrade.py | 31 +++++++++++- tools/tide-chart/main.py | 4 ++ tools/tide-chart/static/trading.js | 74 ++++++++++++++++++++++++++--- tools/tide-chart/tests/test_tool.py | 62 +++++++++++++++++++++++- 4 files changed, 162 insertions(+), 9 deletions(-) diff --git a/tools/tide-chart/gtrade.py b/tools/tide-chart/gtrade.py index 7110fa6..cc39b7f 100644 --- a/tools/tide-chart/gtrade.py +++ b/tools/tide-chart/gtrade.py @@ -27,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) @@ -38,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"}, @@ -58,6 +60,7 @@ "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) @@ -249,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()}", diff --git a/tools/tide-chart/main.py b/tools/tide-chart/main.py index 5106d19..6d14459 100644 --- a/tools/tide-chart/main.py +++ b/tools/tide-chart/main.py @@ -498,6 +498,10 @@ def generate_dashboard_html(client) -> str: " 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" diff --git a/tools/tide-chart/static/trading.js b/tools/tide-chart/static/trading.js index 1863b35..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() { @@ -105,7 +114,7 @@ async function updateTradeTP(tradeIndex, remove) { 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); + 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'; @@ -168,7 +177,7 @@ async function updateTradeSL(tradeIndex, remove) { 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); + 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'; @@ -253,6 +262,7 @@ async function decreasePosition(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) { @@ -918,7 +928,51 @@ async function loadOpenTrades() { var data = await resp.json(); var trades = data.trades || []; var pairNames = data.pair_names || {}; + + // Detect disappeared trades (likely liquidations) before resetting cache + var currentTradeIndices = {}; + trades.forEach(function(item) { + var t = item.trade || item; + currentTradeIndices[parseInt(t.index || '0')] = true; + }); + Object.keys(_openTradesCache).forEach(function(idx) { + if (!currentTradeIndices[idx] && !_closingTradeIndices[idx]) { + var cached = _openTradesCache[idx]; + if (cached && cached.pairLabel) { + var colIdx = parseInt(cached.collateralIndex || '3'); + var colDecimals = (colIdx === 3) ? 6 : 18; + var col = Number(BigInt(cached.collateralAmount || '0')) / Math.pow(10, colDecimals); + var entryPrice = cached.openPrice ? (parseFloat(cached.openPrice) / 1e10).toFixed(2) : '?'; + // Determine close type: TP hit, SL hit, or liquidation + var hadTp = cached.tp && parseFloat(cached.tp) > 0; + var hadSl = cached.sl && parseFloat(cached.sl) > 0; + var closeType = 'liquidation'; + var toastMsg = 'Position liquidated: '; + if (hadTp || hadSl) { + closeType = 'protocol_close'; + toastMsg = 'Position closed by protocol (TP/SL): '; + } + var isLiq = closeType === 'liquidation'; + saveTradeToHistory({ + dir: cached.dir, lev: cached.lev, long: !!cached.long, + pairLabel: cached.pairLabel, + collateral: col.toFixed(2), + entryPrice: entryPrice, + closePrice: '?', + pnlUsd: isLiq ? (-col * 0.9).toFixed(2) : 'pending', + pnlPct: isLiq ? '-90.0' : 'pending', + txHash: null, + closedAt: new Date().toISOString(), + isLiquidation: isLiq + }); + showToast(toastMsg + cached.dir + ' ' + cached.pairLabel + ' ' + cached.lev, isLiq ? 'error' : 'info', 8000); + } + } + }); + if (trades.length === 0) { + _openTradesCache = {}; + _persistOpenCache(); container.innerHTML = '
No open positions
'; return; } @@ -1080,6 +1134,7 @@ async function loadOpenTrades() { }); return; } + _persistOpenCache(); container.innerHTML = html; } catch (e) { container.innerHTML = '
Could not load trades
'; @@ -1157,6 +1212,7 @@ async function loadTradeHistory() { txHash: null, closedAt: null, isLiquidation: isLiquidation, + closeType: closeData.closeType !== undefined ? parseInt(closeData.closeType) : null, _source: 'backend' }); }); @@ -1208,8 +1264,11 @@ async function loadTradeHistory() { var txLink = h.txHash ? ' tx' : ''; - var badge = h.isLiquidation ? 'LIQUIDATED' : 'CLOSED'; - var badgeClass = h.isLiquidation ? 'history-badge liq-badge' : 'history-badge'; + var badge = 'CLOSED'; + var badgeClass = 'history-badge'; + if (h.isLiquidation) { badge = 'LIQUIDATED'; badgeClass = 'history-badge liq-badge'; } + else if (h.closeType === 1) { badge = 'TP HIT'; badgeClass = 'history-badge tp-badge'; } + else if (h.closeType === 2) { badge = 'SL HIT'; badgeClass = 'history-badge sl-badge'; } // Format timestamp var timeStr = ''; if (h.closedAt) { @@ -1251,6 +1310,7 @@ async function closeTrade(tradeIndex, pairIndex) { return; } tradePending = true; + _closingTradeIndices[tradeIndex] = true; try { // Resolve Chainlink feed dynamically from cached pair names var feedAddr = resolveFeedForPairIndex(pairIndex, pairIndexToTicker); @@ -1299,6 +1359,7 @@ async function closeTrade(tradeIndex, pairIndex) { // Immediately update UI: remove closed trade from open positions list var cached = _openTradesCache[tradeIndex] || {}; delete _openTradesCache[tradeIndex]; + _persistOpenCache(); var openContainer = document.getElementById('open-trades-list'); if (openContainer) { var btns = openContainer.querySelectorAll('.close-trade-btn'); @@ -1369,6 +1430,7 @@ async function closeTrade(tradeIndex, pairIndex) { showToast(msg, 'error', 8000); } finally { tradePending = false; + delete _closingTradeIndices[tradeIndex]; } } diff --git a/tools/tide-chart/tests/test_tool.py b/tools/tide-chart/tests/test_tool.py index fc88bc3..92f76c4 100644 --- a/tools/tide-chart/tests/test_tool.py +++ b/tools/tide-chart/tests/test_tool.py @@ -44,6 +44,7 @@ get_contract_config, get_asset_limits, fetch_open_trades, + fetch_trade_history, is_market_open, estimate_trade_fees, calculate_liquidation_price, @@ -620,6 +621,32 @@ def test_gtrade_validate_min_position_size(): assert "below minimum" in err.lower() or "Position size" in err +def test_gtrade_pr30_leverage_limits(): + """Regression: PR #30 fixed stock max leverage to 50 and added commodities_t1 (XAU) at 250.""" + # Stocks: max leverage is 50, not 150 + valid, err = validate_trade_params("TSLA", "long", 51, 200) + assert valid is False + assert "exceed" in err + + valid, err = validate_trade_params("TSLA", "long", 50, 200) + assert valid is True + + # Stocks: min leverage is 1.1 + valid, err = validate_trade_params("SPY", "long", 1.1, 2000) + assert valid is True + + # XAU: commodities_t1 group with max leverage 250 + limits = get_asset_limits("XAU") + assert limits["max_leverage"] == 250 + + valid, err = validate_trade_params("XAU", "long", 250, 200) + assert valid is True + + valid, err = validate_trade_params("XAU", "long", 251, 200) + assert valid is False + assert "exceed" in err + + def test_gtrade_build_trade_summary(): s = build_trade_summary("NVDA", 950.0, "long", 10, 200) assert s["asset"] == "NVDA" @@ -653,7 +680,11 @@ def test_gtrade_contract_config(): assert "crypto" in cfg["group_limits"] assert "stocks" in cfg["group_limits"] assert "commodities" in cfg["group_limits"] + assert "commodities_t1" in cfg["group_limits"] assert cfg["group_limits"]["crypto"]["min_position_usd"] == 1500 + assert cfg["group_limits"]["stocks"]["max_leverage"] == 50 + assert cfg["group_limits"]["stocks"]["min_leverage"] == 1.1 + assert cfg["group_limits"]["commodities_t1"]["max_leverage"] == 250 def test_flask_gtrade_config_route(): @@ -903,13 +934,22 @@ def test_flask_validate_trade_includes_fees(): def test_group_fees_structure(): """Verify GROUP_FEES has expected groups and keys.""" - for group in ["crypto", "stocks", "commodities"]: + for group in ["crypto", "stocks", "commodities", "commodities_t1"]: assert group in GROUP_FEES assert "open_fee_pct" in GROUP_FEES[group] assert "close_fee_pct" in GROUP_FEES[group] assert GROUP_FEES[group]["open_fee_pct"] > 0 +def test_estimate_trade_fees_xau(): + """Verify XAU uses commodities_t1 fee tier, not crypto fallback.""" + fees = estimate_trade_fees("XAU", 200, 100) + assert fees["position_usd"] == 20000.0 + # commodities_t1 fee is 0.01%, not crypto 0.06% + assert fees["fee_pct"] == 0.01 + assert abs(fees["open_fee"] - 2.0) < 0.01 + + def test_liq_threshold_constant(): """Verify LIQ_THRESHOLD_PCT is 90.""" assert LIQ_THRESHOLD_PCT == 90 @@ -924,6 +964,22 @@ def test_dashboard_html_contains_new_css(): assert 'liq-price' in html assert 'fee-estimate' in html assert 'market-warning' in html + # TP/SL/LIQ badge styles + assert 'tp-badge' in html + assert 'sl-badge' in html + assert 'liq-badge' in html + + +def test_fetch_trade_history_empty_address(): + """Verify fetch_trade_history returns empty list for empty address.""" + result = fetch_trade_history("") + assert result == [] + + +def test_backend_global_url(): + """Verify the backend-global URL constant is set.""" + from gtrade import GTRADE_GLOBAL_BACKEND_URL + assert "backend-global.gains.trade" in GTRADE_GLOBAL_BACKEND_URL if __name__ == "__main__": @@ -997,4 +1053,8 @@ def test_dashboard_html_contains_new_css(): test_group_fees_structure() test_liq_threshold_constant() test_dashboard_html_contains_new_css() + test_gtrade_pr30_leverage_limits() + test_estimate_trade_fees_xau() + test_fetch_trade_history_empty_address() + test_backend_global_url() print("All tests passed!")