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:
parent
15b619efa0
commit
d1085cc4cc
7 changed files with 267 additions and 40 deletions
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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={}×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::<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
|
||||||
|
|
|
||||||
|
|
@ -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ı
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
150
src/web/bot.html
150
src/web/bot.html
|
|
@ -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,20 +554,34 @@ 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 (!buyMap.has(tb)) buyMap.set(tb, []);
|
if (tb >= minTime && tb <= maxTime) {
|
||||||
buyMap.get(tb).push(fmt(c.buy_price, 2));
|
if (!buyMap.has(tb)) buyMap.set(tb, []);
|
||||||
|
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 (!sellMap.has(ts)) sellMap.set(ts, []);
|
if (ts >= minTime && ts <= maxTime) {
|
||||||
sellMap.get(ts).push(fmt(c.sell_target, 2));
|
if (!sellMap.has(ts)) sellMap.set(ts, []);
|
||||||
|
sellMap.get(ts).push(fmt(c.sell_target, 2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const markers = [];
|
const markers = [];
|
||||||
|
|
@ -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">Açık pozisyon yok</div></td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-row">Açı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;
|
||||||
const tbody = document.getElementById('closed-body');
|
renderClosedTable();
|
||||||
if (!closedPositionsCache.length) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-row">Henüz işlem yok</div></td></tr>';
|
|
||||||
} else {
|
|
||||||
tbody.innerHTML = closedPositionsCache.map(c => `<tr>
|
|
||||||
<td>${shortDate(c.opened_at)}</td>
|
|
||||||
<td>${fmt(c.buy_price, 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>
|
|
||||||
</tr>`).join('');
|
|
||||||
}
|
|
||||||
renderStats();
|
renderStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderClosedTable() {
|
||||||
|
const tbody = document.getElementById('closed-body');
|
||||||
|
const total = closedPositionsCache.length;
|
||||||
|
if (!total) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-row">Henüz işlem yok</div></td></tr>';
|
||||||
|
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 => `<tr>
|
||||||
|
<td>${shortDate(c.opened_at)}</td>
|
||||||
|
<td>${fmt(c.buy_price, 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>
|
||||||
|
</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>`;
|
||||||
|
}
|
||||||
|
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`);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue