init: CryptoFox Mukan Edition — sunucu tabanlı RedCandle bot

Tauri/desktop bağımlılıkları çıkarıldı, Axum HTTP server + SSE ile
web dashboard eklendi. Bot yönetimi, açık/kapalı pozisyon takibi.
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-19 06:15:05 +03:00
commit a68dc599a9
26 changed files with 4155 additions and 0 deletions

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
BINANCE_API_KEY=your-binance-api-key
BINANCE_API_SECRET=your-binance-api-secret
AUTH_TOKEN=your-dashboard-access-token
DB_PATH=data/bots.db
LISTEN_ADDR=127.0.0.1:4646

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target/
.env
data/
*.db

2371
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

52
Cargo.toml Normal file
View file

@ -0,0 +1,52 @@
[package]
name = "mukan-special-edition"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "mse"
path = "src/main.rs"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# HTTP server
axum = { version = "0.7", features = ["macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["fs", "cors"] }
# HTTP client (Binance REST API)
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Hata yönetimi
anyhow = "1.0"
# Loglama
log = "0.4"
env_logger = "0.11"
# Zaman
chrono = { version = "0.4", features = ["serde"] }
# UUID
uuid = { version = "1", features = ["v4", "serde"] }
# Veritabanı
rusqlite = { version = "0.31", features = ["bundled"] }
# Şifreleme (Binance imzalama)
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
# .env yükleme
dotenvy = "0.15"
# SSE
tokio-stream = { version = "0.1", features = ["sync"] }
futures-core = "0.3"

35
src/api/auth.rs Normal file
View file

@ -0,0 +1,35 @@
use axum::{
extract::{Query, Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use serde::Deserialize;
use crate::AppState;
#[derive(Deserialize)]
pub struct TokenQuery {
pub token: Option<String>,
}
pub async fn require_auth(
State(state): State<AppState>,
Query(query): Query<TokenQuery>,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let header_token = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.to_string());
let token = header_token.or(query.token);
match token.as_deref() {
Some(t) if t == state.auth_token => Ok(next.run(request).await),
_ => Err(StatusCode::UNAUTHORIZED),
}
}

128
src/api/bots.rs Normal file
View file

@ -0,0 +1,128 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::storage::config::BotConfig;
use crate::AppState;
#[derive(Deserialize)]
pub struct CreateBotRequest {
pub name: String,
pub symbol: String,
pub timeframe: crate::binance::models::Timeframe,
pub usdt_amount: f64,
pub profit_percent: f64,
pub testnet: Option<bool>,
}
#[derive(Serialize)]
pub struct BotResponse {
#[serde(flatten)]
pub config: BotConfig,
pub running: bool,
}
pub async fn list_bots(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.lock().await;
let bots = match db.get_bots() {
Ok(b) => b,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
drop(db);
let manager = state.manager.lock().await;
let response: Vec<BotResponse> = bots
.into_iter()
.map(|b| {
let running = manager.is_running(&b.id);
BotResponse { config: b, running }
})
.collect();
Json(response).into_response()
}
pub async fn create_bot(
State(state): State<AppState>,
Json(req): Json<CreateBotRequest>,
) -> impl IntoResponse {
let config = BotConfig {
id: Uuid::new_v4().to_string(),
name: req.name,
symbol: req.symbol.to_uppercase(),
timeframe: req.timeframe,
usdt_amount: req.usdt_amount,
profit_percent: req.profit_percent,
testnet: req.testnet.unwrap_or(false),
active: false,
};
let db = state.db.lock().await;
if let Err(e) = db.insert_bot(&config) {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
drop(db);
Json(BotResponse { config, running: false }).into_response()
}
pub async fn delete_bot(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let mut manager = state.manager.lock().await;
manager.stop(&id).await;
drop(manager);
let db = state.db.lock().await;
if let Err(e) = db.delete_bot(&id) {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
StatusCode::NO_CONTENT.into_response()
}
pub async fn start_bot(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let db = state.db.lock().await;
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 == id) {
Some(c) => c,
None => return StatusCode::NOT_FOUND.into_response(),
};
if let Err(e) = db.set_bot_active(&id, true) {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
drop(db);
let mut manager = state.manager.lock().await;
manager.start(config);
StatusCode::OK.into_response()
}
pub async fn stop_bot(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let db = state.db.lock().await;
if let Err(e) = db.set_bot_active(&id, false) {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
drop(db);
let mut manager = state.manager.lock().await;
manager.stop(&id).await;
StatusCode::OK.into_response()
}

18
src/api/events.rs Normal file
View file

@ -0,0 +1,18 @@
use axum::{extract::State, response::sse::{Event, KeepAlive, Sse}};
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
use crate::AppState;
pub async fn sse_handler(
State(state): State<AppState>,
) -> Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>> {
let rx = state.event_tx.subscribe();
let stream = BroadcastStream::new(rx)
.filter_map(|result| result.ok())
.map(|event| {
let data = serde_json::to_string(&event).unwrap_or_default();
Ok(Event::default().event("trade").data(data))
});
Sse::new(stream).keep_alive(KeepAlive::default())
}

5
src/api/mod.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod auth;
pub mod bots;
pub mod events;
pub mod positions;
pub mod routes;

19
src/api/positions.rs Normal file
View file

@ -0,0 +1,19 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use crate::AppState;
pub async fn open_positions(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.lock().await;
match db.get_positions() {
Ok(p) => Json(p).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub async fn closed_positions(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.lock().await;
match db.get_closed_positions() {
Ok(p) => Json(p).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

29
src/api/routes.rs Normal file
View file

@ -0,0 +1,29 @@
use axum::{
middleware,
routing::{delete, get, post},
Router,
};
use crate::api::{auth::require_auth, bots, events, positions};
use crate::AppState;
pub fn build(state: AppState) -> Router {
let api = Router::new()
.route("/bots", get(bots::list_bots).post(bots::create_bot))
.route("/bots/{id}", delete(bots::delete_bot))
.route("/bots/{id}/start", post(bots::start_bot))
.route("/bots/{id}/stop", post(bots::stop_bot))
.route("/positions", get(positions::open_positions))
.route("/positions/closed", get(positions::closed_positions))
.route("/events", get(events::sse_handler))
.layer(middleware::from_fn_with_state(state.clone(), require_auth));
Router::new()
.nest("/api", api)
.route("/", get(index_handler))
.with_state(state)
}
async fn index_handler() -> axum::response::Html<&'static str> {
axum::response::Html(include_str!("../web/index.html"))
}

349
src/binance/client.rs Normal file
View file

@ -0,0 +1,349 @@
use anyhow::{anyhow, Result};
use reqwest::Client;
use serde_json::Value;
use super::models::{FilledOrder, Kline, OrderSide, SymbolFilters, Timeframe};
const BINANCE_BASE_URL: &str = "https://api.binance.com";
const BINANCE_TESTNET_URL: &str = "https://testnet.binance.vision";
pub struct BinanceClient {
http: Client,
api_key: String,
api_secret: String,
base_url: &'static str,
}
impl BinanceClient {
pub fn new(api_key: String, api_secret: String, testnet: bool) -> Self {
Self {
http: Client::new(),
api_key,
api_secret,
base_url: if testnet {
BINANCE_TESTNET_URL
} else {
BINANCE_BASE_URL
},
}
}
/// Son kapanmış mumu getirir (listenin sondan bir önceki elemanı)
pub async fn get_last_closed_kline(
&self,
symbol: &str,
timeframe: &Timeframe,
) -> Result<Kline> {
let url = format!("{}/api/v3/klines", self.base_url);
let response = self
.http
.get(&url)
.query(&[
("symbol", symbol),
("interval", timeframe.as_str()),
("limit", "2"),
])
.send()
.await?
.json::<Value>()
.await?;
// index 0 = kapanmış mum, index 1 = hâlâ açık mum
let kline_data = response
.as_array()
.and_then(|arr| arr.first())
.ok_or_else(|| anyhow!("Kline verisi alınamadı"))?;
parse_kline(kline_data)
}
/// Market alım emri gönderir, gerçekleşen fiyat ve miktarı döner
pub async fn market_buy(
&self,
symbol: &str,
quantity: &str,
) -> Result<FilledOrder> {
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
let params = format!(
"symbol={}&side=BUY&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", "BUY"),
("type", "MARKET"),
("quantity", quantity),
("timestamp", &timestamp),
("signature", &signature),
])
.send()
.await?
.json::<Value>()
.await?;
parse_filled_order(&response, OrderSide::Buy)
}
/// Limit satış emri gönderir
pub async fn limit_sell(
&self,
symbol: &str,
quantity: &str,
price: &str,
) -> Result<FilledOrder> {
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
let params = format!(
"symbol={}&side=SELL&type=LIMIT&timeInForce=GTC&quantity={}&price={}&timestamp={}",
symbol, quantity, price, 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", "LIMIT"),
("timeInForce", "GTC"),
("quantity", quantity),
("price", price),
("timestamp", &timestamp),
("signature", &signature),
])
.send()
.await?
.json::<Value>()
.await?;
parse_filled_order(&response, OrderSide::Sell)
}
/// Bir emrin güncel durumunu sorgular ("NEW", "FILLED", "CANCELED", ...)
pub async fn get_order_status(&self, symbol: &str, order_id: u64) -> Result<String> {
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
.get(&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 API hatası {}: {}",
code,
response["msg"].as_str().unwrap_or("")
));
}
Ok(response["status"].as_str().unwrap_or("UNKNOWN").to_string())
}
/// 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);
let response = self
.http
.get(&url)
.query(&[("symbol", symbol)])
.send()
.await?
.json::<Value>()
.await?;
let filters = response["symbols"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|s| s["filters"].as_array())
.ok_or_else(|| anyhow!("exchangeInfo parse hatası: {}", symbol))?;
let mut qty_decimals = 2usize;
let mut price_decimals = 2usize;
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);
}
Some("PRICE_FILTER") => {
let tick = filter["tickSize"].as_str().unwrap_or("0.01");
price_decimals = count_decimals(tick);
}
_ => {}
}
}
Ok(SymbolFilters { qty_decimals, price_decimals })
}
/// Tüm USDT spot işlem çiftlerini döner — public endpoint, API key gerektirmez
pub async fn get_usdt_symbols(&self) -> Result<Vec<String>> {
let url = format!("{}/api/v3/ticker/price", self.base_url);
let response = self
.http
.get(&url)
.send()
.await?
.json::<Value>()
.await?;
let symbols = response
.as_array()
.ok_or_else(|| anyhow!("ticker/price parse hatası"))?
.iter()
.filter_map(|item| {
let sym = item["symbol"].as_str()?;
if sym.ends_with("USDT") && !sym.starts_with("USDT") {
Some(sym[..sym.len() - 4].to_string())
} else {
None
}
})
.collect();
Ok(symbols)
}
/// Binance hesabındaki sıfır olmayan tüm bakiyeleri döner: Vec<(asset, free, locked)>
pub async fn get_account_balances(&self) -> Result<Vec<(String, f64, f64)>> {
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
let params = format!("timestamp={}", timestamp);
let signature = self.sign(&params);
let url = format!("{}/api/v3/account", self.base_url);
let response = self
.http
.get(&url)
.header("X-MBX-APIKEY", &self.api_key)
.query(&[
("timestamp", timestamp.as_str()),
("signature", signature.as_str()),
])
.send()
.await?
.json::<Value>()
.await?;
if let Some(code) = response.get("code") {
return Err(anyhow!(
"Binance API hatası {}: {}",
code,
response["msg"].as_str().unwrap_or("")
));
}
let balances = response["balances"]
.as_array()
.ok_or_else(|| anyhow!("Bakiye verisi alınamadı"))?;
let result = balances
.iter()
.filter_map(|b| {
let asset = b["asset"].as_str()?.to_string();
let free: f64 = b["free"].as_str()?.parse().ok()?;
let locked: f64 = b["locked"].as_str()?.parse().ok()?;
if free + locked > 0.0 { Some((asset, free, locked)) } else { None }
})
.collect();
Ok(result)
}
/// HMAC-SHA256 imzası
fn sign(&self, params: &str) -> String {
use hmac::{Hmac, Mac};
type HmacSha256 = Hmac<sha2::Sha256>;
let mut mac = HmacSha256::new_from_slice(self.api_secret.as_bytes())
.expect("HMAC key hatası");
mac.update(params.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
}
/// "0.01000000" → 2, "0.00001000" → 5
fn count_decimals(step: &str) -> usize {
if let Some(dot) = step.find('.') {
step[dot + 1..].trim_end_matches('0').len()
} else {
0
}
}
fn parse_kline(data: &Value) -> Result<Kline> {
let arr = data.as_array().ok_or_else(|| anyhow!("Kline array beklendi"))?;
Ok(Kline {
open_time: arr[0].as_i64().unwrap_or(0),
open: arr[1].as_str().unwrap_or("0").parse()?,
high: arr[2].as_str().unwrap_or("0").parse()?,
low: arr[3].as_str().unwrap_or("0").parse()?,
close: arr[4].as_str().unwrap_or("0").parse()?,
volume: arr[5].as_str().unwrap_or("0").parse()?,
close_time: arr[6].as_i64().unwrap_or(0),
})
}
fn parse_filled_order(data: &Value, side: OrderSide) -> Result<FilledOrder> {
if let Some(code) = data.get("code") {
return Err(anyhow!(
"Binance API hatası {}: {}",
code,
data["msg"].as_str().unwrap_or("bilinmeyen hata")
));
}
let executed_qty: f64 = data["executedQty"]
.as_str()
.unwrap_or("0")
.parse()?;
let quote_qty: f64 = data["cummulativeQuoteQty"]
.as_str()
.unwrap_or("0")
.parse()?;
// Market buy → ortalama gerçekleşme fiyatı
// Limit sell → emir fiyatı (executedQty henüz 0 olabilir)
let price = match side {
OrderSide::Buy => {
if executed_qty > 0.0 { quote_qty / executed_qty } else { 0.0 }
}
OrderSide::Sell => {
data["price"].as_str().unwrap_or("0").parse().unwrap_or(0.0)
}
};
Ok(FilledOrder {
order_id: data["orderId"].as_u64().unwrap_or(0),
symbol: data["symbol"].as_str().unwrap_or("").to_string(),
side,
price,
quantity: executed_qty,
timestamp: data["transactTime"].as_i64().unwrap_or(0),
})
}

2
src/binance/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod client;
pub mod models;

113
src/binance/models.rs Normal file
View file

@ -0,0 +1,113 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Binance kline/mum verisi
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Kline {
pub open_time: i64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub close_time: i64,
}
impl Kline {
/// Mumun kırmızı olup olmadığını döner (close < open)
pub fn is_red(&self) -> bool {
self.close < self.open
}
pub fn open_time_utc(&self) -> DateTime<Utc> {
DateTime::from_timestamp_millis(self.open_time).unwrap_or_default()
}
pub fn close_time_utc(&self) -> DateTime<Utc> {
DateTime::from_timestamp_millis(self.close_time).unwrap_or_default()
}
}
/// Gerçekleşen emir bilgisi
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilledOrder {
pub order_id: u64,
pub symbol: String,
pub side: OrderSide,
pub price: f64,
pub quantity: f64,
pub timestamp: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum OrderSide {
Buy,
Sell,
}
/// Sembol için lot size ve tick size filtreleri
#[derive(Debug, Clone)]
pub struct SymbolFilters {
pub qty_decimals: usize,
pub price_decimals: usize,
}
impl SymbolFilters {
pub fn format_qty(&self, qty: f64) -> String {
format!("{:.prec$}", qty, prec = self.qty_decimals)
}
pub fn format_price(&self, price: f64) -> String {
format!("{:.prec$}", price, prec = self.price_decimals)
}
}
/// Timeframe tanımları
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum Timeframe {
#[serde(rename = "1m")]
OneMinute,
#[serde(rename = "5m")]
FiveMinutes,
#[serde(rename = "15m")]
FifteenMinutes,
#[serde(rename = "30m")]
ThirtyMinutes,
#[serde(rename = "1h")]
OneHour,
#[serde(rename = "4h")]
FourHours,
#[serde(rename = "1d")]
OneDay,
#[serde(rename = "1w")]
OneWeek,
}
impl Timeframe {
/// Binance API için string değer
pub fn as_str(&self) -> &str {
match self {
Timeframe::OneMinute => "1m",
Timeframe::FiveMinutes => "5m",
Timeframe::FifteenMinutes => "15m",
Timeframe::ThirtyMinutes => "30m",
Timeframe::OneHour => "1h",
Timeframe::FourHours => "4h",
Timeframe::OneDay => "1d",
Timeframe::OneWeek => "1w",
}
}
/// Timeframe süresi (saniye)
pub fn duration_secs(&self) -> i64 {
match self {
Timeframe::OneMinute => 60,
Timeframe::FiveMinutes => 300,
Timeframe::FifteenMinutes => 900,
Timeframe::ThirtyMinutes => 1800,
Timeframe::OneHour => 3600,
Timeframe::FourHours => 14400,
Timeframe::OneDay => 86400,
Timeframe::OneWeek => 604800,
}
}
}

76
src/bot/manager.rs Normal file
View file

@ -0,0 +1,76 @@
use std::collections::HashMap;
use std::sync::Arc;
use log::{info, warn};
use tokio::sync::{broadcast, Mutex};
use tokio::task::JoinHandle;
use crate::bot::runner::{BotRunner, TradeEvent};
use crate::storage::config::BotConfig;
use crate::storage::db::Database;
struct RunningBot {
shutdown: Arc<Mutex<bool>>,
#[allow(dead_code)]
handle: JoinHandle<()>,
}
pub struct BotManager {
bots: HashMap<String, RunningBot>,
db: Arc<Mutex<Database>>,
event_tx: broadcast::Sender<TradeEvent>,
api_key: String,
api_secret: String,
}
impl BotManager {
pub fn new(
db: Arc<Mutex<Database>>,
event_tx: broadcast::Sender<TradeEvent>,
api_key: String,
api_secret: String,
) -> Self {
Self { bots: HashMap::new(), db, event_tx, api_key, api_secret }
}
pub fn start(&mut self, config: BotConfig) {
let id = config.id.clone();
if self.bots.contains_key(&id) {
warn!("Bot zaten çalışıyor: {}", id);
return;
}
let shutdown = Arc::new(Mutex::new(false));
let shutdown_clone = Arc::clone(&shutdown);
let db = Arc::clone(&self.db);
let tx = self.event_tx.clone();
let key = self.api_key.clone();
let secret = self.api_secret.clone();
let cfg = config.clone();
let handle = tokio::spawn(async move {
BotRunner::run(cfg, key, secret, shutdown_clone, db, tx).await;
});
info!("Bot başlatıldı: {} ({})", config.symbol, id);
self.bots.insert(id, RunningBot { shutdown, handle });
}
pub async fn stop(&mut self, id: &str) {
if let Some(bot) = self.bots.get(id) {
*bot.shutdown.lock().await = true;
info!("Bot durduruldu: {}", id);
} else {
warn!("Bot bulunamadı: {}", id);
}
self.bots.remove(id);
}
pub fn is_running(&self, id: &str) -> bool {
self.bots.contains_key(id)
}
pub fn running_ids(&self) -> Vec<String> {
self.bots.keys().cloned().collect()
}
}

3
src/bot/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod manager;
pub mod runner;
pub mod strategy;

143
src/bot/runner.rs Normal file
View file

@ -0,0 +1,143 @@
use std::sync::Arc;
use log::{error, info};
use tokio::sync::{broadcast, Mutex};
use tokio::time::{sleep, Duration};
use crate::binance::{client::BinanceClient, models::Timeframe};
use crate::bot::strategy::RedCandleStrategy;
use crate::storage::config::BotConfig;
use crate::storage::db::Database;
use crate::storage::history::TradeRecord;
use crate::storage::positions::OpenPosition;
#[derive(Debug, Clone, serde::Serialize)]
pub struct TradeEvent {
pub bot_id: String,
pub bot_name: String,
pub symbol: String,
pub buy_price: f64,
pub sell_target: f64,
pub quantity: f64,
pub profit_percent: f64,
pub timestamp: i64,
}
pub struct BotRunner;
impl BotRunner {
pub async fn run(
config: BotConfig,
api_key: String,
api_secret: String,
shutdown: Arc<Mutex<bool>>,
db: Arc<Mutex<Database>>,
event_tx: broadcast::Sender<TradeEvent>,
) {
let client = BinanceClient::new(api_key, api_secret, config.testnet);
let mut last_traded_open_time: Option<i64> = None;
info!("[{}] Bot başlatıldı.", config.symbol);
loop {
if *shutdown.lock().await {
info!("[{}] Bot durduruldu.", config.symbol);
break;
}
let wait_secs = seconds_until_trigger(&config.timeframe);
info!("[{}] Sonraki kontrol: {} saniye sonra.", config.symbol, wait_secs);
sleep(Duration::from_secs(wait_secs as u64)).await;
if *shutdown.lock().await {
info!("[{}] Bot durduruldu.", config.symbol);
break;
}
match RedCandleStrategy::execute(
&client,
&config.symbol,
&config.timeframe,
config.usdt_amount,
config.profit_percent,
&mut last_traded_open_time,
)
.await
{
Ok(Some(result)) => {
info!(
"[{}] ✅ İşlem | Alış: {:.4} | Satış hedefi: {:.4} | Kar: %{:.2}",
config.symbol, result.buy_order.price, result.sell_order.price, config.profit_percent
);
let record = TradeRecord {
bot_id: config.id.clone(),
bot_name: config.name.clone(),
symbol: config.symbol.clone(),
buy_price: result.buy_order.price,
sell_target: result.sell_order.price,
quantity: result.buy_order.quantity,
profit_percent: config.profit_percent,
timestamp: result.timestamp,
};
let position = OpenPosition {
bot_id: config.id.clone(),
bot_name: config.name.clone(),
symbol: config.symbol.clone(),
order_id: result.sell_order.order_id,
buy_price: result.buy_order.price,
sell_target: result.sell_order.price,
quantity: result.buy_order.quantity,
profit_percent: config.profit_percent,
opened_at: result.timestamp,
};
{
let db_guard = db.lock().await;
if let Err(e) = db_guard.insert_trade(&record) {
error!("[{}] DB trade kayıt hatası: {}", config.symbol, e);
}
if let Err(e) = db_guard.insert_position(&position) {
error!("[{}] DB pozisyon kayıt hatası: {}", config.symbol, e);
}
}
let event = TradeEvent {
bot_id: config.id.clone(),
bot_name: config.name.clone(),
symbol: config.symbol.clone(),
buy_price: result.buy_order.price,
sell_target: result.sell_order.price,
quantity: result.buy_order.quantity,
profit_percent: config.profit_percent,
timestamp: result.timestamp,
};
event_tx.send(event).ok();
}
Ok(None) => {
info!("[{}] ⏭ İşlem yapılmadı.", config.symbol);
}
Err(e) => {
error!("[{}] ❌ HATA: {:?}", config.symbol, e);
}
}
}
}
}
const TRIGGER_BEFORE_SECS: i64 = 60;
fn seconds_until_trigger(timeframe: &Timeframe) -> i64 {
let now = chrono::Utc::now().timestamp();
let duration = timeframe.duration_secs();
let candle_start = (now / duration) * duration;
let candle_close = candle_start + duration;
let trigger_at = candle_close - TRIGGER_BEFORE_SECS;
let wait = trigger_at - now;
if wait <= 0 {
candle_close + duration - TRIGGER_BEFORE_SECS - now
} else {
wait
}
}

100
src/bot/strategy.rs Normal file
View file

@ -0,0 +1,100 @@
use anyhow::Result;
use chrono::Utc;
use log::info;
use crate::binance::{client::BinanceClient, models::Kline};
/// Kırmızı mum stratejisinin karar ve emir mantığı
pub struct RedCandleStrategy;
impl RedCandleStrategy {
/// Son kapanmış mumu kontrol eder.
/// Kırmızıysa market alım + limit satış emri girer.
/// Aynı mum için daha önce işlem yapıldıysa atlar (last_traded_open_time kontrolü).
pub async fn execute(
client: &BinanceClient,
symbol: &str,
timeframe: &crate::binance::models::Timeframe,
usdt_amount: f64,
profit_percent: f64,
last_traded_open_time: &mut Option<i64>,
) -> Result<Option<TradeResult>> {
let kline = client.get_last_closed_kline(symbol, timeframe).await?;
// Aynı mum için tekrar işlem engeli
if let Some(last_time) = *last_traded_open_time {
if kline.open_time == last_time {
info!(
"[{}] Aynı mum için zaten işlem yapıldı (open_time: {}), atlanıyor.",
symbol, kline.open_time
);
return Ok(None);
}
}
if !kline.is_red() {
info!(
"[{}] Son mum kırmızı değil. Açılış: {:.4} | Kapanış: {:.4}",
symbol, kline.open, kline.close
);
return Ok(None);
}
info!(
"[{}] KIRMIZI MUM! Açılış: {:.4} | Kapanış: {:.4} | Alım yapılıyor...",
symbol, kline.open, kline.close
);
// Exchange filtrelerini al (lot size, tick size)
let filters = client.get_symbol_filters(symbol).await?;
// Alım miktarını hesapla (mevcut kapanış fiyatı üzerinden)
let raw_qty = usdt_amount / kline.close;
let quantity = filters.format_qty(raw_qty);
info!("[{}] Market alım gönderiliyor | Miktar: {} | Fiyat tahmini: {:.4}", symbol, quantity, kline.close);
// Market alım emri
let buy_order = client.market_buy(symbol, &quantity).await?;
info!(
"[{}] Alım gerçekleşti | Fiyat: {:.4} | Miktar: {} | Emir ID: {}",
symbol, buy_order.price, filters.format_qty(buy_order.quantity), buy_order.order_id
);
// Satış fiyatı = bu işlemin alış fiyatı × (1 + kar%)
let sell_price = buy_order.price * (1.0 + profit_percent / 100.0);
let sell_price_str = filters.format_price(sell_price);
let sell_quantity = filters.format_qty(buy_order.quantity);
// Limit satış emri
let sell_order = client
.limit_sell(symbol, &sell_quantity, &sell_price_str)
.await?;
info!(
"[{}] Limit satış emri girildi | Fiyat: {:.4} | Emir ID: {}",
symbol, sell_price, sell_order.order_id
);
*last_traded_open_time = Some(kline.open_time);
Ok(Some(TradeResult {
symbol: symbol.to_string(),
kline,
buy_order,
sell_order,
profit_percent,
timestamp: Utc::now().timestamp_millis(),
}))
}
}
/// İşlem sonucu
#[derive(Debug, Clone)]
pub struct TradeResult {
pub symbol: String,
pub kline: Kline,
pub buy_order: crate::binance::models::FilledOrder,
pub sell_order: crate::binance::models::FilledOrder,
pub profit_percent: f64,
pub timestamp: i64,
}

22
src/config.rs Normal file
View file

@ -0,0 +1,22 @@
use std::env;
#[derive(Clone)]
pub struct AppConfig {
pub api_key: String,
pub api_secret: String,
pub auth_token: String,
pub db_path: String,
pub listen_addr: String,
}
impl AppConfig {
pub fn from_env() -> Self {
Self {
api_key: env::var("BINANCE_API_KEY").expect("BINANCE_API_KEY gerekli"),
api_secret: env::var("BINANCE_API_SECRET").expect("BINANCE_API_SECRET gerekli"),
auth_token: env::var("AUTH_TOKEN").expect("AUTH_TOKEN gerekli"),
db_path: env::var("DB_PATH").unwrap_or_else(|_| "data/bots.db".to_string()),
listen_addr: env::var("LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:4646".to_string()),
}
}
}

74
src/main.rs Normal file
View file

@ -0,0 +1,74 @@
mod api;
mod binance;
mod bot;
mod config;
mod storage;
use std::path::Path;
use std::sync::Arc;
use log::info;
use tokio::net::TcpListener;
use tokio::sync::{broadcast, Mutex};
use bot::manager::BotManager;
use bot::runner::TradeEvent;
use config::AppConfig;
use storage::db::Database;
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Mutex<Database>>,
pub manager: Arc<Mutex<BotManager>>,
pub event_tx: broadcast::Sender<TradeEvent>,
pub auth_token: String,
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let cfg = AppConfig::from_env();
let db = Database::new(Path::new(&cfg.db_path))
.expect("Veritabanı başlatılamadı");
let db = Arc::new(Mutex::new(db));
let (event_tx, _) = broadcast::channel::<TradeEvent>(64);
let manager = BotManager::new(
Arc::clone(&db),
event_tx.clone(),
cfg.api_key.clone(),
cfg.api_secret.clone(),
);
let manager = Arc::new(Mutex::new(manager));
// Aktif botları otomatik başlat
{
let db_guard = db.lock().await;
let bots = db_guard.get_bots().unwrap_or_default();
drop(db_guard);
let mut mgr = manager.lock().await;
for bot in bots.into_iter().filter(|b| b.active) {
info!("Otomatik başlatılıyor: {} ({})", bot.symbol, bot.id);
mgr.start(bot);
}
}
let state = AppState {
db,
manager,
event_tx,
auth_token: cfg.auth_token,
};
let router = api::routes::build(state);
let listener = TcpListener::bind(&cfg.listen_addr).await
.expect("Port dinlenemiyor");
info!("MSE çalışıyor: http://{}", cfg.listen_addr);
axum::serve(listener, router).await.expect("Sunucu hatası");
}

17
src/storage/closed.rs Normal file
View file

@ -0,0 +1,17 @@
use serde::{Deserialize, Serialize};
/// Dolan veya iptal edilen limit satış emri kaydı
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClosedPosition {
pub bot_id: String,
pub bot_name: String,
pub symbol: String,
pub order_id: u64,
pub buy_price: f64,
pub sell_target: f64,
pub quantity: f64,
pub profit_percent: f64,
pub opened_at: i64, // ms
pub closed_at: i64, // ms
pub status: String, // "FILLED" | "CANCELED"
}

16
src/storage/config.rs Normal file
View file

@ -0,0 +1,16 @@
use serde::{Deserialize, Serialize};
use crate::binance::models::Timeframe;
/// Tek bir botun konfigürasyonu
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotConfig {
pub id: String,
pub name: String,
pub symbol: String,
pub timeframe: Timeframe,
pub usdt_amount: f64,
pub profit_percent: f64,
pub testnet: bool,
pub active: bool,
}

246
src/storage/db.rs Normal file
View file

@ -0,0 +1,246 @@
use std::path::Path;
use rusqlite::{params, Connection, Result as SqlResult};
use crate::storage::closed::ClosedPosition;
use crate::storage::config::BotConfig;
use crate::storage::history::TradeRecord;
use crate::storage::positions::OpenPosition;
pub struct Database {
conn: Connection,
}
unsafe impl Send for Database {}
impl Database {
pub fn new(path: &Path) -> SqlResult<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let conn = Connection::open(path)?;
conn.execute_batch(
"PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS bots (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
symbol TEXT NOT NULL,
timeframe TEXT NOT NULL,
usdt_amount REAL NOT NULL,
profit_percent REAL NOT NULL,
testnet INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS trade_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id TEXT NOT NULL,
bot_name TEXT NOT NULL,
symbol TEXT NOT NULL,
buy_price REAL NOT NULL,
sell_target REAL NOT NULL,
quantity REAL NOT NULL,
profit_percent REAL NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS open_positions (
order_id INTEGER PRIMARY KEY,
bot_id TEXT NOT NULL,
bot_name TEXT NOT NULL,
symbol TEXT NOT NULL,
buy_price REAL NOT NULL,
sell_target REAL NOT NULL,
quantity REAL NOT NULL,
profit_percent REAL NOT NULL,
opened_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS closed_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
bot_id TEXT NOT NULL,
bot_name TEXT NOT NULL,
symbol TEXT NOT NULL,
buy_price REAL NOT NULL,
sell_target REAL NOT NULL,
quantity REAL NOT NULL,
profit_percent REAL NOT NULL,
opened_at INTEGER NOT NULL,
closed_at INTEGER NOT NULL,
status TEXT NOT NULL
);",
)?;
Ok(Self { conn })
}
// ─── Bots ────────────────────────────────────────
pub fn insert_bot(&self, b: &BotConfig) -> SqlResult<()> {
self.conn.execute(
"INSERT INTO bots (id, name, symbol, timeframe, usdt_amount, profit_percent, testnet, active)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
b.id, b.name, b.symbol,
serde_json::to_string(&b.timeframe).unwrap_or_default(),
b.usdt_amount, b.profit_percent,
b.testnet as i32, b.active as i32
],
)?;
Ok(())
}
pub fn get_bots(&self) -> SqlResult<Vec<BotConfig>> {
let mut stmt = self.conn.prepare(
"SELECT id, name, symbol, timeframe, usdt_amount, profit_percent, testnet, active FROM bots ORDER BY rowid",
)?;
let rows = stmt.query_map([], |row| {
let timeframe_str: String = row.get(3)?;
let timeframe = serde_json::from_str(&timeframe_str)
.unwrap_or(crate::binance::models::Timeframe::FiveMinutes);
Ok(BotConfig {
id: row.get(0)?,
name: row.get(1)?,
symbol: row.get(2)?,
timeframe,
usdt_amount: row.get(4)?,
profit_percent: row.get(5)?,
testnet: row.get::<_, i32>(6)? != 0,
active: row.get::<_, i32>(7)? != 0,
})
})?;
rows.collect()
}
pub fn delete_bot(&self, id: &str) -> SqlResult<()> {
self.conn.execute("DELETE FROM bots WHERE id = ?1", params![id])?;
Ok(())
}
pub fn set_bot_active(&self, id: &str, active: bool) -> SqlResult<()> {
self.conn.execute(
"UPDATE bots SET active = ?1 WHERE id = ?2",
params![active as i32, id],
)?;
Ok(())
}
// ─── Trade records ───────────────────────────────
pub fn insert_trade(&self, r: &TradeRecord) -> SqlResult<()> {
self.conn.execute(
"INSERT INTO trade_records
(bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, timestamp)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
r.bot_id, r.bot_name, r.symbol,
r.buy_price, r.sell_target, r.quantity,
r.profit_percent, r.timestamp
],
)?;
Ok(())
}
pub fn get_trades(&self) -> SqlResult<Vec<TradeRecord>> {
let mut stmt = self.conn.prepare(
"SELECT bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, timestamp
FROM trade_records ORDER BY timestamp DESC LIMIT 500",
)?;
let rows = stmt.query_map([], |row| {
Ok(TradeRecord {
bot_id: row.get(0)?,
bot_name: row.get(1)?,
symbol: row.get(2)?,
buy_price: row.get(3)?,
sell_target: row.get(4)?,
quantity: row.get(5)?,
profit_percent: row.get(6)?,
timestamp: row.get(7)?,
})
})?;
rows.collect()
}
// ─── Open positions ──────────────────────────────
pub fn insert_position(&self, p: &OpenPosition) -> SqlResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO open_positions
(order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
p.order_id as i64, p.bot_id, p.bot_name, p.symbol,
p.buy_price, p.sell_target, p.quantity,
p.profit_percent, p.opened_at
],
)?;
Ok(())
}
pub fn get_positions(&self) -> SqlResult<Vec<OpenPosition>> {
let mut stmt = self.conn.prepare(
"SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at
FROM open_positions ORDER BY opened_at DESC",
)?;
let rows = stmt.query_map([], |row| {
let order_id: i64 = row.get(0)?;
Ok(OpenPosition {
order_id: order_id as u64,
bot_id: row.get(1)?,
bot_name: row.get(2)?,
symbol: row.get(3)?,
buy_price: row.get(4)?,
sell_target: row.get(5)?,
quantity: row.get(6)?,
profit_percent: row.get(7)?,
opened_at: row.get(8)?,
})
})?;
rows.collect()
}
pub fn remove_position(&self, order_id: u64) -> SqlResult<()> {
self.conn.execute(
"DELETE FROM open_positions WHERE order_id = ?1",
params![order_id as i64],
)?;
Ok(())
}
// ─── Closed positions ────────────────────────────
pub fn insert_closed(&self, c: &ClosedPosition) -> SqlResult<()> {
self.conn.execute(
"INSERT INTO closed_positions
(order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at, closed_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
c.order_id as i64, c.bot_id, c.bot_name, c.symbol,
c.buy_price, c.sell_target, c.quantity,
c.profit_percent, c.opened_at, c.closed_at, c.status
],
)?;
Ok(())
}
pub fn get_closed_positions(&self) -> SqlResult<Vec<ClosedPosition>> {
let mut stmt = self.conn.prepare(
"SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at, closed_at, status
FROM closed_positions ORDER BY closed_at DESC LIMIT 100",
)?;
let rows = stmt.query_map([], |row| {
let order_id: i64 = row.get(0)?;
Ok(ClosedPosition {
order_id: order_id as u64,
bot_id: row.get(1)?,
bot_name: row.get(2)?,
symbol: row.get(3)?,
buy_price: row.get(4)?,
sell_target: row.get(5)?,
quantity: row.get(6)?,
profit_percent: row.get(7)?,
opened_at: row.get(8)?,
closed_at: row.get(9)?,
status: row.get(10)?,
})
})?;
rows.collect()
}
}

14
src/storage/history.rs Normal file
View file

@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
/// Gerçekleşen bir işlemin kaydı
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TradeRecord {
pub bot_id: String,
pub bot_name: String,
pub symbol: String,
pub buy_price: f64,
pub sell_target: f64,
pub quantity: f64,
pub profit_percent: f64,
pub timestamp: i64, // ms
}

5
src/storage/mod.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod closed;
pub mod config;
pub mod db;
pub mod history;
pub mod positions;

15
src/storage/positions.rs Normal file
View file

@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
/// Bekleyen limit satış emri (henüz dolmamış açık pozisyon)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenPosition {
pub bot_id: String,
pub bot_name: String,
pub symbol: String,
pub order_id: u64,
pub buy_price: f64,
pub sell_target: f64,
pub quantity: f64,
pub profit_percent: f64,
pub opened_at: i64, // ms
}

294
src/web/index.html Normal file
View file

@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mukan Special Edition</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f13;
--surface: #1a1a22;
--border: #2a2a38;
--text: #e0e0f0;
--muted: #888;
--accent: #6c63ff;
--green: #2ecc71;
--red: #e74c3c;
--yellow: #f39c12;
}
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; min-height: 100vh; }
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 18px; font-weight: 600; color: var(--accent); }
header .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
header .status-dot.connected { background: var(--green); }
main { padding: 24px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
section { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.section-header { padding: 14px 18px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); display: flex; align-items: center; justify-content: space-between; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 18px; text-align: left; border-bottom: 1px solid var(--border); }
th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 500; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.02); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge-running { background: rgba(46,204,113,0.15); color: var(--green); }
.badge-stopped { background: rgba(136,136,136,0.1); color: var(--muted); }
.btn { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 12px; cursor: pointer; transition: all 0.15s; }
.btn:hover { background: rgba(255,255,255,0.05); }
.btn-start { border-color: var(--green); color: var(--green); }
.btn-start:hover { background: rgba(46,204,113,0.1); }
.btn-stop { border-color: var(--yellow); color: var(--yellow); }
.btn-stop:hover { background: rgba(243,156,18,0.1); }
.btn-delete { border-color: var(--red); color: var(--red); }
.btn-delete:hover { background: rgba(231,76,60,0.1); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: #5a52d5; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; padding: 18px; }
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-group label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.form-group input, .form-group select { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 7px 10px; color: var(--text); font-size: 13px; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: var(--accent); }
.form-group select option { background: var(--surface); }
.form-actions { padding: 0 18px 18px; }
.empty { padding: 24px 18px; color: var(--muted); font-size: 13px; text-align: center; }
#auth-overlay { position: fixed; inset: 0; background: var(--bg); display: flex; align-items: center; justify-content: center; z-index: 100; }
.auth-box { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 32px; width: 320px; display: flex; flex-direction: column; gap: 16px; }
.auth-box h2 { font-size: 16px; color: var(--accent); }
.auth-box input { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 9px 12px; color: var(--text); font-size: 14px; width: 100%; }
.auth-box input:focus { outline: none; border-color: var(--accent); }
.auth-error { color: var(--red); font-size: 12px; display: none; }
</style>
</head>
<body>
<div id="auth-overlay">
<div class="auth-box">
<h2>Mukan Special Edition</h2>
<input type="password" id="token-input" placeholder="Auth token" />
<button class="btn btn-primary" onclick="login()">Giriş</button>
<span class="auth-error" id="auth-error">Geçersiz token</span>
</div>
</div>
<header>
<h1>MSE Dashboard</h1>
<span class="status-dot" id="sse-dot"></span>
<span id="sse-label" style="font-size:12px; color:var(--muted)">bağlanıyor...</span>
</header>
<main>
<section>
<div class="section-header">Botlar <span id="bot-count" style="color:var(--text)">0</span></div>
<table id="bots-table">
<thead><tr><th>İsim</th><th>Sembol</th><th>Timeframe</th><th>USDT</th><th>Kar %</th><th>Testnet</th><th>Durum</th><th></th></tr></thead>
<tbody id="bots-body"><tr><td colspan="8" class="empty">Yükleniyor...</td></tr></tbody>
</table>
</section>
<section>
<div class="section-header">Yeni Bot Ekle</div>
<div class="form-grid">
<div class="form-group"><label>İsim</label><input id="f-name" placeholder="DOGE Bot" /></div>
<div class="form-group"><label>Sembol</label><input id="f-symbol" placeholder="DOGEUSDT" /></div>
<div class="form-group"><label>Timeframe</label>
<select id="f-timeframe">
<option value="1m">1m</option>
<option value="5m" selected>5m</option>
<option value="15m">15m</option>
<option value="30m">30m</option>
<option value="1h">1h</option>
<option value="4h">4h</option>
<option value="1d">1d</option>
</select>
</div>
<div class="form-group"><label>USDT Miktarı</label><input id="f-usdt" type="number" step="0.1" placeholder="10" /></div>
<div class="form-group"><label>Kar %</label><input id="f-profit" type="number" step="0.1" placeholder="2" /></div>
<div class="form-group"><label>Testnet</label>
<select id="f-testnet">
<option value="false">Hayır (Gerçek)</option>
<option value="true">Evet (Testnet)</option>
</select>
</div>
</div>
<div class="form-actions"><button class="btn btn-primary" onclick="createBot()">Bot Ekle</button></div>
</section>
<section>
<div class="section-header">ık Pozisyonlar</div>
<table>
<thead><tr><th>Bot</th><th>Sembol</th><th>Alış</th><th>Hedef</th><th>Miktar</th><th>Zaman</th></tr></thead>
<tbody id="positions-body"><tr><td colspan="6" class="empty">ık pozisyon yok</td></tr></tbody>
</table>
</section>
<section>
<div class="section-header">Kapalı İşlemler</div>
<table>
<thead><tr><th>Bot</th><th>Sembol</th><th>Alış</th><th>Hedef</th><th>Miktar</th><th>Durum</th><th>Zaman</th></tr></thead>
<tbody id="closed-body"><tr><td colspan="7" class="empty">Henüz işlem yok</td></tr></tbody>
</table>
</section>
</main>
<script>
let AUTH_TOKEN = localStorage.getItem('mse_token') || '';
let sseSource = null;
function login() {
const t = document.getElementById('token-input').value.trim();
if (!t) return;
AUTH_TOKEN = t;
localStorage.setItem('mse_token', t);
tryConnect();
}
async function api(method, path, body) {
const res = await fetch('/api' + path, {
method,
headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) { showAuthOverlay(); throw new Error('Unauthorized'); }
return res;
}
function showAuthOverlay() {
document.getElementById('auth-overlay').style.display = 'flex';
}
async function tryConnect() {
const res = await fetch('/api/bots', { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } });
if (res.status === 401) {
document.getElementById('auth-error').style.display = 'block';
return;
}
document.getElementById('auth-overlay').style.display = 'none';
loadAll();
connectSSE();
}
async function loadAll() {
loadBots();
loadPositions();
loadClosed();
}
async function loadBots() {
const res = await api('GET', '/bots');
const bots = await res.json();
document.getElementById('bot-count').textContent = bots.length;
const tbody = document.getElementById('bots-body');
if (!bots.length) { tbody.innerHTML = '<tr><td colspan="8" class="empty">Henüz bot yok</td></tr>'; return; }
tbody.innerHTML = bots.map(b => `
<tr>
<td>${esc(b.name)}</td>
<td><strong>${esc(b.symbol)}</strong></td>
<td>${esc(b.timeframe)}</td>
<td>${b.usdt_amount} USDT</td>
<td>%${b.profit_percent}</td>
<td>${b.testnet ? '🟡 Test' : '🟢 Gerçek'}</td>
<td><span class="badge ${b.running ? 'badge-running' : 'badge-stopped'}">${b.running ? 'Çalışıyor' : 'Durdu'}</span></td>
<td style="display:flex;gap:6px;flex-wrap:wrap">
${b.running
? `<button class="btn btn-stop" onclick="stopBot('${b.id}')">Durdur</button>`
: `<button class="btn btn-start" onclick="startBot('${b.id}')">Başlat</button>`
}
<button class="btn btn-delete" onclick="deleteBot('${b.id}','${esc(b.name)}')">Sil</button>
</td>
</tr>`).join('');
}
async function loadPositions() {
const res = await api('GET', '/positions');
const positions = await res.json();
const tbody = document.getElementById('positions-body');
if (!positions.length) { tbody.innerHTML = '<tr><td colspan="6" class="empty">ık pozisyon yok</td></tr>'; return; }
tbody.innerHTML = positions.map(p => `
<tr>
<td>${esc(p.bot_name)}</td>
<td><strong>${esc(p.symbol)}</strong></td>
<td>${p.buy_price.toFixed(6)}</td>
<td>${p.sell_target.toFixed(6)}</td>
<td>${p.quantity}</td>
<td>${new Date(p.opened_at).toLocaleString('tr-TR')}</td>
</tr>`).join('');
}
async function loadClosed() {
const res = await api('GET', '/positions/closed');
const closed = await res.json();
const tbody = document.getElementById('closed-body');
if (!closed.length) { tbody.innerHTML = '<tr><td colspan="7" class="empty">Henüz işlem yok</td></tr>'; return; }
tbody.innerHTML = closed.map(c => `
<tr>
<td>${esc(c.bot_name)}</td>
<td><strong>${esc(c.symbol)}</strong></td>
<td>${c.buy_price.toFixed(6)}</td>
<td>${c.sell_target.toFixed(6)}</td>
<td>${c.quantity}</td>
<td><span class="badge ${c.status==='FILLED'?'badge-running':'badge-stopped'}">${c.status}</span></td>
<td>${new Date(c.closed_at).toLocaleString('tr-TR')}</td>
</tr>`).join('');
}
async function createBot() {
const name = document.getElementById('f-name').value.trim();
const symbol = document.getElementById('f-symbol').value.trim().toUpperCase();
const timeframe = document.getElementById('f-timeframe').value;
const usdt_amount = parseFloat(document.getElementById('f-usdt').value);
const profit_percent = parseFloat(document.getElementById('f-profit').value);
const testnet = document.getElementById('f-testnet').value === 'true';
if (!name || !symbol || !usdt_amount || !profit_percent) { alert('Tüm alanları doldurun'); return; }
await api('POST', '/bots', { name, symbol, timeframe, usdt_amount, profit_percent, testnet });
loadBots();
}
async function startBot(id) {
await api('POST', `/bots/${id}/start`);
loadBots();
}
async function stopBot(id) {
await api('POST', `/bots/${id}/stop`);
loadBots();
}
async function deleteBot(id, name) {
if (!confirm(`"${name}" botunu silmek istiyor musun?`)) return;
await api('DELETE', `/bots/${id}`);
loadBots();
}
function connectSSE() {
if (sseSource) sseSource.close();
sseSource = new EventSource('/api/events', {
headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN }
});
// EventSource doesn't support custom headers natively; use URL param workaround
sseSource.close();
sseSource = new EventSource(`/api/events?token=${encodeURIComponent(AUTH_TOKEN)}`);
sseSource.addEventListener('trade', () => {
loadPositions();
loadClosed();
});
sseSource.onopen = () => {
document.getElementById('sse-dot').className = 'status-dot connected';
document.getElementById('sse-label').textContent = 'canlı';
};
sseSource.onerror = () => {
document.getElementById('sse-dot').className = 'status-dot';
document.getElementById('sse-label').textContent = 'bağlantı kesildi';
setTimeout(connectSSE, 5000);
};
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
if (AUTH_TOKEN) tryConnect();
else showAuthOverlay();
</script>
</body>
</html>