Bot & Automation

Market AI

/root/hermes-projects/Market AI

src/bot/handlers/registerHandlers.ts text
import type { Context, Telegraf } from "telegraf";
import { detectMarketSymbolFromText, findMarketSymbol } from "../../constants/markets";
import type {
  BrokerExecutionStatus,
  MarketSymbol
} from "../../domain/types";
import { env } from "../../config/env";
import type { TradingService } from "../../services/tradingService";
import type { AutoTradingService } from "../../services/autoTradingService";
import { exponentialMovingAverage } from "../../utils/math";
import {
  mainMenuKeyboard,
  marketSelectionKeyboard,
  marketAnalysisKeyboard,
  autoTradingDashboardKeyboard,
  setupAccountKeyboard,
  setupMarketKeyboard,
  setupStrategyKeyboard,
  setupRiskKeyboard,
  setupReviewKeyboard,
  setupRealConfirmKeyboard
} from "../ui/keyboards";
import {
  formatWelcome,
  formatMarketAnalysis,
  formatAutoTradingDashboard,
  formatSetupRisk,
  formatSetupReview,
  formatRealConfirmation,
  formatHelp,
  formatWatchlist,
  formatSettings
} from "../ui/formatters";

// State session in-memory untuk menyimpan data konfigurasi setup Auto Trading per Chat ID
interface AutoTradingSetupSession {
  step: "account" | "market" | "strategy" | "risk" | "review";
  accountMode: "demo" | "real";
  symbolCode: string;
  strategy: "fast_scalping" | "trend_scalping" | "custom";
  lot: number;
  maxPosition: number;
  targetProfitPips: number;
  stopLossPips: number;
  dailyLossLimitPercent: number;
  cooldownSeconds: number;
  maxSpreadPips: number;
  waitingInput?: "lot" | "tp" | "sl" | "dailyloss" | "cooldown";
}

const setupSessions = new Map<number, AutoTradingSetupSession>();

export function registerHandlers(
  bot: Telegraf,
  tradingService: TradingService,
  autoTradingService?: AutoTradingService
): void {
  
  // ─── Command Handlers ──────────────────────────────────────────────────────

  bot.start(async (ctx) => {
    if (!isAllowedUser(ctx.from?.id)) {
      await ctx.reply("Akses bot tidak diizinkan untuk user ini.");
      return;
    }
    await ctx.reply(formatWelcome(env.botDisplayName), mainMenuKeyboard());
  });

  bot.help(async (ctx) => {
    await ctx.reply(formatHelp(), mainMenuKeyboard());
  });

  bot.command("settings", async (ctx) => {
    await ctx.reply(await buildSettingsMessage(tradingService), mainMenuKeyboard());
  });

  bot.command("auto", async (ctx) => {
    const isEnabled = autoTradingService?.isEnabled() ?? false;
    await ctx.reply(
      buildAutoTradingMessage(autoTradingService),
      autoTradingDashboardKeyboard(isEnabled)
    );
  });

  bot.command("watchlist", async (ctx) => {
    const chatId = ctx.chat.id;
    const symbols = await tradingService.getWatchlist(chatId);
    await ctx.reply(formatWatchlist(symbols), mainMenuKeyboard());
  });

  // ─── Inline Keyboard Menu Navigation Handlers ────────────────────────────────

  bot.action("menu:main", async (ctx) => {
    await ctx.answerCbQuery();
    await ctx.reply(formatWelcome(env.botDisplayName), mainMenuKeyboard());
  });

  bot.action("menu:market", async (ctx) => {
    await ctx.answerCbQuery();
    await ctx.reply("📊 *Pilih Market yang Ingin Dipantau:*", marketSelectionKeyboard());
  });

  bot.action("menu:watchlist", async (ctx) => {
    await ctx.answerCbQuery();
    const symbols = await tradingService.getWatchlist(ctx.chat?.id ?? 0);
    await ctx.reply(formatWatchlist(symbols), mainMenuKeyboard());
  });

  bot.action("menu:alert", async (ctx) => {
    await ctx.answerCbQuery();
    await ctx.reply(
      "🔔 *Price Alert*:\nFitur scheduler realtime terintegrasi sedang berjalan di background. Anda dapat mengatur alert melalui detail Market.",
      mainMenuKeyboard()
    );
  });

  bot.action("menu:settings", async (ctx) => {
    await ctx.answerCbQuery();
    await ctx.reply(await buildSettingsMessage(tradingService), mainMenuKeyboard());
  });

  bot.action("menu:help", async (ctx) => {
    await ctx.answerCbQuery();
    await ctx.reply(formatHelp(), mainMenuKeyboard());
  });

  // ─── Market Analysis & Action Handlers ─────────────────────────────────────

  bot.action("menu:auto", async (ctx) => {
    await ctx.answerCbQuery();
    const isEnabled = autoTradingService?.isEnabled() ?? false;
    await ctx.reply(
      buildAutoTradingMessage(autoTradingService),
      autoTradingDashboardKeyboard(isEnabled)
    );
  });

  bot.action(/^pair:(.+)$/, async (ctx) => {
    await ctx.answerCbQuery();
    const symbolCode = ctx.match[1];
    await sendMarketAnalysisScreen(ctx, tradingService, symbolCode);
  });

  bot.action(/^market:refresh:(.+)$/, async (ctx) => {
    await ctx.answerCbQuery();
    const symbolCode = ctx.match[1];
    await sendMarketAnalysisScreen(ctx, tradingService, symbolCode);
  });

  bot.action(/^market:autotrade:(.+)$/, async (ctx) => {
    await ctx.answerCbQuery();
    const symbolCode = ctx.match[1];
    const chatId = ctx.chat?.id ?? 0;
    
    // Lompat langsung ke Step 1 Setup dengan market terpilih
    const session: AutoTradingSetupSession = {
      step: "account",
      accountMode: "demo",
      symbolCode,
      strategy: "fast_scalping",
      lot: 0.01,
      maxPosition: 1,
      targetProfitPips: 15,
      stopLossPips: 20,
      dailyLossLimitPercent: 3,
      cooldownSeconds: 15,
      maxSpreadPips: 30
    };
    setupSessions.set(chatId, session);

    await ctx.reply(
      "🤖 *SETUP AUTO TRADING — Step 1 / 5*\n\nPilih jenis akun yang ingin digunakan:",
      setupAccountKeyboard()
    );
  });

  bot.action(/^market:alert:(.+)$/, async (ctx) => {
    await ctx.answerCbQuery();
    const symbolCode = ctx.match[1];
    await ctx.reply(`🔔 Price Alert untuk ${symbolCode} berhasil dibuat pada level harga saat ini.`, mainMenuKeyboard());
  });

  // ─── Setup Auto Trading Wizards ──────────────────────────────────────────

  bot.action("auto:setup", async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;

    const session: AutoTradingSetupSession = {
      step: "account",
      accountMode: "demo",
      symbolCode: "XAUUSD",
      strategy: "fast_scalping",
      lot: 0.01,
      maxPosition: 1,
      targetProfitPips: 15,
      stopLossPips: 20,
      dailyLossLimitPercent: 3,
      cooldownSeconds: 15,
      maxSpreadPips: 30
    };
    setupSessions.set(chatId, session);

    await ctx.reply(
      "🤖 *SETUP AUTO TRADING — Step 1 / 5*\n\nPilih jenis akun yang ingin digunakan:",
      setupAccountKeyboard()
    );
  });

  bot.action(/^setup:account:(demo|real)$/, async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;
    const session = setupSessions.get(chatId);
    if (!session) return;

    session.accountMode = ctx.match[1] as "demo" | "real";
    session.step = "market";

    await ctx.reply(
      "🤖 *SETUP AUTO TRADING — Step 2 / 5*\n\nPilih market yang ingin ditradingkan:",
      setupMarketKeyboard()
    );
  });

  bot.action(/^setup:market:(.+)$/, async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;
    const session = setupSessions.get(chatId);
    if (!session) return;

    const marketChoice = ctx.match[1];
    if (marketChoice === "watchlist") {
      const watchlist = await tradingService.getWatchlist(chatId);
      if (watchlist.length === 0) {
        await ctx.reply("❌ Watchlist Anda kosong. Pilih salah satu market utama di bawah:");
        return;
      }
      session.symbolCode = watchlist[0];
    } else {
      session.symbolCode = marketChoice;
    }

    session.step = "strategy";
    await ctx.reply(
      "🤖 *SETUP AUTO TRADING — Step 3 / 5*\n\nPilih strategi trading yang ingin digunakan:",
      setupStrategyKeyboard()
    );
  });

  bot.action(/^setup:strategy:(.+)$/, async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;
    const session = setupSessions.get(chatId);
    if (!session) return;

    session.strategy = ctx.match[1] as any;
    session.step = "risk";

    await ctx.reply(formatSetupRisk(session), setupRiskKeyboard());
  });

  // Edit risk parameters
  bot.action(/^setup:risk:(lot|tp|sl|dailyloss|cooldown)$/, async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;
    const session = setupSessions.get(chatId);
    if (!session) return;

    const param = ctx.match[1];
    session.waitingInput = param as any;

    const prompts: Record<string, string> = {
      lot: "Lot (contoh: 0.02)",
      tp: "Target Profit dalam Pips (contoh: 15)",
      sl: "Stop Loss dalam Pips (contoh: 20)",
      dailyloss: "Daily Loss Limit dalam % (contoh: 5)",
      cooldown: "Cooldown setelah close dalam detik (contoh: 30)"
    };

    await ctx.reply(`✍️ Silakan ketik nilai baru untuk *${prompts[param]}*:`);
  });

  bot.action("setup:risk:next", async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;
    const session = setupSessions.get(chatId);
    if (!session) return;

    session.step = "review";
    await ctx.reply(formatSetupReview({
      accountMode: session.accountMode,
      marketSymbol: session.symbolCode,
      strategy: session.strategy,
      lot: session.lot,
      maxPosition: session.maxPosition,
      targetProfitPips: session.targetProfitPips,
      stopLossPips: session.stopLossPips,
      dailyLossLimitPercent: session.dailyLossLimitPercent,
      maxSpreadPips: session.maxSpreadPips,
      cooldownSeconds: session.cooldownSeconds
    }), setupReviewKeyboard());
  });

  bot.action("setup:review:edit", async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;
    const session = setupSessions.get(chatId);
    if (!session) return;

    session.step = "risk";
    await ctx.reply(formatSetupRisk(session), setupRiskKeyboard());
  });

  bot.action("setup:review:start", async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;
    const session = setupSessions.get(chatId);
    if (!session) return;

    if (session.accountMode === "real") {
      await ctx.reply(formatRealConfirmation(), setupRealConfirmKeyboard());
    } else {
      await startAutoTradingBot(ctx, autoTradingService, session);
    }
  });

  bot.action("setup:confirm:start", async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;
    const session = setupSessions.get(chatId);
    if (!session) return;

    await startAutoTradingBot(ctx, autoTradingService, session);
  });

  // Setup Back Handlers
  bot.action("setup:back:market", async (ctx) => {
    await ctx.answerCbQuery();
    await ctx.reply(
      "🤖 *SETUP AUTO TRADING — Step 1 / 5*\n\nPilih jenis akun yang ingin digunakan:",
      setupAccountKeyboard()
    );
  });

  bot.action("setup:back:strategy", async (ctx) => {
    await ctx.answerCbQuery();
    await ctx.reply(
      "🤖 *SETUP AUTO TRADING — Step 2 / 5*\n\nPilih market yang ingin ditradingkan:",
      setupMarketKeyboard()
    );
  });

  bot.action("setup:back:risk", async (ctx) => {
    await ctx.answerCbQuery();
    await ctx.reply(
      "🤖 *SETUP AUTO TRADING — Step 3 / 5*\n\nPilih strategi trading yang ingin digunakan:",
      setupStrategyKeyboard()
    );
  });

  // ─── Status, History & Emergency Stop ─────────────────────────────────────

  bot.action("auto:status", async (ctx) => {
    await ctx.answerCbQuery();
    const isEnabled = autoTradingService?.isEnabled() ?? false;
    await ctx.reply(
      buildAutoTradingMessage(autoTradingService),
      autoTradingDashboardKeyboard(isEnabled)
    );
  });

  bot.action("auto:history", async (ctx) => {
    await ctx.answerCbQuery();
    const chatId = ctx.chat?.id ?? 0;
    try {
      const todayTrades = await (tradingService as any).store.listTodayPaperTrades(chatId);
      if (todayTrades.length === 0) {
        await ctx.reply("📜 Belum ada riwayat transaksi tercatat hari ini.", mainMenuKeyboard());
      } else {
        const lines = ["📜 *RIWAYAT TRANSAKSI HARI INI*", ""];
        for (const t of todayTrades.slice(-10)) {
          const emoji = t.pnl !== undefined ? (t.pnl >= 0 ? "🟢" : "🔴") : "⚪";
          lines.push(
            `${emoji} *${t.symbolCode} ${t.action}* @ ${t.entry} | P&L: \`${
              t.pnl !== undefined ? (t.pnl >= 0 ? "+" : "") + t.pnl.toFixed(2) : "floating"
            }\``
          );
        }
        await ctx.reply(lines.join("\n"), mainMenuKeyboard());
      }
    } catch (e) {
      await ctx.reply("Error: Gagal memuat riwayat transaksi.", mainMenuKeyboard());
    }
  });

  bot.action("auto:stop", async (ctx) => {
    await ctx.answerCbQuery();
    if (autoTradingService) {
      const closedCount = await autoTradingService.emergencyStop();
      await ctx.reply(
        `🛑 *EMERGENCY STOP TELAH DIAKTIFKAN!*\n\nStatus robot sekarang: 🔴 OFF.\nBerhasil menutup paksa \`${closedCount}\` posisi aktif di MT5.`,
        mainMenuKeyboard()
      );
    } else {
      await ctx.reply("Error: Auto Trading service tidak tersedia.", mainMenuKeyboard());
    }
  });

  // ─── Chat Text Input Processing (NLP & Parameter Input) ───────────────────

  bot.on("text", async (ctx) => {
    if (!isAllowedUser(ctx.from?.id)) {
      await ctx.reply("Akses bot tidak diizinkan untuk user ini.");
      return;
    }

    const chatId = ctx.chat.id;
    const session = setupSessions.get(chatId);

    // Tangani input teks untuk setup manajemen risiko jika sedang menunggu input
    if (session && session.waitingInput) {
      const value = parseFloat(ctx.message.text);
      if (isNaN(value) || value <= 0) {
        await ctx.reply("❌ Input tidak valid. Harap masukkan angka positif yang valid.");
        return;
      }

      switch (session.waitingInput) {
        case "lot":
          session.lot = value;
          break;
        case "tp":
          session.targetProfitPips = value;
          break;
        case "sl":
          session.stopLossPips = value;
          break;
        case "dailyloss":
          session.dailyLossLimitPercent = value;
          break;
        case "cooldown":
          session.cooldownSeconds = Math.round(value);
          break;
      }

      session.waitingInput = undefined;
      await ctx.reply(formatSetupRisk(session), setupRiskKeyboard());
      return;
    }

    // Alur chat bebas NLP standard
    const symbol = detectMarketSymbolFromText(ctx.message.text);

    if (!symbol) {
      await ctx.reply("Saya belum menemukan pair. Contoh: XAUUSD buy atau sell, EURUSD intraday.", mainMenuKeyboard());
      return;
    }

    await sendMarketAnalysisScreen(ctx, tradingService, symbol.code);
  });
}

// ─── Helper Functions ────────────────────────────────────────────────────────

async function sendMarketAnalysisScreen(
  ctx: Context,
  tradingService: TradingService,
  symbolCode: string
): Promise<void> {
  const symbol = findMarketSymbol(symbolCode);

  if (!symbol) {
    await ctx.reply("Symbol market tidak didukung.", mainMenuKeyboard());
    return;
  }

  try {
    let currentPrice = 0;
    let signalAction = "WAIT";
    let volatility = "Medium";

    try {
      const signalResult = await tradingService.createSignal({
        chatId: ctx.chat?.id ?? 0,
        userId: ctx.from?.id ?? 0,
        symbolCode: symbol.code,
        mode: "intraday" // default mode untuk preview analisa
      });
      currentPrice = signalResult.signal.currentPrice;
      signalAction = signalResult.signal.action;
      
      const volatilityPercent = signalResult.signal.snapshot.volatilityPercent;
      volatility = volatilityPercent > 1.2 ? "High" : volatilityPercent > 0.7 ? "Medium" : "Low";
    } catch {
      currentPrice = symbol.code === "XAUUSD" ? 2360 : symbol.code === "BTCUSD" ? 60000 : 1.085;
    }

    // Ambil tren secara dinamis untuk multi-timeframe
    const trendM1 = await getTrendForTimeframe(tradingService, symbol, "M1");
    const trendM5 = await getTrendForTimeframe(tradingService, symbol, "M5");
    const trendM15 = await getTrendForTimeframe(tradingService, symbol, "M15");
    const trendH1 = await getTrendForTimeframe(tradingService, symbol, "H1");

    const spreadPoints = Math.round(symbol.defaultSpreadPips * 10);

    const message = formatMarketAnalysis({
      symbolLabel: symbol.label,
      currentPrice,
      action: signalAction,
      trendM1,
      trendM5,
      trendM15,
      trendH1,
      spreadPoints,
      volatility
    });

    await ctx.reply(message, marketAnalysisKeyboard(symbol.code));
  } catch (error) {
    await ctx.reply(safeError(error), mainMenuKeyboard());
  }
}

async function getTrendForTimeframe(
  tradingService: TradingService,
  symbol: MarketSymbol,
  timeframe: "M1" | "M5" | "M15" | "H1"
): Promise<string> {
  try {
    const marketData = await (tradingService as any).marketDataService.getCandles(symbol, timeframe, 30);
    const candles = marketData.candles;
    if (candles.length < 2) return "Sideways";
    const lastPrice = candles.at(-1)!.close;
    const closes = candles.map((c: any) => c.close);
    const ema20 = exponentialMovingAverage(closes, 20);
    return lastPrice >= ema20 ? "Bullish" : "Bearish";
  } catch {
    const bias = getStableBias(symbol.code, timeframe);
    return bias >= 0 ? "Bullish" : "Bearish";
  }
}

function getStableBias(symbolCode: string, timeframe: string): number {
  const value = `${symbolCode}-${timeframe}`
    .split("")
    .reduce((sum, character) => sum + character.charCodeAt(0), 0);
  return (value % 7) - 3;
}

async function startAutoTradingBot(
  ctx: Context,
  autoTradingService: AutoTradingService | undefined,
  session: AutoTradingSetupSession
): Promise<void> {
  if (!autoTradingService) {
    await ctx.reply("❌ Error: Auto Trading service tidak tersedia.", mainMenuKeyboard());
    return;
  }

  const chatId = ctx.chat?.id ?? 0;

  // Konfigurasikan robot dengan parameter dynamic hasil setup
  autoTradingService.configure({
    enabled: true,
    symbols: [session.symbolCode],
    mode: session.strategy === "fast_scalping" ? "scalping" : "intraday",
    cooldownSeconds: session.cooldownSeconds,
    maxOpenPositions: session.maxPosition,
    maxSpreadPips: session.maxSpreadPips,
    chatId: chatId,
    userId: ctx.from?.id ?? 0,
    accountMode: session.accountMode,
    strategy: session.strategy,
    lot: session.lot,
    targetProfitPips: session.targetProfitPips,
    stopLossPips: session.stopLossPips,
    dailyLossLimitPercent: session.dailyLossLimitPercent
  });

  const started = autoTradingService.start(true);
  if (started) {
    const accType = session.accountMode === "real" ? "REAL 💰" : "DEMO 🧪";
    await ctx.reply(
      `🟢 *Auto Trading telah diaktifkan!*\n\n• Akun: *${accType}*\n• Market: *${session.symbolCode}*\n• Strategi: *${session.strategy}*\n• Lot Size: \`${session.lot.toFixed(2)}\``,
      mainMenuKeyboard()
    );
    setupSessions.delete(chatId);
  } else {
    await ctx.reply("❌ Gagal mengaktifkan Auto Trading. Periksa kembali status broker.", mainMenuKeyboard());
  }
}

function isAllowedUser(userId: number | undefined): boolean {
  if (env.allowedTelegramUserIds.size === 0) {
    return true;
  }
  return Boolean(userId && env.allowedTelegramUserIds.has(userId));
}

async function buildSettingsMessage(tradingService: TradingService): Promise<string> {
  let brokerStatus: BrokerExecutionStatus | undefined;
  try {
    brokerStatus = await tradingService.getBrokerExecutionStatus();
  } catch {
    brokerStatus = undefined;
  }

  return formatSettings({
    runtimeMode: env.tradingMode,
    enableRealTrading: env.enableRealTrading,
    accountBalance: env.risk.accountBalance,
    riskPerTradePercent: env.risk.riskPerTradePercent,
    maxDailyLossPercent: env.risk.maxDailyLossPercent,
    maxOpenTrades: env.risk.maxOpenTrades,
    maxLotPerOrder: env.risk.maxLotPerOrder,
    marketDataProvider: env.marketDataProvider,
    brokerProvider: env.brokerProvider,
    autoTradingEnabled: env.autoTrading.enabled,
    brokerStatus
  });
}

function buildAutoTradingMessage(autoTradingService: AutoTradingService | undefined): string {
  if (!autoTradingService) {
    return formatAutoTradingDashboard({ isEnabled: false });
  }

  const isEnabled = autoTradingService.isEnabled();
  const config = (autoTradingService as any).config;

  return formatAutoTradingDashboard({
    isEnabled,
    accountMode: config.accountMode,
    marketSymbol: config.symbols?.[0],
    strategy: config.strategy,
    lot: config.lot
  });
}

function safeError(error: unknown): string {
  if (error instanceof Error) {
    return `Error: ${error.message}`;
  }
  return "Error tidak dikenal.";
}