From d08ab100c4e696bbe8caa3649cf5728bdbd62445 Mon Sep 17 00:00:00 2001 From: Mukan Erkin Date: Sat, 25 Apr 2026 10:50:52 +0300 Subject: [PATCH] feat(mse): add market sell button for open positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BinanceClient::market_sell() — SELL MARKET order - sell_position handler: cancel limit order then fire market sell - bot.html: "Sat" button (yellow) alongside existing ✕ cancel button - cancel = iptal, coin elde kalır; sat = iptal + anında market satış - Sell kaydı log'a "sell" level ile düşülür; closed status = SOLD Co-Authored-By: Claude Sonnet 4.6 --- src/api/bots.rs | 76 +++++++++++++++++++++++++++++++++++++++++++ src/api/routes.rs | 1 + src/binance/client.rs | 29 +++++++++++++++++ src/web/bot.html | 20 +++++++++--- 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/api/bots.rs b/src/api/bots.rs index 79a8716..e0b7591 100644 --- a/src/api/bots.rs +++ b/src/api/bots.rs @@ -305,3 +305,79 @@ pub async fn cancel_position( StatusCode::OK.into_response() } + +pub async fn sell_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); + + // 1. Limit satış emrini iptal et + if let Err(e) = client.cancel_order(&pos.symbol, order_id).await { + return (StatusCode::BAD_GATEWAY, format!("Emir iptal hatası: {}", e)).into_response(); + } + + // 2. Lot size'a göre miktarı yuvarla ve market satışı gönder + let qty_str = match client.get_symbol_filters(&pos.symbol).await { + Ok(filters) => filters.format_qty(pos.quantity), + Err(_) => format!("{:.8}", pos.quantity), + }; + let sell_result = client.market_sell(&pos.symbol, &qty_str).await; + let actual_price = sell_result.as_ref().map(|o| o.price).unwrap_or(0.0); + let sell_ok = sell_result.is_ok(); + + 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: if sell_ok && actual_price > 0.0 { actual_price } else { pos.sell_target }, + quantity: pos.quantity, + profit_percent: pos.profit_percent, + opened_at: pos.opened_at, + closed_at: chrono::Utc::now().timestamp_millis(), + status: if sell_ok { "SOLD".to_string() } else { "CANCELED".to_string() }, + }; + db.insert_closed(&closed).ok(); + db.remove_position(order_id).ok(); + + if sell_ok { + db.insert_log(&bot_id, "sell", &format!( + "Manuel market satış | {} @ {:.4} | Order #{}", + pos.symbol, actual_price, order_id + )).ok(); + } else { + db.insert_log(&bot_id, "warn", &format!( + "Emir iptal edildi ama market satış başarısız | Order #{}", order_id + )).ok(); + } + + StatusCode::OK.into_response() +} diff --git a/src/api/routes.rs b/src/api/routes.rs index 3333096..1a6fff2 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -22,6 +22,7 @@ pub fn build(state: AppState) -> Router { .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("/bots/:id/positions/:order_id/sell", post(bots::sell_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 36d1a22..2b2f7e9 100644 --- a/src/binance/client.rs +++ b/src/binance/client.rs @@ -172,6 +172,35 @@ impl BinanceClient { Ok(()) } + pub async fn market_sell(&self, symbol: &str, quantity: &str) -> Result { + let timestamp = chrono::Utc::now().timestamp_millis().to_string(); + let params = format!( + "symbol={}&side=SELL&type=MARKET&quantity={}×tamp={}", + symbol, quantity, timestamp + ); + let signature = self.sign(¶ms); + let url = format!("{}/api/v3/order", self.base_url); + + let response = self + .http + .post(&url) + .header("X-MBX-APIKEY", &self.api_key) + .query(&[ + ("symbol", symbol), + ("side", "SELL"), + ("type", "MARKET"), + ("quantity", quantity), + ("timestamp", ×tamp), + ("signature", &signature), + ]) + .send() + .await? + .json::() + .await?; + + parse_filled_order(&response, OrderSide::Sell) + } + /// 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); diff --git a/src/web/bot.html b/src/web/bot.html index a779d59..53368b2 100644 --- a/src/web/bot.html +++ b/src/web/bot.html @@ -165,9 +165,11 @@ .pg-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } .pg-info { font-size: 10px; color: var(--muted); } - /* cancel btn in table */ + /* cancel / sell btns 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); } + .btn-sell { background: none; border: 1px solid var(--yellow); color: var(--yellow); border-radius: 4px; cursor: pointer; font-size: 11px; padding: 2px 8px; line-height: 1; } + .btn-sell:hover { background: rgba(243,156,18,.15); } @@ -211,7 +213,7 @@
- +
ZamanAlışHedefPnL
ZamanAlışHedefPnL
@@ -604,7 +606,7 @@ async function loadPositions() { function renderOpenTable() { const tbody = document.getElementById('open-body'); if (!openPositionsCache.length) { - tbody.innerHTML = '
Açık pozisyon yok
'; + tbody.innerHTML = '
Açık pozisyon yok
'; return; } tbody.innerHTML = openPositionsCache.map(p => { @@ -619,7 +621,8 @@ function renderOpenTable() { ${fmt(p.buy_price, 4)} ${fmt(p.sell_target, 4)} ${pnlHtml} - + + `; }).join(''); } @@ -630,12 +633,19 @@ function updateOpenPnl() { } async function cancelPosition(orderId) { - if (!confirm('Bu pozisyonu Binance\'te iptal et?')) return; + if (!confirm('Limit emri iptal edilecek, coin elde kalacak. Devam?')) 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 sellPosition(orderId) { + if (!confirm('Limit emri iptal edilip market fiyatından satılacak. Devam?')) return; + const res = await api('POST', `/bots/${BOT_ID}/positions/${orderId}/sell`); + if (res.ok) { loadPositions(); loadClosed(); } + else { alert('Satış başarısız: ' + (await res.text())); } +} + async function loadClosed() { const res = await api('GET', `/bots/${BOT_ID}/positions/closed`); closedPositionsCache = await res.json();