feat(mse): commission-aware PnL on open positions

- FilledOrder gains commission_usdt field
- parse_filled_order: sums fills[] commission as rate × qty × price in USDT
- OpenPosition gains buy_commission_usdt; DB migration via ALTER TABLE ADD COLUMN
- runner.rs writes buy_commission_usdt from buy_order.commission_usdt
- bot.html PnL = gross_pnl - buy_commission_pct - estimated_sell_commission_pct

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-25 12:52:24 +03:00
parent a8e80e0a15
commit 8804824199
6 changed files with 68 additions and 35 deletions

View file

@ -562,6 +562,21 @@ fn parse_filled_order(data: &Value, side: OrderSide) -> Result<FilledOrder> {
} }
}; };
// fills'den komisyonu qty * fill_price * rate olarak USDT'ye çevir
let commission_usdt = data["fills"]
.as_array()
.map(|fills| {
fills.iter().fold(0.0f64, |acc, f| {
let qty: f64 = f["qty"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
let fill_price: f64 = f["price"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
let commission: f64 = f["commission"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
// komisyon oranı = commission / qty, USDT karşılığı = oran × qty × fiyat
let rate = if qty > 0.0 { commission / qty } else { 0.001 };
acc + rate * qty * fill_price
})
})
.unwrap_or(0.0);
Ok(FilledOrder { Ok(FilledOrder {
order_id: data["orderId"].as_u64().unwrap_or(0), order_id: data["orderId"].as_u64().unwrap_or(0),
symbol: data["symbol"].as_str().unwrap_or("").to_string(), symbol: data["symbol"].as_str().unwrap_or("").to_string(),
@ -569,5 +584,6 @@ fn parse_filled_order(data: &Value, side: OrderSide) -> Result<FilledOrder> {
price, price,
quantity: executed_qty, quantity: executed_qty,
timestamp: data["transactTime"].as_i64().unwrap_or(0), timestamp: data["transactTime"].as_i64().unwrap_or(0),
commission_usdt,
}) })
} }

View file

@ -37,6 +37,7 @@ pub struct FilledOrder {
pub price: f64, pub price: f64,
pub quantity: f64, pub quantity: f64,
pub timestamp: i64, pub timestamp: i64,
pub commission_usdt: f64,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]

View file

@ -151,6 +151,7 @@ impl BotRunner {
quantity: result.buy_order.quantity, quantity: result.buy_order.quantity,
profit_percent: config.profit_percent, profit_percent: config.profit_percent,
opened_at: result.timestamp, opened_at: result.timestamp,
buy_commission_usdt: result.buy_order.commission_usdt,
}; };
{ {

View file

@ -85,6 +85,10 @@ impl Database {
); );
CREATE INDEX IF NOT EXISTS idx_bot_logs_bot_id ON bot_logs(bot_id, created_at DESC);", CREATE INDEX IF NOT EXISTS idx_bot_logs_bot_id ON bot_logs(bot_id, created_at DESC);",
)?; )?;
// migration: mevcut DB'ye buy_commission_usdt sütunu ekle
conn.execute_batch(
"ALTER TABLE open_positions ADD COLUMN buy_commission_usdt REAL NOT NULL DEFAULT 0;",
).ok();
Ok(Self { conn }) Ok(Self { conn })
} }
@ -188,12 +192,12 @@ impl Database {
pub fn insert_position(&self, p: &OpenPosition) -> SqlResult<()> { pub fn insert_position(&self, p: &OpenPosition) -> SqlResult<()> {
self.conn.execute( self.conn.execute(
"INSERT OR REPLACE INTO open_positions "INSERT OR REPLACE INTO open_positions
(order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at) (order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at, buy_commission_usdt)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![ params![
p.order_id as i64, p.bot_id, p.bot_name, p.symbol, p.order_id as i64, p.bot_id, p.bot_name, p.symbol,
p.buy_price, p.sell_target, p.quantity, p.buy_price, p.sell_target, p.quantity,
p.profit_percent, p.opened_at p.profit_percent, p.opened_at, p.buy_commission_usdt
], ],
)?; )?;
Ok(()) Ok(())
@ -201,7 +205,7 @@ impl Database {
pub fn get_positions(&self) -> SqlResult<Vec<OpenPosition>> { pub fn get_positions(&self) -> SqlResult<Vec<OpenPosition>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at "SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at, buy_commission_usdt
FROM open_positions ORDER BY opened_at DESC", FROM open_positions ORDER BY opened_at DESC",
)?; )?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
@ -216,6 +220,7 @@ impl Database {
quantity: row.get(6)?, quantity: row.get(6)?,
profit_percent: row.get(7)?, profit_percent: row.get(7)?,
opened_at: row.get(8)?, opened_at: row.get(8)?,
buy_commission_usdt: row.get(9)?,
}) })
})?; })?;
rows.collect() rows.collect()
@ -338,7 +343,7 @@ impl Database {
pub fn get_positions_by_bot(&self, bot_id: &str) -> SqlResult<Vec<crate::storage::positions::OpenPosition>> { pub fn get_positions_by_bot(&self, bot_id: &str) -> SqlResult<Vec<crate::storage::positions::OpenPosition>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at "SELECT order_id, bot_id, bot_name, symbol, buy_price, sell_target, quantity, profit_percent, opened_at, buy_commission_usdt
FROM open_positions WHERE bot_id = ?1 ORDER BY opened_at DESC", FROM open_positions WHERE bot_id = ?1 ORDER BY opened_at DESC",
)?; )?;
let rows = stmt.query_map(params![bot_id], |row| { let rows = stmt.query_map(params![bot_id], |row| {
@ -353,6 +358,7 @@ impl Database {
quantity: row.get(6)?, quantity: row.get(6)?,
profit_percent: row.get(7)?, profit_percent: row.get(7)?,
opened_at: row.get(8)?, opened_at: row.get(8)?,
buy_commission_usdt: row.get(9)?,
}) })
})?; })?;
rows.collect() rows.collect()

View file

@ -12,4 +12,5 @@ pub struct OpenPosition {
pub quantity: f64, pub quantity: f64,
pub profit_percent: f64, pub profit_percent: f64,
pub opened_at: i64, // ms pub opened_at: i64, // ms
pub buy_commission_usdt: f64,
} }

View file

@ -621,9 +621,17 @@ function renderOpenTable() {
return; return;
} }
tbody.innerHTML = openPositionsCache.map(p => { tbody.innerHTML = openPositionsCache.map(p => {
const pnl = currentPrice > 0 let pnl = null;
? ((currentPrice - p.buy_price) / p.buy_price * 100) if (currentPrice > 0) {
: null; const grossPnl = (currentPrice - p.buy_price) / p.buy_price * 100;
// alış komisyonu % olarak: buy_commission_usdt / (buy_price * quantity) * 100
const buyCommissionPct = p.buy_commission_usdt > 0
? (p.buy_commission_usdt / (p.buy_price * p.quantity) * 100)
: 0.1;
// satış komisyonu tahmini: aynı oran üzerinden (currentPrice × quantity için)
const sellCommissionPct = buyCommissionPct;
pnl = grossPnl - buyCommissionPct - sellCommissionPct;
}
const pnlHtml = pnl !== null const pnlHtml = pnl !== null
? `<span class="${pnl >= 0 ? 'c-green' : 'c-red'}">${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}%</span>` ? `<span class="${pnl >= 0 ? 'c-green' : 'c-red'}">${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}%</span>`
: '<span class="c-muted"></span>'; : '<span class="c-muted"></span>';