رفتن به محتوای اصلی
سرویکس وب‌سرویس و API داده‌های بازار ایران
فهرست مستندات

راهنمای نمودار OHLC

ساخت نمودار امن OHLC با Lightweight Charts

نمودار کندلی واکنش‌گرا را با API احرازشدهٔ OHLC سرویکس و Lightweight Charts نسخه ۵ بسازید؛ API Key فقط روی سرور می‌ماند.

در این راهنما، مسیر API احرازشدهٔ OHLC سرویکس را به یک نمودار کندلی واقعی با lightweight-charts نسخهٔ ۵ وصل می‌کنیم. مرورگر فقط بک‌اند هم‌مبدأ خودتان را صدا می‌زند. بک‌اند کد نماد و بازه را بررسی می‌کند، هدر X-API-Key را می‌فرستد، مهلت پاسخ و سقف تلاش دوباره دارد و فقط داده‌ای را به فرانت‌اند می‌دهد که نمودار لازم دارد.

همهٔ مقدارهای نمونه در این صفحه جای‌خالی‌اند و هیچ نرخ جاری یا تاریخی واقعی در آن‌ها نیست. API Key را در سامانهٔ مدیریت رمزهای سرور نگه دارید و هرگز آن را داخل کد مرورگر، نشانی درخواست، HTML عمومی، ابزار آمارگیری، گزارش برنامه یا مخزن کد قرار ندهید.

اول مطمئن شوید تاریخچهٔ قابل اتکا در دسترس است

ابتدا مسیر API احرازشدهٔ GET /api/v1/assets/supported را فراخوانی کنید. برای نماد انتخابی، ohlcAvailable و ohlcAvailableFrom را بخوانید. اگر تاریخچه در دسترس نیست، گزینهٔ نمودار را نشان ندهید. مقدار from هم نباید زودتر از مرزی باشد که کاتالوگ برمی‌گرداند.

{
  "code": "USD_RLS",
  "ohlcAvailable": true,
  "ohlcAvailableFrom": "<source-timestamp>"
}

شروع تاریخچه برای هر نماد جداگانه تعیین می‌شود. رکوردهای قدیمی‌تر، کیفیت لازم برای ساخت کندل قابل اتکا را ندارند؛ بنابراین سرویکس پیش از ohlcAvailableFrom کندل نمی‌سازد و تاریخچه را به‌صورت مصنوعی بازسازی نمی‌کند. اگر بازهٔ درخواست از این مرز عبور کند، خطای OHLC_BEFORE_AVAILABLE_HISTORY همراه با availableFrom معتبر برمی‌گردد.

قرارداد OHLC را درست بخوانید

قاعدهرفتار API
بازه‌هافقط 15m، 1h، 4h و 1d پذیرفته می‌شوند.
زمانfrom، to، start و end همگی زمان UTC هستند.
محدودهدرخواست نیمه‌باز است: [from, to). کندل‌ها روی مرزهای UTC ساخته می‌شوند و با ترتیب زمانی صعودی برمی‌گردند.
بازهٔ بدون دادهبازهٔ خالی از پاسخ حذف می‌شود؛ API مقدار صفر یا قیمت پایانی قبلی را جای آن نمی‌گذارد.
کندل جاریcomplete=false یعنی کندل هنوز در حال شکل‌گیری است یا پنجرهٔ درخواستی فقط بخشی از آن را پوشش می‌دهد.
اصلاح دادهجدیدترین اصلاح پذیرفته‌شده پیش از تجمیع اعمال می‌شود؛ پس ممکن است همان کندل در درخواست بعدی تغییر کند.
دقتمقادیر OHLC به شکل عدد JSON و بدون نمایش علمی برمی‌گردند. پیش از مرحلهٔ نمایش، آن‌ها را گرد نکنید.

حداکثر بازه برای 15m برابر ۳۱ روز، برای 1h برابر ۱۸۰ روز و برای 4h برابر ۷۳۰ روز است. در 1d می‌توانید تا ابتدای تاریخچهٔ قابل اتکای نگه‌داری‌شده برگردید. در همهٔ حالت‌ها سقف پاسخ ۵٬۰۰۰ کندل است و هر درخواست پس از احراز هویت موفق، یک واحد عادی از سهمیهٔ دارایی مصرف می‌کند.

یک درخواست احرازشده با cURL بفرستید

این دستور را فقط در ترمینال یا محیط امن بک‌اند اجرا کنید. جای‌خالی‌های داخل علامت زاویه‌دار را همان‌جا با مقدار مناسب جایگزین کنید:

curl --fail-with-body \
  --header 'Accept: application/json' \
  --header 'X-API-Key: <your-api-key>' \
  'https://servix.cc/api/v1/assets/USD_RLS/ohlc?interval=15m&from=<UTC-from>&to=<UTC-to>'

در نمونهٔ عمومی زیر، مقدارهای محافظت‌شده عمداً پنهان شده‌اند و عدد 1 برای تعداد مشاهده، کاملاً ساختگی است:

{
  "code": "USD_RLS",
  "baseCode": "USD",
  "labelEn": "US Dollar / Iranian Rial",
  "labelFa": "دلار آمریکا / ریال ایران",
  "quoteUnit": "RLS",
  "interval": "15m",
  "from": "<UTC-from>",
  "to": "<UTC-to>",
  "availableFrom": "<source-timestamp>",
  "candles": [{
    "start": "<bucket-start>",
    "end": "<bucket-end>",
    "open": "<protected-market-value>",
    "high": "<protected-market-value>",
    "low": "<protected-market-value>",
    "close": "<protected-market-value>",
    "observationCount": 1,
    "complete": false
  }]
}

یک واسط کوچک Node و TypeScript جلوی سرویکس بگذارید

این تابع با سبک Express فقط نمادها و بازه‌های فهرست مجاز محصول را می‌پذیرد. زمان‌ها به UTC تبدیل می‌شوند، API Key در process.env می‌ماند، مهلت پاسخ شش ثانیه است، فقط خطاهای موقت شبکه و سرور با تعداد تلاش محدود دوباره امتحان می‌شوند و پاسخ موفق برای مدت کوتاهی در حافظهٔ موقت می‌ماند. مسیر را بعد از میان‌افزار ورود کاربر و بررسی مجوز دادهٔ بازار ثبت کنید. اگر چند فرایند دارید، در محیط اصلی از حافظهٔ موقت مشترک بک‌اند استفاده کنید.

import type { Request, Response } from 'express'

const API_BASE_URL = 'https://servix.cc'
const ASSETS = new Set(['USD_RLS', 'GOLD_18_RLS'])
const INTERVALS = new Set(['15m', '1h', '4h', '1d'])
const RETRYABLE = new Set([502, 503, 504])
const SAFE_UPSTREAM_ERRORS = new Set([
  'OHLC_UNSUPPORTED_INTERVAL', 'OHLC_INVALID_RANGE', 'OHLC_RANGE_TOO_LARGE',
  'OHLC_BUCKET_LIMIT_EXCEEDED', 'OHLC_BEFORE_AVAILABLE_HISTORY', 'OHLC_UNAVAILABLE',
])
const MAX_CACHE_ENTRIES = 200

type AuthenticatedRequest = Request & {
  user?: { id: string; canReadMarketData: boolean }
}
type Candle = {
  start: string; end: string
  open: number; high: number; low: number; close: number
  observationCount: number; complete: boolean
}
type OhlcPayload = {
  code: string; baseCode: string; labelEn: string; labelFa: string
  quoteUnit: string; interval: string; from: string; to: string
  availableFrom: string; candles: Candle[]
}
const cache = new Map<string, { expiresAt: number; payload: OhlcPayload }>()

class ProxyError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    readonly availableFrom?: string,
  ) { super(code) }
}

function utc(value: unknown): string | null {
  if (typeof value !== 'string' || value.length > 40
      || !/(Z|[+-]\d{2}:\d{2})$/.test(value)) return null
  const parsed = new Date(value)
  return Number.isNaN(parsed.valueOf()) ? null : parsed.toISOString()
}

function object(value: unknown): Record<string, unknown> | null {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
    ? value as Record<string, unknown> : null
}

function requiredText(source: Record<string, unknown>, key: string): string {
  const value = source[key]
  if (typeof value !== 'string' || !value.trim()) throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
  return value
}

function readOhlc(input: unknown, assetCode: string, interval: string): OhlcPayload {
  const source = object(input)
  if (!source || source.code !== assetCode || source.interval !== interval
      || !Array.isArray(source.candles)) throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
  let previousStart = -Infinity
  const candles = source.candles.map((inputCandle): Candle => {
    const candle = object(inputCandle)
    if (!candle) throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
    const start = utc(candle.start)
    const end = utc(candle.end)
    const values = [candle.open, candle.high, candle.low, candle.close]
    const observationCount = candle.observationCount
    const startTime = start ? Date.parse(start) : NaN
    if (!start || !end || start >= end || startTime <= previousStart
        || !values.every(value => typeof value === 'number' && Number.isFinite(value))
        || Number(candle.high) < Math.max(Number(candle.open), Number(candle.close))
        || Number(candle.low) > Math.min(Number(candle.open), Number(candle.close))
        || typeof observationCount !== 'number' || !Number.isInteger(observationCount)
        || observationCount < 1
        || typeof candle.complete !== 'boolean') {
      throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
    }
    previousStart = startTime
    return {
      start, end,
      open: Number(candle.open), high: Number(candle.high),
      low: Number(candle.low), close: Number(candle.close),
      observationCount, complete: candle.complete,
    }
  })
  const from = utc(source.from)
  const to = utc(source.to)
  const availableFrom = utc(source.availableFrom)
  if (!from || !to || !availableFrom) throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
  return {
    code: assetCode,
    baseCode: requiredText(source, 'baseCode'),
    labelEn: requiredText(source, 'labelEn'),
    labelFa: requiredText(source, 'labelFa'),
    quoteUnit: requiredText(source, 'quoteUnit'),
    interval, from, to, availableFrom, candles,
  }
}

function remember(cacheKey: string, payload: OhlcPayload): void {
  if (cache.size >= MAX_CACHE_ENTRIES) {
    const oldest = cache.keys().next()
    if (!oldest.done) cache.delete(oldest.value)
  }
  cache.set(cacheKey, { expiresAt: Date.now() + 20_000, payload })
}

async function requestOhlc(
  url: URL, apiKey: string, assetCode: string, interval: string,
): Promise<OhlcPayload> {
  for (let attempt = 1; attempt <= 3; attempt += 1) {
    let response: globalThis.Response
    try {
      response = await fetch(url, {
        headers: { Accept: 'application/json', 'X-API-Key': apiKey },
        redirect: 'error',
        signal: AbortSignal.timeout(6_000),
      })
    } catch {
      if (attempt === 3) throw new ProxyError(503, 'UPSTREAM_UNAVAILABLE')
      await new Promise(resolve => setTimeout(resolve, attempt * 250))
      continue
    }

    if (RETRYABLE.has(response.status) && attempt < 3) {
      await new Promise(resolve => setTimeout(resolve, attempt * 250))
      continue
    }
    const body: unknown = await response.json().catch(() => null)
    if (response.ok) return readOhlc(body, assetCode, interval)
    const problem = object(body)
    const candidateCode = typeof problem?.code === 'string' ? problem.code : ''
    const code = SAFE_UPSTREAM_ERRORS.has(candidateCode)
      ? candidateCode : 'OHLC_REQUEST_FAILED'
    const availableFrom = utc(problem?.availableFrom)
    if (response.status === 400 && code === 'OHLC_BEFORE_AVAILABLE_HISTORY' && availableFrom) {
      throw new ProxyError(400, code, availableFrom)
    }
    const safeStatus = response.status === 404 || response.status === 429
      ? response.status : response.status === 400 ? 400 : 502
    throw new ProxyError(safeStatus, code)
  }
  throw new ProxyError(503, 'UPSTREAM_UNAVAILABLE')
}

export async function ohlcChartData(
  req: AuthenticatedRequest, res: Response,
): Promise<void> {
  res.set('Cache-Control', 'private, no-store')
  if (!req.user) {
    res.status(401).json({ code: 'AUTHENTICATION_REQUIRED' })
    return
  }
  if (!req.user.canReadMarketData) {
    res.status(403).json({ code: 'MARKET_DATA_ACCESS_DENIED' })
    return
  }
  const assetCode = String(req.query.assetCode ?? '').toUpperCase()
  const interval = String(req.query.interval ?? '')
  const from = utc(req.query.from)
  const to = utc(req.query.to)
  if (!ASSETS.has(assetCode) || !INTERVALS.has(interval) || !from || !to || from >= to) {
    res.status(400).json({ code: 'INVALID_CHART_REQUEST' })
    return
  }

  const apiKey = process.env.SERVIX_API_KEY?.trim()
  if (!apiKey) {
    res.status(503).json({ code: 'CHART_DATA_UNAVAILABLE' })
    return
  }

  const cacheKey = [assetCode, interval, from, to].join('|')
  const cached = cache.get(cacheKey)
  if (cached && cached.expiresAt > Date.now()) {
    res.json(cached.payload)
    return
  }

  const url = new URL('/api/v1/assets/' + assetCode + '/ohlc', API_BASE_URL)
  url.search = new URLSearchParams({ interval, from, to }).toString()
  try {
    const payload = await requestOhlc(url, apiKey, assetCode, interval)
    remember(cacheKey, payload)
    res.json(payload)
  } catch (error) {
    const failure = error instanceof ProxyError
      ? error : new ProxyError(503, 'CHART_DATA_UNAVAILABLE')
    res.status(failure.status).json({
      code: failure.status === 429 ? 'CHART_QUOTA_EXHAUSTED' : failure.code,
      ...(failure.availableFrom ? { availableFrom: failure.availableFrom } : {}),
    })
  }
}

کد نمونه ساختار پاسخ موفق را پیش از ذخیره بررسی می‌کند: code و interval باید همان مقدار انتخاب‌شده باشند؛ زمان شروع و پایان کندل منطقهٔ زمانی داشته باشند و صعودی باشند؛ OHLC عدد معتبر و محدود باشد؛ رابطهٔ high >= open/close >= low برقرار باشد؛ observationCount عدد صحیح مثبت و complete مقدار منطقی باشد. به مرورگر فقط خطای امن و کوتاه برگردانید و اطلاعات ورود، بدنهٔ پاسخ سرویکس یا مقدار بازار را وارد گزارش برنامه نکنید.

نمودار را با Lightweight Charts نسخه ۵ بسازید

پکیج lightweight-charts نسخهٔ ۵ را در برنامهٔ مرورگری نصب کنید. در نسخهٔ ۵، سری کندلی با chart.addSeries(CandlestickSeries, options) ساخته می‌شود. کد زیر بازه را عوض می‌کند، فاصله‌های بدون داده را دست‌نخورده نگه می‌دارد، آخرین کندل را با series.update تازه می‌کند و وقتی نمودار از صفحه برداشته می‌شود همهٔ منابعش را آزاد می‌کند.

import {
  CandlestickSeries,
  ColorType,
  createChart,
  type CandlestickData,
  type UTCTimestamp,
} from 'lightweight-charts'

type Interval = '15m' | '1h' | '4h' | '1d'
type ApiCandle = {
  start: string
  end: string
  open: number
  high: number
  low: number
  close: number
  observationCount: number
  complete: boolean
}

function chartCandle(candle: ApiCandle): CandlestickData<UTCTimestamp> {
  const hasZone = (value: unknown): value is string =>
    typeof value === 'string' && /(Z|[+-]\d{2}:\d{2})$/.test(value)
  const startTime = hasZone(candle.start) ? Date.parse(candle.start) : NaN
  const endTime = hasZone(candle.end) ? Date.parse(candle.end) : NaN
  const time = Math.floor(startTime / 1000)
  const values = [candle.open, candle.high, candle.low, candle.close]
  if (!Number.isFinite(time) || !Number.isFinite(endTime) || startTime >= endTime
      || !values.every(value => typeof value === 'number' && Number.isFinite(value))
      || candle.high < Math.max(candle.open, candle.close)
      || candle.low > Math.min(candle.open, candle.close)
      || !Number.isInteger(candle.observationCount) || candle.observationCount < 1
      || typeof candle.complete !== 'boolean') {
    throw new Error('Invalid OHLC candle.')
  }
  return {
    time: time as UTCTimestamp,
    open: Number(candle.open),
    high: Number(candle.high),
    low: Number(candle.low),
    close: Number(candle.close),
  }
}

function chartCandles(candles: ApiCandle[]): CandlestickData<UTCTimestamp>[] {
  const points = candles.map(chartCandle)
  for (let index = 1; index < points.length; index += 1) {
    if (points[index].time <= points[index - 1].time) {
      throw new Error('OHLC candles must be strictly ascending.')
    }
  }
  return points
}

function readCandles(input: unknown): ApiCandle[] {
  if (typeof input !== 'object' || input === null || !('candles' in input)
      || !Array.isArray(input.candles)) throw new Error('Invalid OHLC response.')
  const candles = input.candles as ApiCandle[]
  chartCandles(candles)
  return candles
}

export function mountOhlcChart(
  container: HTMLElement,
  status: HTMLElement,
  assetCode: string,
  availableFrom: string,
  precision: number,
) {
  const chart = createChart(container, {
    height: 420,
    layout: {
      background: { type: ColorType.Solid, color: '#ffffff' },
      textColor: '#334155',
      attributionLogo: true,
    },
    timeScale: { timeVisible: true, secondsVisible: false },
  })
  const series = chart.addSeries(CandlestickSeries, {
    upColor: '#059669', downColor: '#dc4c64', borderVisible: false,
    wickUpColor: '#059669', wickDownColor: '#dc4c64',
    priceFormat: { type: 'price', precision, minMove: 10 ** -precision },
  })

  let interval: Interval = '1h'
  let request: AbortController | null = null
  let refreshRequest: AbortController | null = null
  let latest: ApiCandle | null = null
  let destroyed = false

  async function load(nextInterval: Interval): Promise<void> {
    request?.abort()
    const activeRequest = new AbortController()
    request = activeRequest
    interval = nextInterval
    status.textContent = 'Loading chart…'
    try {
      const boundary = Date.parse(availableFrom)
      if (!Number.isFinite(boundary)) throw new Error('Invalid history boundary.')
      const to = new Date().toISOString()
      const from = new Date(Math.max(
        boundary, Date.now() - 14 * 24 * 60 * 60 * 1000,
      )).toISOString()
      const query = new URLSearchParams({ assetCode, interval, from, to })
      const response = await fetch('/api/chart-data?' + query, {
        credentials: 'same-origin', signal: activeRequest.signal,
      })
      if (!response.ok) throw new Error('Chart data is unavailable.')
      const candles = readCandles(await response.json())
      if (destroyed || request !== activeRequest) return
      // Missing buckets stay missing: do not insert zeroes or carry a close forward.
      series.setData(chartCandles(candles))
      latest = candles.at(-1) ?? null
      status.textContent = latest?.complete ? 'Up to date' : 'Latest candle is forming'
      chart.timeScale().fitContent()
    } catch (error) {
      if (error instanceof DOMException && error.name === 'AbortError') return
      if (!destroyed) status.textContent = 'Chart data is unavailable.'
    } finally {
      if (request === activeRequest) request = null
    }
  }

  async function refreshLatest(): Promise<void> {
    if (!latest || destroyed || document.hidden) return
    refreshRequest?.abort()
    const activeRefresh = new AbortController()
    refreshRequest = activeRefresh
    try {
      const query = new URLSearchParams({
        assetCode, interval, from: latest.start, to: new Date().toISOString(),
      })
      const response = await fetch('/api/chart-data?' + query, {
        credentials: 'same-origin', signal: activeRefresh.signal,
      })
      if (!response.ok) return
      const newest = readCandles(await response.json()).at(-1)
      if (newest && Date.parse(newest.start) >= Date.parse(latest.start)) {
        series.update(chartCandle(newest))
        latest = newest
        status.textContent = newest.complete ? 'Up to date' : 'Latest candle is forming'
      }
    } catch (error) {
      if (error instanceof DOMException && error.name === 'AbortError') return
      // Keep the last validated chart and let the next bounded refresh try again.
    } finally {
      if (refreshRequest === activeRefresh) refreshRequest = null
    }
  }

  const resizeObserver = new ResizeObserver((entries) => {
    const entry = entries[0]
    if (!entry || destroyed) return
    chart.resize(Math.floor(entry.contentRect.width), 420)
  })
  resizeObserver.observe(container)
  const onVisibilityChange = () => {
    if (document.hidden) refreshRequest?.abort()
    else void refreshLatest()
  }
  document.addEventListener('visibilitychange', onVisibilityChange)
  const refreshTimer = window.setInterval(() => void refreshLatest(), 30_000)

  void load(interval)
  return {
    setInterval(nextInterval: Interval) { void load(nextInterval) },
    destroy() {
      destroyed = true
      request?.abort()
      refreshRequest?.abort()
      window.clearInterval(refreshTimer)
      document.removeEventListener('visibilitychange', onVisibilityChange)
      resizeObserver.disconnect()
      chart.remove()
    },
  }
}

چهار دکمه را به setInterval('15m')، setInterval('1h')، setInterval('4h') و setInterval('1d') وصل کنید. precision و minMove را از تنظیمات همان نماد در محصول خودتان بگیرید. نوع number در JavaScript برای رسم نمودار مناسب است؛ اما محاسبات دقیق مالی را در بک‌اند یا مفسر سازگار با عدد اعشاری انجام دهید. پیش از رسم، روی پاسخ دریافتی toFixed اجرا نکنید.

series.update برای تازه‌کردن آخرین کندل یا افزودن کندل بعدی مناسب است. اگر یک پنجرهٔ قدیمی را برای دریافت اصلاحات دوباره می‌خوانید، کل پاسخ را اعتبارسنجی کنید و سری صعودی را با setData جایگزین کنید. قیمت پایانی کندلی را که هنوز کامل نشده، مقدار نهایی در نظر نگیرید.

خطاها را بدون تلاش دوبارهٔ بی‌پایان مدیریت کنید

نتیجهرفتار مناسب در بک‌اند
OHLC_UNSUPPORTED_INTERVAL یا OHLC_INVALID_RANGEورودی فهرست مجاز را اصلاح کنید و همان درخواست را دوباره نفرستید.
OHLC_RANGE_TOO_LARGE یا OHLC_BUCKET_LIMIT_EXCEEDEDبازهٔ زمانی را کوتاه کنید.
OHLC_BEFORE_AVAILABLE_HISTORYavailableFrom برگشتی را به‌عنوان اولین مرز معتبر بگیرید.
OHLC_UNAVAILABLE یا 404برای همان نماد، وضعیت «دادهٔ نمودار در دسترس نیست» نشان دهید؛ مقدار صفر نسازید.
401 یا 403اطلاعات احراز هویت یا دسترسی حساب را در سرور اصلاح کنید. API Key را از کاربر مرورگر نخواهید.
429به‌روزرسانی دوره‌ای را تا آزاد شدن سهمیه متوقف کنید و درخواست را پشت سر هم تکرار نکنید.
502، 503 یا 504مهلت پاسخ کوتاه، افزایش تدریجی فاصلهٔ تلاش‌ها، کمی تأخیر تصادفی و سقف تلاش مشخص داشته باشید.

درج منبع و چرخهٔ عمر نمودار را دقیق مدیریت کنید

Lightweight Charts طبق مجوزش به درج نام سازنده، TradingView، نیاز دارد؛ لوگوی مربوط را نگه دارید یا متن و لینک خواسته‌شده در مجوز را قرار دهید. جدا از آن، مسیر احرازشدهٔ GET /api/v1/access را بخوانید و الزام درج منبع برای دادهٔ بازار سرویکس را هم رعایت کنید. این دو موضوع مستقل‌اند.

برای حفظ سهمیه، پنجره‌های یکسان OHLC را در حافظهٔ موقت بک‌اند نگه دارید؛ اما برای کندل در حال تشکیل زمان ماندگاری کوتاه‌تری بگذارید. کلید ذخیره باید شامل نماد، بازهٔ زمانی، from و to باشد. هنگام عوض شدن بازه، درخواست قبلی را لغو کنید. وقتی نمودار از صفحه برداشته می‌شود، زمان‌سنج را پاک کنید، ResizeObserver را متوقف کنید و chart.remove() را صدا بزنید.

مستندات API مشتری، احراز هویت با API Key، نمادهای پشتیبانی‌شده و خطاها و سهمیه را هم ببینید. برای شروع می‌توانید حساب بسازید یا پلن‌ها و سهمیهٔ روزانه را مقایسه کنید.