Bot & Automation

Market AI

/root/hermes-projects/Market AI

src/services/autoTradingService.ts text
import type { Telegram } from "telegraf";
import type { TradingMode } from "../domain/types";
import type { TradingService } from "./tradingService";
import type { BrokerAdapter } from "./broker/broker";
import type { MarketDataService } from "./market-data/marketDataService";
import { SafetyFilter, type SafetyFilterConfig } from "./safetyFilter";
import { findMarketSymbol } from "../constants/markets";

export interface AutoTradingConfig {
  enabled: boolean;
  symbols: string[];
  mode: TradingMode;
  scanIntervalSeconds: number;
  cooldownSeconds: number;
  monitorIntervalSeconds: number;
  minConfidence: number;
  maxSpreadPips: number;
  skipRolloverWindow: boolean;
  maxOpenPositions: number;
  chatId?: number;
  userId?: number;
  
  // Setup fields
  accountMode?: "demo" | "real";
  strategy?: "fast_scalping" | "trend_scalping" | "custom";
  lot?: number;
  targetProfitPips?: number;
  stopLossPips?: number;
  dailyLossLimitPercent?: number;
}

export class AutoTradingService {
  private scanTimer: ReturnType<typeof setInterval> | null = null;
  private monitorTimer: ReturnType<typeof setInterval> | null = null;
  private scanning = false;
  private monitoring = false;
  private readonly lastExecutedAt = new Map<string, number>();
  private readonly safetyFilter: SafetyFilter;

  public constructor(
    private readonly tradingService: TradingService,
    private readonly broker: BrokerAdapter,
    private readonly marketDataService: MarketDataService,
    private readonly config: AutoTradingConfig,
    private readonly telegram?: Telegram
  ) {
    this.safetyFilter = new SafetyFilter({
      maxSpreadPips: config.maxSpreadPips,
      skipRolloverWindow: config.skipRolloverWindow,
      maxDailyLossAmount: 0,
      maxOpenPositions: config.maxOpenPositions
    } satisfies SafetyFilterConfig);
  }

  public configure(newConfig: Partial<AutoTradingConfig>): void {
    Object.assign(this.config, newConfig);
    const riskConfig = (this.tradingService as any).riskManager.riskConfig;
    const maxDailyLossAmount = this.config.dailyLossLimitPercent 
      ? riskConfig.accountBalance * (this.config.dailyLossLimitPercent / 100)
      : 0;
    
    this.safetyFilter.updateConfig({
      maxSpreadPips: this.config.maxSpreadPips,
      maxOpenPositions: this.config.maxOpenPositions,
      maxDailyLossAmount
    });
  }

  public async emergencyStop(): Promise<number> {
    this.stop();
    let closedCount = 0;
    try {
      const positions = await this.broker.getPositions();
      for (const pos of positions) {
        await this.broker.closePosition(pos.ticket, pos.symbolCode, pos.entryPrice);
        closedCount++;
      }
    } catch (error) {
      console.error("[emergency] Gagal menutup posisi:", error);
    }
    return closedCount;
  }

  public start(force = false): boolean {
    if (this.scanTimer) {
      return true;
    }

    if (force) {
      this.config.enabled = true;
    }

    if (!this.config.enabled) {
      console.log("[auto] Auto trading disabled by env.");
      return false;
    }

    if (!this.config.chatId || !this.config.userId) {
      console.warn("[auto] Disabled: AUTO_TRADE_CHAT_ID/AUTO_TRADE_USER_ID belum valid.");
      this.config.enabled = false;
      return false;
    }

    // --- Scan loop: cek sinyal & entry baru ---
    void this.runScan();
    this.scanTimer = setInterval(() => {
      void this.runScan();
    }, this.config.scanIntervalSeconds * 1000);

    // --- Monitor loop: pantau floating P&L & auto-close ---
    void this.runMonitor();
    this.monitorTimer = setInterval(() => {
      void this.runMonitor();
    }, this.config.monitorIntervalSeconds * 1000);

    console.log(
      `[auto] Started — symbols: ${this.config.symbols.join(", ")} | scan: ${this.config.scanIntervalSeconds}s | monitor: ${this.config.monitorIntervalSeconds}s | cooldown: ${this.config.cooldownSeconds}s`
    );

    return true;
  }

  public stop(): void {
    this.config.enabled = false;

    if (this.scanTimer) {
      clearInterval(this.scanTimer);
      this.scanTimer = null;
    }

    if (this.monitorTimer) {
      clearInterval(this.monitorTimer);
      this.monitorTimer = null;
    }

    console.log("[auto] Auto trading stopped.");
  }

  public isEnabled(): boolean {
    return this.config.enabled;
  }

  public status(): string {
    return [
      `Enabled: ${this.config.enabled}`,
      `Symbols: ${this.config.symbols.join(", ")}`,
      `Mode: ${this.config.mode}`,
      `Scan interval: ${this.config.scanIntervalSeconds}s`,
      `Monitor interval: ${this.config.monitorIntervalSeconds}s`,
      `Cooldown: ${this.config.cooldownSeconds}s`,
      `Min confidence: ${this.config.minConfidence}%`,
      `Max spread: ${this.config.maxSpreadPips} pips`,
      `Skip rollover: ${this.config.skipRolloverWindow}`,
      `Chat configured: ${Boolean(this.config.chatId)}`,
      `User configured: ${Boolean(this.config.userId)}`
    ].join("\n");
  }

  // ─── Scan Loop ────────────────────────────────────────────────────────────

  private async runScan(): Promise<void> {
    if (this.scanning) {
      return;
    }

    this.scanning = true;

    try {
      for (const symbolCode of this.config.symbols) {
        await this.scanSymbol(symbolCode);
      }
    } finally {
      this.scanning = false;
    }
  }

  private async scanSymbol(symbolCode: string): Promise<void> {
    if (!this.config.chatId || !this.config.userId) {
      return;
    }

    // 1. Cek cooldown
    if (this.isCoolingDown(symbolCode)) {
      const remaining = this.cooldownRemainingSeconds(symbolCode);
      console.log(`[scan] ${symbolCode} cooldown — ${remaining}s remaining.`);
      return;
    }

    // 2. Cek safety filter sebelum analisis
    const safetyResult = await this.runSafetyCheck(symbolCode);

    if (!safetyResult.safe) {
      console.log(`[scan] ${symbolCode} safety filter blocked: ${safetyResult.reasons.join("; ")}`);
      return;
    }

    // 3. Analisis sinyal
    try {
      const result = await this.tradingService.createSignal({
        chatId: this.config.chatId,
        userId: this.config.userId,
        symbolCode,
        mode: this.config.mode
      });

      if (!result.orderIntent) {
        console.log(
          `[scan] ${symbolCode} skip — ${result.signal.action} ${result.signal.confidence}% | ${result.riskNotes.join("; ")}`
        );
        return;
      }

      if (result.signal.confidence < this.config.minConfidence) {
        console.log(
          `[scan] ${symbolCode} skip — confidence ${result.signal.confidence}% < min ${this.config.minConfidence}%`
        );
        return;
      }

      // Terapkan penyesuaian parameter dinamis hasil setup pengguna
      const orderIntent = result.orderIntent;
      const symbol = findMarketSymbol(symbolCode)!;

      if (this.config.lot !== undefined) {
        orderIntent.positionSizing.lotSize = this.config.lot;
      }

      const action = orderIntent.action;
      const entry = orderIntent.entry;

      if (this.config.stopLossPips !== undefined) {
        const stopDistance = this.config.stopLossPips * symbol.pipSize;
        orderIntent.stopLoss = action === "BUY" ? entry - stopDistance : entry + stopDistance;
        orderIntent.positionSizing.stopDistancePips = this.config.stopLossPips;
      }

      if (this.config.targetProfitPips !== undefined) {
        const tpDistance = this.config.targetProfitPips * symbol.pipSize;
        orderIntent.takeProfit1 = action === "BUY" ? entry + tpDistance : entry - tpDistance;
      }

      // Simpan perubahan pending order agar dibaca dengan benar oleh adapter broker
      await (this.tradingService as any).store.savePendingOrder(orderIntent);

      // 4. Entry market dengan pilihan akun Demo / Real
      const trade = await this.tradingService.executePendingOrder(orderIntent.id, {
        desiredExecutionMode: this.config.accountMode
      });
      this.lastExecutedAt.set(symbolCode, Date.now());

      console.log(
        `[scan] ${symbolCode} ENTRY ${trade.action} @ ${trade.entry} | SL: ${trade.stopLoss} | TP1: ${trade.takeProfit1} | lot: ${trade.positionSizing.lotSize} | broker: ${trade.broker}`
      );

      // 5. Kirim notifikasi Telegram
      if (this.telegram && this.config.chatId) {
        await this.sendTelegramNotif(
          this.config.chatId,
          this.formatEntryNotif(trade.symbolCode, trade.action, trade.entry, trade.stopLoss, trade.takeProfit1, trade.positionSizing.lotSize, result.signal.confidence)
        );
      }
    } catch (error) {
      const message = error instanceof Error ? error.message : "unknown error";
      console.error(`[scan] ${symbolCode} failed: ${message}`);
    }
  }

  // ─── Monitor Loop ──────────────────────────────────────────────────────────

  private async runMonitor(): Promise<void> {
    if (this.monitoring) {
      return;
    }

    this.monitoring = true;

    try {
      await this.monitorPositions();
    } finally {
      this.monitoring = false;
    }
  }

  private async monitorPositions(): Promise<void> {
    let positions: Awaited<ReturnType<BrokerAdapter["getPositions"]>>;

    try {
      positions = await this.broker.getPositions();
    } catch (error) {
      const message = error instanceof Error ? error.message : "unknown";
      console.warn(`[monitor] getPositions failed: ${message}`);
      return;
    }

    if (positions.length === 0) {
      return;
    }

    console.log(`[monitor] Checking ${positions.length} open position(s)...`);

    for (const position of positions) {
      await this.checkPositionClose(position);
    }
  }

  private async checkPositionClose(
    position: Awaited<ReturnType<BrokerAdapter["getPositions"]>>[number]
  ): Promise<void> {
    // Ambil harga live untuk hitung floating P&L
    const symbol = findMarketSymbol(position.symbolCode);

    if (!symbol) {
      return;
    }

    let currentPrice: number;

    try {
      const marketData = await this.marketDataService.getCandles(symbol, "M1", 2);
      const lastCandle = marketData.candles.at(-1);

      if (!lastCandle) {
        return;
      }

      currentPrice = lastCandle.close;
    } catch {
      return;
    }

    // Hitung floating P&L
    const priceDiff =
      position.action === "BUY"
        ? currentPrice - position.entryPrice
        : position.entryPrice - currentPrice;

    const floatingPnl = Math.round(priceDiff * position.lotSize * 100 * 100) / 100;

    console.log(
      `[monitor] ${position.symbolCode} ${position.action} @ ${position.entryPrice} | now: ${currentPrice} | P&L: ${floatingPnl >= 0 ? "+" : ""}${floatingPnl}`
    );

    // Cek apakah harga sudah mencapai TP1
    const tp1Hit =
      position.action === "BUY"
        ? currentPrice >= position.takeProfit1
        : currentPrice <= position.takeProfit1;

    // Cek SL hit (safety net — seharusnya broker sudah handle ini)
    const slHit =
      position.action === "BUY"
        ? currentPrice <= position.stopLoss
        : currentPrice >= position.stopLoss;

    if (tp1Hit || slHit) {
      const reason = tp1Hit ? "TP1 tercapai" : "SL hit";

      try {
        await this.broker.closePosition(position.ticket, position.symbolCode, currentPrice);

        console.log(
          `[monitor] ${position.symbolCode} CLOSED — ${reason} | P&L: ${floatingPnl >= 0 ? "+" : ""}${floatingPnl}`
        );

        // Kirim notifikasi Telegram
        const chatId = position.chatId || this.config.chatId;

        if (this.telegram && chatId) {
          await this.sendTelegramNotif(
            chatId,
            this.formatCloseNotif(position.symbolCode, reason, currentPrice, floatingPnl)
          );
        }

        // Set cooldown setelah close
        this.lastExecutedAt.set(position.symbolCode, Date.now());
      } catch (error) {
        const message = error instanceof Error ? error.message : "unknown";
        console.error(`[monitor] Failed to close ${position.symbolCode}: ${message}`);
      }
    }
  }

  // ─── Safety Check ──────────────────────────────────────────────────────────

  private async runSafetyCheck(
    symbolCode: string
  ): Promise<{ safe: boolean; reasons: string[] }> {
    const symbol = findMarketSymbol(symbolCode);

    if (!symbol) {
      return { safe: false, reasons: [`Symbol ${symbolCode} tidak dikenal.`] };
    }

    // Estimasi spread dari data terbaru
    let estimatedSpreadPips = symbol.defaultSpreadPips;

    try {
      const marketData = await this.marketDataService.getCandles(symbol, "M5", 20);
      const candles = marketData.candles;

      if (candles.length >= 2) {
        const lastCandle = candles.at(-1)!;
        const atrProxy = lastCandle.high - lastCandle.low; // proxy ATR 1 candle
        estimatedSpreadPips = SafetyFilter.estimateSpreadPips(
          atrProxy,
          symbol.pipSize,
          symbol.defaultSpreadPips
        );
      }
    } catch {
      // Jika gagal ambil data, pakai default spread
    }

    // Hitung open positions untuk simbol ini
    let openCount = 0;

    try {
      const positions = await this.broker.getPositions();
      openCount = positions.filter((p) => p.symbolCode === symbolCode).length;
    } catch {
      // Jika gagal, anggap 0
    }

    // Hitung daily loss secara dinamis dari store
    const todayLossAmount = this.config.chatId
      ? await this.tradingService.getTodayLoss(this.config.chatId)
      : 0;
 
    return this.safetyFilter.check(symbolCode, estimatedSpreadPips, todayLossAmount, openCount);
  }

  // ─── Cooldown ──────────────────────────────────────────────────────────────

  private isCoolingDown(symbolCode: string): boolean {
    const lastExecution = this.lastExecutedAt.get(symbolCode);

    if (!lastExecution) {
      return false;
    }

    return Date.now() - lastExecution < this.config.cooldownSeconds * 1000;
  }

  private cooldownRemainingSeconds(symbolCode: string): number {
    const lastExecution = this.lastExecutedAt.get(symbolCode);

    if (!lastExecution) {
      return 0;
    }

    const elapsed = (Date.now() - lastExecution) / 1000;
    return Math.max(0, Math.ceil(this.config.cooldownSeconds - elapsed));
  }

  // ─── Telegram Notif ────────────────────────────────────────────────────────

  private async sendTelegramNotif(chatId: number, message: string): Promise<void> {
    try {
      await this.telegram?.sendMessage(chatId, message, { parse_mode: "Markdown" });
    } catch (error) {
      const msg = error instanceof Error ? error.message : "unknown";
      console.warn(`[auto] Telegram notif failed: ${msg}`);
    }
  }

  private formatEntryNotif(
    symbol: string,
    action: string,
    entry: number,
    sl: number | null,
    tp1: number | null,
    lot: number,
    confidence: number
  ): string {
    const emoji = action === "BUY" ? "🟢" : "🔴";
    return [
      `${emoji} *AUTO ENTRY — ${symbol}*`,
      ``,
      `Action: *${action}*`,
      `Entry: \`${entry}\``,
      `Stop Loss: \`${sl ?? "-"}\``,
      `Take Profit 1: \`${tp1 ?? "-"}\``,
      `Lot Size: \`${lot}\``,
      `Confidence: \`${confidence}%\``,
      ``,
      `_Bot auto-entry berdasarkan sinyal AI. Selalu gunakan risk management._`
    ].join("\n");
  }

  private formatCloseNotif(
    symbol: string,
    reason: string,
    closePrice: number,
    pnl: number
  ): string {
    const emoji = pnl >= 0 ? "✅" : "❌";
    const pnlStr = `${pnl >= 0 ? "+" : ""}${pnl.toFixed(2)}`;
    return [
      `${emoji} *AUTO CLOSE — ${symbol}*`,
      ``,
      `Reason: *${reason}*`,
      `Close Price: \`${closePrice}\``,
      `P&L: \`${pnlStr}\``,
      ``,
      `_Cooldown aktif. Bot akan scan kembali setelah ${this.config.cooldownSeconds} detik._`
    ].join("\n");
  }
}