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.";
}