Bot & Automation

Market AI

/root/hermes-projects/Market 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 { MarketSymbol, TechnicalSnapshot, TradingMode, TradingSignal } from "../../domain/types";
import {
  averageTrueRange,
  clamp,
  exponentialMovingAverage,
  macdHistogram,
  relativeStrengthIndex,
  roundTo,
  supportResistance
} from "../../utils/math";

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

  public async analyze(symbol: MarketSymbol, mode: TradingMode): Promise<TradingSignal> {
    const modeConfig = TRADING_MODE_CONFIGS[mode];
    const primaryTimeframe = modeConfig.timeframes[1] ?? modeConfig.timeframes[0];
    const marketData = await this.marketDataService.getCandles(symbol, primaryTimeframe, 220);
    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 levels = supportResistance(candles, 40);
    const volatilityPercent = currentPrice > 0 ? (atr / currentPrice) * 100 : 0;
    const snapshot: TechnicalSnapshot = {
      ema20,
      ema50,
      ema200,
      rsi,
      atr,
      macdHistogram: macd,
      support: levels.support,
      resistance: levels.resistance,
      volatilityPercent
    };

    const bullishScore = scoreBullishSetup(snapshot, currentPrice);
    const bearishScore = scoreBearishSetup(snapshot, currentPrice);
    const directionScore = Math.max(bullishScore, bearishScore);
    let confidence = Math.round(clamp(43 + directionScore * 8 - volatilityPenalty(volatilityPercent), 35, 88));
    const trend = describeTrend(snapshot);
    let action: "BUY" | "SELL" | "WAIT" =
      confidence >= modeConfig.confidenceThreshold
        ? bullishScore > bearishScore
          ? "BUY"
          : "SELL"
        : "WAIT";

    // USER EXPLICIT OVERRIDE: For scalping, ALWAYS pick a direction (no WAIT)
    if (mode === "scalping" && action === "WAIT") {
      action = bullishScore >= bearishScore ? "BUY" : "SELL";
      confidence = Math.max(confidence, modeConfig.confidenceThreshold); // Force pass
    }

    let stopDistance = Math.max(atr * modeConfig.atrStopMultiplier, symbol.pipSize * symbol.defaultSpreadPips * 2);
    let tp1Distance = stopDistance * modeConfig.targetRiskReward;
    let tp2Distance = stopDistance * (modeConfig.targetRiskReward + 0.5);

    // USER EXPLICIT OVERRIDE: Extremely tight SL (1.20) and TP (0.80) for XAUUSD
    if (symbol.code === "XAUUSD") {
      stopDistance = 1.20;
      tp1Distance = 0.80;
      tp2Distance = 1.50;
    } else if (mode === "scalping") {
      const maxSlDistance = 25 * symbol.pipSize;
      const minSlDistance = 15 * symbol.pipSize;
      stopDistance = clamp(stopDistance, minSlDistance, maxSlDistance);
      
      const maxTp1Distance = 20 * symbol.pipSize;
      const minTp1Distance = 10 * symbol.pipSize;
      tp1Distance = clamp(tp1Distance, minTp1Distance, maxTp1Distance);
      tp2Distance = tp1Distance * 1.5;
    }

    const priceDigits = symbol.pipSize >= 0.01 ? 2 : 5;
    const entry = action === "WAIT" ? null : roundTo(currentPrice, priceDigits);
    const stopLoss =
      action === "BUY"
        ? roundTo(currentPrice - stopDistance, priceDigits)
        : action === "SELL"
          ? roundTo(currentPrice + stopDistance, priceDigits)
          : null;
    const takeProfit1 =
      action === "BUY"
        ? roundTo(currentPrice + tp1Distance, priceDigits)
        : action === "SELL"
          ? roundTo(currentPrice - tp1Distance, priceDigits)
          : null;
    const takeProfit2 =
      action === "BUY"
        ? roundTo(currentPrice + tp2Distance, priceDigits)
        : action === "SELL"
          ? roundTo(currentPrice - tp2Distance, priceDigits)
          : null;

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

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 breakout valid atau struktur EMA lebih jelas sebelum entry.";
}