Bot & Automation

Market AI

/root/hermes-projects/Market AI

src/config/env.ts text
import "dotenv/config";
import { z } from "zod";
import type {
  BrokerAccountType,
  BrokerProviderName,
  MarketDataProviderName,
  TradingMode,
  TradingRuntimeMode
} from "../domain/types";

const booleanFromEnv = z.preprocess((value) => {
  if (typeof value !== "string") {
    return value;
  }

  const normalized = value.trim().toLowerCase();
  if (["true", "1", "yes", "y"].includes(normalized)) {
    return true;
  }

  if (["false", "0", "no", "n", ""].includes(normalized)) {
    return false;
  }

  return value;
}, z.boolean());

const optionalNumber = (fallback: number) =>
  z.preprocess((value) => {
    if (value === undefined || value === null || value === "") {
      return fallback;
    }

    return Number(value);
  }, z.number().finite().positive());

const optionalNonNegativeInteger = (fallback: number) =>
  z.preprocess((value) => {
    if (value === undefined || value === null || value === "") {
      return fallback;
    }

    return Number(value);
  }, z.number().int().min(0));

const optionalPositiveInteger = (fallback: number, min = 1) =>
  z.preprocess((value) => {
    if (value === undefined || value === null || value === "") {
      return fallback;
    }

    return Number(value);
  }, z.number().int().min(min));

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  BOT_DISPLAY_NAME: z.string().min(1).default("Treding Forex Ai"),
  TELEGRAM_BOT_TOKEN: z.string().optional(),
  TRADING_MODE: z.enum(["paper", "semi_auto", "full_auto"]).default("paper"),
  ENABLE_REAL_TRADING: booleanFromEnv.default(false),
  ACCOUNT_BALANCE: optionalNumber(1000),
  RISK_PER_TRADE_PERCENT: optionalNumber(1),
  MAX_DAILY_LOSS_PERCENT: optionalNumber(3),
  MAX_OPEN_TRADES: optionalNonNegativeInteger(3),
  MAX_LOT_PER_ORDER: optionalNumber(0.01),
  MARKET_DATA_PROVIDER: z.enum(["demo", "twelvedata"]).default("demo"),
  TWELVEDATA_API_KEY: z.string().optional(),
  DATA_DIR: z.string().min(1).default("./data"),
  ALLOWED_TELEGRAM_USER_IDS: z.string().optional(),
  BROKER_PROVIDER: z.enum(["disabled", "paper", "mt5_bridge"]).default("disabled"),
  BROKER_API_KEY: z.string().optional(),
  BROKER_ACCOUNT_ID: z.string().optional(),
  BROKER_BASE_URL: z.string().optional(),
  MT5_BRIDGE_URL: z.string().url().optional().or(z.literal("")),
  MT5_BRIDGE_TOKEN: z.string().optional(),
  EXNESS_MT5_LOGIN: z.string().optional(),
  EXNESS_MT5_SERVER: z.string().optional(),
  EXNESS_ACCOUNT_TYPE: z.enum(["demo", "real"]).default("demo"),
  AUTO_TRADING_ENABLED: booleanFromEnv.default(false),
  AUTO_TRADE_SYMBOLS: z.string().default("XAUUSD"),
  AUTO_TRADE_MODE: z
    .enum(["scalping", "intraday", "swing", "conservative", "aggressive"])
    .default("intraday"),
  AUTO_SCAN_INTERVAL_SECONDS: optionalPositiveInteger(300, 5),
  AUTO_TRADE_COOLDOWN_SECONDS: optionalPositiveInteger(45, 10),
  AUTO_TRADE_COOLDOWN_MINUTES: optionalNumber(1),
  AUTO_MONITOR_INTERVAL_SECONDS: optionalPositiveInteger(30, 5),
  AUTO_MIN_CONFIDENCE: optionalPositiveInteger(70, 1),
  AUTO_MAX_SPREAD_PIPS: optionalNumber(50),
  AUTO_SKIP_ROLLOVER_WINDOW: booleanFromEnv.default(true),
  AUTO_TRADE_CHAT_ID: z.string().optional(),
  AUTO_TRADE_USER_ID: z.string().optional()
});

const parsedEnv = envSchema.parse(process.env);

export const env = {
  nodeEnv: parsedEnv.NODE_ENV,
  botDisplayName: parsedEnv.BOT_DISPLAY_NAME,
  telegramBotToken: parsedEnv.TELEGRAM_BOT_TOKEN,
  tradingMode: parsedEnv.TRADING_MODE as TradingRuntimeMode,
  enableRealTrading: parsedEnv.ENABLE_REAL_TRADING,
  marketDataProvider: parsedEnv.MARKET_DATA_PROVIDER as MarketDataProviderName,
  twelveDataApiKey: parsedEnv.TWELVEDATA_API_KEY,
  dataDir: parsedEnv.DATA_DIR,
  allowedTelegramUserIds: parseAllowedUserIds(parsedEnv.ALLOWED_TELEGRAM_USER_IDS),
  brokerProvider: parsedEnv.BROKER_PROVIDER as BrokerProviderName,
  brokerApiKey: parsedEnv.BROKER_API_KEY,
  brokerAccountId: parsedEnv.BROKER_ACCOUNT_ID,
  brokerBaseUrl: parsedEnv.BROKER_BASE_URL,
  exness: {
    mt5BridgeUrl: normalizeOptional(parsedEnv.MT5_BRIDGE_URL),
    mt5BridgeToken: parsedEnv.MT5_BRIDGE_TOKEN,
    mt5Login: parsedEnv.EXNESS_MT5_LOGIN,
    mt5Server: parsedEnv.EXNESS_MT5_SERVER,
    accountType: parsedEnv.EXNESS_ACCOUNT_TYPE as BrokerAccountType
  },
  risk: {
    accountBalance: parsedEnv.ACCOUNT_BALANCE,
    riskPerTradePercent: parsedEnv.RISK_PER_TRADE_PERCENT,
    maxDailyLossPercent: parsedEnv.MAX_DAILY_LOSS_PERCENT,
    maxOpenTrades: parsedEnv.MAX_OPEN_TRADES,
    maxLotPerOrder: parsedEnv.MAX_LOT_PER_ORDER
  },
  autoTrading: {
    enabled: parsedEnv.AUTO_TRADING_ENABLED,
    symbols: parseSymbolList(parsedEnv.AUTO_TRADE_SYMBOLS),
    mode: parsedEnv.AUTO_TRADE_MODE as TradingMode,
    scanIntervalSeconds: parsedEnv.AUTO_SCAN_INTERVAL_SECONDS,
    cooldownSeconds: parsedEnv.AUTO_TRADE_COOLDOWN_SECONDS,
    monitorIntervalSeconds: parsedEnv.AUTO_MONITOR_INTERVAL_SECONDS,
    minConfidence: parsedEnv.AUTO_MIN_CONFIDENCE,
    maxSpreadPips: parsedEnv.AUTO_MAX_SPREAD_PIPS,
    skipRolloverWindow: parsedEnv.AUTO_SKIP_ROLLOVER_WINDOW,
    maxOpenPositions: parsedEnv.MAX_OPEN_TRADES, // 0 = tidak ada batas
    chatId:
      parseOptionalPositiveInteger(parsedEnv.AUTO_TRADE_CHAT_ID) ??
      firstAllowedUserId(parsedEnv.ALLOWED_TELEGRAM_USER_IDS),
    userId:
      parseOptionalPositiveInteger(parsedEnv.AUTO_TRADE_USER_ID) ??
      firstAllowedUserId(parsedEnv.ALLOWED_TELEGRAM_USER_IDS)
  }
};

function normalizeOptional(value: string | undefined): string | undefined {
  if (!value?.trim()) {
    return undefined;
  }

  return value.trim();
}

function parseAllowedUserIds(value: string | undefined): Set<number> {
  if (!value?.trim()) {
    return new Set();
  }

  return new Set(
    value
      .split(",")
      .map((item) => Number(item.trim()))
      .filter((item) => Number.isInteger(item) && item > 0)
  );
}

function firstAllowedUserId(value: string | undefined): number | undefined {
  return [...parseAllowedUserIds(value)].at(0);
}

function parseOptionalPositiveInteger(value: string | undefined): number | undefined {
  if (!value?.trim()) {
    return undefined;
  }

  const parsed = Number(value.trim());
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}

function parseSymbolList(value: string): string[] {
  return value
    .split(",")
    .map((item) => item.trim().toUpperCase())
    .filter(Boolean);
}