← Back
β˜†
'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,
};

πŸ“œ Git History

e78fc62feat: auto-trading for Gamma Play + fix fake 100% WR bug10 weeks ago
2cffd08fix: tradeManager β€” clamp TP price to exchange maxPrice + tickSize3 months ago
7bd5a13feat: auto-TP trade manager + $5 position cap3 months ago
Show last diff
Loading...