feat: testnet/live mod toggle, çift API key desteği

This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-19 07:23:21 +03:00
parent 2a6c9620f7
commit b5763d0e81
10 changed files with 199 additions and 54 deletions

View file

@ -1,5 +1,7 @@
BINANCE_API_KEY=your-binance-api-key BINANCE_API_KEY=your-live-api-key
BINANCE_API_SECRET=your-binance-api-secret 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 AUTH_TOKEN=your-dashboard-access-token
DB_PATH=data/bots.db DB_PATH=data/bots.db
LISTEN_ADDR=127.0.0.1:4646 LISTEN_ADDR=127.0.0.1:4646

View file

@ -18,7 +18,14 @@ pub struct SymbolInfo {
} }
pub async fn list_symbols(State(state): State<AppState>) -> impl IntoResponse { pub async fn list_symbols(State(state): State<AppState>) -> 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 { match client.get_usdt_symbols_with_min().await {
Ok(mut symbols) => { Ok(mut symbols) => {
symbols.sort_by(|a, b| a.0.cmp(&b.0)); symbols.sort_by(|a, b| a.0.cmp(&b.0));
@ -39,7 +46,6 @@ pub struct CreateBotRequest {
pub timeframe: crate::binance::models::Timeframe, pub timeframe: crate::binance::models::Timeframe,
pub usdt_amount: f64, pub usdt_amount: f64,
pub profit_percent: f64, pub profit_percent: f64,
pub testnet: Option<bool>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -50,6 +56,9 @@ pub struct BotResponse {
} }
pub async fn list_bots(State(state): State<AppState>) -> impl IntoResponse { pub async fn list_bots(State(state): State<AppState>) -> impl IntoResponse {
let mode = state.current_mode.lock().await.clone();
let is_testnet = mode == "testnet";
let db = state.db.lock().await; let db = state.db.lock().await;
let bots = match db.get_bots() { let bots = match db.get_bots() {
Ok(b) => b, Ok(b) => b,
@ -60,6 +69,7 @@ pub async fn list_bots(State(state): State<AppState>) -> impl IntoResponse {
let manager = state.manager.lock().await; let manager = state.manager.lock().await;
let response: Vec<BotResponse> = bots let response: Vec<BotResponse> = bots
.into_iter() .into_iter()
.filter(|b| b.testnet == is_testnet)
.map(|b| { .map(|b| {
let running = manager.is_running(&b.id); let running = manager.is_running(&b.id);
BotResponse { config: b, running } BotResponse { config: b, running }
@ -73,6 +83,7 @@ pub async fn create_bot(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<CreateBotRequest>, Json(req): Json<CreateBotRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let mode = state.current_mode.lock().await.clone();
let config = BotConfig { let config = BotConfig {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
name: req.name, name: req.name,
@ -80,7 +91,7 @@ pub async fn create_bot(
timeframe: req.timeframe, timeframe: req.timeframe,
usdt_amount: req.usdt_amount, usdt_amount: req.usdt_amount,
profit_percent: req.profit_percent, profit_percent: req.profit_percent,
testnet: req.testnet.unwrap_or(false), testnet: mode == "testnet",
active: false, active: false,
}; };

View file

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

54
src/api/mode.rs Normal file
View file

@ -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<AppState>) -> impl IntoResponse {
let mode = state.current_mode.lock().await.clone();
Json(ModeResponse { mode }).into_response()
}
pub async fn set_mode(
State(state): State<AppState>,
Json(req): Json<SetModeRequest>,
) -> 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()
}

View file

@ -4,7 +4,7 @@ use axum::{
Router, Router,
}; };
use crate::api::{auth::require_auth, bots, events, positions}; use crate::api::{auth::require_auth, bots, events, mode, positions};
use crate::AppState; use crate::AppState;
pub fn build(state: AppState) -> Router { 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("/bots/{id}/stop", post(bots::stop_bot))
.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("/events", get(events::sse_handler)) .route("/events", get(events::sse_handler))
.layer(middleware::from_fn_with_state(state.clone(), require_auth)); .layer(middleware::from_fn_with_state(state.clone(), require_auth));

View file

@ -19,18 +19,22 @@ pub struct BotManager {
bots: HashMap<String, RunningBot>, bots: HashMap<String, RunningBot>,
db: Arc<Mutex<Database>>, db: Arc<Mutex<Database>>,
event_tx: broadcast::Sender<TradeEvent>, event_tx: broadcast::Sender<TradeEvent>,
api_key: String, live_api_key: String,
api_secret: String, live_api_secret: String,
testnet_api_key: String,
testnet_api_secret: String,
} }
impl BotManager { impl BotManager {
pub fn new( pub fn new(
db: Arc<Mutex<Database>>, db: Arc<Mutex<Database>>,
event_tx: broadcast::Sender<TradeEvent>, event_tx: broadcast::Sender<TradeEvent>,
api_key: String, live_api_key: String,
api_secret: String, live_api_secret: String,
testnet_api_key: String,
testnet_api_secret: String,
) -> Self { ) -> 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) { pub fn start(&mut self, config: BotConfig) {
@ -44,8 +48,11 @@ impl BotManager {
let shutdown_clone = Arc::clone(&shutdown); let shutdown_clone = Arc::clone(&shutdown);
let db = Arc::clone(&self.db); let db = Arc::clone(&self.db);
let tx = self.event_tx.clone(); let tx = self.event_tx.clone();
let key = self.api_key.clone(); let (key, secret) = if config.testnet {
let secret = self.api_secret.clone(); (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 cfg = config.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {

View file

@ -2,23 +2,25 @@ use std::env;
#[derive(Clone)] #[derive(Clone)]
pub struct AppConfig { pub struct AppConfig {
pub api_key: String, pub live_api_key: String,
pub api_secret: String, pub live_api_secret: String,
pub testnet_api_key: String,
pub testnet_api_secret: String,
pub auth_token: String, pub auth_token: String,
pub db_path: String, pub db_path: String,
pub listen_addr: String, pub listen_addr: String,
pub testnet: bool,
} }
impl AppConfig { impl AppConfig {
pub fn from_env() -> Self { pub fn from_env() -> Self {
Self { Self {
api_key: env::var("BINANCE_API_KEY").expect("BINANCE_API_KEY gerekli"), live_api_key: env::var("BINANCE_API_KEY").expect("BINANCE_API_KEY gerekli"),
api_secret: env::var("BINANCE_API_SECRET").expect("BINANCE_API_SECRET gerekli"), live_api_secret: env::var("BINANCE_API_SECRET").expect("BINANCE_API_SECRET gerekli"),
auth_token: env::var("AUTH_TOKEN").expect("AUTH_TOKEN gerekli"), testnet_api_key: env::var("BINANCE_TESTNET_API_KEY").expect("BINANCE_TESTNET_API_KEY gerekli"),
db_path: env::var("DB_PATH").unwrap_or_else(|_| "data/bots.db".to_string()), testnet_api_secret: env::var("BINANCE_TESTNET_API_SECRET").expect("BINANCE_TESTNET_API_SECRET gerekli"),
listen_addr: env::var("LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:4646".to_string()), auth_token: env::var("AUTH_TOKEN").expect("AUTH_TOKEN gerekli"),
testnet: env::var("BINANCE_TESTNET").map(|v| v == "true").unwrap_or(false), 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()),
} }
} }
} }

View file

@ -22,9 +22,11 @@ pub struct AppState {
pub manager: Arc<Mutex<BotManager>>, pub manager: Arc<Mutex<BotManager>>,
pub event_tx: broadcast::Sender<TradeEvent>, pub event_tx: broadcast::Sender<TradeEvent>,
pub auth_token: String, pub auth_token: String,
pub api_key: String, pub live_api_key: String,
pub api_secret: String, pub live_api_secret: String,
pub testnet: bool, pub testnet_api_key: String,
pub testnet_api_secret: String,
pub current_mode: Arc<Mutex<String>>,
} }
#[tokio::main] #[tokio::main]
@ -40,22 +42,34 @@ async fn main() {
let (event_tx, _) = broadcast::channel::<TradeEvent>(64); let (event_tx, _) = broadcast::channel::<TradeEvent>(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( let manager = BotManager::new(
Arc::clone(&db), Arc::clone(&db),
event_tx.clone(), event_tx.clone(),
cfg.api_key.clone(), cfg.live_api_key.clone(),
cfg.api_secret.clone(), cfg.live_api_secret.clone(),
cfg.testnet_api_key.clone(),
cfg.testnet_api_secret.clone(),
); );
let manager = Arc::new(Mutex::new(manager)); 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 db_guard = db.lock().await;
let bots = db_guard.get_bots().unwrap_or_default(); let bots = db_guard.get_bots().unwrap_or_default();
drop(db_guard); drop(db_guard);
let is_testnet = mode == "testnet";
let mut mgr = manager.lock().await; 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); info!("Otomatik başlatılıyor: {} ({})", bot.symbol, bot.id);
mgr.start(bot); mgr.start(bot);
} }
@ -66,9 +80,11 @@ async fn main() {
manager, manager,
event_tx, event_tx,
auth_token: cfg.auth_token, auth_token: cfg.auth_token,
api_key: cfg.api_key, live_api_key: cfg.live_api_key,
api_secret: cfg.api_secret, live_api_secret: cfg.live_api_secret,
testnet: cfg.testnet, testnet_api_key: cfg.testnet_api_key,
testnet_api_secret: cfg.testnet_api_secret,
current_mode,
}; };
let router = api::routes::build(state); let router = api::routes::build(state);

View file

@ -66,6 +66,10 @@ impl Database {
opened_at INTEGER NOT NULL, opened_at INTEGER NOT NULL,
closed_at INTEGER NOT NULL, closed_at INTEGER NOT NULL,
status TEXT NOT NULL status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);", );",
)?; )?;
Ok(Self { conn }) Ok(Self { conn })
@ -220,6 +224,24 @@ impl Database {
Ok(()) Ok(())
} }
// ─── Config ──────────────────────────────────────
pub fn get_config(&self, key: &str) -> SqlResult<Option<String>> {
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<Vec<ClosedPosition>> { pub fn get_closed_positions(&self) -> SqlResult<Vec<ClosedPosition>> {
let mut stmt = self.conn.prepare( 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 "SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at, closed_at, status

View file

@ -24,6 +24,11 @@
header h1 { font-size: 18px; font-weight: 600; color: var(--accent); } 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 { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
header .status-dot.connected { background: var(--green); } 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; } 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 { 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; } .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 @@
<label>Coin (USDT çifti)</label> <label>Coin (USDT çifti)</label>
<select id="f-symbol"><option value="">Yükleniyor...</option></select> <select id="f-symbol"><option value="">Yükleniyor...</option></select>
</div> </div>
<div class="form-row"> <div class="form-group">
<div class="form-group"> <label>Timeframe</label>
<label>Timeframe</label> <select id="f-timeframe">
<select id="f-timeframe"> <option value="1m">1m</option>
<option value="1m">1m</option> <option value="5m" selected>5m</option>
<option value="5m" selected>5m</option> <option value="15m">15m</option>
<option value="15m">15m</option> <option value="30m">30m</option>
<option value="30m">30m</option> <option value="1h">1h</option>
<option value="1h">1h</option> <option value="4h">4h</option>
<option value="4h">4h</option> <option value="1d">1d</option>
<option value="1d">1d</option> </select>
</select>
</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>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@ -160,6 +156,11 @@
<h1>MSE Dashboard</h1> <h1>MSE Dashboard</h1>
<span class="status-dot" id="sse-dot"></span> <span class="status-dot" id="sse-dot"></span>
<span id="sse-label" style="font-size:12px; color:var(--muted)">bağlanıyor...</span> <span id="sse-label" style="font-size:12px; color:var(--muted)">bağlanıyor...</span>
<span class="spacer"></span>
<div class="mode-toggle">
<button class="mode-btn" id="btn-testnet" onclick="switchMode('testnet')">Testnet</button>
<button class="mode-btn" id="btn-live" onclick="switchMode('live')">Canlı</button>
</div>
</header> </header>
<main> <main>
@ -196,6 +197,7 @@ let AUTH_TOKEN = localStorage.getItem('mse_token') || '';
let sseSource = null; let sseSource = null;
let symbolSelect = null; let symbolSelect = null;
let allSymbols = []; let allSymbols = [];
let currentMode = 'testnet';
function login() { function login() {
const t = document.getElementById('token-input').value.trim(); const t = document.getElementById('token-input').value.trim();
@ -231,12 +233,38 @@ async function tryConnect() {
} }
async function loadAll() { async function loadAll() {
await loadMode();
loadBots(); loadBots();
loadPositions(); loadPositions();
loadClosed(); loadClosed();
loadSymbols(); 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() { async function loadSymbols() {
const res = await api('GET', '/symbols'); const res = await api('GET', '/symbols');
allSymbols = await res.json(); allSymbols = await res.json();
@ -270,6 +298,8 @@ async function loadSymbols() {
} }
function openModal() { 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'); document.getElementById('modal-overlay').classList.add('open');
if (symbolSelect) symbolSelect.focus(); if (symbolSelect) symbolSelect.focus();
} }
@ -344,12 +374,11 @@ async function createBot() {
const timeframe = document.getElementById('f-timeframe').value; const timeframe = document.getElementById('f-timeframe').value;
const usdt_amount = parseFloat(document.getElementById('f-usdt').value); const usdt_amount = parseFloat(document.getElementById('f-usdt').value);
const profit_percent = parseFloat(document.getElementById('f-profit').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 usdtInput = document.getElementById('f-usdt');
const minNotional = parseFloat(usdtInput.min) || 0; const minNotional = parseFloat(usdtInput.min) || 0;
if (!name || !symbol || !usdt_amount || !profit_percent) { alert('Tüm alanları doldurun'); return; } 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; } 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(); closeModal();
document.getElementById('f-name').value = ''; document.getElementById('f-name').value = '';
if (symbolSelect) symbolSelect.clear(); if (symbolSelect) symbolSelect.clear();