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