feat(mse): earn/futures wallet tabs + 8-decimal price formatting

- 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 <noreply@anthropic.com>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-25 12:11:32 +03:00
parent 799cd53695
commit bad653ff2d
7 changed files with 288 additions and 78 deletions

View file

@ -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/cancel", post(bots::cancel_position))
.route("/bots/:id/positions/:order_id/sell", post(bots::sell_position)) .route("/bots/:id/positions/:order_id/sell", post(bots::sell_position))
.route("/wallet/spot", get(wallet::spot_balances)) .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", get(positions::open_positions))
.route("/positions/closed", get(positions::closed_positions)) .route("/positions/closed", get(positions::closed_positions))
.route("/mode", get(mode::get_mode).post(mode::set_mode)) .route("/mode", get(mode::get_mode).post(mode::set_mode))

View file

@ -5,26 +5,77 @@ use crate::binance::client::BinanceClient;
use crate::AppState; use crate::AppState;
#[derive(Serialize)] #[derive(Serialize)]
pub struct BalanceEntry { pub struct SpotBalance {
pub asset: String, pub asset: String,
pub free: f64, pub free: f64,
pub locked: f64, pub locked: f64,
} }
pub async fn spot_balances(State(state): State<AppState>) -> impl IntoResponse { #[derive(Serialize)]
let mode = state.current_mode.lock().await.clone(); 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" { let (api_key, api_secret) = if mode == "live" {
(state.live_api_key.clone(), state.live_api_secret.clone()) (state.live_api_key.clone(), state.live_api_secret.clone())
} else { } else {
(state.testnet_api_key.clone(), state.testnet_api_secret.clone()) (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<AppState>) -> impl IntoResponse {
let mode = state.current_mode.lock().await.clone();
let client = make_client(&state, &mode);
match client.get_account_balances().await { match client.get_account_balances().await {
Ok(balances) => { Ok(balances) => {
let entries: Vec<BalanceEntry> = balances let entries: Vec<SpotBalance> = balances
.into_iter() .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<AppState>) -> 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<EarnBalance> = 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<AppState>) -> 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<FuturesBalance> = balances
.into_iter()
.map(|(asset, wallet, unrealized_pnl, available)| FuturesBalance { asset, wallet, unrealized_pnl, available })
.collect(); .collect();
Json(entries).into_response() Json(entries).into_response()
} }

View file

@ -362,6 +362,102 @@ impl BinanceClient {
Ok(result) Ok(result)
} }
/// Simple Earn Flexible pozisyonları: Vec<(asset, total, free)>
pub async fn get_earn_flexible_balances(&self) -> Result<Vec<(String, f64, f64)>> {
// 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={}&timestamp={}", current, size, timestamp);
let signature = self.sign(&params);
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::<Value>()
.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<Vec<(String, f64, f64, f64)>> {
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(&params);
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::<Value>()
.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ı /// HMAC-SHA256 imzası
fn sign(&self, params: &str) -> String { fn sign(&self, params: &str) -> String {
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};

View file

@ -330,7 +330,7 @@ async function api(method, path, body) {
return res; return res;
} }
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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 shortDate(ms) { return new Date(ms).toLocaleString('tr-TR',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); }
function buyTime(ms) { function buyTime(ms) {
const tf = TF_SECONDS[bot.timeframe] || 300; 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 res = await fetch(`https://api.binance.com/api/v3/ticker/24hr?symbol=${bot.symbol}`);
const t = await res.json(); const t = await res.json();
const change = parseFloat(t.priceChangePercent); 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'); const chEl = document.getElementById('pb-change');
chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%'; chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)'; chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)';
document.getElementById('pb-high').textContent = fmt(t.highPrice, 2); document.getElementById('pb-high').textContent = fmt(t.highPrice);
document.getElementById('pb-low').textContent = fmt(t.lowPrice, 2); document.getElementById('pb-low').textContent = fmt(t.lowPrice);
const vol = parseFloat(t.quoteVolume); 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); document.getElementById('pb-vol').textContent = vol >= 1e9 ? (vol/1e9).toFixed(2)+'B' : vol >= 1e6 ? (vol/1e6).toFixed(2)+'M' : fmt(vol, 0);
} catch(e) {} } catch(e) {}
@ -461,7 +461,7 @@ function startPriceWs() {
const price = parseFloat(t.c); const price = parseFloat(t.c);
currentPrice = price; currentPrice = price;
const change = parseFloat(t.P); 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'); const chEl = document.getElementById('pb-change');
chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%'; chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)'; chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)';
@ -493,10 +493,10 @@ function initChart() {
if (!p.seriesData || !p.seriesData.size) return; if (!p.seriesData || !p.seriesData.size) return;
const d = p.seriesData.get(candleSeries); const d = p.seriesData.get(candleSeries);
if (!d) return; if (!d) return;
document.getElementById('ohlc-o').textContent = fmt(d.open, 2); document.getElementById('ohlc-o').textContent = fmt(d.open);
document.getElementById('ohlc-h').textContent = fmt(d.high, 2); document.getElementById('ohlc-h').textContent = fmt(d.high);
document.getElementById('ohlc-l').textContent = fmt(d.low, 2); document.getElementById('ohlc-l').textContent = fmt(d.low);
document.getElementById('ohlc-c').textContent = fmt(d.close, 2); document.getElementById('ohlc-c').textContent = fmt(d.close);
}); });
new ResizeObserver(() => { new ResizeObserver(() => {
chart.applyOptions({ width: container.clientWidth, height: container.clientHeight }); 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 }); chart.timeScale().setVisibleLogicalRange({ from: candles.length - 80, to: candles.length + 5 });
if (candles.length) { if (candles.length) {
const last = candles[candles.length - 1]; const last = candles[candles.length - 1];
document.getElementById('ohlc-o').textContent = fmt(last.open, 2); document.getElementById('ohlc-o').textContent = fmt(last.open);
document.getElementById('ohlc-h').textContent = fmt(last.high, 2); document.getElementById('ohlc-h').textContent = fmt(last.high);
document.getElementById('ohlc-l').textContent = fmt(last.low, 2); document.getElementById('ohlc-l').textContent = fmt(last.low);
document.getElementById('ohlc-c').textContent = fmt(last.close, 2); document.getElementById('ohlc-c').textContent = fmt(last.close);
} }
updateChartOverlays(); updateChartOverlays();
} catch(e) {} } catch(e) {}
@ -535,11 +535,11 @@ function startKlineWs() {
const k = JSON.parse(e.data).k; 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) }; 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); candleSeries.update(candle);
document.getElementById('ohlc-o').textContent = fmt(candle.open, 2); document.getElementById('ohlc-o').textContent = fmt(candle.open);
document.getElementById('ohlc-h').textContent = fmt(candle.high, 2); document.getElementById('ohlc-h').textContent = fmt(candle.high);
document.getElementById('ohlc-l').textContent = fmt(candle.low, 2); document.getElementById('ohlc-l').textContent = fmt(candle.low);
document.getElementById('ohlc-c').textContent = fmt(candle.close, 2); document.getElementById('ohlc-c').textContent = fmt(candle.close);
document.getElementById('pb-price').textContent = fmt(candle.close, 2); document.getElementById('pb-price').textContent = fmt(candle.close);
}; };
klineWs.onerror = () => {}; klineWs.onerror = () => {};
klineWs.onclose = () => setTimeout(startKlineWs, 5000); klineWs.onclose = () => setTimeout(startKlineWs, 5000);
@ -553,7 +553,7 @@ function updateChartOverlays() {
priceLines.push(candleSeries.createPriceLine({ priceLines.push(candleSeries.createPriceLine({
price: p.sell_target, color: '#2ecc71', lineWidth: 1, price: p.sell_target, color: '#2ecc71', lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dashed, 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); const t = buyTime(p.opened_at);
if (t < minTime || t > maxTime) return; if (t < minTime || t > maxTime) return;
if (!buyMap.has(t)) buyMap.set(t, []); 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 => { closedPositionsCache.forEach(c => {
const tb = buyTime(c.opened_at); const tb = buyTime(c.opened_at);
if (tb >= minTime && tb <= maxTime) { if (tb >= minTime && tb <= maxTime) {
if (!buyMap.has(tb)) buyMap.set(tb, []); 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') { if (c.status === 'FILLED') {
const ts = Math.floor(c.closed_at / 1000); const ts = Math.floor(c.closed_at / 1000);
if (ts >= minTime && ts <= maxTime) { if (ts >= minTime && ts <= maxTime) {
if (!sellMap.has(ts)) sellMap.set(ts, []); if (!sellMap.has(ts)) sellMap.set(ts, []);
sellMap.get(ts).push(fmt(c.sell_target, 2)); sellMap.get(ts).push(fmt(c.sell_target));
} }
} }
}); });

View file

@ -217,9 +217,9 @@ function renderPositions() {
tbody.innerHTML = page.map(p => `<tr> tbody.innerHTML = page.map(p => `<tr>
<td>${esc(p.bot_name)}</td> <td>${esc(p.bot_name)}</td>
<td><strong>${esc(p.symbol)}</strong></td> <td><strong>${esc(p.symbol)}</strong></td>
<td>${p.buy_price.toFixed(6)}</td> <td>${p.buy_price.toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})}</td>
<td>${p.sell_target.toFixed(6)}</td> <td>${p.sell_target.toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})}</td>
<td>${p.quantity}</td> <td>${parseFloat(p.quantity).toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})}</td>
<td>${new Date(p.opened_at).toLocaleString('tr-TR')}</td> <td>${new Date(p.opened_at).toLocaleString('tr-TR')}</td>
</tr>`).join(''); </tr>`).join('');
renderPagination('positions-pagination', positionsCache.length, posPage, 'goPosPage'); renderPagination('positions-pagination', positionsCache.length, posPage, 'goPosPage');
@ -243,9 +243,9 @@ function renderClosed() {
tbody.innerHTML = page.map(c => `<tr> tbody.innerHTML = page.map(c => `<tr>
<td>${esc(c.bot_name)}</td> <td>${esc(c.bot_name)}</td>
<td><strong>${esc(c.symbol)}</strong></td> <td><strong>${esc(c.symbol)}</strong></td>
<td>${c.buy_price.toFixed(6)}</td> <td>${c.buy_price.toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})}</td>
<td>${c.sell_target.toFixed(6)}</td> <td>${c.sell_target.toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})}</td>
<td>${c.quantity}</td> <td>${parseFloat(c.quantity).toLocaleString('tr-TR',{minimumFractionDigits:0,maximumFractionDigits:8})}</td>
<td><span class="badge ${c.status==='FILLED'?'badge-running':'badge-stopped'}">${c.status}</span></td> <td><span class="badge ${c.status==='FILLED'?'badge-running':'badge-stopped'}">${c.status}</span></td>
<td>${new Date(c.closed_at).toLocaleString('tr-TR')}</td> <td>${new Date(c.closed_at).toLocaleString('tr-TR')}</td>
</tr>`).join(''); </tr>`).join('');

View file

@ -82,7 +82,7 @@ async function api(method, path) {
if (res.status === 401) { window.location.href = '/'; throw new Error('Unauthorized'); } if (res.status === 401) { window.location.href = '/'; throw new Error('Unauthorized'); }
return res; 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 ts(ms) { return new Date(ms).toLocaleString('tr-TR'); }
function onModeChange() { loadOpen(); loadClosed(); } function onModeChange() { loadOpen(); loadClosed(); }
@ -114,9 +114,9 @@ function renderOpen() {
tbody.innerHTML = page.map(p => `<tr> tbody.innerHTML = page.map(p => `<tr>
<td>${p.bot_name}</td> <td>${p.bot_name}</td>
<td>${p.symbol}</td> <td>${p.symbol}</td>
<td>${fmt(p.buy_price, 4)}</td> <td>${fmt(p.buy_price)}</td>
<td>${fmt(p.sell_target, 4)}</td> <td>${fmt(p.sell_target)}</td>
<td>${fmt(p.quantity, 4)}</td> <td>${fmt(p.quantity)}</td>
<td>${ts(p.opened_at)}</td> <td>${ts(p.opened_at)}</td>
</tr>`).join(''); </tr>`).join('');
renderPagination('open-pagination', openCache.length, openPage, 'goOpenPage'); renderPagination('open-pagination', openCache.length, openPage, 'goOpenPage');
@ -129,9 +129,9 @@ function renderClosed() {
tbody.innerHTML = page.map(p => `<tr> tbody.innerHTML = page.map(p => `<tr>
<td>${p.bot_name}</td> <td>${p.bot_name}</td>
<td>${p.symbol}</td> <td>${p.symbol}</td>
<td>${fmt(p.buy_price, 4)}</td> <td>${fmt(p.buy_price)}</td>
<td>${fmt(p.sell_target, 4)}</td> <td>${fmt(p.sell_target)}</td>
<td>${fmt(p.quantity, 4)}</td> <td>${fmt(p.quantity)}</td>
<td><span class="badge badge-${p.status.toLowerCase()}">${p.status}</span></td> <td><span class="badge badge-${p.status.toLowerCase()}">${p.status}</span></td>
<td>${ts(p.closed_at)}</td> <td>${ts(p.closed_at)}</td>
</tr>`).join(''); </tr>`).join('');

View file

@ -49,18 +49,13 @@
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,.02); } tr:hover td { background: rgba(255,255,255,.02); }
.asset-name { font-weight: 600; font-size: 13px; } .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-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; } .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; } .loading { padding: 32px 18px; color: var(--muted); font-size: 13px; text-align: center; }
.usdt-row td { background: rgba(46,204,113,.04); } .usdt-row td { background: rgba(46,204,113,.04); }
.usdt-row .asset-name { color: var(--green); } .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; }
</style> </style>
</head> </head>
<body> <body>
@ -71,9 +66,9 @@
<div class="page-toolbar"> <div class="page-toolbar">
<span class="page-title">Cüzdanlar</span> <span class="page-title">Cüzdanlar</span>
<div class="tab-bar"> <div class="tab-bar">
<button class="tab active" onclick="showTab('spot')">Spot</button> <button class="tab active" id="tab-btn-spot" onclick="showTab('spot')">Spot</button>
<button class="tab" onclick="showTab('earn')">Earn</button> <button class="tab" id="tab-btn-earn" onclick="showTab('earn')">Earn</button>
<button class="tab" onclick="showTab('futures')">Futures</button> <button class="tab" id="tab-btn-futures" onclick="showTab('futures')">Futures</button>
</div> </div>
</div> </div>
@ -92,40 +87,69 @@
<div id="tab-earn" style="display:none"> <div id="tab-earn" style="display:none">
<section> <section>
<div class="section-header"><span class="section-title">Earn Cüzdan</span></div> <div class="section-header">
<div class="coming-soon"> <span class="section-title">Simple Earn — Flexible</span>
<div class="icon">🔒</div> <span class="total-badge" id="earn-total"></span>
<p>Earn cüzdanı desteği yakında eklenecek.</p>
</div> </div>
<table>
<thead><tr><th>Varlık</th><th>Toplam</th><th>Kullanılabilir</th></tr></thead>
<tbody id="earn-body"><tr><td colspan="3" class="loading">Yükleniyor...</td></tr></tbody>
</table>
</section> </section>
</div> </div>
<div id="tab-futures" style="display:none"> <div id="tab-futures" style="display:none">
<section> <section>
<div class="section-header"><span class="section-title">Futures Cüzdan</span></div> <div class="section-header">
<div class="coming-soon"> <span class="section-title">USD-M Futures</span>
<div class="icon">📈</div> <span class="total-badge" id="futures-total"></span>
<p>Futures cüzdanı desteği yakında eklenecek.</p>
</div> </div>
<table>
<thead><tr><th>Varlık</th><th>Cüzdan Bakiyesi</th><th>Gerçekleşmemiş PnL</th><th>Kullanılabilir</th></tr></thead>
<tbody id="futures-body"><tr><td colspan="4" class="loading">Yükleniyor...</td></tr></tbody>
</table>
</section> </section>
</div> </div>
</main> </main>
<script> <script>
function onModeChange() { loadSpot(); } function onModeChange() { loadActive(); }
async function api(path) { async function api(path) {
const res = await fetch('/api' + path, { credentials: 'include' }); const res = await fetch('/api' + path, { credentials: 'include' });
if (res.status === 401) { window.location.href = '/'; throw new Error('Unauthorized'); } if (res.status === 401) { window.location.href = '/'; throw new Error('Unauthorized'); }
return res; return res;
} }
function fmt(n, d=4) { return parseFloat(n).toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: d }); }
function fmt(n) {
const v = parseFloat(n);
if (isNaN(v)) return '—';
// trailing sıfırları at ama max 8 basamak göster
return v.toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: 8 });
}
let activeTab = 'spot';
const loaded = { spot: false, earn: false, futures: false };
function showTab(name) { function showTab(name) {
['spot','earn','futures'].forEach(t => { ['spot','earn','futures'].forEach(t => {
document.getElementById('tab-' + t).style.display = t === name ? '' : 'none'; document.getElementById('tab-' + t).style.display = t === name ? '' : 'none';
document.querySelectorAll('.tab')[['spot','earn','futures'].indexOf(t)].className = 'tab' + (t === name ? ' active' : ''); document.getElementById('tab-btn-' + t).className = 'tab' + (t === name ? ' active' : '');
}); });
activeTab = name;
if (!loaded[name]) loadTab(name);
}
function loadActive() {
loaded.spot = false; loaded.earn = false; loaded.futures = false;
loadTab(activeTab);
}
async function loadTab(name) {
if (name === 'spot') await loadSpot();
else if (name === 'earn') await loadEarn();
else if (name === 'futures') await loadFutures();
loaded[name] = true;
} }
async function loadSpot() { async function loadSpot() {
@ -134,17 +158,10 @@ async function loadSpot() {
document.getElementById('usdt-total').textContent = '—'; document.getElementById('usdt-total').textContent = '—';
const res = await api('/wallet/spot'); const res = await api('/wallet/spot');
if (!res.ok) { if (!res.ok) { tbody.innerHTML = `<tr><td colspan="3" class="empty">Hata: ${await res.text()}</td></tr>`; return; }
tbody.innerHTML = `<tr><td colspan="3" class="empty">Bakiye alınamadı: ${await res.text()}</td></tr>`;
return;
}
const balances = await res.json(); const balances = await res.json();
if (!balances.length) { if (!balances.length) { tbody.innerHTML = '<tr><td colspan="3" class="empty">Bakiye yok</td></tr>'; return; }
tbody.innerHTML = '<tr><td colspan="3" class="empty">Bakiye yok</td></tr>';
return;
}
// USDT'yi en üste al, sonra free'ye göre sırala
balances.sort((a, b) => { balances.sort((a, b) => {
if (a.asset === 'USDT') return -1; if (a.asset === 'USDT') return -1;
if (b.asset === 'USDT') return 1; if (b.asset === 'USDT') return 1;
@ -154,15 +171,58 @@ async function loadSpot() {
const usdt = balances.find(b => b.asset === 'USDT'); const usdt = balances.find(b => b.asset === 'USDT');
if (usdt) { if (usdt) {
document.getElementById('usdt-total').textContent = document.getElementById('usdt-total').textContent =
`${fmt(usdt.free, 2)} serbest / ${fmt(usdt.free + usdt.locked, 2)} USDT toplam`; `${fmt(usdt.free)} serbest / ${fmt(usdt.free + usdt.locked)} USDT toplam`;
} }
tbody.innerHTML = balances.map(b => `<tr class="${b.asset === 'USDT' ? 'usdt-row' : ''}">
<td><span class="asset-name">${b.asset}</span></td>
<td>${fmt(b.free)}</td>
<td>${b.locked > 0 ? `<span class="val-locked">${fmt(b.locked)}</span>` : '<span style="color:var(--muted)"></span>'}</td>
</tr>`).join('');
}
async function loadEarn() {
const tbody = document.getElementById('earn-body');
tbody.innerHTML = '<tr><td colspan="3" class="loading">Yükleniyor...</td></tr>';
document.getElementById('earn-total').textContent = '—';
const res = await api('/wallet/earn');
if (!res.ok) { tbody.innerHTML = `<tr><td colspan="3" class="empty">Hata: ${await res.text()}</td></tr>`; return; }
const balances = await res.json();
if (!balances.length) { tbody.innerHTML = '<tr><td colspan="3" class="empty">Earn pozisyonu yok</td></tr>'; return; }
balances.sort((a, b) => b.total - a.total);
document.getElementById('earn-total').textContent = `${balances.length} varlık`;
tbody.innerHTML = balances.map(b => `<tr>
<td><span class="asset-name">${b.asset}</span></td>
<td>${fmt(b.total)}</td>
<td>${fmt(b.free)}</td>
</tr>`).join('');
}
async function loadFutures() {
const tbody = document.getElementById('futures-body');
tbody.innerHTML = '<tr><td colspan="4" class="loading">Yükleniyor...</td></tr>';
document.getElementById('futures-total').textContent = '—';
const res = await api('/wallet/futures');
if (!res.ok) { tbody.innerHTML = `<tr><td colspan="4" class="empty">Hata: ${await res.text()}</td></tr>`; return; }
const balances = await res.json();
if (!balances.length) { tbody.innerHTML = '<tr><td colspan="4" class="empty">Futures bakiyesi yok</td></tr>'; return; }
balances.sort((a, b) => b.wallet - a.wallet);
const totalWallet = balances.reduce((s, b) => s + b.wallet, 0);
document.getElementById('futures-total').textContent = `${fmt(totalWallet)} USDT toplam`;
tbody.innerHTML = balances.map(b => { tbody.innerHTML = balances.map(b => {
const isUsdt = b.asset === 'USDT'; const pnlClass = b.unrealized_pnl >= 0 ? 'val-pnl-pos' : 'val-pnl-neg';
return `<tr class="${isUsdt ? 'usdt-row' : ''}"> const pnlSign = b.unrealized_pnl >= 0 ? '+' : '';
<td><div class="asset-name">${b.asset}</div></td> return `<tr>
<td><div class="val-free">${fmt(b.free)}</div></td> <td><span class="asset-name">${b.asset}</span></td>
<td><div class="val-locked${b.locked > 0 ? '' : ''}">${b.locked > 0 ? fmt(b.locked) : '<span style="color:var(--muted)"></span>'}</div></td> <td>${fmt(b.wallet)}</td>
<td><span class="${pnlClass}">${pnlSign}${fmt(b.unrealized_pnl)}</span></td>
<td>${fmt(b.available)}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
@ -172,7 +232,8 @@ async function logout() {
window.location.href = '/'; window.location.href = '/';
} }
loadSpot(); loadTab('spot');
loaded.spot = true;
</script> </script>
</body> </body>
</html> </html>