feat(mse): wallet page + insufficient balance skip in strategy

- BinanceClient::get_usdt_spot_balance() helper
- strategy.rs: check USDT balance before market_buy; skip (Ok(None)) if insufficient
- GET /api/wallet/spot endpoint returning all non-zero spot balances
- /wallet page: Spot tab with sortable balance table (USDT pinned top)
- Earn/Futures tabs as placeholders for future implementation
- Header nav: Cüzdanlar link added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-25 12:02:09 +03:00
parent 20732387a6
commit 799cd53695
8 changed files with 247 additions and 1 deletions

View file

@ -4,3 +4,4 @@ pub mod events;
pub mod mode;
pub mod positions;
pub mod routes;
pub mod wallet;

View file

@ -7,7 +7,7 @@ use axum::{
};
use axum_extra::extract::CookieJar;
use crate::api::{auth, bots, events, mode, positions};
use crate::api::{auth, bots, events, mode, positions, wallet};
use crate::AppState;
pub fn build(state: AppState) -> Router {
@ -23,6 +23,7 @@ pub fn build(state: AppState) -> Router {
.route("/bots/:id/log-stream", get(events::bot_log_sse))
.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("/positions", get(positions::open_positions))
.route("/positions/closed", get(positions::closed_positions))
.route("/mode", get(mode::get_mode).post(mode::set_mode))
@ -41,6 +42,7 @@ pub fn build(state: AppState) -> Router {
.route("/bots", get(bots_handler))
.route("/bots/:id", get(bot_detail_handler))
.route("/positions", get(positions_handler))
.route("/wallet", get(wallet_handler))
.with_state(state)
}
@ -105,6 +107,19 @@ async fn bot_detail_handler(
}
}
async fn wallet_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
let token = jar.get("mse_session").map(|c| c.value().to_string());
let authed = match token {
Some(t) => { let db = state.db.lock().await; db.session_exists(&t).unwrap_or(false) }
None => false,
};
if authed { axum::response::Html(crate::tmpl::render("wallet.html", "wallet")).into_response() }
else { Redirect::to("/").into_response() }
}
async fn dashboard_handler(
State(state): State<AppState>,
jar: CookieJar,

33
src/api/wallet.rs Normal file
View file

@ -0,0 +1,33 @@
use axum::{extract::State, response::IntoResponse, Json};
use serde::Serialize;
use crate::binance::client::BinanceClient;
use crate::AppState;
#[derive(Serialize)]
pub struct BalanceEntry {
pub asset: String,
pub free: f64,
pub locked: f64,
}
pub async fn spot_balances(State(state): State<AppState>) -> impl IntoResponse {
let mode = state.current_mode.lock().await.clone();
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");
match client.get_account_balances().await {
Ok(balances) => {
let entries: Vec<BalanceEntry> = balances
.into_iter()
.map(|(asset, free, locked)| BalanceEntry { asset, free, locked })
.collect();
Json(entries).into_response()
}
Err(e) => (axum::http::StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
}
}

View file

@ -309,6 +309,14 @@ impl BinanceClient {
Ok(symbols)
}
pub async fn get_usdt_spot_balance(&self) -> Result<f64> {
let balances = self.get_account_balances().await?;
Ok(balances.iter()
.find(|(asset, _, _)| asset == "USDT")
.map(|(_, free, _)| *free)
.unwrap_or(0.0))
}
/// Binance hesabındaki sıfır olmayan tüm bakiyeleri döner: Vec<(asset, free, locked)>
pub async fn get_account_balances(&self) -> Result<Vec<(String, f64, f64)>> {
let timestamp = chrono::Utc::now().timestamp_millis().to_string();

View file

@ -28,6 +28,15 @@ impl RedCandleStrategy {
symbol, kline.open, kline.close
);
let usdt_balance = client.get_usdt_spot_balance().await?;
if usdt_balance < usdt_amount {
info!(
"[{}] Yetersiz USDT bakiyesi ({:.2} < {:.2}), bekleniyor",
symbol, usdt_balance, usdt_amount
);
return Ok(None);
}
let filters = client.get_symbol_filters(symbol).await?;
let raw_qty = usdt_amount / kline.close;
let quantity = filters.format_qty(raw_qty);

View file

@ -12,6 +12,7 @@ fn env() -> &'static Environment<'static> {
e.add_template_owned("bots.html", include_str!("web/bots.html").to_string()).unwrap();
e.add_template_owned("bot.html", include_str!("web/bot.html").to_string()).unwrap();
e.add_template_owned("positions.html", include_str!("web/positions.html").to_string()).unwrap();
e.add_template_owned("wallet.html", include_str!("web/wallet.html").to_string()).unwrap();
e
})
}

View file

@ -5,6 +5,7 @@
<a href="/dashboard" class="nav-link{% if active_page == 'dashboard' %} active{% endif %}">Dashboard</a>
<a href="/bots" class="nav-link{% if active_page == 'bots' %} active{% endif %}">Botlar</a>
<a href="/positions" class="nav-link{% if active_page == 'positions' %} active{% endif %}">Pozisyonlar</a>
<a href="/wallet" class="nav-link{% if active_page == 'wallet' %} active{% endif %}">Cüzdanlar</a>
</nav>
<div class="spacer"></div>
<div class="mode-toggle">

178
src/web/wallet.html Normal file
View file

@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cüzdanlar — CryptoFox Mukan Edition</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f13; --surface: #1a1a22; --border: #2a2a38;
--text: #e0e0f0; --muted: #666; --accent: #6c63ff;
--green: #2ecc71; --red: #e74c3c; --yellow: #f39c12;
}
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; min-height: 100vh; }
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px; height: 52px; display: flex; align-items: center; gap: 16px; }
.logo { font-size: 16px; font-weight: 700; }
.logo-crypto { color: #d0d0d0; } .logo-fox { color: #00d4ff; }
.h-sep { width: 1px; height: 20px; background: var(--border); }
nav { display: flex; gap: 4px; }
.nav-link { padding: 5px 12px; border-radius: 5px; font-size: 13px; color: var(--muted); text-decoration: none; transition: all .15s; }
.nav-link:hover { color: var(--text); background: rgba(255,255,255,.05); }
.nav-link.active { color: var(--text); background: rgba(108,99,255,.15); }
.spacer { flex: 1; }
.btn { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 12px; cursor: pointer; transition: all .15s; }
.btn:hover { background: rgba(255,255,255,.05); }
.btn-logout { border-color: var(--red); color: var(--red); font-size: 12px; }
.btn-logout:hover { background: rgba(231,76,60,.1); }
.mode-toggle { display: flex; align-items: center; gap: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 3px; }
.mode-btn { padding: 3px 12px; border-radius: 4px; border: none; background: transparent; color: var(--muted); font-size: 11px; font-weight: 600; cursor: pointer; transition: all .15s; }
.mode-btn.active-live { background: rgba(46,204,113,.15); color: var(--green); }
.mode-btn.active-testnet { background: rgba(243,156,18,.15); color: var(--yellow); }
main { padding: 24px; max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.page-toolbar { display: flex; align-items: center; justify-content: space-between; }
.page-title { font-size: 16px; font-weight: 600; }
.tab-bar { display: flex; gap: 4px; }
.tab { padding: 5px 14px; border-radius: 5px; border: 1px solid var(--border); background: transparent; color: var(--muted); font-size: 12px; cursor: pointer; transition: all .15s; }
.tab.active { background: rgba(108,99,255,.15); border-color: var(--accent); color: var(--text); }
.tab:hover:not(.active) { background: rgba(255,255,255,.04); color: var(--text); }
section { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.section-header { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.section-title { font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.total-badge { font-size: 12px; color: var(--text); background: rgba(255,255,255,.06); padding: 2px 10px; border-radius: 10px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 18px; text-align: left; border-bottom: 1px solid var(--border); }
th { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 500; }
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; }
.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; }
</style>
</head>
<body>
{% include "_header.html" %}
<main>
<div class="page-toolbar">
<span class="page-title">Cüzdanlar</span>
<div class="tab-bar">
<button class="tab active" onclick="showTab('spot')">Spot</button>
<button class="tab" onclick="showTab('earn')">Earn</button>
<button class="tab" onclick="showTab('futures')">Futures</button>
</div>
</div>
<div id="tab-spot">
<section>
<div class="section-header">
<span class="section-title">Spot Cüzdan</span>
<span class="total-badge" id="usdt-total"></span>
</div>
<table>
<thead><tr><th>Varlık</th><th>Kullanılabilir</th><th>Kilitli</th></tr></thead>
<tbody id="spot-body"><tr><td colspan="3" class="loading">Yükleniyor...</td></tr></tbody>
</table>
</section>
</div>
<div id="tab-earn" style="display:none">
<section>
<div class="section-header"><span class="section-title">Earn Cüzdan</span></div>
<div class="coming-soon">
<div class="icon">🔒</div>
<p>Earn cüzdanı desteği yakında eklenecek.</p>
</div>
</section>
</div>
<div id="tab-futures" style="display:none">
<section>
<div class="section-header"><span class="section-title">Futures Cüzdan</span></div>
<div class="coming-soon">
<div class="icon">📈</div>
<p>Futures cüzdanı desteği yakında eklenecek.</p>
</div>
</section>
</div>
</main>
<script>
function onModeChange() { loadSpot(); }
async function api(path) {
const res = await fetch('/api' + path, { credentials: 'include' });
if (res.status === 401) { window.location.href = '/'; throw new Error('Unauthorized'); }
return res;
}
function fmt(n, d=4) { return parseFloat(n).toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: d }); }
function showTab(name) {
['spot','earn','futures'].forEach(t => {
document.getElementById('tab-' + t).style.display = t === name ? '' : 'none';
document.querySelectorAll('.tab')[['spot','earn','futures'].indexOf(t)].className = 'tab' + (t === name ? ' active' : '');
});
}
async function loadSpot() {
const tbody = document.getElementById('spot-body');
tbody.innerHTML = '<tr><td colspan="3" class="loading">Yükleniyor...</td></tr>';
document.getElementById('usdt-total').textContent = '—';
const res = await api('/wallet/spot');
if (!res.ok) {
tbody.innerHTML = `<tr><td colspan="3" class="empty">Bakiye alınamadı: ${await res.text()}</td></tr>`;
return;
}
const balances = await res.json();
if (!balances.length) {
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) => {
if (a.asset === 'USDT') return -1;
if (b.asset === 'USDT') return 1;
return (b.free + b.locked) - (a.free + a.locked);
});
const usdt = balances.find(b => b.asset === 'USDT');
if (usdt) {
document.getElementById('usdt-total').textContent =
`${fmt(usdt.free, 2)} serbest / ${fmt(usdt.free + usdt.locked, 2)} USDT toplam`;
}
tbody.innerHTML = balances.map(b => {
const isUsdt = b.asset === 'USDT';
return `<tr class="${isUsdt ? 'usdt-row' : ''}">
<td><div class="asset-name">${b.asset}</div></td>
<td><div class="val-free">${fmt(b.free)}</div></td>
<td><div class="val-locked${b.locked > 0 ? '' : ''}">${b.locked > 0 ? fmt(b.locked) : '<span style="color:var(--muted)"></span>'}</div></td>
</tr>`;
}).join('');
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/';
}
loadSpot();
</script>
</body>
</html>