feat(mse): multi-strategy support — EMA Cross, Bollinger+RSI, MACD, RSI, Supertrend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
78657f5ff4
commit
dadf7b8394
14 changed files with 967 additions and 225 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Vec<Kline>> {
|
||||
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<Vec<String>> {
|
||||
let url = format!("{}/api/v3/ticker/price", self.base_url);
|
||||
|
|
|
|||
|
|
@ -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<dyn Strategy> {
|
||||
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<TradeEvent>,
|
||||
) {
|
||||
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<Kline> = 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<dyn Strategy>,
|
||||
kline: &Kline,
|
||||
db: &Arc<Mutex<Database>>,
|
||||
event_tx: &broadcast::Sender<TradeEvent>,
|
||||
) -> 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<KlineWs> {
|
|||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Option<TradeResult>> {
|
||||
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,
|
||||
}
|
||||
71
src/bot/strategy/bollinger_rsi.rs
Normal file
71
src/bot/strategy/bollinger_rsi.rs
Normal file
|
|
@ -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<f64> = 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::<f64>() / window.len() as f64;
|
||||
let variance: f64 = window.iter().map(|v| (v - sma).powi(2)).sum::<f64>() / 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
|
||||
}
|
||||
}
|
||||
101
src/bot/strategy/ema_cross.rs
Normal file
101
src/bot/strategy/ema_cross.rs
Normal file
|
|
@ -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<f64> = 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
|
||||
}
|
||||
}
|
||||
68
src/bot/strategy/macd.rs
Normal file
68
src/bot/strategy/macd.rs
Normal file
|
|
@ -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<f64> = 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<f64> = 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
|
||||
}
|
||||
}
|
||||
165
src/bot/strategy/mod.rs
Normal file
165
src/bot/strategy/mod.rs
Normal file
|
|
@ -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<Self, Self::Err> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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)
|
||||
}
|
||||
42
src/bot/strategy/red_candle.rs
Normal file
42
src/bot/strategy/red_candle.rs
Normal file
|
|
@ -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 }
|
||||
}
|
||||
65
src/bot/strategy/rsi.rs
Normal file
65
src/bot/strategy/rsi.rs
Normal file
|
|
@ -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<f64> = 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
|
||||
}
|
||||
}
|
||||
73
src/bot/strategy/supertrend.rs
Normal file
73
src/bot/strategy/supertrend.rs
Normal file
|
|
@ -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<i8> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vec<BotConfig>> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -32,10 +32,28 @@
|
|||
<input id="f-profit" type="number" step="0.1" placeholder="2" min="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>USDT Miktarı <span id="f-min-label" style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none"></span></label>
|
||||
<input id="f-usdt" type="number" step="1" placeholder="10" min="1" />
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>USDT Miktarı <span id="f-min-label" style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none"></span></label>
|
||||
<input id="f-usdt" type="number" step="1" placeholder="10" min="1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stop Loss % <span style="font-size:10px;color:var(--muted);font-weight:400">(0 = yok)</span></label>
|
||||
<input id="f-stoploss" type="number" step="0.1" placeholder="0" min="0" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Strateji</label>
|
||||
<select id="f-strategy" onchange="onStrategyChange()">
|
||||
<option value="red_candle">Kırmızı Mum — kırmızı mum kapanınca al</option>
|
||||
<option value="ema_cross">EMA Crossover — hızlı/yavaş EMA kesişimi</option>
|
||||
<option value="bollinger_rsi">Bollinger + RSI — alt band + aşırı satım</option>
|
||||
<option value="macd">MACD — sinyal kesişimi</option>
|
||||
<option value="rsi">RSI Aşırı Satım — oversold geçişinde al</option>
|
||||
<option value="supertrend">Supertrend — ATR tabanlı trend bandı</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="strategy-params-container"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" onclick="closeModal()">İptal</button>
|
||||
|
|
@ -50,6 +68,71 @@
|
|||
let _symbolMinMap = {};
|
||||
let _symbolsLoaded = false;
|
||||
|
||||
const STRATEGY_PARAMS = {
|
||||
red_candle: [],
|
||||
ema_cross: [
|
||||
{ key: 'fast_period', label: 'Hızlı EMA', type: 'int', default: 9, min: 2, max: 50 },
|
||||
{ key: 'slow_period', label: 'Yavaş EMA', type: 'int', default: 21, min: 5, max: 200 },
|
||||
{ key: 'adx_period', label: 'ADX Periyodu', type: 'int', default: 14, min: 5, max: 50 },
|
||||
{ key: 'adx_threshold', label: 'ADX Eşiği', type: 'float', default: 20, min: 0, max: 50 },
|
||||
],
|
||||
bollinger_rsi: [
|
||||
{ key: 'bb_period', label: 'BB Periyodu', type: 'int', default: 20, min: 5, max: 100 },
|
||||
{ key: 'bb_std', label: 'BB Std Sapması', type: 'float', default: 2.0, min: 0.5, max: 4 },
|
||||
{ key: 'rsi_period', label: 'RSI Periyodu', type: 'int', default: 14, min: 2, max: 50 },
|
||||
{ key: 'rsi_oversold', label: 'RSI Aşırı Satım',type: 'int', default: 35, min: 10, max: 49 },
|
||||
{ key: 'rsi_overbought', label: 'RSI Aşırı Alım', type: 'int', default: 65, min: 51, max: 90 },
|
||||
],
|
||||
macd: [
|
||||
{ key: 'fast_period', label: 'Hızlı EMA', type: 'int', default: 12, min: 3, max: 50 },
|
||||
{ key: 'slow_period', label: 'Yavaş EMA', type: 'int', default: 26, min: 5, max: 200 },
|
||||
{ key: 'signal_period', label: 'Sinyal EMA', type: 'int', default: 9, min: 2, max: 50 },
|
||||
{ key: 'require_zero_cross', label: 'Sıfır Filtresi', type: 'bool', default: false },
|
||||
],
|
||||
rsi: [
|
||||
{ key: 'rsi_period', label: 'RSI Periyodu', type: 'int', default: 14, min: 2, max: 50 },
|
||||
{ key: 'rsi_oversold', label: 'Alım Seviyesi', type: 'int', default: 30, min: 5, max: 49 },
|
||||
{ key: 'rsi_overbought',label: 'Satım Seviyesi', type: 'int', default: 70, min: 51, max: 95 },
|
||||
{ key: 'rsi_exit', label: 'Orta Çıkış', type: 'int', default: 50, min: 0, max: 70 },
|
||||
],
|
||||
supertrend: [
|
||||
{ key: 'atr_period', label: 'ATR Periyodu', type: 'int', default: 10, min: 3, max: 50 },
|
||||
{ key: 'multiplier', label: 'Çarpan', type: 'float', default: 3.0, min: 0.5, max: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
window.onStrategyChange = function() {
|
||||
const strategy = document.getElementById('f-strategy').value;
|
||||
const params = STRATEGY_PARAMS[strategy] || [];
|
||||
const container = document.getElementById('strategy-params-container');
|
||||
if (!params.length) { container.innerHTML = ''; return; }
|
||||
|
||||
const rows = [];
|
||||
for (let i = 0; i < params.length; i += 2) {
|
||||
const p1 = params[i];
|
||||
const p2 = params[i + 1];
|
||||
if (p1.type === 'bool') {
|
||||
rows.push(`<div class="form-group" style="flex-direction:row;align-items:center;gap:8px">
|
||||
<input id="fp-${p1.key}" type="checkbox" ${p1.default ? 'checked' : ''} style="width:auto"/>
|
||||
<label style="text-transform:none;font-size:12px">${p1.label}</label>
|
||||
</div>`);
|
||||
} else {
|
||||
const f1 = `<div class="form-group"><label>${p1.label}</label>
|
||||
<input id="fp-${p1.key}" type="number" step="${p1.type==='float'?'0.1':'1'}" value="${p1.default}" min="${p1.min??''}" max="${p1.max??''}"/></div>`;
|
||||
const f2 = p2 && p2.type !== 'bool' ? `<div class="form-group"><label>${p2.label}</label>
|
||||
<input id="fp-${p2.key}" type="number" step="${p2.type==='float'?'0.1':'1'}" value="${p2.default}" min="${p2.min??''}" max="${p2.max??''}"/></div>` : '';
|
||||
rows.push(`<div class="form-row">${f1}${f2}</div>`);
|
||||
if (p2 && p2.type === 'bool') {
|
||||
rows.push(`<div class="form-group" style="flex-direction:row;align-items:center;gap:8px">
|
||||
<input id="fp-${p2.key}" type="checkbox" ${p2.default ? 'checked' : ''} style="width:auto"/>
|
||||
<label style="text-transform:none;font-size:12px">${p2.label}</label>
|
||||
</div>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
container.innerHTML = rows.join('');
|
||||
};
|
||||
|
||||
async function _api(method, path, body) {
|
||||
const res = await fetch('/api' + path, {
|
||||
method, credentials: 'include',
|
||||
|
|
@ -96,6 +179,7 @@
|
|||
document.getElementById('modal-overlay').classList.add('open');
|
||||
await _loadSymbols();
|
||||
if (_symbolSelect) _symbolSelect.focus();
|
||||
onStrategyChange();
|
||||
};
|
||||
|
||||
window.closeModal = function() {
|
||||
|
|
@ -103,7 +187,10 @@
|
|||
document.getElementById('f-name').value = '';
|
||||
document.getElementById('f-usdt').value = '';
|
||||
document.getElementById('f-profit').value = '';
|
||||
document.getElementById('f-stoploss').value = '0';
|
||||
document.getElementById('f-min-label').textContent = '';
|
||||
document.getElementById('f-strategy').value = 'red_candle';
|
||||
document.getElementById('strategy-params-container').innerHTML = '';
|
||||
if (_symbolSelect) _symbolSelect.clear();
|
||||
};
|
||||
|
||||
|
|
@ -113,10 +200,25 @@
|
|||
const timeframe = document.getElementById('f-tf').value;
|
||||
const usdt_amount = parseFloat(document.getElementById('f-usdt').value);
|
||||
const profit_percent = parseFloat(document.getElementById('f-profit').value);
|
||||
const stop_loss_percent = parseFloat(document.getElementById('f-stoploss').value) || 0;
|
||||
const strategy = document.getElementById('f-strategy').value;
|
||||
|
||||
if (!name || !symbol || !usdt_amount || !profit_percent) { alert('Tüm alanları doldurun'); return; }
|
||||
const min = _symbolMinMap[symbol] || 0;
|
||||
if (usdt_amount < min) { alert(`Minimum işlem miktarı ${min} USDT`); document.getElementById('f-usdt').focus(); return; }
|
||||
const res = await _api('POST', '/bots', { name, symbol, timeframe, usdt_amount, profit_percent });
|
||||
|
||||
// Strateji parametrelerini topla
|
||||
const params = STRATEGY_PARAMS[strategy] || [];
|
||||
const strategy_params = {};
|
||||
for (const p of params) {
|
||||
const el = document.getElementById('fp-' + p.key);
|
||||
if (!el) continue;
|
||||
if (p.type === 'bool') strategy_params[p.key] = el.checked;
|
||||
else if (p.type === 'int') strategy_params[p.key] = parseInt(el.value);
|
||||
else strategy_params[p.key] = parseFloat(el.value);
|
||||
}
|
||||
|
||||
const res = await _api('POST', '/bots', { name, symbol, timeframe, usdt_amount, profit_percent, stop_loss_percent, strategy, strategy_params });
|
||||
if (!res.ok) { alert('Bot oluşturulamadı: ' + await res.text()); return; }
|
||||
closeModal();
|
||||
if (typeof onBotCreated === 'function') onBotCreated();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue