در این راهنما، مسیر 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_HISTORY | availableFrom برگشتی را بهعنوان اولین مرز معتبر بگیرید. |
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، نمادهای پشتیبانیشده و خطاها و سهمیه را هم ببینید. برای شروع میتوانید حساب بسازید یا پلنها و سهمیهٔ روزانه را مقایسه کنید.