From d1085cc4cc58f9db2b80aa7cec363b9d84108e70 Mon Sep 17 00:00:00 2001 From: Mukan Erkin Date: Sat, 25 Apr 2026 10:33:44 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20lot=20size=20floor,=20mod=20ge=C3=A7i?= =?UTF-8?q?=C5=9Fi,=20PnL=20kolonu,=20iptal=20butonu,=20sayfalama,=20grafi?= =?UTF-8?q?k=20oklar=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LOT_SIZE: step_size floor + minQty kontrolü (DOGE gibi tam sayı coinler artık çalışır) - Mod geçişi: _header.html'e setMode() + applyModeUI() eklendi, her sayfada çalışır - Açık pozisyonlar: anlık PnL % kolonu + Binance'te iptal butonu - Kapalı işlemler: 20'li sayfalama - Grafik okları: chart zaman aralığı dışındaki markerlar filtrelendi Co-Authored-By: Claude Sonnet 4.6 --- src/api/bots.rs | 56 ++++++++++++++++ src/api/routes.rs | 1 + src/binance/client.rs | 42 +++++++++++- src/binance/models.rs | 13 +++- src/bot/strategy.rs | 7 ++ src/web/_header.html | 38 ++++++++++- src/web/bot.html | 150 ++++++++++++++++++++++++++++++++---------- 7 files changed, 267 insertions(+), 40 deletions(-) diff --git a/src/api/bots.rs b/src/api/bots.rs index 55fb4af..79a8716 100644 --- a/src/api/bots.rs +++ b/src/api/bots.rs @@ -249,3 +249,59 @@ pub async fn get_bot_closed( Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } + +pub async fn cancel_position( + State(state): State, + Path((bot_id, order_id)): Path<(String, u64)>, +) -> impl IntoResponse { + let db = state.db.lock().await; + let positions = match db.get_positions_by_bot(&bot_id) { + Ok(p) => p, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + let pos = match positions.into_iter().find(|p| p.order_id == order_id) { + Some(p) => p, + None => return StatusCode::NOT_FOUND.into_response(), + }; + let bots = match db.get_bots() { + Ok(b) => b, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + let config = match bots.into_iter().find(|b| b.id == bot_id) { + Some(c) => c, + None => return StatusCode::NOT_FOUND.into_response(), + }; + drop(db); + + 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, config.testnet); + + if let Err(e) = client.cancel_order(&pos.symbol, order_id).await { + return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(); + } + + let db = state.db.lock().await; + let closed = crate::storage::closed::ClosedPosition { + bot_id: pos.bot_id.clone(), + bot_name: pos.bot_name.clone(), + symbol: pos.symbol.clone(), + order_id: pos.order_id, + buy_price: pos.buy_price, + sell_target: pos.sell_target, + quantity: pos.quantity, + profit_percent: pos.profit_percent, + opened_at: pos.opened_at, + closed_at: chrono::Utc::now().timestamp_millis(), + status: "CANCELED".to_string(), + }; + db.insert_closed(&closed).ok(); + db.remove_position(order_id).ok(); + db.insert_log(&bot_id, "info", &format!("Pozisyon manuel iptal edildi | Order #{}", order_id)).ok(); + + StatusCode::OK.into_response() +} diff --git a/src/api/routes.rs b/src/api/routes.rs index 8635ea8..3333096 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -21,6 +21,7 @@ pub fn build(state: AppState) -> Router { .route("/bots/:id/positions", get(bots::get_bot_positions)) .route("/bots/:id/positions/closed", get(bots::get_bot_closed)) .route("/bots/:id/log-stream", get(events::bot_log_sse)) + .route("/bots/:id/positions/:order_id/cancel", post(bots::cancel_position)) .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/binance/client.rs b/src/binance/client.rs index c5d86a3..36d1a22 100644 --- a/src/binance/client.rs +++ b/src/binance/client.rs @@ -136,6 +136,42 @@ impl BinanceClient { Ok(response["status"].as_str().unwrap_or("UNKNOWN").to_string()) } + /// Açık bir limit emrini iptal eder + pub async fn cancel_order(&self, symbol: &str, order_id: u64) -> Result<()> { + let timestamp = chrono::Utc::now().timestamp_millis().to_string(); + let order_id_str = order_id.to_string(); + let params = format!( + "symbol={}&orderId={}×tamp={}", + symbol, order_id_str, timestamp + ); + let signature = self.sign(¶ms); + let url = format!("{}/api/v3/order", self.base_url); + + let response = self + .http + .delete(&url) + .header("X-MBX-APIKEY", &self.api_key) + .query(&[ + ("symbol", symbol), + ("orderId", order_id_str.as_str()), + ("timestamp", timestamp.as_str()), + ("signature", signature.as_str()), + ]) + .send() + .await? + .json::() + .await?; + + if let Some(code) = response.get("code") { + return Err(anyhow!( + "Binance iptal hatası {}: {}", + code, + response["msg"].as_str().unwrap_or("") + )); + } + Ok(()) + } + /// Sembolün lot size ve tick size filtrelerini getirir pub async fn get_symbol_filters(&self, symbol: &str) -> Result { let url = format!("{}/api/v3/exchangeInfo", self.base_url); @@ -156,12 +192,16 @@ impl BinanceClient { let mut qty_decimals = 2usize; let mut price_decimals = 2usize; + let mut step_size = 0.01f64; + let mut min_qty = 0.0f64; for filter in filters { match filter["filterType"].as_str() { Some("LOT_SIZE") => { let step = filter["stepSize"].as_str().unwrap_or("0.01"); qty_decimals = count_decimals(step); + step_size = step.parse().unwrap_or(0.01); + min_qty = filter["minQty"].as_str().unwrap_or("0").parse().unwrap_or(0.0); } Some("PRICE_FILTER") => { let tick = filter["tickSize"].as_str().unwrap_or("0.01"); @@ -171,7 +211,7 @@ impl BinanceClient { } } - Ok(SymbolFilters { qty_decimals, price_decimals }) + Ok(SymbolFilters { qty_decimals, price_decimals, step_size, min_qty }) } /// Tüm USDT spot işlem çiftlerini döner — public endpoint, API key gerektirmez diff --git a/src/binance/models.rs b/src/binance/models.rs index bab2294..edc8314 100644 --- a/src/binance/models.rs +++ b/src/binance/models.rs @@ -50,15 +50,26 @@ pub enum OrderSide { pub struct SymbolFilters { pub qty_decimals: usize, pub price_decimals: usize, + pub step_size: f64, + pub min_qty: f64, } impl SymbolFilters { + /// Miktarı step_size'a göre aşağı yuvarlar (floor), sonra formatlar. pub fn format_qty(&self, qty: f64) -> String { - format!("{:.prec$}", qty, prec = self.qty_decimals) + let floored = if self.step_size > 0.0 { + (qty / self.step_size).floor() * self.step_size + } else { + qty + }; + format!("{:.prec$}", floored, prec = self.qty_decimals) } pub fn format_price(&self, price: f64) -> String { format!("{:.prec$}", price, prec = self.price_decimals) } + pub fn qty_is_valid(&self, qty: f64) -> bool { + qty >= self.min_qty + } } /// Timeframe tanımları diff --git a/src/bot/strategy.rs b/src/bot/strategy.rs index ad6509b..a21a069 100644 --- a/src/bot/strategy.rs +++ b/src/bot/strategy.rs @@ -31,6 +31,13 @@ impl RedCandleStrategy { let filters = client.get_symbol_filters(symbol).await?; let raw_qty = usdt_amount / kline.close; let quantity = filters.format_qty(raw_qty); + let qty_f: f64 = quantity.parse().unwrap_or(0.0); + if !filters.qty_is_valid(qty_f) { + return Err(anyhow::anyhow!( + "Miktar ({}) min lot size ({}) altında — USDT miktarını artır", + quantity, filters.min_qty + )); + } info!("[{}] Market alım | Miktar: {} | Fiyat tahmini: {:.6}", symbol, quantity, kline.close); diff --git a/src/web/_header.html b/src/web/_header.html index 3434817..0be915a 100644 --- a/src/web/_header.html +++ b/src/web/_header.html @@ -8,8 +8,42 @@
- - + +
+ diff --git a/src/web/bot.html b/src/web/bot.html index 93b4722..a779d59 100644 --- a/src/web/bot.html +++ b/src/web/bot.html @@ -157,6 +157,17 @@ .stat-row { display: flex; justify-content: space-between; align-items: center; } .stat-row .lbl { font-size: 11px; color: var(--muted); } .stat-row .val { font-size: 12px; font-weight: 600; } + + /* pagination */ + .pagination { display: flex; align-items: center; justify-content: center; gap: 4px; padding: 4px 0; border-top: 1px solid var(--border); } + .pg-btn { background: none; border: 1px solid var(--border); color: var(--muted); border-radius: 3px; padding: 1px 7px; font-size: 10px; cursor: pointer; } + .pg-btn:hover { color: var(--text); border-color: var(--accent); } + .pg-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } + .pg-info { font-size: 10px; color: var(--muted); } + + /* cancel btn in table */ + .btn-cancel { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 12px; padding: 0 2px; line-height: 1; } + .btn-cancel:hover { color: var(--red); } @@ -200,8 +211,8 @@
- - + +
ZamanAlışHedefAdet
ZamanAlışHedefPnL
@@ -217,6 +228,7 @@
+ @@ -298,6 +310,9 @@ let priceWs = null; let priceLines = []; let openPositionsCache = []; let closedPositionsCache = []; +let closedPage = 0; +const CLOSED_PAGE_SIZE = 20; +let currentPrice = 0; const TF_MAP = { '1m':'1m','5m':'5m','15m':'15m','30m':'30m','1h':'1h','4h':'4h','1d':'1d','1w':'1w' }; const TF_SECONDS = { '1m':60,'5m':300,'15m':900,'30m':1800,'1h':3600,'4h':14400,'1d':86400,'1w':604800 }; @@ -322,20 +337,17 @@ function buyTime(ms) { return cur - tf; } +// ── Mode change hook (header'dan çağrılır) ──────── +function onModeChange(_mode) { + loadPositions(); + loadClosed(); +} + // ── Load ────────────────────────────────────────── async function loadBot() { - const [botRes, modeRes] = await Promise.all([ - api('GET', `/bots/${BOT_ID}`), - api('GET', '/mode'), - ]); - if (!botRes.ok) { window.location.href = '/dashboard'; return; } - bot = await botRes.json(); - - if (modeRes.ok) { - const { mode } = await modeRes.json(); - document.getElementById('btn-live').className = 'mode-btn' + (mode === 'live' ? ' active-live' : ''); - document.getElementById('btn-testnet').className = 'mode-btn' + (mode === 'testnet' ? ' active-testnet' : ''); - } + const res = await api('GET', `/bots/${BOT_ID}`); + if (!res.ok) { window.location.href = '/dashboard'; return; } + bot = await res.json(); document.title = `${bot.name} — CryptoFox Mukan Edition`; renderPriceBar(); @@ -445,11 +457,13 @@ function startPriceWs() { priceWs.onmessage = e => { const t = JSON.parse(e.data); const price = parseFloat(t.c); + currentPrice = price; const change = parseFloat(t.P); document.getElementById('pb-price').textContent = fmt(price, 2); const chEl = document.getElementById('pb-change'); chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%'; chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)'; + updateOpenPnl(); }; priceWs.onerror = () => {}; priceWs.onclose = () => setTimeout(startPriceWs, 5000); @@ -540,20 +554,34 @@ function updateChartOverlays() { axisLabelVisible: true, title: fmt(p.sell_target, 2), })); }); + + // Chart'taki mum zaman aralığını al + const tsData = candleSeries.data ? candleSeries.data() : null; + let minTime = 0, maxTime = Infinity; + if (tsData && tsData.length) { + minTime = tsData[0].time; + maxTime = tsData[tsData.length - 1].time; + } + const buyMap = new Map(), sellMap = new Map(); openPositionsCache.forEach(p => { 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)); }); closedPositionsCache.forEach(c => { const tb = buyTime(c.opened_at); - if (!buyMap.has(tb)) buyMap.set(tb, []); - buyMap.get(tb).push(fmt(c.buy_price, 2)); + if (tb >= minTime && tb <= maxTime) { + if (!buyMap.has(tb)) buyMap.set(tb, []); + buyMap.get(tb).push(fmt(c.buy_price, 2)); + } if (c.status === 'FILLED') { const ts = Math.floor(c.closed_at / 1000); - if (!sellMap.has(ts)) sellMap.set(ts, []); - sellMap.get(ts).push(fmt(c.sell_target, 2)); + if (ts >= minTime && ts <= maxTime) { + if (!sellMap.has(ts)) sellMap.set(ts, []); + sellMap.get(ts).push(fmt(c.sell_target, 2)); + } } }); const markers = []; @@ -569,39 +597,89 @@ async function loadPositions() { openPositionsCache = await res.json(); if (candleSeries) updateChartOverlays(); document.getElementById('open-count').textContent = openPositionsCache.length; + renderOpenTable(); + renderStats(); +} + +function renderOpenTable() { const tbody = document.getElementById('open-body'); if (!openPositionsCache.length) { - tbody.innerHTML = '
Açık pozisyon yok
'; - } else { - tbody.innerHTML = openPositionsCache.map(p => ` + tbody.innerHTML = '
Açık pozisyon yok
'; + return; + } + tbody.innerHTML = openPositionsCache.map(p => { + const pnl = currentPrice > 0 + ? ((currentPrice - p.buy_price) / p.buy_price * 100) + : null; + const pnlHtml = pnl !== null + ? `${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}%` + : ''; + return ` ${shortDate(p.opened_at)} ${fmt(p.buy_price, 4)} ${fmt(p.sell_target, 4)} - ${parseFloat(p.quantity).toFixed(4)} - `).join(''); - } - renderStats(); + ${pnlHtml} + + `; + }).join(''); +} + +function updateOpenPnl() { + if (!openPositionsCache.length) return; + renderOpenTable(); +} + +async function cancelPosition(orderId) { + if (!confirm('Bu pozisyonu Binance\'te iptal et?')) return; + const res = await api('POST', `/bots/${BOT_ID}/positions/${orderId}/cancel`); + if (res.ok) { loadPositions(); loadClosed(); } + else { alert('İptal başarısız: ' + (await res.text())); } } async function loadClosed() { const res = await api('GET', `/bots/${BOT_ID}/positions/closed`); closedPositionsCache = await res.json(); + closedPage = 0; if (candleSeries) updateChartOverlays(); document.getElementById('closed-count').textContent = closedPositionsCache.length; - const tbody = document.getElementById('closed-body'); - if (!closedPositionsCache.length) { - tbody.innerHTML = '
Henüz işlem yok
'; - } else { - tbody.innerHTML = closedPositionsCache.map(c => ` - ${shortDate(c.opened_at)} - ${fmt(c.buy_price, 4)} - ${fmt(c.sell_target, 4)} - ${c.status} - `).join(''); - } + renderClosedTable(); renderStats(); } +function renderClosedTable() { + const tbody = document.getElementById('closed-body'); + const total = closedPositionsCache.length; + if (!total) { + tbody.innerHTML = '
Henüz işlem yok
'; + document.getElementById('closed-pagination').innerHTML = ''; + return; + } + const totalPages = Math.ceil(total / CLOSED_PAGE_SIZE); + if (closedPage >= totalPages) closedPage = totalPages - 1; + const start = closedPage * CLOSED_PAGE_SIZE; + const page = closedPositionsCache.slice(start, start + CLOSED_PAGE_SIZE); + tbody.innerHTML = page.map(c => ` + ${shortDate(c.opened_at)} + ${fmt(c.buy_price, 4)} + ${fmt(c.sell_target, 4)} + ${c.status} + `).join(''); + + const pg = document.getElementById('closed-pagination'); + if (totalPages <= 1) { pg.innerHTML = ''; return; } + let btns = ''; + if (closedPage > 0) btns += ``; + const lo = Math.max(0, closedPage - 2), hi = Math.min(totalPages - 1, closedPage + 2); + for (let i = lo; i <= hi; i++) { + btns += ``; + } + if (closedPage < totalPages - 1) btns += ``; + btns += `${start+1}-${Math.min(start+CLOSED_PAGE_SIZE,total)} / ${total}`; + pg.innerHTML = btns; +} + +function goClosedPage(p) { closedPage = p; renderClosedTable(); } + // ── Log ─────────────────────────────────────────── async function loadInitialLogs() { const res = await api('GET', `/bots/${BOT_ID}/logs`);