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:
parent
20732387a6
commit
799cd53695
8 changed files with 247 additions and 1 deletions
|
|
@ -4,3 +4,4 @@ pub mod events;
|
||||||
pub mod mode;
|
pub mod mode;
|
||||||
pub mod positions;
|
pub mod positions;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod wallet;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use axum_extra::extract::CookieJar;
|
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;
|
use crate::AppState;
|
||||||
|
|
||||||
pub fn build(state: AppState) -> Router {
|
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/log-stream", get(events::bot_log_sse))
|
||||||
.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("/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))
|
||||||
|
|
@ -41,6 +42,7 @@ pub fn build(state: AppState) -> Router {
|
||||||
.route("/bots", get(bots_handler))
|
.route("/bots", get(bots_handler))
|
||||||
.route("/bots/:id", get(bot_detail_handler))
|
.route("/bots/:id", get(bot_detail_handler))
|
||||||
.route("/positions", get(positions_handler))
|
.route("/positions", get(positions_handler))
|
||||||
|
.route("/wallet", get(wallet_handler))
|
||||||
.with_state(state)
|
.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(
|
async fn dashboard_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
|
|
|
||||||
33
src/api/wallet.rs
Normal file
33
src/api/wallet.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -309,6 +309,14 @@ impl BinanceClient {
|
||||||
Ok(symbols)
|
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)>
|
/// 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)>> {
|
pub async fn get_account_balances(&self) -> Result<Vec<(String, f64, f64)>> {
|
||||||
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
|
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,15 @@ impl RedCandleStrategy {
|
||||||
symbol, kline.open, kline.close
|
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 filters = client.get_symbol_filters(symbol).await?;
|
||||||
let raw_qty = usdt_amount / kline.close;
|
let raw_qty = usdt_amount / kline.close;
|
||||||
let quantity = filters.format_qty(raw_qty);
|
let quantity = filters.format_qty(raw_qty);
|
||||||
|
|
|
||||||
|
|
@ -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("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("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("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
|
e
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
<a href="/dashboard" class="nav-link{% if active_page == 'dashboard' %} active{% endif %}">Dashboard</a>
|
<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="/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="/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>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="mode-toggle">
|
<div class="mode-toggle">
|
||||||
|
|
|
||||||
178
src/web/wallet.html
Normal file
178
src/web/wallet.html
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue