feat(mse): add market sell button for open positions

- 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 <noreply@anthropic.com>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-25 10:50:52 +03:00
parent 2ff8d57c08
commit d08ab100c4
4 changed files with 121 additions and 5 deletions

View file

@ -305,3 +305,79 @@ pub async fn cancel_position(
StatusCode::OK.into_response()
}
pub async fn sell_position(
State(state): State<AppState>,
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()
}

View file

@ -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))

View file

@ -172,6 +172,35 @@ impl BinanceClient {
Ok(())
}
pub async fn market_sell(&self, symbol: &str, quantity: &str) -> Result<FilledOrder> {
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
let params = format!(
"symbol={}&side=SELL&type=MARKET&quantity={}&timestamp={}",
symbol, quantity, timestamp
);
let signature = self.sign(&params);
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", &timestamp),
("signature", &signature),
])
.send()
.await?
.json::<Value>()
.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<SymbolFilters> {
let url = format!("{}/api/v3/exchangeInfo", self.base_url);

View file

@ -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); }
</style>
</head>
<body>
@ -211,7 +213,7 @@
</div>
<div class="pos-scroll">
<table class="pos-table">
<thead><tr><th>Zaman</th><th>Alış</th><th>Hedef</th><th>PnL</th><th></th></tr></thead>
<thead><tr><th>Zaman</th><th>Alış</th><th>Hedef</th><th>PnL</th><th></th><th></th></tr></thead>
<tbody id="open-body"><tr><td colspan="5"><div class="empty-row"></div></td></tr></tbody>
</table>
</div>
@ -604,7 +606,7 @@ async function loadPositions() {
function renderOpenTable() {
const tbody = document.getElementById('open-body');
if (!openPositionsCache.length) {
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-row">ık pozisyon yok</div></td></tr>';
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-row">ık pozisyon yok</div></td></tr>';
return;
}
tbody.innerHTML = openPositionsCache.map(p => {
@ -619,7 +621,8 @@ function renderOpenTable() {
<td>${fmt(p.buy_price, 4)}</td>
<td class="c-green">${fmt(p.sell_target, 4)}</td>
<td>${pnlHtml}</td>
<td><button class="btn-cancel" title="İptal" onclick="cancelPosition(${p.order_id})"></button></td>
<td><button class="btn-cancel" title="Emri iptal et (coin elde kalır)" onclick="cancelPosition(${p.order_id})"></button></td>
<td><button class="btn-sell" title="Emri iptal et + market satış yap" onclick="sellPosition(${p.order_id})">Sat</button></td>
</tr>`;
}).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();