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