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 {
order_id: data["orderId"].as_u64().unwrap_or(0),
symbol: data["symbol"].as_str().unwrap_or("").to_string(),
@ -569,5 +584,6 @@ fn parse_filled_order(data: &Value, side: OrderSide) -> Result<FilledOrder> {
price,
quantity: executed_qty,
timestamp: data["transactTime"].as_i64().unwrap_or(0),
commission_usdt,
})
}

View file

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

View file

@ -142,15 +142,16 @@ impl BotRunner {
};
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,
bot_id: config.id.clone(),
bot_name: config.name.clone(),
symbol: config.symbol.clone(),
order_id: result.sell_order.order_id,
buy_price: result.buy_order.price,
sell_target: result.sell_order.price,
quantity: result.buy_order.quantity,
profit_percent: config.profit_percent,
opened_at: result.timestamp,
buy_commission_usdt: result.buy_order.commission_usdt,
};
{

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);",
)?;
// 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 })
}
@ -188,12 +192,12 @@ impl Database {
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)",
(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, ?10)",
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
p.profit_percent, p.opened_at, p.buy_commission_usdt
],
)?;
Ok(())
@ -201,21 +205,22 @@ impl Database {
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
"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",
)?;
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)?,
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)?,
buy_commission_usdt: row.get(9)?,
})
})?;
rows.collect()
@ -338,21 +343,22 @@ impl Database {
pub fn get_positions_by_bot(&self, bot_id: &str) -> SqlResult<Vec<crate::storage::positions::OpenPosition>> {
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",
)?;
let rows = stmt.query_map(params![bot_id], |row| {
let order_id: i64 = row.get(0)?;
Ok(crate::storage::positions::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)?,
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)?,
buy_commission_usdt: row.get(9)?,
})
})?;
rows.collect()

View file

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

View file

@ -621,9 +621,17 @@ function renderOpenTable() {
return;
}
tbody.innerHTML = openPositionsCache.map(p => {
const pnl = currentPrice > 0
? ((currentPrice - p.buy_price) / p.buy_price * 100)
: null;
let pnl = null;
if (currentPrice > 0) {
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
? `<span class="${pnl >= 0 ? 'c-green' : 'c-red'}">${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}%</span>`
: '<span class="c-muted"></span>';