Bot & Automation

Treding Forex Ai

/root/hermes-projects/Treding Forex Ai

src/services/strategy/forexStrategy.ts text
import { randomUUID } from "node:crypto";
import { TRADING_MODE_CONFIGS } from "../../constants/risk";
import type { MarketDataService } from "../market-data/marketDataService";
import type {
  Candle,
  BrokerExecutionMode,
  MarketSymbol,
  TechnicalSnapshot,
  Timeframe,
  TradingMode,
  TradingSignal
} from "../../domain/types";
import {
  averageTrueRange,
  clamp,
  exponentialMovingAverage,
  macdHistogram,
  relativeStrengthIndex,
  roundTo,
  supportResistance
} from "../../utils/math";

interface TimeframeAnalysis {
  timeframe: Timeframe;
  candles: Candle[];
  closes: number[];
  currentPrice: number;
  snapshot: TechnicalSnapshot;
}

interface ScalpingDecision {
  action: "BUY" | "SELL" | "WAIT";
  confidence: number;
  trend: string;
  reasons: string[];
}

type ScalpingProfile = "fast_scalping" | "trend_scalping" | "custom";

interface StrategyOptions {
  executionMode?: BrokerExecutionMode;
  scalpingProfile?: ScalpingProfile;
}

export class ForexStrategy {
  public constructor(private readonly marketDataService: MarketDataService) {}

  public getCandles(
    symbol: MarketSymbol,
    timeframe: Timeframe,
    limit: number,
    executionMode?: BrokerExecutionMode
  ) {
    return this.marketDataService.getCandles(symbol, timeframe, limit, { executionMode });
  }

  public async analyze(
    symbol: MarketSymbol,
    mode: TradingMode,
    options?: StrategyOptions
  ): Promise<TradingSignal> {
    const modeConfig = TRADING_MODE_CONFIGS[mode];

    if (mode === "scalping") {
      return this.analyzeScalping(symbol, mode, options);
    }

    const primaryTimeframe = modeConfig.timeframes[1] ?? modeConfig.timeframes[0];
    const primary = await this.buildTimeframeAnalysis(symbol, primaryTimeframe, 220, options);
    const bullishScore = scoreBullishSetup(primary.snapshot, primary.currentPrice);
    const bearishScore = scoreBearishSetup(primary.snapshot, primary.currentPrice);
    const directionScore = Math.max(bullishScore, bearishScore);
    const confidence = Math.round(
      clamp(43 + directionScore * 8 - volatilityPenalty(primary.snapshot.volatilityPercent), 35, 88)
    );
    const trend = describeTrend(primary.snapshot);
    const action: "BUY" | "SELL" | "WAIT" =
      confidence >= modeConfig.confidenceThreshold
        ? bullishScore > bearishScore
          ? "BUY"
          : "SELL"
        : "WAIT";

    return this.buildSignal({
      symbol,
      mode,
      action,
      confidence,
      trend,
      snapshot: primary.snapshot,
      currentPrice: primary.currentPrice,
      timeframe: primary.timeframe,
      reasons: buildReasons(primary.snapshot, trend, bullishScore, bearishScore, action),
      dataWarning: undefined
    });
  }

  private async analyzeScalping(
    symbol: MarketSymbol,
    mode: TradingMode,
    options?: StrategyOptions
  ): Promise<TradingSignal> {
    const [m1, m5, m15] = await Promise.all([
      this.buildTimeframeAnalysis(symbol, "M1", 220, options),
      this.buildTimeframeAnalysis(symbol, "M5", 220, options),
      this.buildTimeframeAnalysis(symbol, "M15", 220, options)
    ]);

    const decision = evaluateScalpingDecision(
      symbol,
      m1,
      m5,
      m15,
      options?.scalpingProfile ?? "trend_scalping"
    );

    return this.buildSignal({
      symbol,
      mode,
      action: decision.action,
      confidence: decision.confidence,
      trend: decision.trend,
      snapshot: m5.snapshot,
      currentPrice: m5.currentPrice,
      timeframe: m5.timeframe,
      reasons: decision.reasons,
      dataWarning: undefined
    });
  }

  private async buildTimeframeAnalysis(
    symbol: MarketSymbol,
    timeframe: Timeframe,
    limit: number,
    options?: { executionMode?: BrokerExecutionMode }
  ): Promise<TimeframeAnalysis> {
    const marketData = await this.marketDataService.getCandles(symbol, timeframe, limit, options);
    const candles = marketData.candles;

    if (candles.length < 60) {
      throw new Error("Data candle tidak cukup untuk analisis");
    }

    const closes = candles.map((candle) => candle.close);
    const currentPrice = closes.at(-1) ?? 0;
    const ema20 = exponentialMovingAverage(closes, 20);
    const ema50 = exponentialMovingAverage(closes, 50);
    const ema200 = exponentialMovingAverage(closes, 200);
    const rsi = relativeStrengthIndex(closes, 14);
    const atr = averageTrueRange(candles, 14);
    const macd = macdHistogram(closes);
    const structureLookback = timeframe === "M1" ? 25 : 40;
    const structureExcludeRecent = timeframe === "M1" ? 2 : timeframe === "M5" ? 2 : 1;
    const levels = supportResistance(candles, structureLookback, structureExcludeRecent);
    const volatilityPercent = currentPrice > 0 ? (atr / currentPrice) * 100 : 0;

    return {
      timeframe,
      candles,
      closes,
      currentPrice,
      snapshot: {
        ema20,
        ema50,
        ema200,
        rsi,
        atr,
        macdHistogram: macd,
        support: levels.support,
        resistance: levels.resistance,
        volatilityPercent
      }
    };
  }

  private buildSignal(params: {
    symbol: MarketSymbol;
    mode: TradingMode;
    action: "BUY" | "SELL" | "WAIT";
    confidence: number;
    trend: string;
    snapshot: TechnicalSnapshot;
    currentPrice: number;
    timeframe: Timeframe;
    reasons: string[];
    dataWarning?: string;
  }): TradingSignal {
    const modeConfig = TRADING_MODE_CONFIGS[params.mode];
    const { stopDistance, tp1Distance, tp2Distance } = calculateTradeDistances(
      params.symbol,
      params.mode,
      params.snapshot.atr,
      modeConfig
    );
    const priceDigits = params.symbol.pipSize >= 0.01 ? 2 : 5;
    const entry = params.action === "WAIT" ? null : roundTo(params.currentPrice, priceDigits);
    const stopLoss =
      params.action === "BUY"
        ? roundTo(params.currentPrice - stopDistance, priceDigits)
        : params.action === "SELL"
          ? roundTo(params.currentPrice + stopDistance, priceDigits)
          : null;
    const takeProfit1 =
      params.action === "BUY"
        ? roundTo(params.currentPrice + tp1Distance, priceDigits)
        : params.action === "SELL"
          ? roundTo(params.currentPrice - tp1Distance, priceDigits)
          : null;
    const takeProfit2 =
      params.action === "BUY"
        ? roundTo(params.currentPrice + tp2Distance, priceDigits)
        : params.action === "SELL"
          ? roundTo(params.currentPrice - tp2Distance, priceDigits)
          : null;

    return {
      id: randomUUID(),
      symbol: params.symbol,
      mode: params.mode,
      timeframe: params.timeframe,
      action: params.action,
      confidence: params.confidence,
      currentPrice: roundTo(params.currentPrice, priceDigits),
      trend: params.trend,
      entry,
      stopLoss,
      takeProfit1,
      takeProfit2,
      riskLevel: classifyRisk(params.confidence, params.snapshot.volatilityPercent),
      riskRewardRatio: params.action === "WAIT" ? 0 : modeConfig.targetRiskReward,
      reasons: params.reasons,
      invalidation: buildInvalidation(params.action, params.snapshot, priceDigits),
      snapshot: params.snapshot,
      dataWarning: params.dataWarning,
      createdAt: new Date().toISOString()
    };
  }
}

function evaluateScalpingDecision(
  symbol: MarketSymbol,
  m1: TimeframeAnalysis,
  m5: TimeframeAnalysis,
  m15: TimeframeAnalysis,
  profile: ScalpingProfile
): ScalpingDecision {
  const m1BullishTrend = isBullishTrend(m1.snapshot, m1.currentPrice);
  const m5BullishTrend = isBullishTrend(m5.snapshot, m5.currentPrice);
  const m15BullishTrend = isBullishTrend(m15.snapshot, m15.currentPrice);
  const m1BearishTrend = isBearishTrend(m1.snapshot, m1.currentPrice);
  const m5BearishTrend = isBearishTrend(m5.snapshot, m5.currentPrice);
  const m15BearishTrend = isBearishTrend(m15.snapshot, m15.currentPrice);

  const bullishTrigger = hasBullishEntryTrigger(m1, m5);
  const bearishTrigger = hasBearishEntryTrigger(m1, m5);
  const buyRoomBlocked = isTooCloseToResistance(symbol, m5, profile);
  const sellRoomBlocked = isTooCloseToSupport(symbol, m5, profile);

  let bullishScore = 0;
  let bearishScore = 0;

  if (m15BullishTrend) bullishScore += 2;
  if (m5BullishTrend) bullishScore += 2;
  if (m1BullishTrend) bullishScore += 1;
  if (bullishTrigger) bullishScore += 2;
  if (m1.snapshot.rsi >= 52 && m1.snapshot.rsi <= 67) bullishScore += 1;
  if (m5.snapshot.macdHistogram > 0) bullishScore += 1;
  if (!buyRoomBlocked) bullishScore += 1;

  if (m15BearishTrend) bearishScore += 2;
  if (m5BearishTrend) bearishScore += 2;
  if (m1BearishTrend) bearishScore += 1;
  if (bearishTrigger) bearishScore += 2;
  if (m1.snapshot.rsi <= 48 && m1.snapshot.rsi >= 33) bearishScore += 1;
  if (m5.snapshot.macdHistogram < 0) bearishScore += 1;
  if (!sellRoomBlocked) bearishScore += 1;

  const directionGap = Math.abs(bullishScore - bearishScore);
  const dominantScore = Math.max(bullishScore, bearishScore);
  const baseConfidence = Math.round(
    clamp(46 + dominantScore * 4 + directionGap * 3 - volatilityPenalty(m5.snapshot.volatilityPercent), 38, 91)
  );

  const trend = describeScalpingTrend(m1, m5, m15);
  const higherTimeframeBullishAligned = m15BullishTrend && m5BullishTrend;
  const higherTimeframeBearishAligned = m15BearishTrend && m5BearishTrend;
  const reasons = [
    `Profil scalping: ${profile}.`,
    `Trend M15: ${describeTrend(m15.snapshot)}.`,
    `Trend M5: ${describeTrend(m5.snapshot)}.`,
    `Momentum M1 RSI ${roundTo(m1.snapshot.rsi, 2)} dan MACD ${roundTo(m1.snapshot.macdHistogram, 5)}.`,
    `Bullish score ${bullishScore}/9, bearish score ${bearishScore}/9.`,
    buyRoomBlocked
      ? `Buy diblok karena terlalu dekat swing resistance ${roundTo(m5.snapshot.resistance, 2)}.`
      : `Buy masih punya ruang ke swing resistance ${roundTo(m5.snapshot.resistance, 2)}.`,
    sellRoomBlocked
      ? `Sell diblok karena terlalu dekat swing support ${roundTo(m5.snapshot.support, 2)}.`
      : `Sell masih punya ruang ke swing support ${roundTo(m5.snapshot.support, 2)}.`
  ];

  if (profile === "fast_scalping") {
    const fastBullishBias = hasFastBullishBias(m5) && !m15BearishTrend;
    const fastBearishBias = hasFastBearishBias(m5) && !m15BullishTrend;
    const fastBullishTrigger =
      bullishTrigger || hasBullishMomentumTrigger(m1) || hasBullishPullbackRejection(m1);
    const fastBearishTrigger =
      bearishTrigger || hasBearishMomentumTrigger(m1) || hasBearishPullbackRejection(m1);
    const fastBullishQuality =
      bullishScore >= 7 &&
      bullishScore >= bearishScore + 2 &&
      m1.snapshot.rsi >= 40 &&
      m1.snapshot.rsi <= 66 &&
      !isExtendedFastEntry(symbol, m1, m5, "BUY");
    const fastBearishQuality =
      bearishScore >= 7 &&
      bearishScore >= bullishScore + 2 &&
      m1.snapshot.rsi >= 34 &&
      m1.snapshot.rsi <= 62 &&
      !isExtendedFastEntry(symbol, m1, m5, "SELL");

    if (fastBullishBias && fastBullishTrigger && fastBullishQuality && !buyRoomBlocked) {
      return {
        action: "BUY",
        confidence: Math.max(baseConfidence, 72),
        trend,
        reasons: [
          ...reasons,
          "Fast scalping BUY lolos: bias M5 bullish, M1 menguat, dan M15 tidak berlawanan kuat."
        ]
      };
    }

    if (fastBearishBias && fastBearishTrigger && fastBearishQuality && !sellRoomBlocked) {
      return {
        action: "SELL",
        confidence: Math.max(baseConfidence, 72),
        trend,
        reasons: [
          ...reasons,
          "Fast scalping SELL lolos: bias M5 bearish, M1 melemah, dan M15 tidak berlawanan kuat."
        ]
      };
    }

    return {
      action: "WAIT",
      confidence: Math.min(baseConfidence, 69),
      trend,
      reasons: [
        ...reasons,
        "Fast scalping WAIT: alignment, momentum, jarak EMA, atau ruang harga belum memenuhi quality gate."
      ]
    };
  }

  if (!higherTimeframeBullishAligned && !higherTimeframeBearishAligned) {
    return {
      action: "WAIT",
      confidence: Math.min(baseConfidence, 66),
      trend,
      reasons: [
        ...reasons,
        "Scalping WAIT: timeframe M15 dan M5 belum satu arah, jadi entry real-account dibatalkan."
      ]
    };
  }

  if (
    higherTimeframeBullishAligned &&
    bullishScore >= 7 &&
    bullishScore >= bearishScore + 2 &&
    bullishTrigger &&
    !buyRoomBlocked
  ) {
    return {
      action: "BUY",
      confidence: Math.max(baseConfidence, 74),
      trend,
      reasons: [...reasons, "Scalping BUY lolos: arah M15/M5 sejalan dan trigger M1 valid."]
    };
  }

  if (
    higherTimeframeBearishAligned &&
    bearishScore >= 7 &&
    bearishScore >= bullishScore + 2 &&
    bearishTrigger &&
    !sellRoomBlocked
  ) {
    return {
      action: "SELL",
      confidence: Math.max(baseConfidence, 74),
      trend,
      reasons: [...reasons, "Scalping SELL lolos: arah M15/M5 sejalan dan trigger M1 valid."]
    };
  }

  return {
    action: "WAIT",
    confidence: Math.min(baseConfidence, 68),
    trend,
    reasons: [
      ...reasons,
      "Scalping WAIT: alignment timeframe atau trigger entry belum cukup bersih untuk akun real."
    ]
  };
}

function isBullishTrend(snapshot: TechnicalSnapshot, currentPrice: number): boolean {
  return (
    snapshot.ema20 > snapshot.ema50 &&
    snapshot.ema50 >= snapshot.ema200 &&
    currentPrice >= snapshot.ema20 &&
    snapshot.macdHistogram > 0
  );
}

function isBearishTrend(snapshot: TechnicalSnapshot, currentPrice: number): boolean {
  return (
    snapshot.ema20 < snapshot.ema50 &&
    snapshot.ema50 <= snapshot.ema200 &&
    currentPrice <= snapshot.ema20 &&
    snapshot.macdHistogram < 0
  );
}

function hasBullishEntryTrigger(m1: TimeframeAnalysis, m5: TimeframeAnalysis): boolean {
  const last = m1.candles.at(-1);
  const previous = m1.candles.at(-2);
  if (!last || !previous) {
    return false;
  }

  const previousEma20 = exponentialMovingAverage(m1.closes.slice(0, -1), 20);
  const lastRange = Math.max(last.high - last.low, Number.EPSILON);
  const closeNearHigh = (last.high - last.close) / lastRange <= 0.3;

  return (
    m5.currentPrice >= m5.snapshot.ema20 &&
    previous.close <= previousEma20 &&
    last.low <= m1.snapshot.ema20 &&
    last.close > last.open &&
    last.close > previous.high &&
    closeNearHigh
  );
}

function hasBearishEntryTrigger(m1: TimeframeAnalysis, m5: TimeframeAnalysis): boolean {
  const last = m1.candles.at(-1);
  const previous = m1.candles.at(-2);
  if (!last || !previous) {
    return false;
  }

  const previousEma20 = exponentialMovingAverage(m1.closes.slice(0, -1), 20);
  const lastRange = Math.max(last.high - last.low, Number.EPSILON);
  const closeNearLow = (last.close - last.low) / lastRange <= 0.3;

  return (
    m5.currentPrice <= m5.snapshot.ema20 &&
    previous.close >= previousEma20 &&
    last.high >= m1.snapshot.ema20 &&
    last.close < last.open &&
    last.close < previous.low &&
    closeNearLow
  );
}

function hasFastBullishBias(m5: TimeframeAnalysis): boolean {
  return (
    m5.snapshot.ema20 > m5.snapshot.ema50 &&
    m5.snapshot.ema50 >= m5.snapshot.ema200 &&
    m5.currentPrice >= m5.snapshot.ema20 &&
    m5.snapshot.macdHistogram > 0
  );
}

function hasFastBearishBias(m5: TimeframeAnalysis): boolean {
  return (
    m5.snapshot.ema20 < m5.snapshot.ema50 &&
    m5.snapshot.ema50 <= m5.snapshot.ema200 &&
    m5.currentPrice <= m5.snapshot.ema20 &&
    m5.snapshot.macdHistogram < 0
  );
}

function hasBullishMomentumTrigger(m1: TimeframeAnalysis): boolean {
  const last = m1.candles.at(-1);
  const previous = m1.candles.at(-2);
  if (!last || !previous) {
    return false;
  }

  const range = Math.max(last.high - last.low, Number.EPSILON);
  const body = Math.abs(last.close - last.open);
  const closeNearHigh = (last.high - last.close) / range <= 0.45;

  return (
    last.close > last.open &&
    last.close > previous.close &&
    last.close >= m1.snapshot.ema20 &&
    body / range >= 0.25 &&
    closeNearHigh &&
    m1.snapshot.rsi >= 50 &&
    m1.snapshot.rsi <= 66 &&
    m1.snapshot.macdHistogram > 0
  );
}

function hasBearishMomentumTrigger(m1: TimeframeAnalysis): boolean {
  const last = m1.candles.at(-1);
  const previous = m1.candles.at(-2);
  if (!last || !previous) {
    return false;
  }

  const range = Math.max(last.high - last.low, Number.EPSILON);
  const body = Math.abs(last.close - last.open);
  const closeNearLow = (last.close - last.low) / range <= 0.45;

  return (
    last.close < last.open &&
    last.close < previous.close &&
    last.close <= m1.snapshot.ema20 &&
    body / range >= 0.25 &&
    closeNearLow &&
    m1.snapshot.rsi <= 50 &&
    m1.snapshot.rsi >= 34 &&
    m1.snapshot.macdHistogram < 0
  );
}

function hasBullishPullbackRejection(m1: TimeframeAnalysis): boolean {
  const last = m1.candles.at(-1);
  const previous = m1.candles.at(-2);
  if (!last || !previous) {
    return false;
  }

  const range = Math.max(last.high - last.low, Number.EPSILON);
  const body = Math.abs(last.close - last.open);

  return (
    m1.snapshot.rsi <= 44 &&
    m1.snapshot.macdHistogram > 0 &&
    last.low <= m1.snapshot.ema20 &&
    last.close >= m1.snapshot.ema20 &&
    last.close > last.open &&
    last.close > previous.close &&
    body / range >= 0.2 &&
    (last.high - last.close) / range <= 0.5
  );
}

function hasBearishPullbackRejection(m1: TimeframeAnalysis): boolean {
  const last = m1.candles.at(-1);
  const previous = m1.candles.at(-2);
  if (!last || !previous) {
    return false;
  }

  const range = Math.max(last.high - last.low, Number.EPSILON);
  const body = Math.abs(last.close - last.open);

  return (
    m1.snapshot.rsi >= 56 &&
    m1.snapshot.macdHistogram < 0 &&
    last.high >= m1.snapshot.ema20 &&
    last.close <= m1.snapshot.ema20 &&
    last.close < last.open &&
    last.close < previous.close &&
    body / range >= 0.2 &&
    (last.close - last.low) / range <= 0.5
  );
}

function isExtendedFastEntry(
  symbol: MarketSymbol,
  m1: TimeframeAnalysis,
  m5: TimeframeAnalysis,
  action: "BUY" | "SELL"
): boolean {
  const m1DirectionalDistance =
    action === "BUY"
      ? m1.currentPrice - m1.snapshot.ema20
      : m1.snapshot.ema20 - m1.currentPrice;
  const m5DirectionalDistance =
    action === "BUY"
      ? m5.currentPrice - m5.snapshot.ema20
      : m5.snapshot.ema20 - m5.currentPrice;
  const minimumNoiseRoom = symbol.pipSize * symbol.defaultSpreadPips * 1.5;
  const maxM1Distance = Math.max(m1.snapshot.atr * 0.85, minimumNoiseRoom);
  const maxM5Distance = Math.max(m5.snapshot.atr * 1.2, minimumNoiseRoom * 2);

  return m1DirectionalDistance > maxM1Distance || m5DirectionalDistance > maxM5Distance;
}

function isTooCloseToResistance(
  symbol: MarketSymbol,
  analysis: TimeframeAnalysis,
  profile: ScalpingProfile
): boolean {
  if (analysis.currentPrice >= analysis.snapshot.resistance) {
    return false;
  }

  const room = analysis.snapshot.resistance - analysis.currentPrice;
  return room <= minimumStructureRoom(symbol, analysis.snapshot.atr, profile);
}

function isTooCloseToSupport(
  symbol: MarketSymbol,
  analysis: TimeframeAnalysis,
  profile: ScalpingProfile
): boolean {
  if (analysis.currentPrice <= analysis.snapshot.support) {
    return false;
  }

  const room = analysis.currentPrice - analysis.snapshot.support;
  return room <= minimumStructureRoom(symbol, analysis.snapshot.atr, profile);
}

function minimumStructureRoom(
  symbol: MarketSymbol,
  atr: number,
  profile: ScalpingProfile
): number {
  if (profile === "fast_scalping") {
    return Math.max(atr * 0.18, symbol.pipSize * symbol.defaultSpreadPips * 1.2);
  }

  return Math.max(atr * 0.35, symbol.pipSize * symbol.defaultSpreadPips * 2);
}

function describeScalpingTrend(
  m1: TimeframeAnalysis,
  m5: TimeframeAnalysis,
  m15: TimeframeAnalysis
): string {
  const bias = [
    `M15 ${describeTrend(m15.snapshot)}`,
    `M5 ${describeTrend(m5.snapshot)}`,
    `M1 ${describeTrend(m1.snapshot)}`
  ];
  return bias.join(" | ");
}

function calculateTradeDistances(
  symbol: MarketSymbol,
  mode: TradingMode,
  atr: number,
  modeConfig: (typeof TRADING_MODE_CONFIGS)[TradingMode]
): { stopDistance: number; tp1Distance: number; tp2Distance: number } {
  let stopDistance = Math.max(atr * modeConfig.atrStopMultiplier, symbol.pipSize * symbol.defaultSpreadPips * 2.5);
  let tp1Distance = stopDistance * modeConfig.targetRiskReward;
  let tp2Distance = stopDistance * (modeConfig.targetRiskReward + 0.5);

  if (mode === "scalping") {
    const minSlDistance = symbol.code === "XAUUSD" ? 8 * symbol.pipSize : 15 * symbol.pipSize;
    const maxSlDistance = symbol.code === "XAUUSD" ? 18 * symbol.pipSize : 25 * symbol.pipSize;
    stopDistance = clamp(stopDistance, minSlDistance, maxSlDistance);

    const minTp1Distance = symbol.code === "XAUUSD" ? 6 * symbol.pipSize : 10 * symbol.pipSize;
    const maxTp1Distance = symbol.code === "XAUUSD" ? 16 * symbol.pipSize : 20 * symbol.pipSize;
    tp1Distance = clamp(tp1Distance, minTp1Distance, maxTp1Distance);
    tp2Distance = Math.max(tp1Distance * 1.4, tp1Distance + symbol.pipSize * 6);
  }

  return { stopDistance, tp1Distance, tp2Distance };
}

function scoreBullishSetup(snapshot: TechnicalSnapshot, currentPrice: number): number {
  let score = 0;

  if (snapshot.ema20 > snapshot.ema50) score += 1;
  if (snapshot.ema50 > snapshot.ema200) score += 1;
  if (currentPrice > snapshot.ema20) score += 1;
  if (snapshot.rsi > 52 && snapshot.rsi < 72) score += 1;
  if (snapshot.macdHistogram > 0) score += 1;

  return score;
}

function scoreBearishSetup(snapshot: TechnicalSnapshot, currentPrice: number): number {
  let score = 0;

  if (snapshot.ema20 < snapshot.ema50) score += 1;
  if (snapshot.ema50 < snapshot.ema200) score += 1;
  if (currentPrice < snapshot.ema20) score += 1;
  if (snapshot.rsi < 48 && snapshot.rsi > 28) score += 1;
  if (snapshot.macdHistogram < 0) score += 1;

  return score;
}

function describeTrend(snapshot: TechnicalSnapshot): string {
  if (snapshot.ema20 > snapshot.ema50 && snapshot.ema50 > snapshot.ema200) {
    return "Bullish terstruktur";
  }

  if (snapshot.ema20 < snapshot.ema50 && snapshot.ema50 < snapshot.ema200) {
    return "Bearish terstruktur";
  }

  return "Sideways / transisi";
}

function volatilityPenalty(volatilityPercent: number): number {
  if (volatilityPercent > 1.2) return 9;
  if (volatilityPercent > 0.7) return 5;
  return 0;
}

function classifyRisk(confidence: number, volatilityPercent: number): "Low" | "Medium" | "High" {
  if (confidence >= 74 && volatilityPercent < 0.7) return "Low";
  if (confidence >= 60 && volatilityPercent < 1.2) return "Medium";
  return "High";
}

function buildReasons(
  snapshot: TechnicalSnapshot,
  trend: string,
  bullishScore: number,
  bearishScore: number,
  action: "BUY" | "SELL" | "WAIT"
): string[] {
  return [
    `Trend utama: ${trend}.`,
    `EMA score bullish ${bullishScore}/5 dan bearish ${bearishScore}/5.`,
    `RSI ${roundTo(snapshot.rsi, 2)} menunjukkan momentum ${describeRsi(snapshot.rsi)}.`,
    `MACD histogram ${roundTo(snapshot.macdHistogram, 5)}.`,
    `Support ${roundTo(snapshot.support, 5)} dan resistance ${roundTo(snapshot.resistance, 5)}.`,
    action === "WAIT"
      ? "Setup belum cukup bersih, prioritas WAIT."
      : "Setup memenuhi threshold mode, tetap wajib gunakan stop loss."
  ];
}

function describeRsi(rsi: number): string {
  if (rsi >= 70) return "overbought";
  if (rsi <= 30) return "oversold";
  if (rsi > 52) return "bullish ringan";
  if (rsi < 48) return "bearish ringan";
  return "netral";
}

function buildInvalidation(
  action: "BUY" | "SELL" | "WAIT",
  snapshot: TechnicalSnapshot,
  priceDigits: number
): string {
  if (action === "BUY") {
    return `Batal jika harga close di bawah EMA50 (${roundTo(snapshot.ema50, priceDigits)}) atau momentum MACD berubah negatif.`;
  }

  if (action === "SELL") {
    return `Batal jika harga close di atas EMA50 (${roundTo(snapshot.ema50, priceDigits)}) atau momentum MACD berubah positif.`;
  }

  return "Tunggu alignment timeframe dan trigger candle yang lebih bersih sebelum entry.";
}