From bad653ff2dc766375127cba227363b52c0bab68c Mon Sep 17 00:00:00 2001 From: Mukan Erkin Date: Sat, 25 Apr 2026 12:11:32 +0300 Subject: [PATCH] feat(mse): earn/futures wallet tabs + 8-decimal price formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BinanceClient::get_earn_flexible_balances() — /sapi/v1/simple-earn/flexible/position - BinanceClient::get_futures_balances() — /fapi/v2/account (fapi.binance.com) - GET /api/wallet/earn and /api/wallet/futures endpoints - wallet.html: Earn and Futures tabs now show real data - fmt() default changed from d=2 to d=8 (trailing zeros stripped) across bot.html, positions.html, index.html — fixes DOGE showing as 0.10 instead of 0.09849 Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.rs | 2 + src/api/wallet.rs | 63 +++++++++++++++++-- src/binance/client.rs | 96 +++++++++++++++++++++++++++++ src/web/bot.html | 44 +++++++------- src/web/index.html | 12 ++-- src/web/positions.html | 14 ++--- src/web/wallet.html | 135 ++++++++++++++++++++++++++++++----------- 7 files changed, 288 insertions(+), 78 deletions(-) diff --git a/src/api/routes.rs b/src/api/routes.rs index 339da73..d9fb1b2 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -24,6 +24,8 @@ pub fn build(state: AppState) -> Router { .route("/bots/:id/positions/:order_id/cancel", post(bots::cancel_position)) .route("/bots/:id/positions/:order_id/sell", post(bots::sell_position)) .route("/wallet/spot", get(wallet::spot_balances)) + .route("/wallet/earn", get(wallet::earn_balances)) + .route("/wallet/futures", get(wallet::futures_balances)) .route("/positions", get(positions::open_positions)) .route("/positions/closed", get(positions::closed_positions)) .route("/mode", get(mode::get_mode).post(mode::set_mode)) diff --git a/src/api/wallet.rs b/src/api/wallet.rs index 1db0742..6d412f0 100644 --- a/src/api/wallet.rs +++ b/src/api/wallet.rs @@ -5,26 +5,77 @@ use crate::binance::client::BinanceClient; use crate::AppState; #[derive(Serialize)] -pub struct BalanceEntry { +pub struct SpotBalance { pub asset: String, pub free: f64, pub locked: f64, } -pub async fn spot_balances(State(state): State) -> impl IntoResponse { - let mode = state.current_mode.lock().await.clone(); +#[derive(Serialize)] +pub struct EarnBalance { + pub asset: String, + pub total: f64, + pub free: f64, +} + +#[derive(Serialize)] +pub struct FuturesBalance { + pub asset: String, + pub wallet: f64, + pub unrealized_pnl: f64, + pub available: f64, +} + +fn make_client(state: &AppState, mode: &str) -> BinanceClient { let (api_key, api_secret) = if mode == "live" { (state.live_api_key.clone(), state.live_api_secret.clone()) } else { (state.testnet_api_key.clone(), state.testnet_api_secret.clone()) }; - let client = BinanceClient::new(api_key, api_secret, mode == "testnet"); + BinanceClient::new(api_key, api_secret, mode == "testnet") +} + +pub async fn spot_balances(State(state): State) -> impl IntoResponse { + let mode = state.current_mode.lock().await.clone(); + let client = make_client(&state, &mode); match client.get_account_balances().await { Ok(balances) => { - let entries: Vec = balances + let entries: Vec = balances .into_iter() - .map(|(asset, free, locked)| BalanceEntry { asset, free, locked }) + .map(|(asset, free, locked)| SpotBalance { asset, free, locked }) + .collect(); + Json(entries).into_response() + } + Err(e) => (axum::http::StatusCode::BAD_GATEWAY, e.to_string()).into_response(), + } +} + +pub async fn earn_balances(State(state): State) -> impl IntoResponse { + let mode = state.current_mode.lock().await.clone(); + let client = make_client(&state, &mode); + + match client.get_earn_flexible_balances().await { + Ok(balances) => { + let entries: Vec = balances + .into_iter() + .map(|(asset, total, free)| EarnBalance { asset, total, free }) + .collect(); + Json(entries).into_response() + } + Err(e) => (axum::http::StatusCode::BAD_GATEWAY, e.to_string()).into_response(), + } +} + +pub async fn futures_balances(State(state): State) -> impl IntoResponse { + let mode = state.current_mode.lock().await.clone(); + let client = make_client(&state, &mode); + + match client.get_futures_balances().await { + Ok(balances) => { + let entries: Vec = balances + .into_iter() + .map(|(asset, wallet, unrealized_pnl, available)| FuturesBalance { asset, wallet, unrealized_pnl, available }) .collect(); Json(entries).into_response() } diff --git a/src/binance/client.rs b/src/binance/client.rs index db8e50d..e349ede 100644 --- a/src/binance/client.rs +++ b/src/binance/client.rs @@ -362,6 +362,102 @@ impl BinanceClient { Ok(result) } + /// Simple Earn Flexible pozisyonları: Vec<(asset, total, free)> + pub async fn get_earn_flexible_balances(&self) -> Result> { + // SAPI endpoint — testnet desteklemiyor, her zaman prod'dan çek + let url = "https://api.binance.com/sapi/v1/simple-earn/flexible/position"; + let mut results = Vec::new(); + let mut current = 1u32; + let size = 100u32; + + loop { + let timestamp = chrono::Utc::now().timestamp_millis().to_string(); + let params = format!("current={}&size={}×tamp={}", current, size, timestamp); + let signature = self.sign(¶ms); + + let response = self + .http + .get(url) + .header("X-MBX-APIKEY", &self.api_key) + .query(&[ + ("current", current.to_string().as_str()), + ("size", size.to_string().as_str()), + ("timestamp", timestamp.as_str()), + ("signature", signature.as_str()), + ]) + .send() + .await? + .json::() + .await?; + + if let Some(code) = response.get("code") { + return Err(anyhow!("Earn API hatası {}: {}", code, response["msg"].as_str().unwrap_or(""))); + } + + let rows = match response["rows"].as_array() { + Some(r) => r.clone(), + None => break, + }; + let total_pages = response["total"].as_u64().unwrap_or(0); + + for row in &rows { + let asset = match row["asset"].as_str() { Some(a) => a.to_string(), None => continue }; + let total: f64 = row["totalAmount"].as_str().unwrap_or("0").parse().unwrap_or(0.0); + let free: f64 = row["freeAmount"].as_str().unwrap_or("0").parse().unwrap_or(0.0); + if total > 0.0 { + results.push((asset, total, free)); + } + } + + if (current as u64) * (size as u64) >= total_pages { break; } + current += 1; + } + + Ok(results) + } + + /// USD-M Futures hesap bakiyesi: Vec<(asset, wallet_balance, unrealized_pnl, available)> + pub async fn get_futures_balances(&self) -> Result> { + let futures_url = if self.base_url == BINANCE_TESTNET_URL { + "https://testnet.binancefutures.com" + } else { + "https://fapi.binance.com" + }; + let url = format!("{}/fapi/v2/account", futures_url); + let timestamp = chrono::Utc::now().timestamp_millis().to_string(); + let params = format!("timestamp={}", timestamp); + let signature = self.sign(¶ms); + + let response = self + .http + .get(&url) + .header("X-MBX-APIKEY", &self.api_key) + .query(&[("timestamp", timestamp.as_str()), ("signature", signature.as_str())]) + .send() + .await? + .json::() + .await?; + + if let Some(code) = response.get("code") { + return Err(anyhow!("Futures API hatası {}: {}", code, response["msg"].as_str().unwrap_or(""))); + } + + let assets = match response["assets"].as_array() { + Some(a) => a, + None => return Ok(vec![]), + }; + + let result = assets.iter().filter_map(|a| { + let asset = a["asset"].as_str()?.to_string(); + let wallet: f64 = a["walletBalance"].as_str()?.parse().ok()?; + let upnl: f64 = a["unrealizedProfit"].as_str()?.parse().ok()?; + let available: f64 = a["availableBalance"].as_str()?.parse().ok()?; + if wallet.abs() > 0.0 || upnl.abs() > 0.0 { Some((asset, wallet, upnl, available)) } else { None } + }).collect(); + + Ok(result) + } + /// HMAC-SHA256 imzası fn sign(&self, params: &str) -> String { use hmac::{Hmac, Mac}; diff --git a/src/web/bot.html b/src/web/bot.html index 4705af8..390c34f 100644 --- a/src/web/bot.html +++ b/src/web/bot.html @@ -330,7 +330,7 @@ async function api(method, path, body) { return res; } function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } -function fmt(n, d=2) { return parseFloat(n).toLocaleString('tr-TR', { minimumFractionDigits: d, maximumFractionDigits: d }); } +function fmt(n, d=8) { const v = parseFloat(n); if (isNaN(v)) return '—'; return v.toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: d }); } function shortDate(ms) { return new Date(ms).toLocaleString('tr-TR',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); } function buyTime(ms) { const tf = TF_SECONDS[bot.timeframe] || 300; @@ -441,12 +441,12 @@ async function loadTicker() { const res = await fetch(`https://api.binance.com/api/v3/ticker/24hr?symbol=${bot.symbol}`); const t = await res.json(); const change = parseFloat(t.priceChangePercent); - document.getElementById('pb-price').textContent = fmt(t.lastPrice, 2); + document.getElementById('pb-price').textContent = fmt(t.lastPrice); const chEl = document.getElementById('pb-change'); chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%'; chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)'; - document.getElementById('pb-high').textContent = fmt(t.highPrice, 2); - document.getElementById('pb-low').textContent = fmt(t.lowPrice, 2); + document.getElementById('pb-high').textContent = fmt(t.highPrice); + document.getElementById('pb-low').textContent = fmt(t.lowPrice); const vol = parseFloat(t.quoteVolume); document.getElementById('pb-vol').textContent = vol >= 1e9 ? (vol/1e9).toFixed(2)+'B' : vol >= 1e6 ? (vol/1e6).toFixed(2)+'M' : fmt(vol, 0); } catch(e) {} @@ -461,7 +461,7 @@ function startPriceWs() { const price = parseFloat(t.c); currentPrice = price; const change = parseFloat(t.P); - document.getElementById('pb-price').textContent = fmt(price, 2); + document.getElementById('pb-price').textContent = fmt(price); const chEl = document.getElementById('pb-change'); chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%'; chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)'; @@ -493,10 +493,10 @@ function initChart() { if (!p.seriesData || !p.seriesData.size) return; const d = p.seriesData.get(candleSeries); if (!d) return; - document.getElementById('ohlc-o').textContent = fmt(d.open, 2); - document.getElementById('ohlc-h').textContent = fmt(d.high, 2); - document.getElementById('ohlc-l').textContent = fmt(d.low, 2); - document.getElementById('ohlc-c').textContent = fmt(d.close, 2); + document.getElementById('ohlc-o').textContent = fmt(d.open); + document.getElementById('ohlc-h').textContent = fmt(d.high); + document.getElementById('ohlc-l').textContent = fmt(d.low); + document.getElementById('ohlc-c').textContent = fmt(d.close); }); new ResizeObserver(() => { chart.applyOptions({ width: container.clientWidth, height: container.clientHeight }); @@ -516,10 +516,10 @@ async function loadKlines() { chart.timeScale().setVisibleLogicalRange({ from: candles.length - 80, to: candles.length + 5 }); if (candles.length) { const last = candles[candles.length - 1]; - document.getElementById('ohlc-o').textContent = fmt(last.open, 2); - document.getElementById('ohlc-h').textContent = fmt(last.high, 2); - document.getElementById('ohlc-l').textContent = fmt(last.low, 2); - document.getElementById('ohlc-c').textContent = fmt(last.close, 2); + document.getElementById('ohlc-o').textContent = fmt(last.open); + document.getElementById('ohlc-h').textContent = fmt(last.high); + document.getElementById('ohlc-l').textContent = fmt(last.low); + document.getElementById('ohlc-c').textContent = fmt(last.close); } updateChartOverlays(); } catch(e) {} @@ -535,11 +535,11 @@ function startKlineWs() { const k = JSON.parse(e.data).k; const candle = { time: Math.floor(k.t/1000), open: parseFloat(k.o), high: parseFloat(k.h), low: parseFloat(k.l), close: parseFloat(k.c) }; candleSeries.update(candle); - document.getElementById('ohlc-o').textContent = fmt(candle.open, 2); - document.getElementById('ohlc-h').textContent = fmt(candle.high, 2); - document.getElementById('ohlc-l').textContent = fmt(candle.low, 2); - document.getElementById('ohlc-c').textContent = fmt(candle.close, 2); - document.getElementById('pb-price').textContent = fmt(candle.close, 2); + document.getElementById('ohlc-o').textContent = fmt(candle.open); + document.getElementById('ohlc-h').textContent = fmt(candle.high); + document.getElementById('ohlc-l').textContent = fmt(candle.low); + document.getElementById('ohlc-c').textContent = fmt(candle.close); + document.getElementById('pb-price').textContent = fmt(candle.close); }; klineWs.onerror = () => {}; klineWs.onclose = () => setTimeout(startKlineWs, 5000); @@ -553,7 +553,7 @@ function updateChartOverlays() { priceLines.push(candleSeries.createPriceLine({ price: p.sell_target, color: '#2ecc71', lineWidth: 1, lineStyle: LightweightCharts.LineStyle.Dashed, - axisLabelVisible: true, title: fmt(p.sell_target, 2), + axisLabelVisible: true, title: fmt(p.sell_target), })); }); @@ -570,19 +570,19 @@ function updateChartOverlays() { const t = buyTime(p.opened_at); if (t < minTime || t > maxTime) return; if (!buyMap.has(t)) buyMap.set(t, []); - buyMap.get(t).push(fmt(p.buy_price, 2)); + buyMap.get(t).push(fmt(p.buy_price)); }); closedPositionsCache.forEach(c => { const tb = buyTime(c.opened_at); if (tb >= minTime && tb <= maxTime) { if (!buyMap.has(tb)) buyMap.set(tb, []); - buyMap.get(tb).push(fmt(c.buy_price, 2)); + buyMap.get(tb).push(fmt(c.buy_price)); } if (c.status === 'FILLED') { const ts = Math.floor(c.closed_at / 1000); if (ts >= minTime && ts <= maxTime) { if (!sellMap.has(ts)) sellMap.set(ts, []); - sellMap.get(ts).push(fmt(c.sell_target, 2)); + sellMap.get(ts).push(fmt(c.sell_target)); } } }); diff --git a/src/web/index.html b/src/web/index.html index b9f994d..5bbe164 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -217,9 +217,9 @@ function renderPositions() { tbody.innerHTML = page.map(p => ` ${esc(p.bot_name)} ${esc(p.symbol)} - ${p.buy_price.toFixed(6)} - ${p.sell_target.toFixed(6)} - ${p.quantity} + ${p.buy_price.toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})} + ${p.sell_target.toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})} + ${parseFloat(p.quantity).toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})} ${new Date(p.opened_at).toLocaleString('tr-TR')} `).join(''); renderPagination('positions-pagination', positionsCache.length, posPage, 'goPosPage'); @@ -243,9 +243,9 @@ function renderClosed() { tbody.innerHTML = page.map(c => ` ${esc(c.bot_name)} ${esc(c.symbol)} - ${c.buy_price.toFixed(6)} - ${c.sell_target.toFixed(6)} - ${c.quantity} + ${c.buy_price.toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})} + ${c.sell_target.toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})} + ${parseFloat(c.quantity).toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})} ${c.status} ${new Date(c.closed_at).toLocaleString('tr-TR')} `).join(''); diff --git a/src/web/positions.html b/src/web/positions.html index 9f29b4c..2ff1c2e 100644 --- a/src/web/positions.html +++ b/src/web/positions.html @@ -82,7 +82,7 @@ async function api(method, path) { if (res.status === 401) { window.location.href = '/'; throw new Error('Unauthorized'); } return res; } -function fmt(n, d=2) { return parseFloat(n).toLocaleString('tr-TR', { minimumFractionDigits: d, maximumFractionDigits: d }); } +function fmt(n, d=8) { const v = parseFloat(n); if (isNaN(v)) return '—'; return v.toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: d }); } function ts(ms) { return new Date(ms).toLocaleString('tr-TR'); } function onModeChange() { loadOpen(); loadClosed(); } @@ -114,9 +114,9 @@ function renderOpen() { tbody.innerHTML = page.map(p => ` ${p.bot_name} ${p.symbol} - ${fmt(p.buy_price, 4)} - ${fmt(p.sell_target, 4)} - ${fmt(p.quantity, 4)} + ${fmt(p.buy_price)} + ${fmt(p.sell_target)} + ${fmt(p.quantity)} ${ts(p.opened_at)} `).join(''); renderPagination('open-pagination', openCache.length, openPage, 'goOpenPage'); @@ -129,9 +129,9 @@ function renderClosed() { tbody.innerHTML = page.map(p => ` ${p.bot_name} ${p.symbol} - ${fmt(p.buy_price, 4)} - ${fmt(p.sell_target, 4)} - ${fmt(p.quantity, 4)} + ${fmt(p.buy_price)} + ${fmt(p.sell_target)} + ${fmt(p.quantity)} ${p.status} ${ts(p.closed_at)} `).join(''); diff --git a/src/web/wallet.html b/src/web/wallet.html index ca2201a..41d26fc 100644 --- a/src/web/wallet.html +++ b/src/web/wallet.html @@ -49,18 +49,13 @@ tr:last-child td { border-bottom: none; } tr:hover td { background: rgba(255,255,255,.02); } .asset-name { font-weight: 600; font-size: 13px; } - .asset-full { font-size: 11px; color: var(--muted); margin-top: 1px; } - .val-free { color: var(--text); } .val-locked { color: var(--yellow); } - .val-usdt { font-size: 11px; color: var(--muted); margin-top: 1px; } + .val-pnl-pos { color: var(--green); } + .val-pnl-neg { color: var(--red); } .empty { padding: 32px 18px; color: var(--muted); font-size: 13px; text-align: center; } .loading { padding: 32px 18px; color: var(--muted); font-size: 13px; text-align: center; } .usdt-row td { background: rgba(46,204,113,.04); } .usdt-row .asset-name { color: var(--green); } - - .coming-soon { padding: 48px 18px; text-align: center; color: var(--muted); } - .coming-soon .icon { font-size: 32px; margin-bottom: 12px; } - .coming-soon p { font-size: 13px; } @@ -71,9 +66,9 @@
Cüzdanlar
- - - + + +
@@ -92,40 +87,69 @@