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