init: CryptoFox Mukan Edition — sunucu tabanlı RedCandle bot
Tauri/desktop bağımlılıkları çıkarıldı, Axum HTTP server + SSE ile web dashboard eklendi. Bot yönetimi, açık/kapalı pozisyon takibi.
This commit is contained in:
commit
a68dc599a9
26 changed files with 4155 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
BINANCE_API_KEY=your-binance-api-key
|
||||||
|
BINANCE_API_SECRET=your-binance-api-secret
|
||||||
|
AUTH_TOKEN=your-dashboard-access-token
|
||||||
|
DB_PATH=data/bots.db
|
||||||
|
LISTEN_ADDR=127.0.0.1:4646
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/target/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
2371
Cargo.lock
generated
Normal file
2371
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
52
Cargo.toml
Normal file
52
Cargo.toml
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
[package]
|
||||||
|
name = "mukan-special-edition"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "mse"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# HTTP server
|
||||||
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["fs", "cors"] }
|
||||||
|
|
||||||
|
# HTTP client (Binance REST API)
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# Hata yönetimi
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
# Loglama
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
|
||||||
|
# Zaman
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# UUID
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# Veritabanı
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
|
||||||
|
# Şifreleme (Binance imzalama)
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
# .env yükleme
|
||||||
|
dotenvy = "0.15"
|
||||||
|
|
||||||
|
# SSE
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
|
futures-core = "0.3"
|
||||||
35
src/api/auth.rs
Normal file
35
src/api/auth.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, Request, State},
|
||||||
|
http::StatusCode,
|
||||||
|
middleware::Next,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TokenQuery {
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn require_auth(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<TokenQuery>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let header_token = request
|
||||||
|
.headers()
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let token = header_token.or(query.token);
|
||||||
|
|
||||||
|
match token.as_deref() {
|
||||||
|
Some(t) if t == state.auth_token => Ok(next.run(request).await),
|
||||||
|
_ => Err(StatusCode::UNAUTHORIZED),
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/api/bots.rs
Normal file
128
src/api/bots.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::storage::config::BotConfig;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateBotRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub timeframe: crate::binance::models::Timeframe,
|
||||||
|
pub usdt_amount: f64,
|
||||||
|
pub profit_percent: f64,
|
||||||
|
pub testnet: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BotResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub config: BotConfig,
|
||||||
|
pub running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_bots(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let db = state.db.lock().await;
|
||||||
|
let bots = match db.get_bots() {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
let manager = state.manager.lock().await;
|
||||||
|
let response: Vec<BotResponse> = bots
|
||||||
|
.into_iter()
|
||||||
|
.map(|b| {
|
||||||
|
let running = manager.is_running(&b.id);
|
||||||
|
BotResponse { config: b, running }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Json(response).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_bot(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<CreateBotRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let config = BotConfig {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
name: req.name,
|
||||||
|
symbol: req.symbol.to_uppercase(),
|
||||||
|
timeframe: req.timeframe,
|
||||||
|
usdt_amount: req.usdt_amount,
|
||||||
|
profit_percent: req.profit_percent,
|
||||||
|
testnet: req.testnet.unwrap_or(false),
|
||||||
|
active: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let db = state.db.lock().await;
|
||||||
|
if let Err(e) = db.insert_bot(&config) {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||||
|
}
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
Json(BotResponse { config, running: false }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_bot(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let mut manager = state.manager.lock().await;
|
||||||
|
manager.stop(&id).await;
|
||||||
|
drop(manager);
|
||||||
|
|
||||||
|
let db = state.db.lock().await;
|
||||||
|
if let Err(e) = db.delete_bot(&id) {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_bot(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let db = state.db.lock().await;
|
||||||
|
let bots = match db.get_bots() {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
let config = match bots.into_iter().find(|b| b.id == id) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return StatusCode::NOT_FOUND.into_response(),
|
||||||
|
};
|
||||||
|
if let Err(e) = db.set_bot_active(&id, true) {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||||
|
}
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
let mut manager = state.manager.lock().await;
|
||||||
|
manager.start(config);
|
||||||
|
|
||||||
|
StatusCode::OK.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop_bot(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let db = state.db.lock().await;
|
||||||
|
if let Err(e) = db.set_bot_active(&id, false) {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||||
|
}
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
let mut manager = state.manager.lock().await;
|
||||||
|
manager.stop(&id).await;
|
||||||
|
|
||||||
|
StatusCode::OK.into_response()
|
||||||
|
}
|
||||||
18
src/api/events.rs
Normal file
18
src/api/events.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
use axum::{extract::State, response::sse::{Event, KeepAlive, Sse}};
|
||||||
|
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub async fn sse_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||||
|
let rx = state.event_tx.subscribe();
|
||||||
|
let stream = BroadcastStream::new(rx)
|
||||||
|
.filter_map(|result| result.ok())
|
||||||
|
.map(|event| {
|
||||||
|
let data = serde_json::to_string(&event).unwrap_or_default();
|
||||||
|
Ok(Event::default().event("trade").data(data))
|
||||||
|
});
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
|
}
|
||||||
5
src/api/mod.rs
Normal file
5
src/api/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod auth;
|
||||||
|
pub mod bots;
|
||||||
|
pub mod events;
|
||||||
|
pub mod positions;
|
||||||
|
pub mod routes;
|
||||||
19
src/api/positions.rs
Normal file
19
src/api/positions.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub async fn open_positions(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let db = state.db.lock().await;
|
||||||
|
match db.get_positions() {
|
||||||
|
Ok(p) => Json(p).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn closed_positions(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let db = state.db.lock().await;
|
||||||
|
match db.get_closed_positions() {
|
||||||
|
Ok(p) => Json(p).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/api/routes.rs
Normal file
29
src/api/routes.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
use axum::{
|
||||||
|
middleware,
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::api::{auth::require_auth, bots, events, positions};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub fn build(state: AppState) -> Router {
|
||||||
|
let api = Router::new()
|
||||||
|
.route("/bots", get(bots::list_bots).post(bots::create_bot))
|
||||||
|
.route("/bots/{id}", delete(bots::delete_bot))
|
||||||
|
.route("/bots/{id}/start", post(bots::start_bot))
|
||||||
|
.route("/bots/{id}/stop", post(bots::stop_bot))
|
||||||
|
.route("/positions", get(positions::open_positions))
|
||||||
|
.route("/positions/closed", get(positions::closed_positions))
|
||||||
|
.route("/events", get(events::sse_handler))
|
||||||
|
.layer(middleware::from_fn_with_state(state.clone(), require_auth));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.nest("/api", api)
|
||||||
|
.route("/", get(index_handler))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index_handler() -> axum::response::Html<&'static str> {
|
||||||
|
axum::response::Html(include_str!("../web/index.html"))
|
||||||
|
}
|
||||||
349
src/binance/client.rs
Normal file
349
src/binance/client.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::models::{FilledOrder, Kline, OrderSide, SymbolFilters, Timeframe};
|
||||||
|
|
||||||
|
const BINANCE_BASE_URL: &str = "https://api.binance.com";
|
||||||
|
const BINANCE_TESTNET_URL: &str = "https://testnet.binance.vision";
|
||||||
|
|
||||||
|
pub struct BinanceClient {
|
||||||
|
http: Client,
|
||||||
|
api_key: String,
|
||||||
|
api_secret: String,
|
||||||
|
base_url: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BinanceClient {
|
||||||
|
pub fn new(api_key: String, api_secret: String, testnet: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
http: Client::new(),
|
||||||
|
api_key,
|
||||||
|
api_secret,
|
||||||
|
base_url: if testnet {
|
||||||
|
BINANCE_TESTNET_URL
|
||||||
|
} else {
|
||||||
|
BINANCE_BASE_URL
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Son kapanmış mumu getirir (listenin sondan bir önceki elemanı)
|
||||||
|
pub async fn get_last_closed_kline(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
timeframe: &Timeframe,
|
||||||
|
) -> Result<Kline> {
|
||||||
|
let url = format!("{}/api/v3/klines", self.base_url);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.query(&[
|
||||||
|
("symbol", symbol),
|
||||||
|
("interval", timeframe.as_str()),
|
||||||
|
("limit", "2"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Value>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// index 0 = kapanmış mum, index 1 = hâlâ açık mum
|
||||||
|
let kline_data = response
|
||||||
|
.as_array()
|
||||||
|
.and_then(|arr| arr.first())
|
||||||
|
.ok_or_else(|| anyhow!("Kline verisi alınamadı"))?;
|
||||||
|
|
||||||
|
parse_kline(kline_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Market alım emri gönderir, gerçekleşen fiyat ve miktarı döner
|
||||||
|
pub async fn market_buy(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
quantity: &str,
|
||||||
|
) -> Result<FilledOrder> {
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
|
||||||
|
let params = format!(
|
||||||
|
"symbol={}&side=BUY&type=MARKET&quantity={}×tamp={}",
|
||||||
|
symbol, quantity, timestamp
|
||||||
|
);
|
||||||
|
let signature = self.sign(¶ms);
|
||||||
|
let url = format!("{}/api/v3/order", self.base_url);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("X-MBX-APIKEY", &self.api_key)
|
||||||
|
.query(&[
|
||||||
|
("symbol", symbol),
|
||||||
|
("side", "BUY"),
|
||||||
|
("type", "MARKET"),
|
||||||
|
("quantity", quantity),
|
||||||
|
("timestamp", ×tamp),
|
||||||
|
("signature", &signature),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Value>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_filled_order(&response, OrderSide::Buy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limit satış emri gönderir
|
||||||
|
pub async fn limit_sell(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
quantity: &str,
|
||||||
|
price: &str,
|
||||||
|
) -> Result<FilledOrder> {
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
|
||||||
|
let params = format!(
|
||||||
|
"symbol={}&side=SELL&type=LIMIT&timeInForce=GTC&quantity={}&price={}×tamp={}",
|
||||||
|
symbol, quantity, price, timestamp
|
||||||
|
);
|
||||||
|
let signature = self.sign(¶ms);
|
||||||
|
let url = format!("{}/api/v3/order", self.base_url);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("X-MBX-APIKEY", &self.api_key)
|
||||||
|
.query(&[
|
||||||
|
("symbol", symbol),
|
||||||
|
("side", "SELL"),
|
||||||
|
("type", "LIMIT"),
|
||||||
|
("timeInForce", "GTC"),
|
||||||
|
("quantity", quantity),
|
||||||
|
("price", price),
|
||||||
|
("timestamp", ×tamp),
|
||||||
|
("signature", &signature),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Value>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_filled_order(&response, OrderSide::Sell)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bir emrin güncel durumunu sorgular ("NEW", "FILLED", "CANCELED", ...)
|
||||||
|
pub async fn get_order_status(&self, symbol: &str, order_id: u64) -> Result<String> {
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
|
||||||
|
let order_id_str = order_id.to_string();
|
||||||
|
let params = format!(
|
||||||
|
"symbol={}&orderId={}×tamp={}",
|
||||||
|
symbol, order_id_str, timestamp
|
||||||
|
);
|
||||||
|
let signature = self.sign(¶ms);
|
||||||
|
let url = format!("{}/api/v3/order", self.base_url);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("X-MBX-APIKEY", &self.api_key)
|
||||||
|
.query(&[
|
||||||
|
("symbol", symbol),
|
||||||
|
("orderId", order_id_str.as_str()),
|
||||||
|
("timestamp", timestamp.as_str()),
|
||||||
|
("signature", signature.as_str()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Value>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(code) = response.get("code") {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Binance API hatası {}: {}",
|
||||||
|
code,
|
||||||
|
response["msg"].as_str().unwrap_or("")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response["status"].as_str().unwrap_or("UNKNOWN").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sembolün lot size ve tick size filtrelerini getirir
|
||||||
|
pub async fn get_symbol_filters(&self, symbol: &str) -> Result<SymbolFilters> {
|
||||||
|
let url = format!("{}/api/v3/exchangeInfo", self.base_url);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.query(&[("symbol", symbol)])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Value>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let filters = response["symbols"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|arr| arr.first())
|
||||||
|
.and_then(|s| s["filters"].as_array())
|
||||||
|
.ok_or_else(|| anyhow!("exchangeInfo parse hatası: {}", symbol))?;
|
||||||
|
|
||||||
|
let mut qty_decimals = 2usize;
|
||||||
|
let mut price_decimals = 2usize;
|
||||||
|
|
||||||
|
for filter in filters {
|
||||||
|
match filter["filterType"].as_str() {
|
||||||
|
Some("LOT_SIZE") => {
|
||||||
|
let step = filter["stepSize"].as_str().unwrap_or("0.01");
|
||||||
|
qty_decimals = count_decimals(step);
|
||||||
|
}
|
||||||
|
Some("PRICE_FILTER") => {
|
||||||
|
let tick = filter["tickSize"].as_str().unwrap_or("0.01");
|
||||||
|
price_decimals = count_decimals(tick);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SymbolFilters { qty_decimals, price_decimals })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tüm USDT spot işlem çiftlerini döner — public endpoint, API key gerektirmez
|
||||||
|
pub async fn get_usdt_symbols(&self) -> Result<Vec<String>> {
|
||||||
|
let url = format!("{}/api/v3/ticker/price", self.base_url);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Value>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let symbols = response
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| anyhow!("ticker/price parse hatası"))?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let sym = item["symbol"].as_str()?;
|
||||||
|
if sym.ends_with("USDT") && !sym.starts_with("USDT") {
|
||||||
|
Some(sym[..sym.len() - 4].to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(symbols)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binance hesabındaki sıfır olmayan tüm bakiyeleri döner: Vec<(asset, free, locked)>
|
||||||
|
pub async fn get_account_balances(&self) -> Result<Vec<(String, f64, f64)>> {
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
|
||||||
|
let params = format!("timestamp={}", timestamp);
|
||||||
|
let signature = self.sign(¶ms);
|
||||||
|
let url = format!("{}/api/v3/account", self.base_url);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("X-MBX-APIKEY", &self.api_key)
|
||||||
|
.query(&[
|
||||||
|
("timestamp", timestamp.as_str()),
|
||||||
|
("signature", signature.as_str()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<Value>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(code) = response.get("code") {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Binance API hatası {}: {}",
|
||||||
|
code,
|
||||||
|
response["msg"].as_str().unwrap_or("")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let balances = response["balances"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| anyhow!("Bakiye verisi alınamadı"))?;
|
||||||
|
|
||||||
|
let result = balances
|
||||||
|
.iter()
|
||||||
|
.filter_map(|b| {
|
||||||
|
let asset = b["asset"].as_str()?.to_string();
|
||||||
|
let free: f64 = b["free"].as_str()?.parse().ok()?;
|
||||||
|
let locked: f64 = b["locked"].as_str()?.parse().ok()?;
|
||||||
|
if free + locked > 0.0 { Some((asset, free, locked)) } else { None }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HMAC-SHA256 imzası
|
||||||
|
fn sign(&self, params: &str) -> String {
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||||
|
let mut mac = HmacSha256::new_from_slice(self.api_secret.as_bytes())
|
||||||
|
.expect("HMAC key hatası");
|
||||||
|
mac.update(params.as_bytes());
|
||||||
|
hex::encode(mac.finalize().into_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "0.01000000" → 2, "0.00001000" → 5
|
||||||
|
fn count_decimals(step: &str) -> usize {
|
||||||
|
if let Some(dot) = step.find('.') {
|
||||||
|
step[dot + 1..].trim_end_matches('0').len()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_kline(data: &Value) -> Result<Kline> {
|
||||||
|
let arr = data.as_array().ok_or_else(|| anyhow!("Kline array beklendi"))?;
|
||||||
|
Ok(Kline {
|
||||||
|
open_time: arr[0].as_i64().unwrap_or(0),
|
||||||
|
open: arr[1].as_str().unwrap_or("0").parse()?,
|
||||||
|
high: arr[2].as_str().unwrap_or("0").parse()?,
|
||||||
|
low: arr[3].as_str().unwrap_or("0").parse()?,
|
||||||
|
close: arr[4].as_str().unwrap_or("0").parse()?,
|
||||||
|
volume: arr[5].as_str().unwrap_or("0").parse()?,
|
||||||
|
close_time: arr[6].as_i64().unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_filled_order(data: &Value, side: OrderSide) -> Result<FilledOrder> {
|
||||||
|
if let Some(code) = data.get("code") {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Binance API hatası {}: {}",
|
||||||
|
code,
|
||||||
|
data["msg"].as_str().unwrap_or("bilinmeyen hata")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let executed_qty: f64 = data["executedQty"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("0")
|
||||||
|
.parse()?;
|
||||||
|
let quote_qty: f64 = data["cummulativeQuoteQty"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("0")
|
||||||
|
.parse()?;
|
||||||
|
|
||||||
|
// Market buy → ortalama gerçekleşme fiyatı
|
||||||
|
// Limit sell → emir fiyatı (executedQty henüz 0 olabilir)
|
||||||
|
let price = match side {
|
||||||
|
OrderSide::Buy => {
|
||||||
|
if executed_qty > 0.0 { quote_qty / executed_qty } else { 0.0 }
|
||||||
|
}
|
||||||
|
OrderSide::Sell => {
|
||||||
|
data["price"].as_str().unwrap_or("0").parse().unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(FilledOrder {
|
||||||
|
order_id: data["orderId"].as_u64().unwrap_or(0),
|
||||||
|
symbol: data["symbol"].as_str().unwrap_or("").to_string(),
|
||||||
|
side,
|
||||||
|
price,
|
||||||
|
quantity: executed_qty,
|
||||||
|
timestamp: data["transactTime"].as_i64().unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
2
src/binance/mod.rs
Normal file
2
src/binance/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod client;
|
||||||
|
pub mod models;
|
||||||
113
src/binance/models.rs
Normal file
113
src/binance/models.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Binance kline/mum verisi
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Kline {
|
||||||
|
pub open_time: i64,
|
||||||
|
pub open: f64,
|
||||||
|
pub high: f64,
|
||||||
|
pub low: f64,
|
||||||
|
pub close: f64,
|
||||||
|
pub volume: f64,
|
||||||
|
pub close_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kline {
|
||||||
|
/// Mumun kırmızı olup olmadığını döner (close < open)
|
||||||
|
pub fn is_red(&self) -> bool {
|
||||||
|
self.close < self.open
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_time_utc(&self) -> DateTime<Utc> {
|
||||||
|
DateTime::from_timestamp_millis(self.open_time).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_time_utc(&self) -> DateTime<Utc> {
|
||||||
|
DateTime::from_timestamp_millis(self.close_time).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gerçekleşen emir bilgisi
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FilledOrder {
|
||||||
|
pub order_id: u64,
|
||||||
|
pub symbol: String,
|
||||||
|
pub side: OrderSide,
|
||||||
|
pub price: f64,
|
||||||
|
pub quantity: f64,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum OrderSide {
|
||||||
|
Buy,
|
||||||
|
Sell,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sembol için lot size ve tick size filtreleri
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SymbolFilters {
|
||||||
|
pub qty_decimals: usize,
|
||||||
|
pub price_decimals: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SymbolFilters {
|
||||||
|
pub fn format_qty(&self, qty: f64) -> String {
|
||||||
|
format!("{:.prec$}", qty, prec = self.qty_decimals)
|
||||||
|
}
|
||||||
|
pub fn format_price(&self, price: f64) -> String {
|
||||||
|
format!("{:.prec$}", price, prec = self.price_decimals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timeframe tanımları
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Timeframe {
|
||||||
|
#[serde(rename = "1m")]
|
||||||
|
OneMinute,
|
||||||
|
#[serde(rename = "5m")]
|
||||||
|
FiveMinutes,
|
||||||
|
#[serde(rename = "15m")]
|
||||||
|
FifteenMinutes,
|
||||||
|
#[serde(rename = "30m")]
|
||||||
|
ThirtyMinutes,
|
||||||
|
#[serde(rename = "1h")]
|
||||||
|
OneHour,
|
||||||
|
#[serde(rename = "4h")]
|
||||||
|
FourHours,
|
||||||
|
#[serde(rename = "1d")]
|
||||||
|
OneDay,
|
||||||
|
#[serde(rename = "1w")]
|
||||||
|
OneWeek,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Timeframe {
|
||||||
|
/// Binance API için string değer
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Timeframe::OneMinute => "1m",
|
||||||
|
Timeframe::FiveMinutes => "5m",
|
||||||
|
Timeframe::FifteenMinutes => "15m",
|
||||||
|
Timeframe::ThirtyMinutes => "30m",
|
||||||
|
Timeframe::OneHour => "1h",
|
||||||
|
Timeframe::FourHours => "4h",
|
||||||
|
Timeframe::OneDay => "1d",
|
||||||
|
Timeframe::OneWeek => "1w",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timeframe süresi (saniye)
|
||||||
|
pub fn duration_secs(&self) -> i64 {
|
||||||
|
match self {
|
||||||
|
Timeframe::OneMinute => 60,
|
||||||
|
Timeframe::FiveMinutes => 300,
|
||||||
|
Timeframe::FifteenMinutes => 900,
|
||||||
|
Timeframe::ThirtyMinutes => 1800,
|
||||||
|
Timeframe::OneHour => 3600,
|
||||||
|
Timeframe::FourHours => 14400,
|
||||||
|
Timeframe::OneDay => 86400,
|
||||||
|
Timeframe::OneWeek => 604800,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/bot/manager.rs
Normal file
76
src/bot/manager.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use tokio::sync::{broadcast, Mutex};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::bot::runner::{BotRunner, TradeEvent};
|
||||||
|
use crate::storage::config::BotConfig;
|
||||||
|
use crate::storage::db::Database;
|
||||||
|
|
||||||
|
struct RunningBot {
|
||||||
|
shutdown: Arc<Mutex<bool>>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
handle: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BotManager {
|
||||||
|
bots: HashMap<String, RunningBot>,
|
||||||
|
db: Arc<Mutex<Database>>,
|
||||||
|
event_tx: broadcast::Sender<TradeEvent>,
|
||||||
|
api_key: String,
|
||||||
|
api_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BotManager {
|
||||||
|
pub fn new(
|
||||||
|
db: Arc<Mutex<Database>>,
|
||||||
|
event_tx: broadcast::Sender<TradeEvent>,
|
||||||
|
api_key: String,
|
||||||
|
api_secret: String,
|
||||||
|
) -> Self {
|
||||||
|
Self { bots: HashMap::new(), db, event_tx, api_key, api_secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&mut self, config: BotConfig) {
|
||||||
|
let id = config.id.clone();
|
||||||
|
if self.bots.contains_key(&id) {
|
||||||
|
warn!("Bot zaten çalışıyor: {}", id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shutdown = Arc::new(Mutex::new(false));
|
||||||
|
let shutdown_clone = Arc::clone(&shutdown);
|
||||||
|
let db = Arc::clone(&self.db);
|
||||||
|
let tx = self.event_tx.clone();
|
||||||
|
let key = self.api_key.clone();
|
||||||
|
let secret = self.api_secret.clone();
|
||||||
|
let cfg = config.clone();
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
BotRunner::run(cfg, key, secret, shutdown_clone, db, tx).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("Bot başlatıldı: {} ({})", config.symbol, id);
|
||||||
|
self.bots.insert(id, RunningBot { shutdown, handle });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&mut self, id: &str) {
|
||||||
|
if let Some(bot) = self.bots.get(id) {
|
||||||
|
*bot.shutdown.lock().await = true;
|
||||||
|
info!("Bot durduruldu: {}", id);
|
||||||
|
} else {
|
||||||
|
warn!("Bot bulunamadı: {}", id);
|
||||||
|
}
|
||||||
|
self.bots.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&self, id: &str) -> bool {
|
||||||
|
self.bots.contains_key(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn running_ids(&self) -> Vec<String> {
|
||||||
|
self.bots.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/bot/mod.rs
Normal file
3
src/bot/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod manager;
|
||||||
|
pub mod runner;
|
||||||
|
pub mod strategy;
|
||||||
143
src/bot/runner.rs
Normal file
143
src/bot/runner.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::{error, info};
|
||||||
|
use tokio::sync::{broadcast, Mutex};
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
|
||||||
|
use crate::binance::{client::BinanceClient, models::Timeframe};
|
||||||
|
use crate::bot::strategy::RedCandleStrategy;
|
||||||
|
use crate::storage::config::BotConfig;
|
||||||
|
use crate::storage::db::Database;
|
||||||
|
use crate::storage::history::TradeRecord;
|
||||||
|
use crate::storage::positions::OpenPosition;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct TradeEvent {
|
||||||
|
pub bot_id: String,
|
||||||
|
pub bot_name: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub buy_price: f64,
|
||||||
|
pub sell_target: f64,
|
||||||
|
pub quantity: f64,
|
||||||
|
pub profit_percent: f64,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BotRunner;
|
||||||
|
|
||||||
|
impl BotRunner {
|
||||||
|
pub async fn run(
|
||||||
|
config: BotConfig,
|
||||||
|
api_key: String,
|
||||||
|
api_secret: String,
|
||||||
|
shutdown: Arc<Mutex<bool>>,
|
||||||
|
db: Arc<Mutex<Database>>,
|
||||||
|
event_tx: broadcast::Sender<TradeEvent>,
|
||||||
|
) {
|
||||||
|
let client = BinanceClient::new(api_key, api_secret, config.testnet);
|
||||||
|
let mut last_traded_open_time: Option<i64> = None;
|
||||||
|
|
||||||
|
info!("[{}] Bot başlatıldı.", config.symbol);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if *shutdown.lock().await {
|
||||||
|
info!("[{}] Bot durduruldu.", config.symbol);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wait_secs = seconds_until_trigger(&config.timeframe);
|
||||||
|
info!("[{}] Sonraki kontrol: {} saniye sonra.", config.symbol, wait_secs);
|
||||||
|
sleep(Duration::from_secs(wait_secs as u64)).await;
|
||||||
|
|
||||||
|
if *shutdown.lock().await {
|
||||||
|
info!("[{}] Bot durduruldu.", config.symbol);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
match RedCandleStrategy::execute(
|
||||||
|
&client,
|
||||||
|
&config.symbol,
|
||||||
|
&config.timeframe,
|
||||||
|
config.usdt_amount,
|
||||||
|
config.profit_percent,
|
||||||
|
&mut last_traded_open_time,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(result)) => {
|
||||||
|
info!(
|
||||||
|
"[{}] ✅ İşlem | Alış: {:.4} | Satış hedefi: {:.4} | Kar: %{:.2}",
|
||||||
|
config.symbol, result.buy_order.price, result.sell_order.price, config.profit_percent
|
||||||
|
);
|
||||||
|
|
||||||
|
let record = TradeRecord {
|
||||||
|
bot_id: config.id.clone(),
|
||||||
|
bot_name: config.name.clone(),
|
||||||
|
symbol: config.symbol.clone(),
|
||||||
|
buy_price: result.buy_order.price,
|
||||||
|
sell_target: result.sell_order.price,
|
||||||
|
quantity: result.buy_order.quantity,
|
||||||
|
profit_percent: config.profit_percent,
|
||||||
|
timestamp: result.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let position = OpenPosition {
|
||||||
|
bot_id: config.id.clone(),
|
||||||
|
bot_name: config.name.clone(),
|
||||||
|
symbol: config.symbol.clone(),
|
||||||
|
order_id: result.sell_order.order_id,
|
||||||
|
buy_price: result.buy_order.price,
|
||||||
|
sell_target: result.sell_order.price,
|
||||||
|
quantity: result.buy_order.quantity,
|
||||||
|
profit_percent: config.profit_percent,
|
||||||
|
opened_at: result.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let db_guard = db.lock().await;
|
||||||
|
if let Err(e) = db_guard.insert_trade(&record) {
|
||||||
|
error!("[{}] DB trade kayıt hatası: {}", config.symbol, e);
|
||||||
|
}
|
||||||
|
if let Err(e) = db_guard.insert_position(&position) {
|
||||||
|
error!("[{}] DB pozisyon kayıt hatası: {}", config.symbol, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = TradeEvent {
|
||||||
|
bot_id: config.id.clone(),
|
||||||
|
bot_name: config.name.clone(),
|
||||||
|
symbol: config.symbol.clone(),
|
||||||
|
buy_price: result.buy_order.price,
|
||||||
|
sell_target: result.sell_order.price,
|
||||||
|
quantity: result.buy_order.quantity,
|
||||||
|
profit_percent: config.profit_percent,
|
||||||
|
timestamp: result.timestamp,
|
||||||
|
};
|
||||||
|
event_tx.send(event).ok();
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
info!("[{}] ⏭ İşlem yapılmadı.", config.symbol);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("[{}] ❌ HATA: {:?}", config.symbol, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIGGER_BEFORE_SECS: i64 = 60;
|
||||||
|
|
||||||
|
fn seconds_until_trigger(timeframe: &Timeframe) -> i64 {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let duration = timeframe.duration_secs();
|
||||||
|
let candle_start = (now / duration) * duration;
|
||||||
|
let candle_close = candle_start + duration;
|
||||||
|
let trigger_at = candle_close - TRIGGER_BEFORE_SECS;
|
||||||
|
let wait = trigger_at - now;
|
||||||
|
if wait <= 0 {
|
||||||
|
candle_close + duration - TRIGGER_BEFORE_SECS - now
|
||||||
|
} else {
|
||||||
|
wait
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/bot/strategy.rs
Normal file
100
src/bot/strategy.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::Utc;
|
||||||
|
use log::info;
|
||||||
|
|
||||||
|
use crate::binance::{client::BinanceClient, models::Kline};
|
||||||
|
|
||||||
|
/// Kırmızı mum stratejisinin karar ve emir mantığı
|
||||||
|
pub struct RedCandleStrategy;
|
||||||
|
|
||||||
|
impl RedCandleStrategy {
|
||||||
|
/// Son kapanmış mumu kontrol eder.
|
||||||
|
/// Kırmızıysa market alım + limit satış emri girer.
|
||||||
|
/// Aynı mum için daha önce işlem yapıldıysa atlar (last_traded_open_time kontrolü).
|
||||||
|
pub async fn execute(
|
||||||
|
client: &BinanceClient,
|
||||||
|
symbol: &str,
|
||||||
|
timeframe: &crate::binance::models::Timeframe,
|
||||||
|
usdt_amount: f64,
|
||||||
|
profit_percent: f64,
|
||||||
|
last_traded_open_time: &mut Option<i64>,
|
||||||
|
) -> Result<Option<TradeResult>> {
|
||||||
|
let kline = client.get_last_closed_kline(symbol, timeframe).await?;
|
||||||
|
|
||||||
|
// Aynı mum için tekrar işlem engeli
|
||||||
|
if let Some(last_time) = *last_traded_open_time {
|
||||||
|
if kline.open_time == last_time {
|
||||||
|
info!(
|
||||||
|
"[{}] Aynı mum için zaten işlem yapıldı (open_time: {}), atlanıyor.",
|
||||||
|
symbol, kline.open_time
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !kline.is_red() {
|
||||||
|
info!(
|
||||||
|
"[{}] Son mum kırmızı değil. Açılış: {:.4} | Kapanış: {:.4}",
|
||||||
|
symbol, kline.open, kline.close
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[{}] KIRMIZI MUM! Açılış: {:.4} | Kapanış: {:.4} | Alım yapılıyor...",
|
||||||
|
symbol, kline.open, kline.close
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exchange filtrelerini al (lot size, tick size)
|
||||||
|
let filters = client.get_symbol_filters(symbol).await?;
|
||||||
|
|
||||||
|
// Alım miktarını hesapla (mevcut kapanış fiyatı üzerinden)
|
||||||
|
let raw_qty = usdt_amount / kline.close;
|
||||||
|
let quantity = filters.format_qty(raw_qty);
|
||||||
|
info!("[{}] Market alım gönderiliyor | Miktar: {} | Fiyat tahmini: {:.4}", symbol, quantity, kline.close);
|
||||||
|
|
||||||
|
// Market alım emri
|
||||||
|
let buy_order = client.market_buy(symbol, &quantity).await?;
|
||||||
|
info!(
|
||||||
|
"[{}] Alım gerçekleşti | Fiyat: {:.4} | Miktar: {} | Emir ID: {}",
|
||||||
|
symbol, buy_order.price, filters.format_qty(buy_order.quantity), buy_order.order_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Satış fiyatı = bu işlemin alış fiyatı × (1 + kar%)
|
||||||
|
let sell_price = buy_order.price * (1.0 + profit_percent / 100.0);
|
||||||
|
let sell_price_str = filters.format_price(sell_price);
|
||||||
|
let sell_quantity = filters.format_qty(buy_order.quantity);
|
||||||
|
|
||||||
|
// Limit satış emri
|
||||||
|
let sell_order = client
|
||||||
|
.limit_sell(symbol, &sell_quantity, &sell_price_str)
|
||||||
|
.await?;
|
||||||
|
info!(
|
||||||
|
"[{}] Limit satış emri girildi | Fiyat: {:.4} | Emir ID: {}",
|
||||||
|
symbol, sell_price, sell_order.order_id
|
||||||
|
);
|
||||||
|
|
||||||
|
*last_traded_open_time = Some(kline.open_time);
|
||||||
|
|
||||||
|
Ok(Some(TradeResult {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
kline,
|
||||||
|
buy_order,
|
||||||
|
sell_order,
|
||||||
|
profit_percent,
|
||||||
|
timestamp: Utc::now().timestamp_millis(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// İşlem sonucu
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TradeResult {
|
||||||
|
pub symbol: String,
|
||||||
|
pub kline: Kline,
|
||||||
|
pub buy_order: crate::binance::models::FilledOrder,
|
||||||
|
pub sell_order: crate::binance::models::FilledOrder,
|
||||||
|
pub profit_percent: f64,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
22
src/config.rs
Normal file
22
src/config.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub api_key: String,
|
||||||
|
pub api_secret: String,
|
||||||
|
pub auth_token: String,
|
||||||
|
pub db_path: String,
|
||||||
|
pub listen_addr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
Self {
|
||||||
|
api_key: env::var("BINANCE_API_KEY").expect("BINANCE_API_KEY gerekli"),
|
||||||
|
api_secret: env::var("BINANCE_API_SECRET").expect("BINANCE_API_SECRET gerekli"),
|
||||||
|
auth_token: env::var("AUTH_TOKEN").expect("AUTH_TOKEN gerekli"),
|
||||||
|
db_path: env::var("DB_PATH").unwrap_or_else(|_| "data/bots.db".to_string()),
|
||||||
|
listen_addr: env::var("LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:4646".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/main.rs
Normal file
74
src/main.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
mod api;
|
||||||
|
mod binance;
|
||||||
|
mod bot;
|
||||||
|
mod config;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::info;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::{broadcast, Mutex};
|
||||||
|
|
||||||
|
use bot::manager::BotManager;
|
||||||
|
use bot::runner::TradeEvent;
|
||||||
|
use config::AppConfig;
|
||||||
|
use storage::db::Database;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Arc<Mutex<Database>>,
|
||||||
|
pub manager: Arc<Mutex<BotManager>>,
|
||||||
|
pub event_tx: broadcast::Sender<TradeEvent>,
|
||||||
|
pub auth_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
let cfg = AppConfig::from_env();
|
||||||
|
|
||||||
|
let db = Database::new(Path::new(&cfg.db_path))
|
||||||
|
.expect("Veritabanı başlatılamadı");
|
||||||
|
let db = Arc::new(Mutex::new(db));
|
||||||
|
|
||||||
|
let (event_tx, _) = broadcast::channel::<TradeEvent>(64);
|
||||||
|
|
||||||
|
let manager = BotManager::new(
|
||||||
|
Arc::clone(&db),
|
||||||
|
event_tx.clone(),
|
||||||
|
cfg.api_key.clone(),
|
||||||
|
cfg.api_secret.clone(),
|
||||||
|
);
|
||||||
|
let manager = Arc::new(Mutex::new(manager));
|
||||||
|
|
||||||
|
// Aktif botları otomatik başlat
|
||||||
|
{
|
||||||
|
let db_guard = db.lock().await;
|
||||||
|
let bots = db_guard.get_bots().unwrap_or_default();
|
||||||
|
drop(db_guard);
|
||||||
|
|
||||||
|
let mut mgr = manager.lock().await;
|
||||||
|
for bot in bots.into_iter().filter(|b| b.active) {
|
||||||
|
info!("Otomatik başlatılıyor: {} ({})", bot.symbol, bot.id);
|
||||||
|
mgr.start(bot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db,
|
||||||
|
manager,
|
||||||
|
event_tx,
|
||||||
|
auth_token: cfg.auth_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
let router = api::routes::build(state);
|
||||||
|
let listener = TcpListener::bind(&cfg.listen_addr).await
|
||||||
|
.expect("Port dinlenemiyor");
|
||||||
|
|
||||||
|
info!("MSE çalışıyor: http://{}", cfg.listen_addr);
|
||||||
|
axum::serve(listener, router).await.expect("Sunucu hatası");
|
||||||
|
}
|
||||||
17
src/storage/closed.rs
Normal file
17
src/storage/closed.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Dolan veya iptal edilen limit satış emri kaydı
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClosedPosition {
|
||||||
|
pub bot_id: String,
|
||||||
|
pub bot_name: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub order_id: u64,
|
||||||
|
pub buy_price: f64,
|
||||||
|
pub sell_target: f64,
|
||||||
|
pub quantity: f64,
|
||||||
|
pub profit_percent: f64,
|
||||||
|
pub opened_at: i64, // ms
|
||||||
|
pub closed_at: i64, // ms
|
||||||
|
pub status: String, // "FILLED" | "CANCELED"
|
||||||
|
}
|
||||||
16
src/storage/config.rs
Normal file
16
src/storage/config.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::binance::models::Timeframe;
|
||||||
|
|
||||||
|
/// Tek bir botun konfigürasyonu
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BotConfig {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub timeframe: Timeframe,
|
||||||
|
pub usdt_amount: f64,
|
||||||
|
pub profit_percent: f64,
|
||||||
|
pub testnet: bool,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
246
src/storage/db.rs
Normal file
246
src/storage/db.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection, Result as SqlResult};
|
||||||
|
|
||||||
|
use crate::storage::closed::ClosedPosition;
|
||||||
|
use crate::storage::config::BotConfig;
|
||||||
|
use crate::storage::history::TradeRecord;
|
||||||
|
use crate::storage::positions::OpenPosition;
|
||||||
|
|
||||||
|
pub struct Database {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for Database {}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn new(path: &Path) -> SqlResult<Self> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"PRAGMA journal_mode=WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS bots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
timeframe TEXT NOT NULL,
|
||||||
|
usdt_amount REAL NOT NULL,
|
||||||
|
profit_percent REAL NOT NULL,
|
||||||
|
testnet INTEGER NOT NULL DEFAULT 0,
|
||||||
|
active INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS trade_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bot_id TEXT NOT NULL,
|
||||||
|
bot_name TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
buy_price REAL NOT NULL,
|
||||||
|
sell_target REAL NOT NULL,
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
profit_percent REAL NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS open_positions (
|
||||||
|
order_id INTEGER PRIMARY KEY,
|
||||||
|
bot_id TEXT NOT NULL,
|
||||||
|
bot_name TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
buy_price REAL NOT NULL,
|
||||||
|
sell_target REAL NOT NULL,
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
profit_percent REAL NOT NULL,
|
||||||
|
opened_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS closed_positions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_id INTEGER NOT NULL,
|
||||||
|
bot_id TEXT NOT NULL,
|
||||||
|
bot_name TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
buy_price REAL NOT NULL,
|
||||||
|
sell_target REAL NOT NULL,
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
profit_percent REAL NOT NULL,
|
||||||
|
opened_at INTEGER NOT NULL,
|
||||||
|
closed_at INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL
|
||||||
|
);",
|
||||||
|
)?;
|
||||||
|
Ok(Self { conn })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bots ────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn insert_bot(&self, b: &BotConfig) -> SqlResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO bots (id, name, symbol, timeframe, usdt_amount, profit_percent, testnet, active)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
params![
|
||||||
|
b.id, b.name, b.symbol,
|
||||||
|
serde_json::to_string(&b.timeframe).unwrap_or_default(),
|
||||||
|
b.usdt_amount, b.profit_percent,
|
||||||
|
b.testnet as i32, b.active as i32
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_bots(&self) -> SqlResult<Vec<BotConfig>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, name, symbol, timeframe, usdt_amount, profit_percent, testnet, active FROM bots ORDER BY rowid",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
let timeframe_str: String = row.get(3)?;
|
||||||
|
let timeframe = serde_json::from_str(&timeframe_str)
|
||||||
|
.unwrap_or(crate::binance::models::Timeframe::FiveMinutes);
|
||||||
|
Ok(BotConfig {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
symbol: row.get(2)?,
|
||||||
|
timeframe,
|
||||||
|
usdt_amount: row.get(4)?,
|
||||||
|
profit_percent: row.get(5)?,
|
||||||
|
testnet: row.get::<_, i32>(6)? != 0,
|
||||||
|
active: row.get::<_, i32>(7)? != 0,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_bot(&self, id: &str) -> SqlResult<()> {
|
||||||
|
self.conn.execute("DELETE FROM bots WHERE id = ?1", params![id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_bot_active(&self, id: &str, active: bool) -> SqlResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE bots SET active = ?1 WHERE id = ?2",
|
||||||
|
params![active as i32, id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Trade records ───────────────────────────────
|
||||||
|
|
||||||
|
pub fn insert_trade(&self, r: &TradeRecord) -> SqlResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO trade_records
|
||||||
|
(bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, timestamp)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
params![
|
||||||
|
r.bot_id, r.bot_name, r.symbol,
|
||||||
|
r.buy_price, r.sell_target, r.quantity,
|
||||||
|
r.profit_percent, r.timestamp
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_trades(&self) -> SqlResult<Vec<TradeRecord>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, timestamp
|
||||||
|
FROM trade_records ORDER BY timestamp DESC LIMIT 500",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok(TradeRecord {
|
||||||
|
bot_id: row.get(0)?,
|
||||||
|
bot_name: row.get(1)?,
|
||||||
|
symbol: row.get(2)?,
|
||||||
|
buy_price: row.get(3)?,
|
||||||
|
sell_target: row.get(4)?,
|
||||||
|
quantity: row.get(5)?,
|
||||||
|
profit_percent: row.get(6)?,
|
||||||
|
timestamp: row.get(7)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Open positions ──────────────────────────────
|
||||||
|
|
||||||
|
pub fn insert_position(&self, p: &OpenPosition) -> SqlResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO open_positions
|
||||||
|
(order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
params![
|
||||||
|
p.order_id as i64, p.bot_id, p.bot_name, p.symbol,
|
||||||
|
p.buy_price, p.sell_target, p.quantity,
|
||||||
|
p.profit_percent, p.opened_at
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_positions(&self) -> SqlResult<Vec<OpenPosition>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at
|
||||||
|
FROM open_positions ORDER BY opened_at DESC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
let order_id: i64 = row.get(0)?;
|
||||||
|
Ok(OpenPosition {
|
||||||
|
order_id: order_id as u64,
|
||||||
|
bot_id: row.get(1)?,
|
||||||
|
bot_name: row.get(2)?,
|
||||||
|
symbol: row.get(3)?,
|
||||||
|
buy_price: row.get(4)?,
|
||||||
|
sell_target: row.get(5)?,
|
||||||
|
quantity: row.get(6)?,
|
||||||
|
profit_percent: row.get(7)?,
|
||||||
|
opened_at: row.get(8)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_position(&self, order_id: u64) -> SqlResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM open_positions WHERE order_id = ?1",
|
||||||
|
params![order_id as i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Closed positions ────────────────────────────
|
||||||
|
|
||||||
|
pub fn insert_closed(&self, c: &ClosedPosition) -> SqlResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO closed_positions
|
||||||
|
(order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at, closed_at, status)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
|
params![
|
||||||
|
c.order_id as i64, c.bot_id, c.bot_name, c.symbol,
|
||||||
|
c.buy_price, c.sell_target, c.quantity,
|
||||||
|
c.profit_percent, c.opened_at, c.closed_at, c.status
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_closed_positions(&self) -> SqlResult<Vec<ClosedPosition>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at, closed_at, status
|
||||||
|
FROM closed_positions ORDER BY closed_at DESC LIMIT 100",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
let order_id: i64 = row.get(0)?;
|
||||||
|
Ok(ClosedPosition {
|
||||||
|
order_id: order_id as u64,
|
||||||
|
bot_id: row.get(1)?,
|
||||||
|
bot_name: row.get(2)?,
|
||||||
|
symbol: row.get(3)?,
|
||||||
|
buy_price: row.get(4)?,
|
||||||
|
sell_target: row.get(5)?,
|
||||||
|
quantity: row.get(6)?,
|
||||||
|
profit_percent: row.get(7)?,
|
||||||
|
opened_at: row.get(8)?,
|
||||||
|
closed_at: row.get(9)?,
|
||||||
|
status: row.get(10)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/storage/history.rs
Normal file
14
src/storage/history.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Gerçekleşen bir işlemin kaydı
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TradeRecord {
|
||||||
|
pub bot_id: String,
|
||||||
|
pub bot_name: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub buy_price: f64,
|
||||||
|
pub sell_target: f64,
|
||||||
|
pub quantity: f64,
|
||||||
|
pub profit_percent: f64,
|
||||||
|
pub timestamp: i64, // ms
|
||||||
|
}
|
||||||
5
src/storage/mod.rs
Normal file
5
src/storage/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod closed;
|
||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod history;
|
||||||
|
pub mod positions;
|
||||||
15
src/storage/positions.rs
Normal file
15
src/storage/positions.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Bekleyen limit satış emri (henüz dolmamış açık pozisyon)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenPosition {
|
||||||
|
pub bot_id: String,
|
||||||
|
pub bot_name: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub order_id: u64,
|
||||||
|
pub buy_price: f64,
|
||||||
|
pub sell_target: f64,
|
||||||
|
pub quantity: f64,
|
||||||
|
pub profit_percent: f64,
|
||||||
|
pub opened_at: i64, // ms
|
||||||
|
}
|
||||||
294
src/web/index.html
Normal file
294
src/web/index.html
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="tr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mukan Special Edition</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--bg: #0f0f13;
|
||||||
|
--surface: #1a1a22;
|
||||||
|
--border: #2a2a38;
|
||||||
|
--text: #e0e0f0;
|
||||||
|
--muted: #888;
|
||||||
|
--accent: #6c63ff;
|
||||||
|
--green: #2ecc71;
|
||||||
|
--red: #e74c3c;
|
||||||
|
--yellow: #f39c12;
|
||||||
|
}
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; min-height: 100vh; }
|
||||||
|
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
|
||||||
|
header h1 { font-size: 18px; font-weight: 600; color: var(--accent); }
|
||||||
|
header .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||||
|
header .status-dot.connected { background: var(--green); }
|
||||||
|
main { padding: 24px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
||||||
|
section { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||||
|
.section-header { padding: 14px 18px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 10px 18px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||||
|
th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 500; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge-running { background: rgba(46,204,113,0.15); color: var(--green); }
|
||||||
|
.badge-stopped { background: rgba(136,136,136,0.1); color: var(--muted); }
|
||||||
|
.btn { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 12px; cursor: pointer; transition: all 0.15s; }
|
||||||
|
.btn:hover { background: rgba(255,255,255,0.05); }
|
||||||
|
.btn-start { border-color: var(--green); color: var(--green); }
|
||||||
|
.btn-start:hover { background: rgba(46,204,113,0.1); }
|
||||||
|
.btn-stop { border-color: var(--yellow); color: var(--yellow); }
|
||||||
|
.btn-stop:hover { background: rgba(243,156,18,0.1); }
|
||||||
|
.btn-delete { border-color: var(--red); color: var(--red); }
|
||||||
|
.btn-delete:hover { background: rgba(231,76,60,0.1); }
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover { background: #5a52d5; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; padding: 18px; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.form-group label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.form-group input, .form-group select { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 7px 10px; color: var(--text); font-size: 13px; }
|
||||||
|
.form-group input:focus, .form-group select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.form-group select option { background: var(--surface); }
|
||||||
|
.form-actions { padding: 0 18px 18px; }
|
||||||
|
.empty { padding: 24px 18px; color: var(--muted); font-size: 13px; text-align: center; }
|
||||||
|
#auth-overlay { position: fixed; inset: 0; background: var(--bg); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||||
|
.auth-box { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 32px; width: 320px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.auth-box h2 { font-size: 16px; color: var(--accent); }
|
||||||
|
.auth-box input { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 9px 12px; color: var(--text); font-size: 14px; width: 100%; }
|
||||||
|
.auth-box input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.auth-error { color: var(--red); font-size: 12px; display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="auth-overlay">
|
||||||
|
<div class="auth-box">
|
||||||
|
<h2>Mukan Special Edition</h2>
|
||||||
|
<input type="password" id="token-input" placeholder="Auth token" />
|
||||||
|
<button class="btn btn-primary" onclick="login()">Giriş</button>
|
||||||
|
<span class="auth-error" id="auth-error">Geçersiz token</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>MSE Dashboard</h1>
|
||||||
|
<span class="status-dot" id="sse-dot"></span>
|
||||||
|
<span id="sse-label" style="font-size:12px; color:var(--muted)">bağlanıyor...</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<div class="section-header">Botlar <span id="bot-count" style="color:var(--text)">0</span></div>
|
||||||
|
<table id="bots-table">
|
||||||
|
<thead><tr><th>İsim</th><th>Sembol</th><th>Timeframe</th><th>USDT</th><th>Kar %</th><th>Testnet</th><th>Durum</th><th></th></tr></thead>
|
||||||
|
<tbody id="bots-body"><tr><td colspan="8" class="empty">Yükleniyor...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-header">Yeni Bot Ekle</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group"><label>İsim</label><input id="f-name" placeholder="DOGE Bot" /></div>
|
||||||
|
<div class="form-group"><label>Sembol</label><input id="f-symbol" placeholder="DOGEUSDT" /></div>
|
||||||
|
<div class="form-group"><label>Timeframe</label>
|
||||||
|
<select id="f-timeframe">
|
||||||
|
<option value="1m">1m</option>
|
||||||
|
<option value="5m" selected>5m</option>
|
||||||
|
<option value="15m">15m</option>
|
||||||
|
<option value="30m">30m</option>
|
||||||
|
<option value="1h">1h</option>
|
||||||
|
<option value="4h">4h</option>
|
||||||
|
<option value="1d">1d</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>USDT Miktarı</label><input id="f-usdt" type="number" step="0.1" placeholder="10" /></div>
|
||||||
|
<div class="form-group"><label>Kar %</label><input id="f-profit" type="number" step="0.1" placeholder="2" /></div>
|
||||||
|
<div class="form-group"><label>Testnet</label>
|
||||||
|
<select id="f-testnet">
|
||||||
|
<option value="false">Hayır (Gerçek)</option>
|
||||||
|
<option value="true">Evet (Testnet)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button class="btn btn-primary" onclick="createBot()">Bot Ekle</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-header">Açık Pozisyonlar</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Bot</th><th>Sembol</th><th>Alış</th><th>Hedef</th><th>Miktar</th><th>Zaman</th></tr></thead>
|
||||||
|
<tbody id="positions-body"><tr><td colspan="6" class="empty">Açık pozisyon yok</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-header">Kapalı İşlemler</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Bot</th><th>Sembol</th><th>Alış</th><th>Hedef</th><th>Miktar</th><th>Durum</th><th>Zaman</th></tr></thead>
|
||||||
|
<tbody id="closed-body"><tr><td colspan="7" class="empty">Henüz işlem yok</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let AUTH_TOKEN = localStorage.getItem('mse_token') || '';
|
||||||
|
let sseSource = null;
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
const t = document.getElementById('token-input').value.trim();
|
||||||
|
if (!t) return;
|
||||||
|
AUTH_TOKEN = t;
|
||||||
|
localStorage.setItem('mse_token', t);
|
||||||
|
tryConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const res = await fetch('/api' + path, {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 401) { showAuthOverlay(); throw new Error('Unauthorized'); }
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAuthOverlay() {
|
||||||
|
document.getElementById('auth-overlay').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryConnect() {
|
||||||
|
const res = await fetch('/api/bots', { headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } });
|
||||||
|
if (res.status === 401) {
|
||||||
|
document.getElementById('auth-error').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('auth-overlay').style.display = 'none';
|
||||||
|
loadAll();
|
||||||
|
connectSSE();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loadBots();
|
||||||
|
loadPositions();
|
||||||
|
loadClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBots() {
|
||||||
|
const res = await api('GET', '/bots');
|
||||||
|
const bots = await res.json();
|
||||||
|
document.getElementById('bot-count').textContent = bots.length;
|
||||||
|
const tbody = document.getElementById('bots-body');
|
||||||
|
if (!bots.length) { tbody.innerHTML = '<tr><td colspan="8" class="empty">Henüz bot yok</td></tr>'; return; }
|
||||||
|
tbody.innerHTML = bots.map(b => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(b.name)}</td>
|
||||||
|
<td><strong>${esc(b.symbol)}</strong></td>
|
||||||
|
<td>${esc(b.timeframe)}</td>
|
||||||
|
<td>${b.usdt_amount} USDT</td>
|
||||||
|
<td>%${b.profit_percent}</td>
|
||||||
|
<td>${b.testnet ? '🟡 Test' : '🟢 Gerçek'}</td>
|
||||||
|
<td><span class="badge ${b.running ? 'badge-running' : 'badge-stopped'}">${b.running ? 'Çalışıyor' : 'Durdu'}</span></td>
|
||||||
|
<td style="display:flex;gap:6px;flex-wrap:wrap">
|
||||||
|
${b.running
|
||||||
|
? `<button class="btn btn-stop" onclick="stopBot('${b.id}')">Durdur</button>`
|
||||||
|
: `<button class="btn btn-start" onclick="startBot('${b.id}')">Başlat</button>`
|
||||||
|
}
|
||||||
|
<button class="btn btn-delete" onclick="deleteBot('${b.id}','${esc(b.name)}')">Sil</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPositions() {
|
||||||
|
const res = await api('GET', '/positions');
|
||||||
|
const positions = await res.json();
|
||||||
|
const tbody = document.getElementById('positions-body');
|
||||||
|
if (!positions.length) { tbody.innerHTML = '<tr><td colspan="6" class="empty">Açık pozisyon yok</td></tr>'; return; }
|
||||||
|
tbody.innerHTML = positions.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(p.bot_name)}</td>
|
||||||
|
<td><strong>${esc(p.symbol)}</strong></td>
|
||||||
|
<td>${p.buy_price.toFixed(6)}</td>
|
||||||
|
<td>${p.sell_target.toFixed(6)}</td>
|
||||||
|
<td>${p.quantity}</td>
|
||||||
|
<td>${new Date(p.opened_at).toLocaleString('tr-TR')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClosed() {
|
||||||
|
const res = await api('GET', '/positions/closed');
|
||||||
|
const closed = await res.json();
|
||||||
|
const tbody = document.getElementById('closed-body');
|
||||||
|
if (!closed.length) { tbody.innerHTML = '<tr><td colspan="7" class="empty">Henüz işlem yok</td></tr>'; return; }
|
||||||
|
tbody.innerHTML = closed.map(c => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(c.bot_name)}</td>
|
||||||
|
<td><strong>${esc(c.symbol)}</strong></td>
|
||||||
|
<td>${c.buy_price.toFixed(6)}</td>
|
||||||
|
<td>${c.sell_target.toFixed(6)}</td>
|
||||||
|
<td>${c.quantity}</td>
|
||||||
|
<td><span class="badge ${c.status==='FILLED'?'badge-running':'badge-stopped'}">${c.status}</span></td>
|
||||||
|
<td>${new Date(c.closed_at).toLocaleString('tr-TR')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBot() {
|
||||||
|
const name = document.getElementById('f-name').value.trim();
|
||||||
|
const symbol = document.getElementById('f-symbol').value.trim().toUpperCase();
|
||||||
|
const timeframe = document.getElementById('f-timeframe').value;
|
||||||
|
const usdt_amount = parseFloat(document.getElementById('f-usdt').value);
|
||||||
|
const profit_percent = parseFloat(document.getElementById('f-profit').value);
|
||||||
|
const testnet = document.getElementById('f-testnet').value === 'true';
|
||||||
|
if (!name || !symbol || !usdt_amount || !profit_percent) { alert('Tüm alanları doldurun'); return; }
|
||||||
|
await api('POST', '/bots', { name, symbol, timeframe, usdt_amount, profit_percent, testnet });
|
||||||
|
loadBots();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBot(id) {
|
||||||
|
await api('POST', `/bots/${id}/start`);
|
||||||
|
loadBots();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopBot(id) {
|
||||||
|
await api('POST', `/bots/${id}/stop`);
|
||||||
|
loadBots();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBot(id, name) {
|
||||||
|
if (!confirm(`"${name}" botunu silmek istiyor musun?`)) return;
|
||||||
|
await api('DELETE', `/bots/${id}`);
|
||||||
|
loadBots();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
if (sseSource) sseSource.close();
|
||||||
|
sseSource = new EventSource('/api/events', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN }
|
||||||
|
});
|
||||||
|
// EventSource doesn't support custom headers natively; use URL param workaround
|
||||||
|
sseSource.close();
|
||||||
|
sseSource = new EventSource(`/api/events?token=${encodeURIComponent(AUTH_TOKEN)}`);
|
||||||
|
|
||||||
|
sseSource.addEventListener('trade', () => {
|
||||||
|
loadPositions();
|
||||||
|
loadClosed();
|
||||||
|
});
|
||||||
|
sseSource.onopen = () => {
|
||||||
|
document.getElementById('sse-dot').className = 'status-dot connected';
|
||||||
|
document.getElementById('sse-label').textContent = 'canlı';
|
||||||
|
};
|
||||||
|
sseSource.onerror = () => {
|
||||||
|
document.getElementById('sse-dot').className = 'status-dot';
|
||||||
|
document.getElementById('sse-label').textContent = 'bağlantı kesildi';
|
||||||
|
setTimeout(connectSSE, 5000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AUTH_TOKEN) tryConnect();
|
||||||
|
else showAuthOverlay();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue