Bot & Automation

Market AI

/root/hermes-projects/Market AI

src/services/broker/broker.ts text
import type {
  BrokerExecutionMode,
  BrokerExecutionStatus,
  ExecutedTrade,
  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(): Promise<BrokerExecutionStatus>;
  getPositions(): Promise<OpenPosition[]>;
  closePosition(ticket: string, symbolCode: string, currentPrice: number): 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,
      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 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 getExecutionStatus(): Promise<BrokerExecutionStatus> {
    return {
      provider: "guarded",
      connected: false,
      currentMode: "unknown",
      allowDemoTrading: false,
      allowRealTrading: 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 {
  public constructor(
    private readonly bridgeUrl: string,
    private readonly bridgeToken: string
  ) {}

  public async execute(
    order: OrderIntent,
    options?: { desiredExecutionMode?: BrokerExecutionMode }
  ): Promise<ExecutedTrade> {
    if (options?.desiredExecutionMode) {
      const status = await this.getExecutionStatus();
      validateDesiredExecutionMode(status, options.desiredExecutionMode);
    }

    const response = await fetch(`${this.bridgeUrl.replace(/\/$/, "")}/orders`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${this.bridgeToken}`
      },
      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;
    };

    if (!response.ok || !payload.orderId) {
      throw new Error(payload.detail ?? payload.message ?? "MT5 bridge menolak order.");
    }

    return {
      ...order,
      status: "executed",
      executedAt: new Date().toISOString(),
      broker: payload.dryRun ? "dry_run" : payload.accountTradeMode === "demo" ? "mt5_demo" : "real",
      brokerOrderId: payload.orderId
    };
  }

  public async getPositions(): Promise<OpenPosition[]> {
    const response = await fetch(`${this.bridgeUrl.replace(/\/$/, "")}/positions`, {
      headers: { Authorization: `Bearer ${this.bridgeToken}` }
    });

    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 getExecutionStatus(): Promise<BrokerExecutionStatus> {
    const response = await fetch(`${this.bridgeUrl.replace(/\/$/, "")}/health`, {
      headers: { Authorization: `Bearer ${this.bridgeToken}` }
    });

    const payload = (await response.json().catch(() => ({}))) as {
      dryRun?: boolean;
      allowDemoTrading?: boolean;
      allowRealTrading?: boolean;
      mt5Connected?: 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,
      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),
        accountServer: payload.accountServer,
        accountLoginLast4: payload.accountLoginLast4
      })
    };
  }

  public async closePosition(
    ticket: string,
    symbolCode: string,
    // currentPrice tidak digunakan — MT5 akan menggunakan bid/ask live dari terminal
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    _currentPrice: number
  ): Promise<{ pnl: number }> {
    const response = await fetch(
      `${this.bridgeUrl.replace(/\/$/, "")}/positions/${ticket}/close`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${this.bridgeToken}`
        },
        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 };
  }
}

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

function buildBridgeStatusDetail(params: {
  connected: boolean;
  currentMode: "demo" | "real" | "unknown";
  dryRun: boolean;
  allowDemoTrading: boolean;
  allowRealTrading: boolean;
  accountServer?: string;
  accountLoginLast4?: string;
}): 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 guardLabel = `demo=${params.allowDemoTrading && !params.dryRun}, real=${params.allowRealTrading && !params.dryRun}`;

  return `Bridge aktif pada ${modeLabel}${serverLabel}${loginLabel}. Gate: ${guardLabel}.`;
}