Bot & Automation

Treding Forex Ai

/root/hermes-projects/Treding Forex Ai

src/services/autoTradingService.ts text
import { randomUUID } from "node:crypto";
import type { Telegram } from "telegraf";
import type { ClosedPosition, OpenPosition, 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;
  maxConsecutiveLosses: number;
  lossCooldownSeconds: number;
  minReentryDelaySeconds: number;
  maxSpreadTargetRatio: number;
  minRewardRisk: number;
  maxConfigurablePositions: 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 lastEntryCandleBySymbol = new Map<string, string>();
  private readonly pendingScanSymbols = new Set<string>();
  private readonly knownOpenPositions = new Map<string, OpenPosition>();
  private readonly processedClosedTickets = new Set<string>();
  private readonly consecutiveLossesBySymbol = new Map<string, number>();
  private readonly lossBlockedUntilBySymbol = 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);
    this.config.maxOpenPositions = Math.min(
      this.config.maxConfigurablePositions,
      Math.max(1, Math.floor(this.config.maxOpenPositions))
    );
    const maxDailyLossAmount = this.config.dailyLossLimitPercent 
      ? this.tradingService.getAccountBalance() * (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({
        desiredExecutionMode: this.config.accountMode
      });
      for (const pos of positions) {
        await this.broker.closePosition(pos.ticket, pos.symbolCode, pos.entryPrice, {
          desiredExecutionMode: this.config.accountMode
        });
        closedCount++;
      }
    } catch (error) {
      console.error("[emergency] Gagal menutup posisi:", error);
    }
    return closedCount;
  }

  public start(force = false): boolean {
    if (this.scanTimer) {
      if (force && this.config.enabled) {
        void this.runScan();
      }
      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 getDashboardConfig(): Pick<
    AutoTradingConfig,
    "accountMode" | "symbols" | "strategy" | "lot"
  > {
    return {
      accountMode: this.config.accountMode,
      symbols: [...this.config.symbols],
      strategy: this.config.strategy,
      lot: this.config.lot
    };
  }

  public status(): string {
    return [
      `Enabled: ${this.config.enabled}`,
      `Symbols: ${this.config.symbols.join(", ")}`,
      `Mode: ${this.config.mode}`,
      `Account mode: ${this.config.accountMode ?? "demo"}`,
      `Strategy: ${this.config.strategy ?? "trend_scalping"}`,
      `Lot override: ${this.config.lot ?? "auto"}`,
      `TP override: ${this.config.targetProfitPips ?? "auto"} pips`,
      `SL override: ${this.config.stopLossPips ?? "auto"} pips`,
      `Continuous re-entry: ${this.shouldContinuousReentry()}`,
      `Minimum re-entry delay: ${this.config.minReentryDelaySeconds}s`,
      `Loss guard: ${this.config.maxConsecutiveLosses} losses / ${this.config.lossCooldownSeconds}s pause`,
      `Minimum reward:risk: ${this.config.minRewardRisk}`,
      `Maximum simultaneous positions: ${this.config.maxOpenPositions}`,
      `Max spread/target ratio: ${this.config.maxSpreadTargetRatio > 0 ? this.config.maxSpreadTargetRatio : "disabled"}`,
      `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> {
    this.queueScanSymbols();

    if (this.scanning) {
      return;
    }

    this.scanning = true;

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

    if (this.pendingScanSymbols.size > 0) {
      void this.runScan();
    }
  }

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

    const brokerStatus = await this.tradingService
      .getBrokerExecutionStatus(this.config.accountMode)
      .catch(() => null);
    if (brokerStatus) {
      if (!brokerStatus.connected) {
        console.log(`[scan] ${symbolCode} skip - bridge belum connected.`);
        return;
      }

      if (!brokerStatus.terminalTradeAllowed) {
        console.log(`[scan] ${symbolCode} skip - Algo Trading MT5 masih OFF. ${brokerStatus.detail}`);
        return;
      }

      if (!brokerStatus.accountTradeAllowed) {
        console.log(`[scan] ${symbolCode} skip - trading account MT5 belum diizinkan. ${brokerStatus.detail}`);
        return;
      }

      if (
        this.config.accountMode === "demo" &&
        (!brokerStatus.allowDemoTrading || brokerStatus.currentMode !== "demo")
      ) {
        console.log(`[scan] ${symbolCode} skip - mode demo belum siap. ${brokerStatus.detail}`);
        return;
      }

      if (
        this.config.accountMode === "real" &&
        (!brokerStatus.allowRealTrading || brokerStatus.currentMode !== "real")
      ) {
        console.log(`[scan] ${symbolCode} skip - mode real belum siap. ${brokerStatus.detail}`);
        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,
        executionMode: this.config.accountMode,
        strategyProfile: this.config.strategy,
        maxOpenTradesOverride: this.config.maxOpenPositions
      });

      if (!result.orderIntent) {
        const strategyReason = result.signal.reasons.at(-1) ?? "Alasan strategi tidak tersedia.";
        console.log(`[scan-reason] ${symbolCode}: ${strategyReason}`);
        console.log(
          `[scan] ${symbolCode} skip — ${result.signal.action} ${result.signal.confidence}% | ${result.riskNotes.join("; ")}`
        );
        return;
      }

      if (result.signal.dataWarning) {
        console.log(`[scan] ${symbolCode} skip - data warning: ${result.signal.dataWarning}`);
        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;
        orderIntent.takeProfit2 = orderIntent.takeProfit1;
      }

      const stopDistance = Math.abs(entry - orderIntent.stopLoss);
      const targetDistance = Math.abs(orderIntent.takeProfit1 - entry);
      const rewardRisk = stopDistance > 0 ? targetDistance / stopDistance : 0;
      if (this.config.minRewardRisk > 0 && rewardRisk < this.config.minRewardRisk) {
        console.log(
          `[scan] ${symbolCode} skip - reward:risk ${rewardRisk.toFixed(2)} < minimum ${this.config.minRewardRisk.toFixed(2)}.`
        );
        return;
      }
      orderIntent.riskRewardRatio = rewardRisk;

      const activeCandleKey = await this.getActiveM1CandleKey(symbolCode);
      if (activeCandleKey && this.lastEntryCandleBySymbol.get(symbolCode) === activeCandleKey) {
        console.log(`[scan] ${symbolCode} skip - candle M1 ${activeCandleKey} sudah dipakai entry.`);
        return;
      }

      const availableSlots = await this.resolveAvailableEntrySlots(symbolCode);
      if (availableSlots <= 0) {
        console.log(`[scan] ${symbolCode} skip - tidak ada slot posisi yang tersedia.`);
        return;
      }

      for (let index = 0; index < availableSlots; index += 1) {
        const batchOrder =
          index === 0
            ? orderIntent
            : {
                ...orderIntent,
                id: randomUUID(),
                positionSizing: { ...orderIntent.positionSizing },
                createdAt: new Date().toISOString()
              };

        await this.tradingService.savePendingOrder(batchOrder);
        const trade = await this.tradingService.executePendingOrder(batchOrder.id, {
          desiredExecutionMode: this.config.accountMode
        });
        this.lastExecutedAt.set(symbolCode, Date.now());
        if (activeCandleKey) {
          this.lastEntryCandleBySymbol.set(symbolCode, activeCandleKey);
        }

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

        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}`);
    }
  }

  private async resolveAvailableEntrySlots(symbolCode: string): Promise<number> {
    const positions = await this.broker.getPositions({
      desiredExecutionMode: this.config.accountMode
    });
    const openForSymbol = positions.filter(
      (position) => position.symbolCode === symbolCode
    ).length;
    return Math.max(0, this.config.maxOpenPositions - openForSymbol);
  }

  // ─── 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({
        desiredExecutionMode: this.config.accountMode
      });
    } catch (error) {
      const message = error instanceof Error ? error.message : "unknown";
      console.warn(`[monitor] getPositions failed: ${message}`);
      return;
    }

    await this.reconcileBrokerClosures(positions);
    for (const position of positions) {
      this.knownOpenPositions.set(position.ticket, position);
    }

    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, {
        executionMode: this.config.accountMode
      });
      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}`
    );

    // MT5 sudah mengelola SL/TP dengan bid/ask broker yang benar. Jangan tutup
    // berdasarkan candle close karena spread dapat membuat profit aktual lebih kecil.
    if (this.broker.usesBrokerManagedStops()) {
      return;
    }

    // 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 {
        const closeResult = await this.broker.closePosition(position.ticket, position.symbolCode, currentPrice, {
          desiredExecutionMode: this.config.accountMode
        });
        await this.tradingService.recordBrokerClosure(
          position.ticket,
          currentPrice,
          closeResult.pnl
        );

        this.knownOpenPositions.delete(position.ticket);
        this.processedClosedTickets.add(position.ticket);

        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)
          );
        }

        this.recordTradeOutcome(position.symbolCode, closeResult.pnl);
        this.lastExecutedAt.set(position.symbolCode, Date.now());

        if (tp1Hit && this.shouldContinuousReentry()) {
          console.log(
            `[monitor] ${position.symbolCode} fast-scalping re-entry triggered after ${reason}.`
          );
          this.queueScanSymbols(position.symbolCode);
          void this.runScan();
          return;
        }

      } catch (error) {
        const message = error instanceof Error ? error.message : "unknown";
        console.error(`[monitor] Failed to close ${position.symbolCode}: ${message}`);
      }
    }
  }

  private async reconcileBrokerClosures(currentPositions: OpenPosition[]): Promise<void> {
    const currentTickets = new Set(currentPositions.map((position) => position.ticket));

    for (const [ticket, position] of this.knownOpenPositions) {
      if (currentTickets.has(ticket) || this.processedClosedTickets.has(ticket)) {
        continue;
      }

      let closure: ClosedPosition | null;
      try {
        closure = await this.broker.getPositionClosure(ticket, {
          desiredExecutionMode: this.config.accountMode
        });
      } catch (error) {
        const message = error instanceof Error ? error.message : "unknown";
        console.warn(`[monitor] Failed to reconcile closed position ${ticket}: ${message}`);
        continue;
      }

      if (!closure) {
        continue;
      }

      this.knownOpenPositions.delete(ticket);
      this.processedClosedTickets.add(ticket);
      await this.handleBrokerClosure(position, closure);
    }
  }

  private async handleBrokerClosure(
    position: OpenPosition,
    closure: ClosedPosition
  ): Promise<void> {
    const reason = this.formatClosureReason(closure.reason);
    console.log(
      `[monitor] ${position.symbolCode} broker closed - ${reason} | P&L: ${closure.pnl >= 0 ? "+" : ""}${closure.pnl}`
    );
    await this.tradingService.recordBrokerClosure(
      position.ticket,
      closure.closePrice,
      closure.pnl
    );
    this.recordTradeOutcome(position.symbolCode, closure.pnl);
    this.lastExecutedAt.set(position.symbolCode, Date.now());

    const chatId = position.chatId || this.config.chatId;
    if (this.telegram && chatId) {
      await this.sendTelegramNotif(
        chatId,
        this.formatCloseNotif(position.symbolCode, reason, closure.closePrice, closure.pnl)
      );
    }

    if (closure.reason === "take_profit" && this.shouldContinuousReentry()) {
      console.log(
        `[monitor] ${position.symbolCode} fast-scalping re-entry triggered after broker TP.`
      );
      this.queueScanSymbols(position.symbolCode);
      void this.runScan();
      return;
    }
  }

  private recordTradeOutcome(symbolCode: string, pnl: number): void {
    if (pnl >= 0) {
      this.consecutiveLossesBySymbol.delete(symbolCode);
      return;
    }

    const losses = (this.consecutiveLossesBySymbol.get(symbolCode) ?? 0) + 1;
    this.consecutiveLossesBySymbol.set(symbolCode, losses);
    if (losses < this.config.maxConsecutiveLosses) {
      return;
    }

    const blockedUntil = Date.now() + this.config.lossCooldownSeconds * 1000;
    this.lossBlockedUntilBySymbol.set(symbolCode, blockedUntil);
    this.consecutiveLossesBySymbol.set(symbolCode, 0);
    console.warn(
      `[safety] ${symbolCode} paused ${this.config.lossCooldownSeconds}s after ${losses} consecutive losses.`
    );
  }

  private formatClosureReason(reason: ClosedPosition["reason"]): string {
    switch (reason) {
      case "take_profit":
        return "TP tercapai";
      case "stop_loss":
        return "SL hit";
      case "stop_out":
        return "Stop out";
      case "manual":
        return "Ditutup manual";
      default:
        return "Ditutup broker";
    }
  }

  // ─── 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.`] };
    }

    // MT5 wajib memakai spread bid/ask aktual. Paper mode tetap boleh memakai estimasi.
    let currentSpreadPips = symbol.defaultSpreadPips;

    try {
      const quote = await this.broker.getQuote(symbolCode, {
        desiredExecutionMode: this.config.accountMode
      });
      if (quote) {
        currentSpreadPips = quote.spreadPrice / symbol.pipSize;
      } else {
        const marketData = await this.marketDataService.getCandles(symbol, "M5", 20, {
          executionMode: this.config.accountMode
        });
        const lastCandle = marketData.candles.at(-1);
        if (lastCandle) {
          currentSpreadPips = SafetyFilter.estimateSpreadPips(
            lastCandle.high - lastCandle.low,
            symbol.pipSize,
            symbol.defaultSpreadPips
          );
        }
      }
    } catch (error) {
      if (this.broker.usesBrokerManagedStops()) {
        const message = error instanceof Error ? error.message : "unknown";
        return {
          safe: false,
          reasons: [`Spread aktual MT5 tidak tersedia (${message}) - entry diblok.`]
        };
      }
    }

    const spreadTargetLimit =
      this.config.targetProfitPips !== undefined && this.config.maxSpreadTargetRatio > 0
        ? this.config.targetProfitPips * this.config.maxSpreadTargetRatio
        : null;

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

    try {
      const positions = await this.broker.getPositions({
        desiredExecutionMode: this.config.accountMode
      });
      openCount = positions.filter((p) => p.symbolCode === symbolCode).length;
    } catch (error) {
      if (this.broker.usesBrokerManagedStops()) {
        const message = error instanceof Error ? error.message : "unknown";
        return {
          safe: false,
          reasons: [`Posisi MT5 tidak dapat diverifikasi (${message}) - entry diblok.`]
        };
      }
    }

    // Hitung daily loss secara dinamis dari store
    const todayLossAmount = this.config.chatId
      ? await this.tradingService.getTodayLoss(this.config.chatId)
      : 0;
 
    const result = this.safetyFilter.check(
      symbolCode,
      currentSpreadPips,
      todayLossAmount,
      openCount
    );
    if (spreadTargetLimit !== null && currentSpreadPips > spreadTargetLimit) {
      result.reasons.push(
        `Spread aktual ${currentSpreadPips.toFixed(1)} pips terlalu besar terhadap target ` +
          `${this.config.targetProfitPips ?? "auto"} pips (maks ${spreadTargetLimit.toFixed(1)}).`
      );
      result.safe = false;
    }
    return result;
  }

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

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

    if (lossBlockedUntil > Date.now()) {
      return true;
    }

    if (!lastExecution) {
      return false;
    }

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

  private async getActiveM1CandleKey(symbolCode: string): Promise<string | null> {
    const symbol = findMarketSymbol(symbolCode);

    if (!symbol) {
      return null;
    }

    try {
      const marketData = await this.marketDataService.getCandles(symbol, "M1", 2);
      return marketData.candles.at(-1)?.time ?? null;
    } catch {
      return null;
    }
  }

  private cooldownRemainingSeconds(symbolCode: string): number {
    const lastExecution = this.lastExecutedAt.get(symbolCode);
    const lossBlockedUntil = this.lossBlockedUntilBySymbol.get(symbolCode) ?? 0;
    const lossRemaining = Math.max(0, Math.ceil((lossBlockedUntil - Date.now()) / 1000));

    if (!lastExecution) {
      return lossRemaining;
    }

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

  private effectiveReentryDelaySeconds(): number {
    return Math.max(this.config.cooldownSeconds, this.config.minReentryDelaySeconds);
  }

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

  private shouldContinuousReentry(): boolean {
    return this.config.mode === "scalping" && this.config.strategy === "fast_scalping";
  }

  private queueScanSymbols(symbolCode?: string): void {
    const symbols = symbolCode ? [symbolCode] : this.config.symbols;
    for (const code of symbols) {
      this.pendingScanSymbols.add(code);
    }
  }

  private dequeueScanSymbols(): string[] {
    const ordered = [
      ...this.pendingScanSymbols,
      ...this.config.symbols.filter((code) => !this.pendingScanSymbols.has(code))
    ];
    this.pendingScanSymbols.clear();
    return [...new Set(ordered)];
  }

  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)}`;
    const followUpNote =
      pnl >= 0 && this.shouldContinuousReentry()
        ? "_Fast scalping aktif. Bot akan scan ulang segera untuk entry berikutnya._"
        : this.config.cooldownSeconds === 0
          ? "_Bot siap scan ulang segera._"
          : `_Cooldown aktif. Bot akan scan kembali setelah ${this.config.cooldownSeconds} detik._`;
    return [
      `${emoji} *AUTO CLOSE — ${symbol}*`,
      ``,
      `Reason: *${reason}*`,
      `Close Price: \`${closePrice}\``,
      `P&L: \`${pnlStr}\``,
      ``,
      followUpNote
    ].join("\n");
  }
}