From dadf7b8394118aa1d02ab86a8c4dac0a7f9f258b Mon Sep 17 00:00:00 2001 From: Mukan Erkin Date: Sat, 25 Apr 2026 21:42:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(mse):=20multi-strategy=20support=20?= =?UTF-8?q?=E2=80=94=20EMA=20Cross,=20Bollinger+RSI,=20MACD,=20RSI,=20Supe?= =?UTF-8?q?rtrend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/api/bots.rs | 13 ++ src/binance/client.rs | 20 ++ src/bot/runner.rs | 302 +++++++++++++++++++----------- src/bot/strategy.rs | 95 ---------- src/bot/strategy/bollinger_rsi.rs | 71 +++++++ src/bot/strategy/ema_cross.rs | 101 ++++++++++ src/bot/strategy/macd.rs | 68 +++++++ src/bot/strategy/mod.rs | 165 ++++++++++++++++ src/bot/strategy/red_candle.rs | 42 +++++ src/bot/strategy/rsi.rs | 65 +++++++ src/bot/strategy/supertrend.rs | 73 ++++++++ src/storage/config.rs | 4 + src/storage/db.rs | 63 +++++-- src/web/_bot_modal.html | 110 ++++++++++- 14 files changed, 967 insertions(+), 225 deletions(-) delete mode 100644 src/bot/strategy.rs create mode 100644 src/bot/strategy/bollinger_rsi.rs create mode 100644 src/bot/strategy/ema_cross.rs create mode 100644 src/bot/strategy/macd.rs create mode 100644 src/bot/strategy/mod.rs create mode 100644 src/bot/strategy/red_candle.rs create mode 100644 src/bot/strategy/rsi.rs create mode 100644 src/bot/strategy/supertrend.rs diff --git a/src/api/bots.rs b/src/api/bots.rs index e0b7591..29bc241 100644 --- a/src/api/bots.rs +++ b/src/api/bots.rs @@ -40,6 +40,16 @@ pub struct CreateBotRequest { pub timeframe: crate::binance::models::Timeframe, pub usdt_amount: f64, pub profit_percent: f64, + #[serde(default)] + pub stop_loss_percent: f64, + #[serde(default = "default_strategy")] + pub strategy: crate::bot::strategy::StrategyKind, + #[serde(default = "serde_json::Value::default")] + pub strategy_params: serde_json::Value, +} + +fn default_strategy() -> crate::bot::strategy::StrategyKind { + crate::bot::strategy::StrategyKind::RedCandle } #[derive(Serialize)] @@ -88,6 +98,9 @@ pub async fn create_bot( timeframe: req.timeframe, usdt_amount: req.usdt_amount, profit_percent: req.profit_percent, + stop_loss_percent: req.stop_loss_percent, + strategy: req.strategy, + strategy_params: req.strategy_params, testnet: mode == "testnet", active: false, }; diff --git a/src/binance/client.rs b/src/binance/client.rs index 8e9c46a..4b3b66d 100644 --- a/src/binance/client.rs +++ b/src/binance/client.rs @@ -247,6 +247,26 @@ impl BinanceClient { Ok(SymbolFilters { qty_decimals, price_decimals, step_size, min_qty, min_notional }) } + /// Kline geçmişi çeker — strateji indikatörü için + pub async fn get_klines(&self, symbol: &str, interval: &str, limit: usize) -> Result> { + let url = format!( + "{}/api/v3/klines?symbol={}&interval={}&limit={}", + self.base_url, symbol, interval, limit + ); + let data: Value = self.http.get(&url).send().await?.json().await?; + let arr = data.as_array().ok_or_else(|| anyhow!("klines parse hatası"))?; + let candles = arr.iter().map(|k| Kline { + open_time: k[0].as_i64().unwrap_or(0), + open: k[1].as_str().unwrap_or("0").parse().unwrap_or(0.0), + high: k[2].as_str().unwrap_or("0").parse().unwrap_or(0.0), + low: k[3].as_str().unwrap_or("0").parse().unwrap_or(0.0), + close: k[4].as_str().unwrap_or("0").parse().unwrap_or(0.0), + volume: k[5].as_str().unwrap_or("0").parse().unwrap_or(0.0), + close_time: k[6].as_i64().unwrap_or(0), + }).collect(); + Ok(candles) + } + /// Tüm USDT spot işlem çiftlerini döner — public endpoint, API key gerektirmez pub async fn get_usdt_symbols(&self) -> Result> { let url = format!("{}/api/v3/ticker/price", self.base_url); diff --git a/src/bot/runner.rs b/src/bot/runner.rs index b65ccd8..aa2d4d1 100644 --- a/src/bot/runner.rs +++ b/src/bot/runner.rs @@ -8,7 +8,16 @@ use tokio::time::{sleep, Duration}; use tokio_tungstenite::connect_async; use crate::binance::{client::BinanceClient, models::Kline}; -use crate::bot::strategy::RedCandleStrategy; +use crate::bot::strategy::{ + Signal, StrategyKind, + red_candle::RedCandleStrategy, + ema_cross::EmaCrossStrategy, + bollinger_rsi::BollingerRsiStrategy, + macd::MacdStrategy, + rsi::RsiStrategy, + supertrend::SupertrendStrategy, + Strategy, +}; use crate::storage::closed::ClosedPosition; use crate::storage::config::BotConfig; use crate::storage::db::Database; @@ -16,7 +25,7 @@ use crate::storage::history::TradeRecord; use crate::storage::positions::OpenPosition; const BINANCE_WS_URL: &str = "wss://stream.binance.com/ws"; -const BINANCE_TESTNET_WS_URL: &str = "wss://testnet.binance.vision/ws"; +const KLINE_HISTORY_LIMIT: usize = 300; #[derive(Debug, Clone, serde::Serialize)] pub struct TradeEvent { @@ -32,6 +41,19 @@ pub struct TradeEvent { pub struct BotRunner; +/// Config'ten strateji nesnesi oluşturur +fn build_strategy(config: &BotConfig) -> Box { + let params = &config.strategy_params; + match config.strategy { + StrategyKind::RedCandle => Box::new(RedCandleStrategy::new(params, config.profit_percent, config.stop_loss_percent)), + StrategyKind::EmaCross => Box::new(EmaCrossStrategy::new(params)), + StrategyKind::BollingerRsi => Box::new(BollingerRsiStrategy::new(params)), + StrategyKind::Macd => Box::new(MacdStrategy::new(params)), + StrategyKind::Rsi => Box::new(RsiStrategy::new(params)), + StrategyKind::Supertrend => Box::new(SupertrendStrategy::new(params)), + } +} + impl BotRunner { pub async fn run( config: BotConfig, @@ -42,13 +64,31 @@ impl BotRunner { event_tx: broadcast::Sender, ) { let client = BinanceClient::new(api_key.clone(), api_secret.clone(), config.testnet); - // Fiyat verisi her zaman canlı Binance stream'inden gelir let ws_base = BINANCE_WS_URL; let stream = format!("{}/{}@kline_{}", ws_base, config.symbol.to_lowercase(), config.timeframe.as_str()); + let strategy = build_strategy(&config); + let min_candles = strategy.min_candles(); - info!("[{}] Bot başlatıldı. WS: {}", config.symbol, stream); + info!("[{}] Bot başlatıldı. Strateji: {} | WS: {}", config.symbol, config.strategy, stream); - // Açık pozisyonların order durumunu periyodik sorgula + // Kline geçmişini tut — strateji indikatörü için + let mut candle_buf: Vec = Vec::with_capacity(KLINE_HISTORY_LIMIT + 10); + + // Başlangıçta geçmiş kline'ları çek + let history_limit = (min_candles + 50).max(KLINE_HISTORY_LIMIT); + match client.get_klines(&config.symbol, config.timeframe.as_str(), history_limit).await { + Ok(mut hist) => { + // Son mum henüz kapanmamış olabilir, onu çıkar + if hist.len() > 1 { hist.pop(); } + info!("[{}] {} adet geçmiş mum yüklendi.", config.symbol, hist.len()); + candle_buf.extend(hist); + } + Err(e) => { + warn!("[{}] Geçmiş mum çekilemedi: {}", config.symbol, e); + } + } + + // Açık pozisyon kontrolü için arka plan task { let db2 = Arc::clone(&db); let ev2 = event_tx.clone(); @@ -94,100 +134,54 @@ impl BotRunner { Err(_) => continue, }; - let kline = match parse_kline_message(&text) { + let kline_ws = match parse_kline_message(&text) { Some(k) => k, None => continue, }; // Sadece kapanmış mumları işle - if !kline.is_closed { - continue; - } + if !kline_ws.is_closed { continue; } + let kline = Kline::from(&kline_ws); info!("[{}] Mum kapandı. Açılış: {:.6} | Kapanış: {:.6}", config.symbol, kline.open, kline.close); { let db_guard = db.lock().await; db_guard.insert_log(&config.id, "info", &format!("Mum kapandı. Açılış: {:.6} | Kapanış: {:.6}", kline.open, kline.close)).ok(); } - let kline_data = Kline::from(&kline); - match RedCandleStrategy::execute( - &client, - &config.symbol, - &kline_data, - config.usdt_amount, - config.profit_percent, - ) - .await - { - Ok(Some(result)) => { - info!( - "[{}] İşlem | Alış: {:.6} | Satış hedefi: {:.6} | Kar: %{:.2}", - config.symbol, result.buy_order.price, result.sell_order.price, config.profit_percent - ); - { + // Buffer güncelle + candle_buf.push(kline.clone()); + if candle_buf.len() > KLINE_HISTORY_LIMIT + 50 { + candle_buf.drain(0..50); + } + + // Yeterli mum var mı? + if candle_buf.len() < min_candles { + info!("[{}] Strateji için yeterli mum yok ({}/{}), bekleniyor.", config.symbol, candle_buf.len(), min_candles); + continue; + } + + // Sinyal üret + let signal = strategy.generate_signal(&candle_buf); + + match signal { + Signal::Buy => { + info!("[{}] BUY sinyali! Strateji: {}", config.symbol, config.strategy); + if let Err(e) = execute_buy(&client, &config, &strategy, &kline, &db, &event_tx).await { + error!("[{}] Alım hatası: {:?}", config.symbol, e); let db_guard = db.lock().await; - db_guard.insert_log(&config.id, "buy", &format!("İşlem gerçekleşti | Alış: {:.6} | Satış hedefi: {:.6} | Miktar: {:.6} | Kar: %{:.2}", result.buy_order.price, result.sell_order.price, result.buy_order.quantity, config.profit_percent)).ok(); - } - - 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, - buy_commission_usdt: result.buy_order.commission_usdt, - }; - - { - 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); - } - } - - event_tx.send(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, - }).ok(); - } - Ok(None) => { - info!("[{}] İşlem yapılmadı (yeşil mum).", config.symbol); - { - let db_guard = db.lock().await; - db_guard.insert_log(&config.id, "skip", "Yeşil mum, işlem yapılmadı.").ok(); + db_guard.insert_log(&config.id, "error", &format!("Alım hatası: {}", e)).ok(); } } - Err(e) => { - error!("[{}] Strateji hatası: {:?}", config.symbol, e); - { - let db_guard = db.lock().await; - db_guard.insert_log(&config.id, "error", &format!("Strateji hatası: {}", e)).ok(); - } + Signal::Sell => { + info!("[{}] SELL sinyali (strateji) — açık pozisyon kontrolü yapılmıyor, bot bakkal modunda çalışıyor.", config.symbol); + let db_guard = db.lock().await; + db_guard.insert_log(&config.id, "skip", "Sell sinyali — bakkal modunda görmezden gelindi.").ok(); + } + Signal::Hold => { + info!("[{}] HOLD — işlem yok.", config.symbol); + let db_guard = db.lock().await; + db_guard.insert_log(&config.id, "skip", "Hold sinyali, işlem yapılmadı.").ok(); } } } @@ -211,15 +205,118 @@ impl BotRunner { } } - if *shutdown.lock().await { - break; - } - + if *shutdown.lock().await { break; } sleep(Duration::from_secs(5)).await; } } } +async fn execute_buy( + client: &BinanceClient, + config: &BotConfig, + strategy: &Box, + kline: &Kline, + db: &Arc>, + event_tx: &broadcast::Sender, +) -> anyhow::Result<()> { + // Bakiye kontrolü + let usdt_balance = client.get_usdt_spot_balance().await?; + if usdt_balance < config.usdt_amount { + info!("[{}] Yetersiz USDT bakiyesi ({:.2} < {:.2}), bekleniyor", config.symbol, usdt_balance, config.usdt_amount); + let db_guard = db.lock().await; + db_guard.insert_log(&config.id, "skip", &format!("Yetersiz bakiye: {:.2} USDT", usdt_balance)).ok(); + return Ok(()); + } + + let filters = client.get_symbol_filters(&config.symbol).await?; + let raw_qty = config.usdt_amount / kline.close; + 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 + )); + } + if !filters.notional_is_valid(qty_f, kline.close) { + return Err(anyhow::anyhow!( + "İşlem tutarı ({:.2} USDT) min notional ({} USDT) altında — USDT miktarını artır", + qty_f * kline.close, filters.min_notional + )); + } + + info!("[{}] Market alım | Miktar: {} | Fiyat tahmini: {:.6}", config.symbol, quantity, kline.close); + let buy_order = client.market_buy(&config.symbol, &quantity).await?; + info!( + "[{}] Alım gerçekleşti | Fiyat: {:.6} | Miktar: {} | Emir ID: {}", + config.symbol, buy_order.price, filters.format_qty(buy_order.quantity), buy_order.order_id + ); + + let sell_price = strategy.sell_price(buy_order.price); + let sell_price_str = filters.format_price(sell_price); + let sell_quantity = filters.format_qty(buy_order.quantity); + + let sell_order = client.limit_sell(&config.symbol, &sell_quantity, &sell_price_str).await?; + info!( + "[{}] Limit satış emri | Fiyat: {:.6} | Emir ID: {}", + config.symbol, sell_price, sell_order.order_id + ); + + let timestamp = chrono::Utc::now().timestamp_millis(); + + let record = TradeRecord { + bot_id: config.id.clone(), + bot_name: config.name.clone(), + symbol: config.symbol.clone(), + buy_price: buy_order.price, + sell_target: sell_price, + quantity: buy_order.quantity, + profit_percent: config.profit_percent, + timestamp, + }; + + let position = OpenPosition { + bot_id: config.id.clone(), + bot_name: config.name.clone(), + symbol: config.symbol.clone(), + order_id: sell_order.order_id, + buy_price: buy_order.price, + sell_target: sell_price, + quantity: buy_order.quantity, + profit_percent: config.profit_percent, + opened_at: timestamp, + buy_commission_usdt: buy_order.commission_usdt, + }; + + { + let db_guard = db.lock().await; + db_guard.insert_log(&config.id, "buy", &format!( + "İşlem gerçekleşti | Alış: {:.6} | Satış hedefi: {:.6} | Miktar: {:.6} | Kar: %{:.2}", + buy_order.price, sell_price, buy_order.quantity, config.profit_percent + )).ok(); + 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); + } + } + + event_tx.send(TradeEvent { + bot_id: config.id.clone(), + bot_name: config.name.clone(), + symbol: config.symbol.clone(), + buy_price: buy_order.price, + sell_target: sell_price, + quantity: buy_order.quantity, + profit_percent: config.profit_percent, + timestamp, + }).ok(); + + Ok(()) +} + struct KlineWs { open: f64, close: f64, @@ -246,9 +343,17 @@ fn parse_kline_message(text: &str) -> Option { }) } -impl KlineWs { - fn is_red(&self) -> bool { - self.close < self.open +impl From<&KlineWs> for Kline { + fn from(k: &KlineWs) -> Self { + Kline { + open_time: k.open_time, + close_time: k.close_time, + open: k.open, + close: k.close, + high: k.high, + low: k.low, + volume: k.volume, + } } } @@ -313,18 +418,3 @@ async fn check_open_positions( } } } - -// Kline dönüşümü strategy için -impl From<&KlineWs> for Kline { - fn from(k: &KlineWs) -> Self { - Kline { - open_time: k.open_time, - close_time: k.close_time, - open: k.open, - close: k.close, - high: k.high, - low: k.low, - volume: k.volume, - } - } -} diff --git a/src/bot/strategy.rs b/src/bot/strategy.rs deleted file mode 100644 index fac9b46..0000000 --- a/src/bot/strategy.rs +++ /dev/null @@ -1,95 +0,0 @@ -use anyhow::Result; -use chrono::Utc; -use log::info; - -use crate::binance::{client::BinanceClient, models::Kline}; - -pub struct RedCandleStrategy; - -impl RedCandleStrategy { - /// Kapanmış kline verilir. Kırmızıysa market alım + limit satış emri girer. - pub async fn execute( - client: &BinanceClient, - symbol: &str, - kline: &Kline, - usdt_amount: f64, - profit_percent: f64, - ) -> Result> { - if !kline.is_red() { - info!( - "[{}] Son mum kırmızı değil. Açılış: {:.6} | Kapanış: {:.6}", - symbol, kline.open, kline.close - ); - return Ok(None); - } - - info!( - "[{}] KIRMIZI MUM! Açılış: {:.6} | Kapanış: {:.6} | Alım yapılıyor...", - symbol, kline.open, kline.close - ); - - let usdt_balance = client.get_usdt_spot_balance().await?; - if usdt_balance < usdt_amount { - info!( - "[{}] Yetersiz USDT bakiyesi ({:.2} < {:.2}), bekleniyor", - symbol, usdt_balance, usdt_amount - ); - return Ok(None); - } - - let filters = client.get_symbol_filters(symbol).await?; - let raw_qty = usdt_amount / kline.close; - 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 - )); - } - if !filters.notional_is_valid(qty_f, kline.close) { - return Err(anyhow::anyhow!( - "İşlem tutarı ({:.2} USDT) min notional ({} USDT) altında — USDT miktarını artır", - qty_f * kline.close, filters.min_notional - )); - } - - info!("[{}] Market alım | Miktar: {} | Fiyat tahmini: {:.6}", symbol, quantity, kline.close); - - let buy_order = client.market_buy(symbol, &quantity).await?; - info!( - "[{}] Alım gerçekleşti | Fiyat: {:.6} | Miktar: {} | Emir ID: {}", - symbol, buy_order.price, filters.format_qty(buy_order.quantity), buy_order.order_id - ); - - // +0.2: alış (%0.1) + satış (%0.1) komisyonları dahil, net kar = profit_percent - let sell_price = buy_order.price * (1.0 + (profit_percent + 0.2) / 100.0); - let sell_price_str = filters.format_price(sell_price); - let sell_quantity = filters.format_qty(buy_order.quantity); - - let sell_order = client.limit_sell(symbol, &sell_quantity, &sell_price_str).await?; - info!( - "[{}] Limit satış emri | Fiyat: {:.6} | Emir ID: {}", - symbol, sell_price, sell_order.order_id - ); - - Ok(Some(TradeResult { - symbol: symbol.to_string(), - kline: kline.clone(), - buy_order, - sell_order, - profit_percent, - timestamp: Utc::now().timestamp_millis(), - })) - } -} - -#[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, -} diff --git a/src/bot/strategy/bollinger_rsi.rs b/src/bot/strategy/bollinger_rsi.rs new file mode 100644 index 0000000..fd3afa4 --- /dev/null +++ b/src/bot/strategy/bollinger_rsi.rs @@ -0,0 +1,71 @@ +use serde_json::Value; +use crate::binance::models::Kline; +use super::{Signal, Strategy, param_f64, param_i64, rsi}; + +pub struct BollingerRsiStrategy { + bb_period: usize, + bb_std: f64, + rsi_period: usize, + rsi_oversold: f64, + rsi_overbought: f64, + profit_pct: f64, + stop_loss_pct: f64, +} + +impl BollingerRsiStrategy { + pub fn new(params: &Value) -> Self { + Self { + bb_period: param_i64(params, "bb_period", 20) as usize, + bb_std: param_f64(params, "bb_std", 2.0), + rsi_period: param_i64(params, "rsi_period", 14) as usize, + rsi_oversold: param_f64(params, "rsi_oversold", 35.0), + rsi_overbought: param_f64(params, "rsi_overbought", 65.0), + profit_pct: param_f64(params, "profit_target_pct", 1.5), + stop_loss_pct: param_f64(params, "stop_loss_pct", 2.0), + } + } +} + +impl Strategy for BollingerRsiStrategy { + fn generate_signal(&self, candles: &[Kline]) -> Signal { + let n = candles.len(); + if n < self.min_candles() { return Signal::Hold; } + + let closes: Vec = candles.iter().map(|c| c.close).collect(); + + // Bollinger Bands (son kapanmış mum = n-2) + let idx = n - 2; + let start = if idx + 1 >= self.bb_period { idx + 1 - self.bb_period } else { 0 }; + let window = &closes[start..=idx]; + let sma: f64 = window.iter().sum::() / window.len() as f64; + let variance: f64 = window.iter().map(|v| (v - sma).powi(2)).sum::() / window.len() as f64; + let std = variance.sqrt(); + let upper = sma + self.bb_std * std; + let lower = sma - self.bb_std * std; + + // RSI + let rsi_vals = rsi(&closes, self.rsi_period); + let curr_rsi = rsi_vals[idx]; + let curr_close = closes[idx]; + + if curr_close <= lower && curr_rsi <= self.rsi_oversold { + return Signal::Buy; + } + if curr_close >= upper && curr_rsi >= self.rsi_overbought { + return Signal::Sell; + } + Signal::Hold + } + + fn sell_price(&self, buy_price: f64) -> f64 { + buy_price * (1.0 + (self.profit_pct + 0.2) / 100.0) + } + + fn stop_price(&self, buy_price: f64) -> f64 { + if self.stop_loss_pct > 0.0 { buy_price * (1.0 - self.stop_loss_pct / 100.0) } else { 0.0 } + } + + fn min_candles(&self) -> usize { + self.bb_period + self.rsi_period + 5 + } +} diff --git a/src/bot/strategy/ema_cross.rs b/src/bot/strategy/ema_cross.rs new file mode 100644 index 0000000..bf40f5c --- /dev/null +++ b/src/bot/strategy/ema_cross.rs @@ -0,0 +1,101 @@ +use serde_json::Value; +use crate::binance::models::Kline; +use super::{Signal, Strategy, param_f64, param_i64, ema, atr}; + +pub struct EmaCrossStrategy { + fast_period: usize, + slow_period: usize, + adx_period: usize, + adx_threshold: f64, + profit_pct: f64, + stop_loss_pct: f64, +} + +impl EmaCrossStrategy { + pub fn new(params: &Value) -> Self { + Self { + fast_period: param_i64(params, "fast_period", 9) as usize, + slow_period: param_i64(params, "slow_period", 21) as usize, + adx_period: param_i64(params, "adx_period", 14) as usize, + adx_threshold: param_f64(params, "adx_threshold", 20.0), + profit_pct: param_f64(params, "profit_target_pct", 2.0), + stop_loss_pct: param_f64(params, "stop_loss_pct", 1.5), + } + } + + fn calc_adx(&self, candles: &[Kline]) -> f64 { + let n = candles.len(); + if n < self.adx_period + 2 { return 0.0; } + let p = self.adx_period; + + let atr_vals = atr(candles, p); + + let mut plus_dm = vec![0.0f64; n]; + let mut minus_dm = vec![0.0f64; n]; + for i in 1..n { + let up = candles[i].high - candles[i-1].high; + let down = candles[i-1].low - candles[i].low; + if up > down && up > 0.0 { plus_dm[i] = up; } + if down > up && down > 0.0 { minus_dm[i] = down; } + } + + let plus_dm_ema = ema(&plus_dm, p); + let minus_dm_ema = ema(&minus_dm, p); + + let mut dx_vals = vec![0.0f64; n]; + for i in 0..n { + let a = atr_vals[i]; + if a == 0.0 { continue; } + let plus_di = 100.0 * plus_dm_ema[i] / a; + let minus_di = 100.0 * minus_dm_ema[i] / a; + let denom = plus_di + minus_di; + if denom > 0.0 { + dx_vals[i] = 100.0 * (plus_di - minus_di).abs() / denom; + } + } + + let adx = ema(&dx_vals, p); + // son kapanmış mum = index n-2 + if n >= 2 { adx[n - 2] } else { 0.0 } + } +} + +impl Strategy for EmaCrossStrategy { + fn generate_signal(&self, candles: &[Kline]) -> Signal { + let n = candles.len(); + if n < self.min_candles() { return Signal::Hold; } + + let closes: Vec = candles.iter().map(|c| c.close).collect(); + let fast = ema(&closes, self.fast_period); + let slow = ema(&closes, self.slow_period); + + // son kapanmış = n-2, bir önceki = n-3 + let (pf, cf) = (fast[n-3], fast[n-2]); + let (ps, cs) = (slow[n-3], slow[n-2]); + + let cross_up = pf <= ps && cf > cs; + let cross_down = pf >= ps && cf < cs; + + if !cross_up && !cross_down { return Signal::Hold; } + + if self.adx_threshold > 0.0 { + let adx = self.calc_adx(candles); + if adx < self.adx_threshold { return Signal::Hold; } + } + + if cross_up { Signal::Buy } + else { Signal::Sell } + } + + fn sell_price(&self, buy_price: f64) -> f64 { + buy_price * (1.0 + (self.profit_pct + 0.2) / 100.0) + } + + fn stop_price(&self, buy_price: f64) -> f64 { + if self.stop_loss_pct > 0.0 { buy_price * (1.0 - self.stop_loss_pct / 100.0) } else { 0.0 } + } + + fn min_candles(&self) -> usize { + self.slow_period + self.adx_period + 10 + } +} diff --git a/src/bot/strategy/macd.rs b/src/bot/strategy/macd.rs new file mode 100644 index 0000000..b2269b2 --- /dev/null +++ b/src/bot/strategy/macd.rs @@ -0,0 +1,68 @@ +use serde_json::Value; +use crate::binance::models::Kline; +use super::{Signal, Strategy, param_f64, param_i64, ema}; + +pub struct MacdStrategy { + fast_period: usize, + slow_period: usize, + signal_period: usize, + require_zero: bool, + profit_pct: f64, + stop_loss_pct: f64, +} + +impl MacdStrategy { + pub fn new(params: &Value) -> Self { + Self { + fast_period: param_i64(params, "fast_period", 12) as usize, + slow_period: param_i64(params, "slow_period", 26) as usize, + signal_period: param_i64(params, "signal_period", 9) as usize, + require_zero: params.get("require_zero_cross").and_then(|v| v.as_bool()).unwrap_or(false), + profit_pct: param_f64(params, "profit_target_pct", 2.0), + stop_loss_pct: param_f64(params, "stop_loss_pct", 2.0), + } + } +} + +impl Strategy for MacdStrategy { + fn generate_signal(&self, candles: &[Kline]) -> Signal { + let n = candles.len(); + if n < self.min_candles() { return Signal::Hold; } + + let closes: Vec = candles.iter().map(|c| c.close).collect(); + let fast_ema = ema(&closes, self.fast_period); + let slow_ema = ema(&closes, self.slow_period); + + let macd_line: Vec = fast_ema.iter().zip(slow_ema.iter()).map(|(f, s)| f - s).collect(); + let signal_line = ema(&macd_line, self.signal_period); + + let idx = n - 2; // son kapanmış mum + let (pm, cm) = (macd_line[idx - 1], macd_line[idx]); + let (ps, cs) = (signal_line[idx - 1], signal_line[idx]); + + let cross_up = pm <= ps && cm > cs; + let cross_down = pm >= ps && cm < cs; + + if !cross_up && !cross_down { return Signal::Hold; } + + if self.require_zero { + if cross_up && cm < 0.0 { return Signal::Hold; } + if cross_down && cm > 0.0 { return Signal::Hold; } + } + + if cross_up { Signal::Buy } + else { Signal::Sell } + } + + fn sell_price(&self, buy_price: f64) -> f64 { + buy_price * (1.0 + (self.profit_pct + 0.2) / 100.0) + } + + fn stop_price(&self, buy_price: f64) -> f64 { + if self.stop_loss_pct > 0.0 { buy_price * (1.0 - self.stop_loss_pct / 100.0) } else { 0.0 } + } + + fn min_candles(&self) -> usize { + self.slow_period + self.signal_period + 10 + } +} diff --git a/src/bot/strategy/mod.rs b/src/bot/strategy/mod.rs new file mode 100644 index 0000000..180e422 --- /dev/null +++ b/src/bot/strategy/mod.rs @@ -0,0 +1,165 @@ +use crate::binance::models::Kline; + +pub mod red_candle; +pub mod ema_cross; +pub mod bollinger_rsi; +pub mod macd; +pub mod rsi; +pub mod supertrend; + +/// Strateji sinyali +#[derive(Debug, Clone, PartialEq)] +pub enum Signal { + Buy, + Sell, + Hold, +} + +/// Tüm stratejilerin uygulaması gereken trait +pub trait Strategy: Send + Sync { + /// Son kapanmış mumu içeren kline dizisini alır. + /// candles.last() = son kapanmış mum (açık mum değil) + fn generate_signal(&self, candles: &[Kline]) -> Signal; + + /// Alış fiyatından limit satış fiyatı hesaplar + fn sell_price(&self, buy_price: f64) -> f64; + + /// Stop loss fiyatı (0.0 = stop loss yok) + fn stop_price(&self, buy_price: f64) -> f64; + + /// Sinyal üretmek için gereken minimum mum sayısı + fn min_candles(&self) -> usize; +} + +/// Strateji tipi — DB'de saklanır, config'ten okunur +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StrategyKind { + RedCandle, + EmaCross, + BollingerRsi, + Macd, + Rsi, + Supertrend, +} + +impl StrategyKind { + pub fn as_str(&self) -> &'static str { + match self { + StrategyKind::RedCandle => "red_candle", + StrategyKind::EmaCross => "ema_cross", + StrategyKind::BollingerRsi => "bollinger_rsi", + StrategyKind::Macd => "macd", + StrategyKind::Rsi => "rsi", + StrategyKind::Supertrend => "supertrend", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + StrategyKind::RedCandle => "Kırmızı Mum", + StrategyKind::EmaCross => "EMA Crossover", + StrategyKind::BollingerRsi => "Bollinger + RSI", + StrategyKind::Macd => "MACD", + StrategyKind::Rsi => "RSI Aşırı Satım", + StrategyKind::Supertrend => "Supertrend", + } + } +} + +impl std::fmt::Display for StrategyKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for StrategyKind { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "red_candle" => Ok(StrategyKind::RedCandle), + "ema_cross" => Ok(StrategyKind::EmaCross), + "bollinger_rsi" => Ok(StrategyKind::BollingerRsi), + "macd" => Ok(StrategyKind::Macd), + "rsi" => Ok(StrategyKind::Rsi), + "supertrend" => Ok(StrategyKind::Supertrend), + _ => Err(()), + } + } +} + +/// JSON params'tan f64 çeker, yoksa default döner +pub fn param_f64(params: &serde_json::Value, key: &str, default: f64) -> f64 { + params.get(key).and_then(|v| v.as_f64()).unwrap_or(default) +} + +pub fn param_i64(params: &serde_json::Value, key: &str, default: i64) -> i64 { + params.get(key).and_then(|v| v.as_i64()).unwrap_or(default) +} + +/// EMA hesabı (exponential moving average, span parametreli) +pub fn ema(values: &[f64], span: usize) -> Vec { + if values.is_empty() || span == 0 { + return vec![]; + } + let k = 2.0 / (span as f64 + 1.0); + let mut result = vec![0.0f64; values.len()]; + result[0] = values[0]; + for i in 1..values.len() { + result[i] = values[i] * k + result[i - 1] * (1.0 - k); + } + result +} + +/// RSI hesabı (Wilder's smoothing = ewm com=period-1) +pub fn rsi(closes: &[f64], period: usize) -> Vec { + if closes.len() < period + 1 { + return vec![50.0; closes.len()]; + } + let k = 1.0 / period as f64; // com = period-1 → alpha = 1/period + let mut avg_gain = 0.0f64; + let mut avg_loss = 0.0f64; + + for i in 1..=period { + let diff = closes[i] - closes[i - 1]; + if diff > 0.0 { avg_gain += diff; } else { avg_loss += diff.abs(); } + } + avg_gain /= period as f64; + avg_loss /= period as f64; + + let mut result = vec![50.0f64; closes.len()]; + if avg_loss == 0.0 { + result[period] = 100.0; + } else { + result[period] = 100.0 - 100.0 / (1.0 + avg_gain / avg_loss); + } + + for i in (period + 1)..closes.len() { + let diff = closes[i] - closes[i - 1]; + let gain = if diff > 0.0 { diff } else { 0.0 }; + let loss = if diff < 0.0 { diff.abs() } else { 0.0 }; + avg_gain = avg_gain * (1.0 - k) + gain * k; + avg_loss = avg_loss * (1.0 - k) + loss * k; + result[i] = if avg_loss == 0.0 { + 100.0 + } else { + 100.0 - 100.0 / (1.0 + avg_gain / avg_loss) + }; + } + result +} + +/// ATR hesabı +pub fn atr(candles: &[Kline], period: usize) -> Vec { + if candles.len() < 2 { + return vec![0.0; candles.len()]; + } + let mut tr_vals = vec![0.0f64; candles.len()]; + for i in 1..candles.len() { + let h = candles[i].high; + let l = candles[i].low; + let pc = candles[i - 1].close; + tr_vals[i] = (h - l).max((h - pc).abs()).max((l - pc).abs()); + } + ema(&tr_vals, period) +} diff --git a/src/bot/strategy/red_candle.rs b/src/bot/strategy/red_candle.rs new file mode 100644 index 0000000..9dd0777 --- /dev/null +++ b/src/bot/strategy/red_candle.rs @@ -0,0 +1,42 @@ +use serde_json::Value; +use crate::binance::models::Kline; +use super::{Signal, Strategy, param_f64}; + +pub struct RedCandleStrategy { + profit_pct: f64, + stop_loss_pct: f64, +} + +impl RedCandleStrategy { + pub fn new(params: &Value, profit_pct: f64, stop_loss_pct: f64) -> Self { + Self { + profit_pct: param_f64(params, "profit_target_pct", profit_pct), + stop_loss_pct: param_f64(params, "stop_loss_pct", stop_loss_pct), + } + } +} + +impl Strategy for RedCandleStrategy { + fn generate_signal(&self, candles: &[Kline]) -> Signal { + let last = match candles.last() { + Some(c) => c, + None => return Signal::Hold, + }; + if last.close < last.open { Signal::Buy } else { Signal::Hold } + } + + fn sell_price(&self, buy_price: f64) -> f64 { + // +0.2 komisyon dahil, net kar = profit_pct + buy_price * (1.0 + (self.profit_pct + 0.2) / 100.0) + } + + fn stop_price(&self, buy_price: f64) -> f64 { + if self.stop_loss_pct > 0.0 { + buy_price * (1.0 - self.stop_loss_pct / 100.0) + } else { + 0.0 + } + } + + fn min_candles(&self) -> usize { 1 } +} diff --git a/src/bot/strategy/rsi.rs b/src/bot/strategy/rsi.rs new file mode 100644 index 0000000..767294e --- /dev/null +++ b/src/bot/strategy/rsi.rs @@ -0,0 +1,65 @@ +use serde_json::Value; +use crate::binance::models::Kline; +use super::{Signal, Strategy, param_f64, param_i64, rsi}; + +pub struct RsiStrategy { + period: usize, + oversold: f64, + overbought: f64, + exit_level: f64, + profit_pct: f64, + stop_loss_pct: f64, +} + +impl RsiStrategy { + pub fn new(params: &Value) -> Self { + Self { + period: param_i64(params, "rsi_period", 14) as usize, + oversold: param_f64(params, "rsi_oversold", 30.0), + overbought: param_f64(params, "rsi_overbought", 70.0), + exit_level: param_f64(params, "rsi_exit", 50.0), + profit_pct: param_f64(params, "profit_target_pct", 2.0), + stop_loss_pct: param_f64(params, "stop_loss_pct", 3.0), + } + } +} + +impl Strategy for RsiStrategy { + fn generate_signal(&self, candles: &[Kline]) -> Signal { + let n = candles.len(); + if n < self.min_candles() { return Signal::Hold; } + + let closes: Vec = candles.iter().map(|c| c.close).collect(); + let rsi_vals = rsi(&closes, self.period); + + let idx = n - 2; // son kapanmış mum + let curr = rsi_vals[idx]; + let prev = rsi_vals[idx - 1]; + + // BUY: oversold bölgesine girdi (geçiş anı) + if prev >= self.oversold && curr < self.oversold { + return Signal::Buy; + } + // SELL: aşırı alım + if curr >= self.overbought { + return Signal::Sell; + } + // SELL: orta seviyeye döndü + if self.exit_level > 0.0 && prev < self.exit_level && curr >= self.exit_level { + return Signal::Sell; + } + Signal::Hold + } + + fn sell_price(&self, buy_price: f64) -> f64 { + buy_price * (1.0 + (self.profit_pct + 0.2) / 100.0) + } + + fn stop_price(&self, buy_price: f64) -> f64 { + if self.stop_loss_pct > 0.0 { buy_price * (1.0 - self.stop_loss_pct / 100.0) } else { 0.0 } + } + + fn min_candles(&self) -> usize { + self.period + 10 + } +} diff --git a/src/bot/strategy/supertrend.rs b/src/bot/strategy/supertrend.rs new file mode 100644 index 0000000..a1b89d8 --- /dev/null +++ b/src/bot/strategy/supertrend.rs @@ -0,0 +1,73 @@ +use serde_json::Value; +use crate::binance::models::Kline; +use super::{Signal, Strategy, param_f64, param_i64, atr}; + +pub struct SupertrendStrategy { + atr_period: usize, + multiplier: f64, + profit_pct: f64, + stop_loss_pct: f64, +} + +impl SupertrendStrategy { + pub fn new(params: &Value) -> Self { + Self { + atr_period: param_i64(params, "atr_period", 10) as usize, + multiplier: param_f64(params, "multiplier", 3.0), + profit_pct: param_f64(params, "profit_target_pct", 3.0), + stop_loss_pct: param_f64(params, "stop_loss_pct", 2.0), + } + } + + fn calc_direction(&self, candles: &[Kline]) -> Vec { + let n = candles.len(); + let atr_vals = atr(candles, self.atr_period); + + let mut upper = vec![0.0f64; n]; + let mut lower = vec![0.0f64; n]; + let mut dir = vec![1i8; n]; + + for i in 1..n { + let hl2 = (candles[i].high + candles[i].low) / 2.0; + let a = atr_vals[i]; + let ub = hl2 + self.multiplier * a; + let lb = hl2 - self.multiplier * a; + + upper[i] = if ub < upper[i-1] || candles[i-1].close > upper[i-1] { ub } else { upper[i-1] }; + lower[i] = if lb > lower[i-1] || candles[i-1].close < lower[i-1] { lb } else { lower[i-1] }; + + let prev_dir = dir[i-1]; + dir[i] = if candles[i].close > upper[i] { 1 } + else if candles[i].close < lower[i] { -1 } + else { prev_dir }; + } + dir + } +} + +impl Strategy for SupertrendStrategy { + fn generate_signal(&self, candles: &[Kline]) -> Signal { + let n = candles.len(); + if n < self.min_candles() { return Signal::Hold; } + + let dir = self.calc_direction(candles); + let curr = dir[n - 2]; // son kapanmış mum + let prev = dir[n - 3]; + + if prev == -1 && curr == 1 { Signal::Buy } + else if prev == 1 && curr == -1 { Signal::Sell } + else { Signal::Hold } + } + + fn sell_price(&self, buy_price: f64) -> f64 { + buy_price * (1.0 + (self.profit_pct + 0.2) / 100.0) + } + + fn stop_price(&self, buy_price: f64) -> f64 { + if self.stop_loss_pct > 0.0 { buy_price * (1.0 - self.stop_loss_pct / 100.0) } else { 0.0 } + } + + fn min_candles(&self) -> usize { + self.atr_period * 3 + 5 + } +} diff --git a/src/storage/config.rs b/src/storage/config.rs index 7b6cdbb..1c21ac1 100644 --- a/src/storage/config.rs +++ b/src/storage/config.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::binance::models::Timeframe; +use crate::bot::strategy::StrategyKind; /// Tek bir botun konfigürasyonu #[derive(Debug, Clone, Serialize, Deserialize)] @@ -11,6 +12,9 @@ pub struct BotConfig { pub timeframe: Timeframe, pub usdt_amount: f64, pub profit_percent: f64, + pub stop_loss_percent: f64, + pub strategy: StrategyKind, + pub strategy_params: serde_json::Value, pub testnet: bool, pub active: bool, } diff --git a/src/storage/db.rs b/src/storage/db.rs index fc66da9..081a2f5 100644 --- a/src/storage/db.rs +++ b/src/storage/db.rs @@ -23,14 +23,17 @@ impl Database { 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 + 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, + stop_loss_percent REAL NOT NULL DEFAULT 0, + strategy TEXT NOT NULL DEFAULT 'red_candle', + strategy_params TEXT NOT NULL DEFAULT '{}', + testnet INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS trade_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -85,10 +88,19 @@ impl Database { ); CREATE INDEX IF NOT EXISTS idx_bot_logs_bot_id ON bot_logs(bot_id, created_at DESC);", )?; - // migration: mevcut DB'ye buy_commission_usdt sütunu ekle + // migrations — idempotent (.ok() ile hata yutulur) conn.execute_batch( "ALTER TABLE open_positions ADD COLUMN buy_commission_usdt REAL NOT NULL DEFAULT 0;", ).ok(); + conn.execute_batch( + "ALTER TABLE bots ADD COLUMN stop_loss_percent REAL NOT NULL DEFAULT 0;", + ).ok(); + conn.execute_batch( + "ALTER TABLE bots ADD COLUMN strategy TEXT NOT NULL DEFAULT 'red_candle';", + ).ok(); + conn.execute_batch( + "ALTER TABLE bots ADD COLUMN strategy_params TEXT NOT NULL DEFAULT '{}';", + ).ok(); Ok(Self { conn }) } @@ -96,12 +108,14 @@ impl Database { 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)", + "INSERT INTO bots (id, name, symbol, timeframe, usdt_amount, profit_percent, stop_loss_percent, strategy, strategy_params, testnet, active) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ b.id, b.name, b.symbol, serde_json::to_string(&b.timeframe).unwrap_or_default(), - b.usdt_amount, b.profit_percent, + b.usdt_amount, b.profit_percent, b.stop_loss_percent, + b.strategy.as_str(), + b.strategy_params.to_string(), b.testnet as i32, b.active as i32 ], )?; @@ -110,21 +124,30 @@ impl Database { pub fn get_bots(&self) -> SqlResult> { let mut stmt = self.conn.prepare( - "SELECT id, name, symbol, timeframe, usdt_amount, profit_percent, testnet, active FROM bots ORDER BY rowid", + "SELECT id, name, symbol, timeframe, usdt_amount, profit_percent, stop_loss_percent, strategy, strategy_params, 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); + let strategy_str: String = row.get(7)?; + let strategy = strategy_str.parse() + .unwrap_or(crate::bot::strategy::StrategyKind::RedCandle); + let params_str: String = row.get(8)?; + let strategy_params: serde_json::Value = serde_json::from_str(¶ms_str) + .unwrap_or(serde_json::Value::Object(Default::default())); Ok(BotConfig { - id: row.get(0)?, - name: row.get(1)?, - symbol: row.get(2)?, + 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, + usdt_amount: row.get(4)?, + profit_percent: row.get(5)?, + stop_loss_percent: row.get(6)?, + strategy, + strategy_params, + testnet: row.get::<_, i32>(9)? != 0, + active: row.get::<_, i32>(10)? != 0, }) })?; rows.collect() diff --git a/src/web/_bot_modal.html b/src/web/_bot_modal.html index 15e63e8..5308a86 100644 --- a/src/web/_bot_modal.html +++ b/src/web/_bot_modal.html @@ -32,10 +32,28 @@ -
- - +
+
+ + +
+
+ + +
+
+ + +
+