diff --git a/.env.example b/.env.example index 524f69e..b7bbd0e 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ -BINANCE_API_KEY=your-binance-api-key -BINANCE_API_SECRET=your-binance-api-secret +BINANCE_API_KEY=your-live-api-key +BINANCE_API_SECRET=your-live-api-secret +BINANCE_TESTNET_API_KEY=your-testnet-api-key +BINANCE_TESTNET_API_SECRET=your-testnet-api-secret AUTH_TOKEN=your-dashboard-access-token DB_PATH=data/bots.db LISTEN_ADDR=127.0.0.1:4646 diff --git a/src/api/bots.rs b/src/api/bots.rs index 3441f1c..07c19e8 100644 --- a/src/api/bots.rs +++ b/src/api/bots.rs @@ -18,7 +18,14 @@ pub struct SymbolInfo { } pub async fn list_symbols(State(state): State) -> impl IntoResponse { - let client = BinanceClient::new(state.api_key, state.api_secret, state.testnet); + let mode = state.current_mode.lock().await.clone(); + let is_testnet = mode == "testnet"; + let (key, secret) = if is_testnet { + (state.testnet_api_key, state.testnet_api_secret) + } else { + (state.live_api_key, state.live_api_secret) + }; + let client = BinanceClient::new(key, secret, is_testnet); match client.get_usdt_symbols_with_min().await { Ok(mut symbols) => { symbols.sort_by(|a, b| a.0.cmp(&b.0)); @@ -39,7 +46,6 @@ pub struct CreateBotRequest { pub timeframe: crate::binance::models::Timeframe, pub usdt_amount: f64, pub profit_percent: f64, - pub testnet: Option, } #[derive(Serialize)] @@ -50,6 +56,9 @@ pub struct BotResponse { } pub async fn list_bots(State(state): State) -> impl IntoResponse { + let mode = state.current_mode.lock().await.clone(); + let is_testnet = mode == "testnet"; + let db = state.db.lock().await; let bots = match db.get_bots() { Ok(b) => b, @@ -60,6 +69,7 @@ pub async fn list_bots(State(state): State) -> impl IntoResponse { let manager = state.manager.lock().await; let response: Vec = bots .into_iter() + .filter(|b| b.testnet == is_testnet) .map(|b| { let running = manager.is_running(&b.id); BotResponse { config: b, running } @@ -73,6 +83,7 @@ pub async fn create_bot( State(state): State, Json(req): Json, ) -> impl IntoResponse { + let mode = state.current_mode.lock().await.clone(); let config = BotConfig { id: Uuid::new_v4().to_string(), name: req.name, @@ -80,7 +91,7 @@ pub async fn create_bot( timeframe: req.timeframe, usdt_amount: req.usdt_amount, profit_percent: req.profit_percent, - testnet: req.testnet.unwrap_or(false), + testnet: mode == "testnet", active: false, }; diff --git a/src/api/mod.rs b/src/api/mod.rs index 4c80c22..b2cf961 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod bots; pub mod events; +pub mod mode; pub mod positions; pub mod routes; diff --git a/src/api/mode.rs b/src/api/mode.rs new file mode 100644 index 0000000..0cae58f --- /dev/null +++ b/src/api/mode.rs @@ -0,0 +1,54 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +#[derive(Serialize)] +pub struct ModeResponse { + pub mode: String, +} + +#[derive(Deserialize)] +pub struct SetModeRequest { + pub mode: String, +} + +pub async fn get_mode(State(state): State) -> impl IntoResponse { + let mode = state.current_mode.lock().await.clone(); + Json(ModeResponse { mode }).into_response() +} + +pub async fn set_mode( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + if req.mode != "live" && req.mode != "testnet" { + return (StatusCode::BAD_REQUEST, "Geçersiz mod").into_response(); + } + + let mut current = state.current_mode.lock().await; + if *current == req.mode { + return Json(ModeResponse { mode: current.clone() }).into_response(); + } + *current = req.mode.clone(); + drop(current); + + // DB'ye kaydet + let db = state.db.lock().await; + if let Err(e) = db.set_config("current_mode", &req.mode) { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); + } + drop(db); + + // Tüm çalışan botları durdur + let mut manager = state.manager.lock().await; + let running = manager.running_ids(); + for id in running { + manager.stop(&id).await; + let db = state.db.lock().await; + let _ = db.set_bot_active(&id, false); + } + drop(manager); + + Json(ModeResponse { mode: req.mode }).into_response() +} diff --git a/src/api/routes.rs b/src/api/routes.rs index 94ca887..0c9d087 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -4,7 +4,7 @@ use axum::{ Router, }; -use crate::api::{auth::require_auth, bots, events, positions}; +use crate::api::{auth::require_auth, bots, events, mode, positions}; use crate::AppState; pub fn build(state: AppState) -> Router { @@ -16,6 +16,7 @@ pub fn build(state: AppState) -> Router { .route("/bots/{id}/stop", post(bots::stop_bot)) .route("/positions", get(positions::open_positions)) .route("/positions/closed", get(positions::closed_positions)) + .route("/mode", get(mode::get_mode).post(mode::set_mode)) .route("/events", get(events::sse_handler)) .layer(middleware::from_fn_with_state(state.clone(), require_auth)); diff --git a/src/bot/manager.rs b/src/bot/manager.rs index 342153d..c8c7daf 100644 --- a/src/bot/manager.rs +++ b/src/bot/manager.rs @@ -19,18 +19,22 @@ pub struct BotManager { bots: HashMap, db: Arc>, event_tx: broadcast::Sender, - api_key: String, - api_secret: String, + live_api_key: String, + live_api_secret: String, + testnet_api_key: String, + testnet_api_secret: String, } impl BotManager { pub fn new( db: Arc>, event_tx: broadcast::Sender, - api_key: String, - api_secret: String, + live_api_key: String, + live_api_secret: String, + testnet_api_key: String, + testnet_api_secret: String, ) -> Self { - Self { bots: HashMap::new(), db, event_tx, api_key, api_secret } + Self { bots: HashMap::new(), db, event_tx, live_api_key, live_api_secret, testnet_api_key, testnet_api_secret } } pub fn start(&mut self, config: BotConfig) { @@ -44,8 +48,11 @@ impl BotManager { 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 (key, secret) = if config.testnet { + (self.testnet_api_key.clone(), self.testnet_api_secret.clone()) + } else { + (self.live_api_key.clone(), self.live_api_secret.clone()) + }; let cfg = config.clone(); let handle = tokio::spawn(async move { diff --git a/src/config.rs b/src/config.rs index ade3827..35eed00 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,23 +2,25 @@ use std::env; #[derive(Clone)] pub struct AppConfig { - pub api_key: String, - pub api_secret: String, + pub live_api_key: String, + pub live_api_secret: String, + pub testnet_api_key: String, + pub testnet_api_secret: String, pub auth_token: String, pub db_path: String, pub listen_addr: String, - pub testnet: bool, } 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()), - testnet: env::var("BINANCE_TESTNET").map(|v| v == "true").unwrap_or(false), + live_api_key: env::var("BINANCE_API_KEY").expect("BINANCE_API_KEY gerekli"), + live_api_secret: env::var("BINANCE_API_SECRET").expect("BINANCE_API_SECRET gerekli"), + testnet_api_key: env::var("BINANCE_TESTNET_API_KEY").expect("BINANCE_TESTNET_API_KEY gerekli"), + testnet_api_secret: env::var("BINANCE_TESTNET_API_SECRET").expect("BINANCE_TESTNET_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()), } } } diff --git a/src/main.rs b/src/main.rs index 6a969ca..88bde82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,9 +22,11 @@ pub struct AppState { pub manager: Arc>, pub event_tx: broadcast::Sender, pub auth_token: String, - pub api_key: String, - pub api_secret: String, - pub testnet: bool, + pub live_api_key: String, + pub live_api_secret: String, + pub testnet_api_key: String, + pub testnet_api_secret: String, + pub current_mode: Arc>, } #[tokio::main] @@ -40,22 +42,34 @@ async fn main() { let (event_tx, _) = broadcast::channel::(64); + // Modu DB'den oku, yoksa "testnet" default + let mode = { + let db_guard = db.lock().await; + let m = db_guard.get_config("current_mode").unwrap_or_default().unwrap_or_else(|| "testnet".to_string()); + drop(db_guard); + m + }; + let current_mode = Arc::new(Mutex::new(mode.clone())); + let manager = BotManager::new( Arc::clone(&db), event_tx.clone(), - cfg.api_key.clone(), - cfg.api_secret.clone(), + cfg.live_api_key.clone(), + cfg.live_api_secret.clone(), + cfg.testnet_api_key.clone(), + cfg.testnet_api_secret.clone(), ); let manager = Arc::new(Mutex::new(manager)); - // Aktif botları otomatik başlat + // Aktif botları otomatik başlat (mevcut modla eşleşenler) { let db_guard = db.lock().await; let bots = db_guard.get_bots().unwrap_or_default(); drop(db_guard); + let is_testnet = mode == "testnet"; let mut mgr = manager.lock().await; - for bot in bots.into_iter().filter(|b| b.active) { + for bot in bots.into_iter().filter(|b| b.active && b.testnet == is_testnet) { info!("Otomatik başlatılıyor: {} ({})", bot.symbol, bot.id); mgr.start(bot); } @@ -66,9 +80,11 @@ async fn main() { manager, event_tx, auth_token: cfg.auth_token, - api_key: cfg.api_key, - api_secret: cfg.api_secret, - testnet: cfg.testnet, + live_api_key: cfg.live_api_key, + live_api_secret: cfg.live_api_secret, + testnet_api_key: cfg.testnet_api_key, + testnet_api_secret: cfg.testnet_api_secret, + current_mode, }; let router = api::routes::build(state); diff --git a/src/storage/db.rs b/src/storage/db.rs index bfdde3b..62787a0 100644 --- a/src/storage/db.rs +++ b/src/storage/db.rs @@ -66,6 +66,10 @@ impl Database { opened_at INTEGER NOT NULL, closed_at INTEGER NOT NULL, status TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL );", )?; Ok(Self { conn }) @@ -220,6 +224,24 @@ impl Database { Ok(()) } + // ─── Config ────────────────────────────────────── + + pub fn get_config(&self, key: &str) -> SqlResult> { + let mut stmt = self.conn.prepare("SELECT value FROM config WHERE key = ?1")?; + let mut rows = stmt.query_map(params![key], |row| row.get(0))?; + Ok(rows.next().transpose()?) + } + + pub fn set_config(&self, key: &str, value: &str) -> SqlResult<()> { + self.conn.execute( + "INSERT INTO config (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + )?; + Ok(()) + } + + // ─── Closed positions ──────────────────────────── + pub fn get_closed_positions(&self) -> SqlResult> { 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 diff --git a/src/web/index.html b/src/web/index.html index 93d1239..d6aee9d 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -24,6 +24,11 @@ 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); } + header .spacer { flex: 1; } + .mode-toggle { display: flex; align-items: center; gap: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 4px; } + .mode-btn { padding: 4px 14px; border-radius: 4px; border: none; background: transparent; color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.15s; } + .mode-btn.active-live { background: rgba(46,204,113,0.15); color: var(--green); } + .mode-btn.active-testnet { background: rgba(243,156,18,0.15); color: var(--yellow); } 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; } @@ -117,26 +122,17 @@ -
-
- - -
-
- - -
+
+ +
@@ -160,6 +156,11 @@

MSE Dashboard

bağlanıyor... + +
+ + +
@@ -196,6 +197,7 @@ let AUTH_TOKEN = localStorage.getItem('mse_token') || ''; let sseSource = null; let symbolSelect = null; let allSymbols = []; +let currentMode = 'testnet'; function login() { const t = document.getElementById('token-input').value.trim(); @@ -231,12 +233,38 @@ async function tryConnect() { } async function loadAll() { + await loadMode(); loadBots(); loadPositions(); loadClosed(); loadSymbols(); } +async function loadMode() { + const res = await api('GET', '/mode'); + const data = await res.json(); + currentMode = data.mode; + updateModeUI(); +} + +function updateModeUI() { + const btnTestnet = document.getElementById('btn-testnet'); + const btnLive = document.getElementById('btn-live'); + btnTestnet.className = 'mode-btn' + (currentMode === 'testnet' ? ' active-testnet' : ''); + btnLive.className = 'mode-btn' + (currentMode === 'live' ? ' active-live' : ''); +} + +async function switchMode(mode) { + if (mode === currentMode) return; + if (mode === 'live') { + if (!confirm('Canlı moda geçilecek. Çalışan tüm botlar durdurulacak. Devam edilsin mi?')) return; + } + await api('POST', '/mode', { mode }); + currentMode = mode; + updateModeUI(); + loadAll(); +} + async function loadSymbols() { const res = await api('GET', '/symbols'); allSymbols = await res.json(); @@ -270,6 +298,8 @@ async function loadSymbols() { } function openModal() { + const modeLabel = currentMode === 'testnet' ? '🟡 Testnet' : '🟢 Canlı'; + document.querySelector('.modal-header h2').textContent = `Yeni Bot Ekle — ${modeLabel}`; document.getElementById('modal-overlay').classList.add('open'); if (symbolSelect) symbolSelect.focus(); } @@ -344,12 +374,11 @@ async function createBot() { 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'; const usdtInput = document.getElementById('f-usdt'); const minNotional = parseFloat(usdtInput.min) || 0; if (!name || !symbol || !usdt_amount || !profit_percent) { alert('Tüm alanları doldurun'); return; } if (usdt_amount < minNotional) { alert(`Minimum işlem miktarı ${minNotional} USDT`); usdtInput.focus(); return; } - await api('POST', '/bots', { name, symbol, timeframe, usdt_amount, profit_percent, testnet }); + await api('POST', '/bots', { name, symbol, timeframe, usdt_amount, profit_percent }); closeModal(); document.getElementById('f-name').value = ''; if (symbolSelect) symbolSelect.clear();