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