β ΠΠ°Π·Π°Π΄'use strict';
const trading = require('./trading');
const config = require('../config');
const logger = require('../utils/logger');
const axios = require('axios');
const { logOpenTrade, logTrade } = require('./tradeLogger');
// βββ Push notification helper ββββββββββββββββββββββββββ
async function sendTradePush(title, body) {
try {
const { broadcastPushNotification } = require('../routes/notifications');
await broadcastPushNotification({
title,
body,
icon: '/pwa-192x192.png',
data: { url: '/?tab=trading' },
});
} catch (err) {
logger.error(`[TRADE_MGR] Push error: ${err.message}`);
}
}
// βββ In-memory active trades ββββββββββββββββββββββββββββ
// { symbol, buyOrderId, qty, entryPrice, tpPrice, tpOrderId, status }
// status: PENDING_FILL | FILLED_PLACING_TP | TP_PLACED | CLOSED | ERROR
const activeTrades = new Map();
const MAX_POSITION_COST = 6; // $6 max per position (allows $5 + rounding)
// Cache exchange info for price limits (refreshed on startup)
let exchangeFilters = {};
async function loadExchangeFilters() {
try {
const resp = await axios.get('https://eapi.binance.com/eapi/v1/exchangeInfo');
for (const sym of (resp.data.optionSymbols || [])) {
const priceFilter = sym.filters?.find(f => f.filterType === 'PRICE_FILTER');
if (priceFilter) {
exchangeFilters[sym.symbol] = {
maxPrice: parseFloat(priceFilter.maxPrice),
minPrice: parseFloat(priceFilter.minPrice),
tickSize: parseFloat(priceFilter.tickSize),
};
}
}
logger.info(`[TRADE_MGR] Loaded filters for ${Object.keys(exchangeFilters).length} symbols`);
} catch (err) {
logger.error(`[TRADE_MGR] Failed to load exchange filters: ${err.message}`);
}
}
// Load on startup
loadExchangeFilters();
// Refresh every hour
setInterval(loadExchangeFilters, 3600000);
function clampPrice(symbol, price) {
const f = exchangeFilters[symbol];
if (!f) return price;
// Clamp to maxPrice
let p = Math.min(price, f.maxPrice);
// Round to tick size
p = Math.round(p / f.tickSize) * f.tickSize;
return parseFloat(p.toFixed(4));
}
// βββ Open trade with auto-TP ββββββββββββββββββββββββββββ
// source: 'manual' (UI/API) or 'auto_gamma' (auto-trader)
async function openTradeWithTP({ symbol, quantity, price, tpPct, source = 'manual' }) {
// Validate $5 cap
const cost = price * quantity;
if (cost > MAX_POSITION_COST) {
throw new Error(`Position cost $${cost.toFixed(2)} exceeds $${MAX_POSITION_COST} limit. Reduce qty or price.`);
}
// Place BUY order
const buyResult = await trading.placeOrder({ symbol, side: 'BUY', quantity, price });
// Calculate TP price (clamped to exchange maxPrice)
const rawTp = price * (1 + tpPct / 100);
const tpPrice = clampPrice(symbol, rawTp);
// Store trade for monitoring
const trade = {
symbol,
buyOrderId: buyResult.orderId,
qty: parseFloat(quantity),
entryPrice: price,
tpPct,
tpPrice,
tpOrderId: null,
status: 'PENDING_FILL',
source,
createdAt: new Date().toISOString(),
};
activeTrades.set(buyResult.orderId, trade);
const isAuto = source === 'auto_gamma';
const tag = isAuto ? 'π€ AUTO' : 'π€ MANUAL';
logger.info(`[TRADE_MGR] ${tag} Trade opened: ${symbol} qty=${quantity} entry=$${price} TP=$${tpPrice} (${tpPct}%)`);
// Push notification on entry (auto trades only β manual user already knows)
if (isAuto) {
const parts = symbol.split('-');
const asset = parts[0];
const strike = parts[2];
const type = parts[3] === 'C' ? 'CALL' : 'PUT';
const cost = (price * quantity).toFixed(2);
sendTradePush(
`π€ AUTO: ${asset} ${type} $${strike}`,
`ΠΡ
ΠΎΠ΄ $${price} Γ ${quantity} = $${cost}. TP $${tpPrice} (+${tpPct}%)`
);
}
// Try immediate check β maybe already filled
setTimeout(() => checkTrade(buyResult.orderId).catch(() => {}), 2000);
return { ...buyResult, tpPrice, tpPct };
}
// βββ Check single trade β fill β place TP βββββββββββββββ
async function checkTrade(buyOrderId) {
const trade = activeTrades.get(buyOrderId);
if (!trade || trade.status === 'TP_PLACED' || trade.status === 'CLOSED') return;
try {
const order = await trading.queryOrder(trade.symbol, buyOrderId);
if (order.status === 'FILLED' && trade.status === 'PENDING_FILL') {
trade.status = 'FILLED_PLACING_TP';
// Use actual fill price if available
const fillPrice = parseFloat(order.avgPrice) || trade.entryPrice;
trade.entryPrice = fillPrice;
trade.tpPrice = clampPrice(trade.symbol, fillPrice * (1 + trade.tpPct / 100));
const filledQty = parseFloat(order.executedQty) || trade.qty;
logger.info(`[TRADE_MGR] BUY filled: ${trade.symbol} @ $${fillPrice}. Placing TP SELL @ $${trade.tpPrice}`);
// Log open trade to journal
try {
await logOpenTrade({
tradeId: `tm-${buyOrderId}`,
symbol: trade.symbol,
side: 'BUY',
quantity: filledQty,
entryPrice: fillPrice,
entryTime: new Date().toISOString(),
source: trade.source || 'manual',
});
} catch (logErr) {
logger.error(`[TRADE_MGR] Journal log error: ${logErr.message}`);
}
try {
const tpResult = await trading.placeOrder({
symbol: trade.symbol,
side: 'SELL',
quantity: filledQty,
price: trade.tpPrice,
reduceOnly: true,
});
trade.tpOrderId = tpResult.orderId;
trade.status = 'TP_PLACED';
logger.info(`[TRADE_MGR] TP SELL placed: orderId=${tpResult.orderId} @ $${trade.tpPrice}`);
} catch (err) {
trade.status = 'ERROR';
logger.error(`[TRADE_MGR] Failed to place TP: ${err.response?.data?.msg || err.message}`);
}
} else if (order.status === 'CANCELED' || order.status === 'EXPIRED' || order.status === 'REJECTED') {
trade.status = 'CLOSED';
logger.info(`[TRADE_MGR] BUY order ${order.status}: ${trade.symbol}`);
}
} catch (err) {
logger.error(`[TRADE_MGR] checkTrade error: ${err.response?.data?.msg || err.message}`);
}
}
// βββ Check TP fills βββββββββββββββββββββββββββββββββββββ
async function checkTPFills() {
for (const [buyOrderId, trade] of activeTrades) {
if (trade.status !== 'TP_PLACED' || !trade.tpOrderId) continue;
try {
const order = await trading.queryOrder(trade.symbol, trade.tpOrderId);
if (order.status === 'FILLED') {
const sellPrice = parseFloat(order.avgPrice) || trade.tpPrice;
const pnl = (sellPrice - trade.entryPrice) * trade.qty;
const roi = ((sellPrice / trade.entryPrice) - 1) * 100;
trade.status = 'CLOSED';
trade.exitReason = 'TP';
const tag = trade.source === 'auto_gamma' ? 'π€' : 'π€';
logger.info(`[TRADE_MGR] ${tag} β
TP HIT! ${trade.symbol} entry=$${trade.entryPrice} exit=$${sellPrice} PnL=$${pnl.toFixed(4)} ROI=${roi.toFixed(0)}%`);
// Log closed trade to journal
try {
await logTrade({
tradeId: `tm-${buyOrderId}`,
symbol: trade.symbol,
side: 'BUY',
quantity: trade.qty,
entryPrice: trade.entryPrice,
exitPrice: sellPrice,
entryTime: trade.createdAt,
exitTime: new Date().toISOString(),
source: trade.source || 'manual',
exitReason: 'TP',
});
} catch (logErr) {
logger.error(`[TRADE_MGR] Journal close error: ${logErr.message}`);
}
// Push notification β TP hit
const parts = trade.symbol.split('-');
sendTradePush(
`${tag} β
TP HIT +${roi.toFixed(0)}%!`,
`${parts[0]} ${parts[3] === 'C' ? 'CALL' : 'PUT'} $${parts[2]}: $${trade.entryPrice} β $${sellPrice.toFixed(4)}. PnL $${pnl.toFixed(2)}`
);
} else if (order.status === 'CANCELED' || order.status === 'EXPIRED' || order.status === 'REJECTED') {
trade.status = 'CLOSED';
trade.exitReason = order.status;
const tag = trade.source === 'auto_gamma' ? 'π€' : 'π€';
logger.warn(`[TRADE_MGR] ${tag} TP order ${order.status}: ${trade.symbol}`);
// Push β TP cancelled/expired (likely option expired)
if (trade.source === 'auto_gamma') {
const parts = trade.symbol.split('-');
sendTradePush(
`${tag} β° Expired: ${parts[0]} ${parts[3] === 'C' ? 'CALL' : 'PUT'} $${parts[2]}`,
`TP order ${order.status}. Entry $${trade.entryPrice}, qty ${trade.qty}`
);
}
}
} catch (err) {
// ignore query errors silently
}
}
}
// βββ Monitor cycle (called from scheduler every 30s) ββββ
async function monitorTrades() {
if (!config.trading.enabled) return;
if (activeTrades.size === 0) return;
// Check pending BUY fills β place TP
for (const [buyOrderId, trade] of activeTrades) {
if (trade.status === 'PENDING_FILL') {
await checkTrade(buyOrderId);
}
}
// Check TP fills
await checkTPFills();
// Clean up closed trades older than 1 hour
const now = Date.now();
for (const [id, trade] of activeTrades) {
if (trade.status === 'CLOSED' && now - new Date(trade.createdAt).getTime() > 3600000) {
activeTrades.delete(id);
}
}
}
// βββ Get all trades (for API) βββββββββββββββββββββββββββ
function getTrades() {
return Array.from(activeTrades.values());
}
// Get exchange maxPrice for a symbol (used by scheduler for TP room check)
function getMaxPrice(symbol) {
const f = exchangeFilters[symbol];
return f ? f.maxPrice : null;
}
module.exports = {
openTradeWithTP,
monitorTrades,
getTrades,
clampPrice,
getMaxPrice,
MAX_POSITION_COST,
};