Bot & Automation
Treding Forex Ai
/root/hermes-projects/Treding Forex Ai
src/services/broker/broker.ts
text
import type {
BrokerExecutionMode,
BrokerExecutionStatus,
ClosedPosition,
ExecutedTrade,
MarketQuote,
Mt5BridgeConnectionConfig,
OpenPosition,
OrderIntent
} from "../../domain/types";
import type { JsonStore } from "../storage/jsonStore";
import { calcPnl } from "../tradingService";
export interface BrokerAdapter {
execute(
order: OrderIntent,
options?: { desiredExecutionMode?: BrokerExecutionMode }
): Promise<ExecutedTrade>;
getExecutionStatus(options?: { desiredExecutionMode?: BrokerExecutionMode }): Promise<BrokerExecutionStatus>;
getPositions(options?: { desiredExecutionMode?: BrokerExecutionMode }): Promise<OpenPosition[]>;
getPositionClosure(
ticket: string,
options?: { desiredExecutionMode?: BrokerExecutionMode }
): Promise<ClosedPosition | null>;
getQuote(
symbolCode: string,
options?: { desiredExecutionMode?: BrokerExecutionMode }
): Promise<MarketQuote | null>;
usesBrokerManagedStops(): boolean;
closePosition(
ticket: string,
symbolCode: string,
currentPrice: number,
options?: { desiredExecutionMode?: BrokerExecutionMode }
): Promise<{ pnl: number }>;
}
export class PaperBrokerAdapter implements BrokerAdapter {
public constructor(private readonly store: JsonStore) {}
public async execute(order: OrderIntent): Promise<ExecutedTrade> {
const trade: ExecutedTrade = {
...order,
status: "executed",
executedAt: new Date().toISOString(),
broker: "paper",
brokerOrderId: `paper-${order.id}`
};
await this.store.appendPaperTrade(trade);
await this.store.removePendingOrder(order.id);
return trade;
}
public async getExecutionStatus(): Promise<BrokerExecutionStatus> {
return {
provider: "paper",
connected: true,
currentMode: "paper",
allowDemoTrading: true,
allowRealTrading: false,
terminalTradeAllowed: true,
accountTradeAllowed: true,
detail: "Paper broker aktif. Order tidak dikirim ke MT5."
};
}
public async getPositions(): Promise<OpenPosition[]> {
const allOpen = await this.store.listAllOpenTrades();
return allOpen.map((trade) => ({
ticket: trade.brokerOrderId,
symbolCode: trade.symbolCode,
action: trade.action,
lotSize: trade.positionSizing.lotSize,
entryPrice: trade.entry,
stopLoss: trade.stopLoss,
takeProfit1: trade.takeProfit1,
takeProfit2: trade.takeProfit2,
floatingPnl: 0, // paper mode: dihitung saat monitor dengan harga live
openedAt: trade.executedAt,
chatId: trade.chatId,
orderId: trade.id
}));
}
public async getPositionClosure(): Promise<ClosedPosition | null> {
return null;
}
public async getQuote(): Promise<MarketQuote | null> {
return null;
}
public usesBrokerManagedStops(): boolean {
return false;
}
public async closePosition(
ticket: string,
_symbolCode: string,
currentPrice: number
): Promise<{ pnl: number }> {
const allOpen = await this.store.listAllOpenTrades();
const trade = allOpen.find((t) => t.brokerOrderId === ticket);
if (!trade) {
throw new Error(`Paper position ${ticket} tidak ditemukan.`);
}
const pnl = calcPnl(trade, currentPrice);
await this.store.closeTrade(trade.id, currentPrice, pnl);
return { pnl };
}
}
export class GuardedRealBrokerAdapter implements BrokerAdapter {
public async execute(): Promise<ExecutedTrade> {
throw new Error(
"Real broker adapter belum dikonfigurasi. Tetap gunakan paper trading sampai broker dipilih dan diaudit."
);
}
public async getPositions(): Promise<OpenPosition[]> {
return [];
}
public async getPositionClosure(): Promise<ClosedPosition | null> {
return null;
}
public async getQuote(): Promise<MarketQuote | null> {
return null;
}
public usesBrokerManagedStops(): boolean {
return true;
}
public async getExecutionStatus(): Promise<BrokerExecutionStatus> {
return {
provider: "guarded",
connected: false,
currentMode: "unknown",
allowDemoTrading: false,
allowRealTrading: false,
terminalTradeAllowed: false,
accountTradeAllowed: false,
detail: "Broker real belum siap. Bridge MT5 belum dikonfigurasi penuh."
};
}
public async closePosition(): Promise<{ pnl: number }> {
throw new Error("Real broker adapter belum dikonfigurasi.");
}
}
export class Mt5BridgeBrokerAdapter implements BrokerAdapter {
private readonly connectionsByMode: Map<BrokerExecutionMode, Mt5BridgeConnectionConfig>;
public constructor(connections: Mt5BridgeConnectionConfig[]) {
this.connectionsByMode = new Map(
connections.map(
(connection) =>
[connection.mode, connection] satisfies [BrokerExecutionMode, Mt5BridgeConnectionConfig]
)
);
}
public async execute(
order: OrderIntent,
options?: { desiredExecutionMode?: BrokerExecutionMode }
): Promise<ExecutedTrade> {
const desiredMode = options?.desiredExecutionMode ?? "demo";
const connection = this.resolveConnection(desiredMode);
const status = await this.getExecutionStatus({ desiredExecutionMode: desiredMode });
validateDesiredExecutionMode(status, desiredMode);
const response = await fetch(`${connection.url.replace(/\/$/, "")}/orders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${connection.token}`
},
body: JSON.stringify({
clientOrderId: order.id,
symbol: order.symbolCode,
side: order.action.toLowerCase(),
lotSize: order.positionSizing.lotSize,
entry: order.entry,
stopLoss: order.stopLoss,
takeProfit: order.takeProfit1,
metadata: {
mode: order.mode,
confidence: order.confidence,
riskLevel: order.riskLevel,
riskRewardRatio: order.riskRewardRatio
}
})
});
const payload = (await response.json().catch(() => ({}))) as {
orderId?: string;
dryRun?: boolean;
message?: string;
detail?: string;
accountTradeMode?: string | null;
entryPrice?: number;
stopLoss?: number;
takeProfit?: number;
};
if (!response.ok || !payload.orderId) {
throw new Error(payload.detail ?? payload.message ?? "MT5 bridge menolak order.");
}
const entry = payload.entryPrice ?? order.entry;
const stopLoss = payload.stopLoss ?? order.stopLoss;
const takeProfit1 = payload.takeProfit ?? order.takeProfit1;
return {
...order,
entry,
stopLoss,
takeProfit1,
takeProfit2: takeProfit1,
status: "executed",
executedAt: new Date().toISOString(),
broker: payload.dryRun ? "dry_run" : payload.accountTradeMode === "demo" ? "mt5_demo" : "real",
brokerOrderId: payload.orderId
};
}
public async getPositions(options?: { desiredExecutionMode?: BrokerExecutionMode }): Promise<OpenPosition[]> {
const connection = this.resolveConnection(options?.desiredExecutionMode);
const response = await fetchWithRetry(
`${connection.url.replace(/\/$/, "")}/positions`,
{ headers: { Authorization: `Bearer ${connection.token}` } },
2
);
if (!response.ok) {
const err = (await response.json().catch(() => ({ message: "unknown" }))) as {
message?: string;
detail?: string;
};
throw new Error(err.detail ?? err.message ?? "MT5 bridge gagal fetch positions.");
}
const payload = (await response.json()) as {
positions: Array<{
ticket: string;
symbol: string;
side: string;
volume: number;
openPrice: number;
sl: number;
tp: number;
profit: number;
openTime: string;
clientOrderId?: string;
}>;
};
return (payload.positions ?? []).map((p) => ({
ticket: String(p.ticket),
symbolCode: p.symbol,
action: p.side.toUpperCase() === "BUY" ? "BUY" : "SELL",
lotSize: p.volume,
entryPrice: p.openPrice,
stopLoss: p.sl,
takeProfit1: p.tp,
takeProfit2: p.tp,
floatingPnl: p.profit,
openedAt: p.openTime,
chatId: 0,
orderId: p.clientOrderId ?? ""
}));
}
public async getPositionClosure(
ticket: string,
options?: { desiredExecutionMode?: BrokerExecutionMode }
): Promise<ClosedPosition | null> {
const connection = this.resolveConnection(options?.desiredExecutionMode);
const response = await fetchWithRetry(
`${connection.url.replace(/\/$/, "")}/positions/${encodeURIComponent(ticket)}/closure`,
{ headers: { Authorization: `Bearer ${connection.token}` } },
2
);
const payload = (await response.json().catch(() => ({}))) as {
found?: boolean;
ticket?: string;
reason?: ClosedPosition["reason"];
closePrice?: number;
pnl?: number;
closeTime?: string;
detail?: string;
message?: string;
};
if (!response.ok) {
throw new Error(payload.detail ?? payload.message ?? "MT5 bridge gagal membaca riwayat posisi.");
}
if (!payload.found) {
return null;
}
return {
ticket: payload.ticket ?? ticket,
reason: payload.reason ?? "unknown",
closePrice: payload.closePrice ?? 0,
pnl: payload.pnl ?? 0,
closedAt: payload.closeTime ?? ""
};
}
public async getQuote(
symbolCode: string,
options?: { desiredExecutionMode?: BrokerExecutionMode }
): Promise<MarketQuote> {
const connection = this.resolveConnection(options?.desiredExecutionMode);
const url = new URL(`${connection.url.replace(/\/$/, "")}/market-data/quote`);
url.searchParams.set("symbol", symbolCode);
const response = await fetchWithRetry(
url.toString(),
{ headers: { Authorization: `Bearer ${connection.token}` } },
2
);
const payload = (await response.json().catch(() => ({}))) as {
symbol?: string;
bid?: number;
ask?: number;
spreadPrice?: number;
detail?: string;
message?: string;
};
if (
!response.ok ||
!payload.symbol ||
!Number.isFinite(payload.bid) ||
!Number.isFinite(payload.ask) ||
!Number.isFinite(payload.spreadPrice)
) {
throw new Error(payload.detail ?? payload.message ?? "MT5 bridge gagal membaca spread aktual.");
}
return {
symbolCode: payload.symbol,
bid: payload.bid!,
ask: payload.ask!,
spreadPrice: payload.spreadPrice!
};
}
public usesBrokerManagedStops(): boolean {
return true;
}
public async getExecutionStatus(
options?: { desiredExecutionMode?: BrokerExecutionMode }
): Promise<BrokerExecutionStatus> {
const connection = this.resolveConnection(options?.desiredExecutionMode);
const response = await fetchWithRetry(
`${connection.url.replace(/\/$/, "")}/health`,
{ headers: { Authorization: `Bearer ${connection.token}` } },
2
);
const payload = (await response.json().catch(() => ({}))) as {
dryRun?: boolean;
allowDemoTrading?: boolean;
allowRealTrading?: boolean;
mt5Connected?: boolean;
terminalTradeAllowed?: boolean;
tradeAllowed?: boolean;
accountTradeMode?: string;
accountServer?: string;
accountLoginLast4?: string;
detail?: string;
message?: string;
};
if (!response.ok) {
throw new Error(payload.detail ?? payload.message ?? "MT5 bridge health gagal dibaca.");
}
const currentMode =
payload.accountTradeMode === "demo" || payload.accountTradeMode === "real"
? payload.accountTradeMode
: "unknown";
return {
provider: "mt5_bridge",
connected: Boolean(payload.mt5Connected),
currentMode,
allowDemoTrading: Boolean(payload.allowDemoTrading) && !payload.dryRun,
allowRealTrading: Boolean(payload.allowRealTrading) && !payload.dryRun,
terminalTradeAllowed: Boolean(payload.terminalTradeAllowed),
accountTradeAllowed: Boolean(payload.tradeAllowed),
accountServer: payload.accountServer,
accountLoginLast4: payload.accountLoginLast4,
detail: buildBridgeStatusDetail({
connected: Boolean(payload.mt5Connected),
currentMode,
dryRun: Boolean(payload.dryRun),
allowDemoTrading: Boolean(payload.allowDemoTrading),
allowRealTrading: Boolean(payload.allowRealTrading),
terminalTradeAllowed: Boolean(payload.terminalTradeAllowed),
accountTradeAllowed: Boolean(payload.tradeAllowed),
accountServer: payload.accountServer,
accountLoginLast4: payload.accountLoginLast4,
targetMode: connection.mode
})
};
}
public async closePosition(
ticket: string,
symbolCode: string,
// currentPrice tidak digunakan — MT5 akan menggunakan bid/ask live dari terminal
_currentPrice: number,
options?: { desiredExecutionMode?: BrokerExecutionMode }
): Promise<{ pnl: number }> {
const connection = this.resolveConnection(options?.desiredExecutionMode);
const response = await fetch(
`${connection.url.replace(/\/$/, "")}/positions/${ticket}/close`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${connection.token}`
},
body: JSON.stringify({ symbol: symbolCode })
}
);
const payload = (await response.json().catch(() => ({}))) as {
pnl?: number;
message?: string;
detail?: string;
};
if (!response.ok) {
throw new Error(payload.detail ?? payload.message ?? "MT5 bridge gagal close position.");
}
return { pnl: payload.pnl ?? 0 };
}
private resolveConnection(desiredMode?: BrokerExecutionMode): Mt5BridgeConnectionConfig {
if (desiredMode) {
const connection = this.connectionsByMode.get(desiredMode);
if (!connection) {
throw new Error(`Bridge MT5 untuk mode ${desiredMode} belum dikonfigurasi.`);
}
return connection;
}
const defaultConnection = this.connectionsByMode.get("demo") ?? this.connectionsByMode.get("real");
if (!defaultConnection) {
throw new Error("Tidak ada bridge MT5 yang dikonfigurasi.");
}
return defaultConnection;
}
}
function validateDesiredExecutionMode(
status: BrokerExecutionStatus,
desiredMode: BrokerExecutionMode
): void {
if (!status.connected) {
throw new Error("Bridge MT5 belum terhubung. Jalankan bridge dan tunnel terlebih dahulu.");
}
if (desiredMode === "demo") {
if (!status.allowDemoTrading) {
throw new Error("Mode demo belum aktif di bridge MT5.");
}
if (status.currentMode !== "demo") {
throw new Error(
`Bridge MT5 saat ini tidak terhubung ke akun demo. ${status.detail}`
);
}
return;
}
if (!status.allowRealTrading) {
throw new Error("Mode realtime masih dikunci di bridge MT5.");
}
if (status.currentMode !== "real") {
throw new Error(
`Bridge MT5 saat ini tidak terhubung ke akun real. ${status.detail}`
);
}
}
async function fetchWithRetry(
input: string,
init: RequestInit,
attempts: number
): Promise<Response> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await fetch(input, init);
} catch (error) {
lastError = error;
if (attempt < attempts) {
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
}
throw lastError instanceof Error ? lastError : new Error("MT5 bridge request failed.");
}
function buildBridgeStatusDetail(params: {
connected: boolean;
currentMode: "demo" | "real" | "unknown";
dryRun: boolean;
allowDemoTrading: boolean;
allowRealTrading: boolean;
terminalTradeAllowed: boolean;
accountTradeAllowed: boolean;
accountServer?: string;
accountLoginLast4?: string;
targetMode?: BrokerExecutionMode;
}): string {
if (!params.connected) {
return "MT5 belum connected.";
}
const modeLabel =
params.currentMode === "unknown" ? "mode akun tidak diketahui" : `akun ${params.currentMode}`;
const serverLabel = params.accountServer ? ` server ${params.accountServer}` : "";
const loginLabel = params.accountLoginLast4 ? ` login ${params.accountLoginLast4}` : "";
const targetLabel = params.targetMode ? ` target=${params.targetMode}` : "";
const guardLabel = `demo=${params.allowDemoTrading && !params.dryRun}, real=${params.allowRealTrading && !params.dryRun}, algo=${params.terminalTradeAllowed}, account=${params.accountTradeAllowed}`;
return `Bridge aktif pada ${modeLabel}${serverLabel}${loginLabel}.${targetLabel} Gate: ${guardLabel}.`;
}