fix: lot size floor, mod geçişi, PnL kolonu, iptal butonu, sayfalama, grafik okları

- 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 <noreply@anthropic.com>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-25 10:33:44 +03:00
parent 15b619efa0
commit d1085cc4cc
7 changed files with 267 additions and 40 deletions

View file

@ -249,3 +249,59 @@ pub async fn get_bot_closed(
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
pub async fn cancel_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);
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()
}

View file

@ -21,6 +21,7 @@ pub fn build(state: AppState) -> Router {
.route("/bots/:id/positions", get(bots::get_bot_positions)) .route("/bots/:id/positions", get(bots::get_bot_positions))
.route("/bots/:id/positions/closed", get(bots::get_bot_closed)) .route("/bots/:id/positions/closed", get(bots::get_bot_closed))
.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("/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))

View file

@ -136,6 +136,42 @@ impl BinanceClient {
Ok(response["status"].as_str().unwrap_or("UNKNOWN").to_string()) 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={}&timestamp={}",
symbol, order_id_str, timestamp
);
let signature = self.sign(&params);
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::<Value>()
.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 /// Sembolün lot size ve tick size filtrelerini getirir
pub async fn get_symbol_filters(&self, symbol: &str) -> Result<SymbolFilters> { pub async fn get_symbol_filters(&self, symbol: &str) -> Result<SymbolFilters> {
let url = format!("{}/api/v3/exchangeInfo", self.base_url); let url = format!("{}/api/v3/exchangeInfo", self.base_url);
@ -156,12 +192,16 @@ impl BinanceClient {
let mut qty_decimals = 2usize; let mut qty_decimals = 2usize;
let mut price_decimals = 2usize; let mut price_decimals = 2usize;
let mut step_size = 0.01f64;
let mut min_qty = 0.0f64;
for filter in filters { for filter in filters {
match filter["filterType"].as_str() { match filter["filterType"].as_str() {
Some("LOT_SIZE") => { Some("LOT_SIZE") => {
let step = filter["stepSize"].as_str().unwrap_or("0.01"); let step = filter["stepSize"].as_str().unwrap_or("0.01");
qty_decimals = count_decimals(step); 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") => { Some("PRICE_FILTER") => {
let tick = filter["tickSize"].as_str().unwrap_or("0.01"); 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 /// Tüm USDT spot işlem çiftlerini döner — public endpoint, API key gerektirmez

View file

@ -50,15 +50,26 @@ pub enum OrderSide {
pub struct SymbolFilters { pub struct SymbolFilters {
pub qty_decimals: usize, pub qty_decimals: usize,
pub price_decimals: usize, pub price_decimals: usize,
pub step_size: f64,
pub min_qty: f64,
} }
impl SymbolFilters { impl SymbolFilters {
/// Miktarı step_size'a göre aşağı yuvarlar (floor), sonra formatlar.
pub fn format_qty(&self, qty: f64) -> String { 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 { pub fn format_price(&self, price: f64) -> String {
format!("{:.prec$}", price, prec = self.price_decimals) format!("{:.prec$}", price, prec = self.price_decimals)
} }
pub fn qty_is_valid(&self, qty: f64) -> bool {
qty >= self.min_qty
}
} }
/// Timeframe tanımları /// Timeframe tanımları

View file

@ -31,6 +31,13 @@ impl RedCandleStrategy {
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);
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); info!("[{}] Market alım | Miktar: {} | Fiyat tahmini: {:.6}", symbol, quantity, kline.close);

View file

@ -8,8 +8,42 @@
</nav> </nav>
<div class="spacer"></div> <div class="spacer"></div>
<div class="mode-toggle"> <div class="mode-toggle">
<button class="mode-btn" id="btn-live">Canlı</button> <button class="mode-btn" id="btn-live" onclick="setMode('live')">Canlı</button>
<button class="mode-btn" id="btn-testnet">Testnet</button> <button class="mode-btn" id="btn-testnet" onclick="setMode('testnet')">Testnet</button>
</div> </div>
<button class="btn btn-logout" onclick="logout()">Çıkış</button> <button class="btn btn-logout" onclick="logout()">Çıkış</button>
</header> </header>
<script>
(function() {
async function initMode() {
try {
const res = await fetch('/api/mode', { credentials: 'include' });
if (!res.ok) return;
const { mode } = await res.json();
applyModeUI(mode);
} catch(e) {}
}
window.setMode = async function(mode) {
try {
const res = await fetch('/api/mode', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
});
if (!res.ok) { alert('Mod değiştirilemedi'); return; }
applyModeUI(mode);
if (typeof onModeChange === 'function') onModeChange(mode);
else window.location.reload();
} catch(e) { alert('Bağlantı hatası'); }
};
function applyModeUI(mode) {
const live = document.getElementById('btn-live');
const test = document.getElementById('btn-testnet');
if (!live || !test) return;
live.className = 'mode-btn' + (mode === 'live' ? ' active-live' : '');
test.className = 'mode-btn' + (mode === 'testnet' ? ' active-testnet' : '');
}
initMode();
})();
</script>

View file

@ -157,6 +157,17 @@
.stat-row { display: flex; justify-content: space-between; align-items: center; } .stat-row { display: flex; justify-content: space-between; align-items: center; }
.stat-row .lbl { font-size: 11px; color: var(--muted); } .stat-row .lbl { font-size: 11px; color: var(--muted); }
.stat-row .val { font-size: 12px; font-weight: 600; } .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); }
</style> </style>
</head> </head>
<body> <body>
@ -200,8 +211,8 @@
</div> </div>
<div class="pos-scroll"> <div class="pos-scroll">
<table class="pos-table"> <table class="pos-table">
<thead><tr><th>Zaman</th><th>Alış</th><th>Hedef</th><th>Adet</th></tr></thead> <thead><tr><th>Zaman</th><th>Alış</th><th>Hedef</th><th>PnL</th><th></th></tr></thead>
<tbody id="open-body"><tr><td colspan="4"><div class="empty-row"></div></td></tr></tbody> <tbody id="open-body"><tr><td colspan="5"><div class="empty-row"></div></td></tr></tbody>
</table> </table>
</div> </div>
</div> </div>
@ -217,6 +228,7 @@
<tbody id="closed-body"><tr><td colspan="4"><div class="empty-row"></div></td></tr></tbody> <tbody id="closed-body"><tr><td colspan="4"><div class="empty-row"></div></td></tr></tbody>
</table> </table>
</div> </div>
<div class="pagination" id="closed-pagination"></div>
</div> </div>
</div> </div>
@ -298,6 +310,9 @@ let priceWs = null;
let priceLines = []; let priceLines = [];
let openPositionsCache = []; let openPositionsCache = [];
let closedPositionsCache = []; 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_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 }; 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; return cur - tf;
} }
// ── Mode change hook (header'dan çağrılır) ────────
function onModeChange(_mode) {
loadPositions();
loadClosed();
}
// ── Load ────────────────────────────────────────── // ── Load ──────────────────────────────────────────
async function loadBot() { async function loadBot() {
const [botRes, modeRes] = await Promise.all([ const res = await api('GET', `/bots/${BOT_ID}`);
api('GET', `/bots/${BOT_ID}`), if (!res.ok) { window.location.href = '/dashboard'; return; }
api('GET', '/mode'), bot = await res.json();
]);
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' : '');
}
document.title = `${bot.name} — CryptoFox Mukan Edition`; document.title = `${bot.name} — CryptoFox Mukan Edition`;
renderPriceBar(); renderPriceBar();
@ -445,11 +457,13 @@ function startPriceWs() {
priceWs.onmessage = e => { priceWs.onmessage = e => {
const t = JSON.parse(e.data); const t = JSON.parse(e.data);
const price = parseFloat(t.c); const price = parseFloat(t.c);
currentPrice = price;
const change = parseFloat(t.P); const change = parseFloat(t.P);
document.getElementById('pb-price').textContent = fmt(price, 2); document.getElementById('pb-price').textContent = fmt(price, 2);
const chEl = document.getElementById('pb-change'); const chEl = document.getElementById('pb-change');
chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%'; chEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)'; chEl.style.color = change >= 0 ? 'var(--green)' : 'var(--red)';
updateOpenPnl();
}; };
priceWs.onerror = () => {}; priceWs.onerror = () => {};
priceWs.onclose = () => setTimeout(startPriceWs, 5000); priceWs.onclose = () => setTimeout(startPriceWs, 5000);
@ -540,21 +554,35 @@ function updateChartOverlays() {
axisLabelVisible: true, title: fmt(p.sell_target, 2), 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(); const buyMap = new Map(), sellMap = new Map();
openPositionsCache.forEach(p => { openPositionsCache.forEach(p => {
const t = buyTime(p.opened_at); const t = buyTime(p.opened_at);
if (t < minTime || t > maxTime) return;
if (!buyMap.has(t)) buyMap.set(t, []); if (!buyMap.has(t)) buyMap.set(t, []);
buyMap.get(t).push(fmt(p.buy_price, 2)); buyMap.get(t).push(fmt(p.buy_price, 2));
}); });
closedPositionsCache.forEach(c => { closedPositionsCache.forEach(c => {
const tb = buyTime(c.opened_at); const tb = buyTime(c.opened_at);
if (tb >= minTime && tb <= maxTime) {
if (!buyMap.has(tb)) buyMap.set(tb, []); if (!buyMap.has(tb)) buyMap.set(tb, []);
buyMap.get(tb).push(fmt(c.buy_price, 2)); buyMap.get(tb).push(fmt(c.buy_price, 2));
}
if (c.status === 'FILLED') { if (c.status === 'FILLED') {
const ts = Math.floor(c.closed_at / 1000); const ts = Math.floor(c.closed_at / 1000);
if (ts >= minTime && ts <= maxTime) {
if (!sellMap.has(ts)) sellMap.set(ts, []); if (!sellMap.has(ts)) sellMap.set(ts, []);
sellMap.get(ts).push(fmt(c.sell_target, 2)); sellMap.get(ts).push(fmt(c.sell_target, 2));
} }
}
}); });
const markers = []; const markers = [];
buyMap.forEach((_, time) => markers.push({ time, position: 'belowBar', color: '#6c63ff', shape: 'arrowUp', size: 1 })); buyMap.forEach((_, time) => markers.push({ time, position: 'belowBar', color: '#6c63ff', shape: 'arrowUp', size: 1 }));
@ -569,39 +597,89 @@ async function loadPositions() {
openPositionsCache = await res.json(); openPositionsCache = await res.json();
if (candleSeries) updateChartOverlays(); if (candleSeries) updateChartOverlays();
document.getElementById('open-count').textContent = openPositionsCache.length; document.getElementById('open-count').textContent = openPositionsCache.length;
renderOpenTable();
renderStats();
}
function renderOpenTable() {
const tbody = document.getElementById('open-body'); const tbody = document.getElementById('open-body');
if (!openPositionsCache.length) { if (!openPositionsCache.length) {
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-row">ık pozisyon yok</div></td></tr>'; tbody.innerHTML = '<tr><td colspan="5"><div class="empty-row">ık pozisyon yok</div></td></tr>';
} else { return;
tbody.innerHTML = openPositionsCache.map(p => `<tr> }
tbody.innerHTML = openPositionsCache.map(p => {
const pnl = currentPrice > 0
? ((currentPrice - p.buy_price) / p.buy_price * 100)
: null;
const pnlHtml = pnl !== null
? `<span class="${pnl >= 0 ? 'c-green' : 'c-red'}">${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}%</span>`
: '<span class="c-muted"></span>';
return `<tr>
<td>${shortDate(p.opened_at)}</td> <td>${shortDate(p.opened_at)}</td>
<td>${fmt(p.buy_price, 4)}</td> <td>${fmt(p.buy_price, 4)}</td>
<td class="c-green">${fmt(p.sell_target, 4)}</td> <td class="c-green">${fmt(p.sell_target, 4)}</td>
<td class="c-muted">${parseFloat(p.quantity).toFixed(4)}</td> <td>${pnlHtml}</td>
</tr>`).join(''); <td><button class="btn-cancel" title="İptal" onclick="cancelPosition(${p.order_id})"></button></td>
} </tr>`;
renderStats(); }).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() { async function loadClosed() {
const res = await api('GET', `/bots/${BOT_ID}/positions/closed`); const res = await api('GET', `/bots/${BOT_ID}/positions/closed`);
closedPositionsCache = await res.json(); closedPositionsCache = await res.json();
closedPage = 0;
if (candleSeries) updateChartOverlays(); if (candleSeries) updateChartOverlays();
document.getElementById('closed-count').textContent = closedPositionsCache.length; document.getElementById('closed-count').textContent = closedPositionsCache.length;
renderClosedTable();
renderStats();
}
function renderClosedTable() {
const tbody = document.getElementById('closed-body'); const tbody = document.getElementById('closed-body');
if (!closedPositionsCache.length) { const total = closedPositionsCache.length;
if (!total) {
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-row">Henüz işlem yok</div></td></tr>'; tbody.innerHTML = '<tr><td colspan="4"><div class="empty-row">Henüz işlem yok</div></td></tr>';
} else { document.getElementById('closed-pagination').innerHTML = '';
tbody.innerHTML = closedPositionsCache.map(c => `<tr> 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 => `<tr>
<td>${shortDate(c.opened_at)}</td> <td>${shortDate(c.opened_at)}</td>
<td>${fmt(c.buy_price, 4)}</td> <td>${fmt(c.buy_price, 4)}</td>
<td>${fmt(c.sell_target, 4)}</td> <td>${fmt(c.sell_target, 4)}</td>
<td class="${c.status === 'FILLED' ? 'c-green' : 'c-muted'}" style="font-size:10px;font-weight:600">${c.status}</td> <td class="${c.status === 'FILLED' ? 'c-green' : 'c-muted'}" style="font-size:10px;font-weight:600">${c.status}</td>
</tr>`).join(''); </tr>`).join('');
const pg = document.getElementById('closed-pagination');
if (totalPages <= 1) { pg.innerHTML = ''; return; }
let btns = '';
if (closedPage > 0) btns += `<button class="pg-btn" onclick="goClosedPage(${closedPage-1})"></button>`;
const lo = Math.max(0, closedPage - 2), hi = Math.min(totalPages - 1, closedPage + 2);
for (let i = lo; i <= hi; i++) {
btns += `<button class="pg-btn${i===closedPage?' active':''}" onclick="goClosedPage(${i})">${i+1}</button>`;
} }
renderStats(); if (closedPage < totalPages - 1) btns += `<button class="pg-btn" onclick="goClosedPage(${closedPage+1})"></button>`;
btns += `<span class="pg-info">${start+1}-${Math.min(start+CLOSED_PAGE_SIZE,total)} / ${total}</span>`;
pg.innerHTML = btns;
} }
function goClosedPage(p) { closedPage = p; renderClosedTable(); }
// ── Log ─────────────────────────────────────────── // ── Log ───────────────────────────────────────────
async function loadInitialLogs() { async function loadInitialLogs() {
const res = await api('GET', `/bots/${BOT_ID}/logs`); const res = await api('GET', `/bots/${BOT_ID}/logs`);