Bot & Automation

Market AI

/root/hermes-projects/Market AI

src/services/market-data/marketDataService.ts text
import { MARKET_SYMBOLS } from "../../constants/markets";
import type { Candle, MarketDataProviderName, MarketDataResult, MarketSymbol, Timeframe } from "../../domain/types";

export interface MarketDataService {
  getCandles(symbol: MarketSymbol, timeframe: Timeframe, limit: number): Promise<MarketDataResult>;
}

export function createMarketDataService(
  provider: MarketDataProviderName,
  twelveDataApiKey: string | undefined
): MarketDataService {
  const demoService = new DemoMarketDataService();

  if (provider === "twelvedata") {
    return new FallbackMarketDataService(new TwelveDataMarketDataService(twelveDataApiKey), demoService);
  }

  return demoService;
}

class FallbackMarketDataService implements MarketDataService {
  public constructor(
    private readonly primary: MarketDataService,
    private readonly fallback: MarketDataService
  ) {}

  public async getCandles(symbol: MarketSymbol, timeframe: Timeframe, limit: number): Promise<MarketDataResult> {
    try {
      return await this.primary.getCandles(symbol, timeframe, limit);
    } catch (error) {
      const fallbackResult = await this.fallback.getCandles(symbol, timeframe, limit);
      const message = error instanceof Error ? error.message : "market data provider failed";

      return {
        ...fallbackResult,
        warning: `Provider realtime gagal, memakai demo data. Detail aman: ${message}`
      };
    }
  }
}

class DemoMarketDataService implements MarketDataService {
  public async getCandles(symbol: MarketSymbol, timeframe: Timeframe, limit: number): Promise<MarketDataResult> {
    return {
      candles: buildDemoCandles(symbol, timeframe, limit),
      source: "demo"
    };
  }
}

class TwelveDataMarketDataService implements MarketDataService {
  public constructor(private readonly apiKey: string | undefined) {}

  public async getCandles(symbol: MarketSymbol, timeframe: Timeframe, limit: number): Promise<MarketDataResult> {
    if (!this.apiKey) {
      throw new Error("TWELVEDATA_API_KEY kosong");
    }

    const query = new URLSearchParams({
      symbol: toTwelveDataSymbol(symbol),
      interval: toTwelveDataInterval(timeframe),
      outputsize: String(limit),
      apikey: this.apiKey
    });

    const response = await fetch(`https://api.twelvedata.com/time_series?${query.toString()}`);
    const payload = (await response.json()) as TwelveDataResponse;

    if (!response.ok || payload.status === "error" || !Array.isArray(payload.values)) {
      throw new Error(payload.message ?? "Twelve Data response tidak valid");
    }

    const candles = payload.values
      .map((value) => ({
        time: value.datetime,
        open: Number(value.open),
        high: Number(value.high),
        low: Number(value.low),
        close: Number(value.close),
        volume: Number(value.volume ?? 0)
      }))
      .filter((candle) => Number.isFinite(candle.close))
      .reverse();

    return {
      candles,
      source: "twelvedata"
    };
  }
}

interface TwelveDataResponse {
  status?: "ok" | "error";
  message?: string;
  values?: Array<{
    datetime: string;
    open: string;
    high: string;
    low: string;
    close: string;
    volume?: string;
  }>;
}

function buildDemoCandles(symbol: MarketSymbol, timeframe: Timeframe, limit: number): Candle[] {
  const basePrice = getBasePrice(symbol.code);
  const volatility = symbol.code === "XAUUSD" ? 2.8 : symbol.pipSize * 18;
  const trendBias = getStableBias(symbol.code, timeframe) * symbol.pipSize * (symbol.code === "XAUUSD" ? 8 : 1);
  const start = Date.now() - limit * timeframeMinutes(timeframe) * 60_000;
  const candles: Candle[] = [];
  let previousClose = basePrice;

  for (let index = 0; index < limit; index += 1) {
    const wave = Math.sin(index / 5) * volatility;
    const microWave = Math.cos(index / 11) * volatility * 0.4;
    const close = basePrice + trendBias * index + wave + microWave;
    const open = previousClose;
    const high = Math.max(open, close) + volatility * 0.45;
    const low = Math.min(open, close) - volatility * 0.45;

    candles.push({
      time: new Date(start + index * timeframeMinutes(timeframe) * 60_000).toISOString(),
      open,
      high,
      low,
      close,
      volume: 1000 + index * 7 + Math.abs(Math.round(wave * 100))
    });

    previousClose = close;
  }

  return candles;
}

function getBasePrice(symbolCode: string): number {
  const basePrices: Record<string, number> = {
    EURUSD: 1.085,
    GBPUSD: 1.271,
    USDJPY: 157.2,
    GBPJPY: 199.4,
    AUDUSD: 0.665,
    XAUUSD: 2360,
    BTCUSD: 60000
  };

  return basePrices[symbolCode] ?? 1;
}

function getStableBias(symbolCode: string, timeframe: Timeframe): number {
  const value = `${symbolCode}-${timeframe}`
    .split("")
    .reduce((sum, character) => sum + character.charCodeAt(0), 0);

  return (value % 7) - 3;
}

function timeframeMinutes(timeframe: Timeframe): number {
  const values: Record<Timeframe, number> = {
    M1: 1,
    M5: 5,
    M15: 15,
    H1: 60,
    H4: 240,
    D1: 1440,
    W1: 10080
  };

  return values[timeframe];
}

function toTwelveDataInterval(timeframe: Timeframe): string {
  const values: Record<Timeframe, string> = {
    M1: "1min",
    M5: "5min",
    M15: "15min",
    H1: "1h",
    H4: "4h",
    D1: "1day",
    W1: "1week"
  };

  return values[timeframe];
}

function toTwelveDataSymbol(symbol: MarketSymbol): string {
  if (symbol.code === "XAUUSD") {
    return "XAU/USD";
  }
  if (symbol.code === "BTCUSD") {
    return "BTC/USD";
  }

  const knownSymbol = MARKET_SYMBOLS.find((item) => item.code === symbol.code);
  return knownSymbol?.label ?? symbol.label;
}