From 34a0d9a19e2ebbcce9461c9e8ed27273eedef00a Mon Sep 17 00:00:00 2001 From: alaa-eddine Date: Fri, 13 Mar 2026 02:52:04 +0100 Subject: [PATCH 1/2] Market Date Providers refactor + implementation of FMP and Alpaca --- src/PineTS.class.ts | 4 +- src/index.ts | 12 + src/marketData/Alpaca/AlpacaProvider.class.ts | 637 ++++++++++++++++++ src/marketData/BaseProvider.ts | 241 +++++++ .../Binance/BinanceProvider.class.ts | 83 +-- src/marketData/FMP/FMPProvider.class.ts | 531 +++++++++++++++ src/marketData/IProvider.ts | 33 +- src/marketData/Mock/MockProvider.class.ts | 65 +- src/marketData/Provider.class.ts | 12 +- src/marketData/aggregation.ts | 253 +++++++ src/marketData/types.ts | 312 +++++++++ tests/marketData/aggregation.test.ts | 363 ++++++++++ .../baseProviderAggregation.test.ts | 210 ++++++ 13 files changed, 2648 insertions(+), 108 deletions(-) create mode 100644 src/marketData/Alpaca/AlpacaProvider.class.ts create mode 100644 src/marketData/BaseProvider.ts create mode 100644 src/marketData/FMP/FMPProvider.class.ts create mode 100644 src/marketData/aggregation.ts create mode 100644 src/marketData/types.ts create mode 100644 tests/marketData/aggregation.test.ts create mode 100644 tests/marketData/baseProviderAggregation.test.ts diff --git a/src/PineTS.class.ts b/src/PineTS.class.ts index 3301c07..8a07976 100644 --- a/src/PineTS.class.ts +++ b/src/PineTS.class.ts @@ -107,9 +107,9 @@ export class PineTS { const _ohlc4 = marketData.map((d) => (d.high + d.low + d.open + d.close) / 4); const _hlcc4 = marketData.map((d) => (d.high + d.low + d.close + d.close) / 4); const _openTime = marketData.map((d) => d.openTime); - // Providers should supply closeTime in TV convention (= next bar open). + // Providers should supply closeTime as session close time (TV convention). // Safety-net for array-based data or providers that omit closeTime: - // estimate as openTime + timeframe duration. + // estimate as openTime + timeframe duration (accurate for 24/7 crypto). const tfDurationMs = getTimeframeDurationMs(this.timeframe); const _closeTime = marketData.map((d) => d.closeTime != null ? d.closeTime : d.openTime + tfDurationMs diff --git a/src/index.ts b/src/index.ts index be1096a..8363d72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,4 +6,16 @@ import { Context } from './Context.class'; import { Provider } from './marketData/Provider.class'; import { Indicator } from './Indicator'; +// Provider classes for direct instantiation +export { BaseProvider } from './marketData/BaseProvider'; +export { BinanceProvider } from './marketData/Binance/BinanceProvider.class'; +export { FMPProvider } from './marketData/FMP/FMPProvider.class'; +export { AlpacaProvider } from './marketData/Alpaca/AlpacaProvider.class'; + +// Provider types +export type { IProvider, ISymbolInfo, BaseProviderConfig, ApiKeyProviderConfig } from './marketData/IProvider'; +export type { Kline, PeriodType } from './marketData/types'; +export { computeNextPeriodStart, localTimeToUTC, computeSessionClose, TIMEFRAME_SECONDS, TIMEFRAME_PERIOD_INFO } from './marketData/types'; +export { aggregateCandles, selectSubTimeframe, getAggregationRatio } from './marketData/aggregation'; + export { PineTS, Context, Provider, Indicator }; diff --git a/src/marketData/Alpaca/AlpacaProvider.class.ts b/src/marketData/Alpaca/AlpacaProvider.class.ts new file mode 100644 index 0000000..2a7640f --- /dev/null +++ b/src/marketData/Alpaca/AlpacaProvider.class.ts @@ -0,0 +1,637 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { ISymbolInfo, ApiKeyProviderConfig } from '@pinets/marketData/IProvider'; +import { BaseProvider } from '@pinets/marketData/BaseProvider'; +import { Kline, PeriodType, computeNextPeriodStart, localTimeToUTC } from '@pinets/marketData/types'; + +// ── Constants ──────────────────────────────────────────────────────────── + +const ALPACA_DATA_URL = 'https://data.alpaca.markets'; +const ALPACA_TRADING_URL_PAPER = 'https://paper-api.alpaca.markets/v2'; +const ALPACA_TRADING_URL_LIVE = 'https://api.alpaca.markets/v2'; + +/** Max bars per page (Alpaca caps at 10,000). */ +const MAX_PAGE_LIMIT = 10_000; + +/** + * Maps PineTS timeframes to Alpaca timeframe strings. + * + * Alpaca supports: 1Min-59Min, 1Hour-23Hour, 1Day, 1Week, 1Month-12Month + */ +const TIMEFRAME_TO_ALPACA: Record = { + // Minutes + '1': '1Min', + '3': '3Min', + '5': '5Min', + '15': '15Min', + '30': '30Min', + // Hours + '60': '1Hour', + '120': '2Hour', + '240': '4Hour', + '4H': '4Hour', + // Daily / Weekly / Monthly + '1D': '1Day', + 'D': '1Day', + '1W': '1Week', + 'W': '1Week', + '1M': '1Month', + 'M': '1Month', +}; + +/** + * Maps exchange names from Alpaca asset info to IANA timezones. + */ +const EXCHANGE_TIMEZONE: Record = { + 'NASDAQ': 'America/New_York', + 'NYSE': 'America/New_York', + 'AMEX': 'America/New_York', + 'ARCA': 'America/New_York', + 'BATS': 'America/New_York', + 'OTC': 'America/New_York', + 'CRYPTO': 'Etc/UTC', +}; + +const EXCHANGE_SESSION: Record = { + 'NASDAQ': '0930-1600', + 'NYSE': '0930-1600', + 'AMEX': '0930-1600', + 'ARCA': '0930-1600', + 'BATS': '0930-1600', + 'OTC': '0930-1600', + 'CRYPTO': '24x7', +}; + +/** + * Maps Alpaca timeframe unit suffixes to PeriodType for calendar-aware closeTime. + */ +const ALPACA_UNIT_TO_PERIOD: Record = { + 'Min': 'minute', + 'Hour': 'hour', + 'Day': 'day', + 'Week': 'week', + 'Month': 'month', +}; + +// ── Config ─────────────────────────────────────────────────────────────── + +/** + * Configuration for AlpacaProvider. + * + * @property apiKey - Alpaca API Key ID + * @property apiSecret - Alpaca API Secret Key + * @property paper - Use paper trading endpoint for asset info (default: true) + * @property feed - Market data feed: 'sip' (paid, full market) or 'iex' (free tier). Default: 'sip' + * @property dataUrl - Override the market data base URL + * @property tradingUrl - Override the trading/asset API base URL + */ +export interface AlpacaProviderConfig extends ApiKeyProviderConfig { + apiSecret: string; + paper?: boolean; + feed?: 'sip' | 'iex'; + dataUrl?: string; + tradingUrl?: string; +} + +// ── Alpaca API response shapes ────────────────────────────────────────── + +interface AlpacaBar { + t: string; // ISO 8601 timestamp + o: number; // open + h: number; // high + l: number; // low + c: number; // close + v: number; // volume + n: number; // trade count + vw: number; // VWAP +} + +interface AlpacaBarsResponse { + bars: Record; + next_page_token: string | null; +} + +interface AlpacaCalendarDay { + date: string; // "2024-11-29" + open: string; // "09:30" + close: string; // "13:00" (early close) or "16:00" (normal) + session_open: string; // "09:30" + session_close: string; // "16:00" +} + +interface AlpacaAsset { + id: string; + class: string; // 'us_equity' | 'crypto' + exchange: string; // 'NASDAQ' | 'NYSE' | 'CRYPTO' etc + symbol: string; + name: string; + status: string; // 'active' | 'inactive' + tradable: boolean; + marginable: boolean; + shortable: boolean; + easy_to_borrow: boolean; + fractionable: boolean; + attributes: string[]; + min_order_size?: string; + min_trade_increment?: string; + price_increment?: string; +} + +// ── Provider ───────────────────────────────────────────────────────────── + +/** + * Alpaca Markets data provider. + * + * Supports US stocks and crypto via Alpaca's Market Data API v2. + * All timeframes (1Min through 1Month) are natively supported. + * + * ## Usage + * + * ### Direct instantiation: + * ```typescript + * const alpaca = new AlpacaProvider({ + * apiKey: 'PK...', + * apiSecret: '...', + * }); + * const pineTS = new PineTS(alpaca, 'AAPL', 'D', null, sDate, eDate); + * ``` + * + * ### Via Provider registry: + * ```typescript + * Provider.Alpaca.configure({ apiKey: 'PK...', apiSecret: '...' }); + * const pineTS = new PineTS(Provider.Alpaca, 'AAPL', 'D', null, sDate, eDate); + * ``` + * + * ## API Keys + * Get free API keys at https://alpaca.markets/ + * Free tier provides IEX data; paid plan adds SIP (full market) data. + * + * ## Symbol Format + * - Stocks: `AAPL`, `MSFT`, `SPY` + * - Crypto: `BTC/USD`, `ETH/USD` (slash notation) + */ +export class AlpacaProvider extends BaseProvider { + private _apiKey: string | null = null; + private _apiSecret: string | null = null; + private _dataUrl: string = ALPACA_DATA_URL; + private _tradingUrl: string = ALPACA_TRADING_URL_PAPER; + private _feed: 'sip' | 'iex' | null = null; + private _assetCache: Map = new Map(); + /** Calendar cache: date string "YYYY-MM-DD" → { open, close } times. */ + private _calendarCache: Map = new Map(); + + constructor(config?: AlpacaProviderConfig) { + super({ requiresApiKey: true, providerName: 'Alpaca' }); + if (config?.apiKey && config?.apiSecret) { + this.configure(config); + } + } + + configure(config: AlpacaProviderConfig): void { + super.configure(config); + this._apiKey = config.apiKey; + this._apiSecret = config.apiSecret; + + if (config.feed) this._feed = config.feed; + if (config.dataUrl) this._dataUrl = config.dataUrl; + if (config.tradingUrl) { + this._tradingUrl = config.tradingUrl; + } else if (config.paper === false) { + this._tradingUrl = ALPACA_TRADING_URL_LIVE; + } + } + + // ── Auth headers ───────────────────────────────────────────────────── + + private _headers(): Record { + return { + 'APCA-API-KEY-ID': this._apiKey!, + 'APCA-API-SECRET-KEY': this._apiSecret!, + }; + } + + // ── Market Data ────────────────────────────────────────────────────── + + protected getSupportedTimeframes(): Set { + return new Set(['1', '3', '5', '15', '30', '45', '60', '120', '180', '240', 'D', 'W', 'M']); + } + + protected async _getMarketDataNative( + tickerId: string, + timeframe: string, + limit?: number, + sDate?: number, + eDate?: number, + ): Promise { + this.ensureConfigured(); + + try { + const alpacaTf = this._resolveTimeframe(timeframe); + if (!alpacaTf) { + console.error(`Alpaca: Unsupported timeframe: ${timeframe}`); + return []; + } + + const isCrypto = this._isCrypto(tickerId); + const allBars = await this._fetchAllBars(tickerId, alpacaTf, isCrypto, sDate, eDate, limit); + + if (allBars.length === 0) return []; + + const { periodType, multiplier } = this._parseAlpacaTimeframe(alpacaTf); + + if (isCrypto) { + // Crypto: 24/7 market, use fixed duration (no session boundaries) + return this._convertBarsCrypto(allBars, periodType, multiplier); + } + + // Stocks: use Alpaca Calendar API for exact session close times + const firstBarDate = allBars[0].t.slice(0, 10); + const lastBarDate = allBars[allBars.length - 1].t.slice(0, 10); + // Fetch a bit beyond the last bar for weekly/monthly close lookups + const paddedEnd = this._addDaysToDate(lastBarDate, 40); + await this._ensureCalendar(firstBarDate, paddedEnd); + + return this._convertBarsStock(allBars, periodType, multiplier); + } catch (error) { + console.error('Error in AlpacaProvider.getMarketData:', error); + return []; + } + } + + /** + * Fetch all bars with automatic pagination. + */ + private async _fetchAllBars( + tickerId: string, + alpacaTf: string, + isCrypto: boolean, + sDate?: number, + eDate?: number, + limit?: number, + ): Promise { + const allBars: AlpacaBar[] = []; + let pageToken: string | null = null; + const maxBars = limit || Infinity; + // Use the max page size to minimise round-trips + const pageLimit = Math.min(limit || MAX_PAGE_LIMIT, MAX_PAGE_LIMIT); + + do { + const url = this._buildBarsUrl(tickerId, alpacaTf, isCrypto, sDate, eDate, pageLimit, pageToken); + const response = await fetch(url, { headers: this._headers() }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Alpaca HTTP ${response.status}: ${text}`); + } + + const data: AlpacaBarsResponse = await response.json(); + + // Alpaca wraps bars in { bars: { SYMBOL: [...] } } + const symbolKey = Object.keys(data.bars || {})[0]; + if (!symbolKey || !data.bars[symbolKey]) break; + + allBars.push(...data.bars[symbolKey]); + pageToken = data.next_page_token; + + // Stop if we have enough bars + if (allBars.length >= maxBars) { + break; + } + } while (pageToken); + + // Trim to exact limit + if (limit && limit > 0 && allBars.length > limit) { + return allBars.slice(0, limit); + } + + return allBars; + } + + /** + * Build the bars URL for stocks or crypto. + */ + private _buildBarsUrl( + tickerId: string, + alpacaTf: string, + isCrypto: boolean, + sDate?: number, + eDate?: number, + limit?: number, + pageToken?: string | null, + ): string { + const base = isCrypto + ? `${this._dataUrl}/v1beta3/crypto/us/bars` + : `${this._dataUrl}/v2/stocks/bars`; + + const params = new URLSearchParams(); + params.set('symbols', tickerId); + params.set('timeframe', alpacaTf); + params.set('sort', 'asc'); + + if (sDate) params.set('start', new Date(sDate).toISOString()); + if (eDate) params.set('end', new Date(eDate).toISOString()); + if (limit) params.set('limit', String(Math.min(limit, MAX_PAGE_LIMIT))); + if (pageToken) params.set('page_token', pageToken); + if (!isCrypto && this._feed) params.set('feed', this._feed); + + return `${base}?${params.toString()}`; + } + + // ── Symbol Info ────────────────────────────────────────────────────── + + async getSymbolInfo(tickerId: string): Promise { + this.ensureConfigured(); + + try { + const asset = await this._fetchAsset(tickerId); + if (!asset) { + console.error(`Alpaca: Symbol ${tickerId} not found`); + return null; + } + + const exchange = asset.exchange || ''; + const isCrypto = asset.class === 'crypto'; + const timezone = isCrypto ? 'Etc/UTC' : (EXCHANGE_TIMEZONE[exchange] || 'America/New_York'); + const session = isCrypto ? '24x7' : (EXCHANGE_SESSION[exchange] || '0930-1600'); + + // Determine asset type + let type = 'stock'; + if (isCrypto) type = 'crypto'; + + // Derive currency from crypto symbol (e.g., BTC/USD → USD) + const currency = isCrypto + ? (tickerId.split('/')[1] || 'USD') + : 'USD'; // Alpaca is US-only for equities + + // Base asset for crypto (e.g., BTC/USD → BTC) + const baseCurrency = isCrypto + ? (tickerId.split('/')[0] || tickerId) + : currency; + + // Price precision from asset info + const priceIncrement = asset.price_increment + ? parseFloat(asset.price_increment) + : 0.01; + const pricescale = Math.round(1 / priceIncrement); + + const symbolInfo: ISymbolInfo = { + // Symbol Identification + ticker: asset.symbol, + tickerid: `${exchange}:${asset.symbol}`, + prefix: exchange, + root: isCrypto ? tickerId.split('/')[0] : asset.symbol, + description: asset.name || asset.symbol, + type, + main_tickerid: `${exchange}:${asset.symbol}`, + current_contract: '', + isin: '', + + // Currency & Location + basecurrency: baseCurrency, + currency, + timezone, + country: isCrypto ? '' : 'US', + + // Price & Contract Info + mintick: priceIncrement, + pricescale, + minmove: 1, + pointvalue: 1, + mincontract: asset.min_order_size ? parseFloat(asset.min_order_size) : 0, + + // Session & Market + session, + volumetype: 'base', + expiration_date: 0, + + // Company Data (not provided by Alpaca asset endpoint) + employees: 0, + industry: '', + sector: '', + shareholders: 0, + shares_outstanding_float: 0, + shares_outstanding_total: 0, + + // Analyst Ratings (not provided) + recommendations_buy: 0, + recommendations_buy_strong: 0, + recommendations_date: 0, + recommendations_hold: 0, + recommendations_sell: 0, + recommendations_sell_strong: 0, + recommendations_total: 0, + + // Price Targets (not provided) + target_price_average: 0, + target_price_date: 0, + target_price_estimates: 0, + target_price_high: 0, + target_price_low: 0, + target_price_median: 0, + }; + + return symbolInfo; + } catch (error) { + console.error('Error in AlpacaProvider.getSymbolInfo:', error); + return null; + } + } + + // ── Bar conversion ───────────────────────────────────────────────── + + /** + * Convert bars for crypto (24/7 — no session boundaries). + * closeTime = next bar's openTime, or openTime + period for last bar. + */ + private _convertBarsCrypto(bars: AlpacaBar[], periodType: PeriodType, multiplier: number): Kline[] { + return bars.map((bar, i, arr) => { + const openTime = new Date(bar.t).getTime(); + const closeTime = (i < arr.length - 1) + ? new Date(arr[i + 1].t).getTime() + : computeNextPeriodStart(openTime, periodType, multiplier); + return this._toKline(bar, openTime, closeTime); + }); + } + + /** + * Convert bars for stocks using the Alpaca trading calendar. + * closeTime = exact session close from the calendar (handles early closes, DST). + */ + private _convertBarsStock(bars: AlpacaBar[], periodType: PeriodType, multiplier: number): Kline[] { + const TZ = 'America/New_York'; // All Alpaca US equity exchanges + + return bars.map((bar) => { + const openTime = new Date(bar.t).getTime(); + const barDate = bar.t.slice(0, 10); // "YYYY-MM-DD" + + let closeTime: number; + + if (periodType === 'day') { + // Daily: session close on the bar's date + const cal = this._calendarCache.get(barDate); + const closeStr = cal ? cal.close : '16:00'; + closeTime = localTimeToUTC(barDate, closeStr, TZ); + } else if (periodType === 'minute' || periodType === 'hour') { + // Intraday: min(openTime + barDuration, session close on that day) + const MS_PER_UNIT: Record = { minute: 60_000, hour: 3_600_000 }; + const barEndMs = openTime + multiplier * MS_PER_UNIT[periodType]; + const cal = this._calendarCache.get(barDate); + const closeStr = cal ? cal.close : '16:00'; + const sessionEndMs = localTimeToUTC(barDate, closeStr, TZ); + closeTime = Math.min(barEndMs, sessionEndMs); + } else if (periodType === 'week') { + // Weekly: find the last trading day in that week from the calendar + closeTime = this._weeklyCloseFromCalendar(barDate, TZ); + } else if (periodType === 'month') { + // Monthly: find the last trading day of the month from the calendar + closeTime = this._monthlyCloseFromCalendar(barDate, TZ); + } else { + closeTime = computeNextPeriodStart(openTime, periodType, multiplier); + } + + return this._toKline(bar, openTime, closeTime); + }); + } + + /** Build a Kline from an AlpacaBar + computed times. */ + private _toKline(bar: AlpacaBar, openTime: number, closeTime: number): Kline { + return { + openTime, + open: bar.o, + high: bar.h, + low: bar.l, + close: bar.c, + volume: bar.v, + closeTime, + quoteAssetVolume: 0, + numberOfTrades: bar.n, + takerBuyBaseAssetVolume: 0, + takerBuyQuoteAssetVolume: 0, + ignore: 0, + }; + } + + /** + * Find the last trading day of the week containing `barDate` and return + * its session close time in UTC ms. + */ + private _weeklyCloseFromCalendar(barDate: string, timezone: string): number { + const d = new Date(barDate + 'T00:00:00Z'); + const dayOfWeek = d.getUTCDay(); // 0=Sun..6=Sat + // Go to end of week (Friday = day 5). If on Saturday, next Friday is +6 days ahead. + const fridayOffset = dayOfWeek <= 5 ? (5 - dayOfWeek) : (5 - dayOfWeek + 7); + // Search backward from Friday to find the last actual trading day + for (let offset = fridayOffset; offset >= 0; offset--) { + const checkMs = d.getTime() + offset * 86_400_000; + const checkDate = new Date(checkMs).toISOString().split('T')[0]; + const cal = this._calendarCache.get(checkDate); + if (cal) { + return localTimeToUTC(checkDate, cal.close, timezone); + } + } + // Fallback: Friday at 16:00 ET + const fridayMs = d.getTime() + fridayOffset * 86_400_000; + const fridayDate = new Date(fridayMs).toISOString().split('T')[0]; + return localTimeToUTC(fridayDate, '16:00', timezone); + } + + /** + * Find the last trading day of the month containing `barDate` and return + * its session close time in UTC ms. + */ + private _monthlyCloseFromCalendar(barDate: string, timezone: string): number { + const [year, month] = barDate.split('-').map(Number); + // Last day of the month + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); // month is 1-based here, Date.UTC(y, m, 0) = last day of m-1+1 + // Search backward from last day of month + for (let day = lastDay; day >= 1; day--) { + const checkDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const cal = this._calendarCache.get(checkDate); + if (cal) { + return localTimeToUTC(checkDate, cal.close, timezone); + } + } + // Fallback: last weekday of month at 16:00 + const lastDate = new Date(Date.UTC(year, month, 0)); + while (lastDate.getUTCDay() === 0 || lastDate.getUTCDay() === 6) { + lastDate.setUTCDate(lastDate.getUTCDate() - 1); + } + return localTimeToUTC(lastDate.toISOString().split('T')[0], '16:00', timezone); + } + + // ── Calendar API ──────────────────────────────────────────────────── + + /** + * Ensure the calendar cache covers the given date range. + * Fetches from Alpaca's `GET /v2/calendar` endpoint, which returns + * per-day trading hours including early closes (data from 1970-2029). + */ + private async _ensureCalendar(startDate: string, endDate: string): Promise { + // Check if we already have data for this range + if (this._calendarCache.has(startDate) && this._calendarCache.has(endDate)) { + return; // Likely already fetched + } + + const url = `${this._tradingUrl}/calendar?start=${startDate}&end=${endDate}`; + const response = await fetch(url, { headers: this._headers() }); + if (!response.ok) { + console.warn(`Alpaca calendar API returned ${response.status}, falling back to defaults`); + return; + } + + const days: AlpacaCalendarDay[] = await response.json(); + for (const day of days) { + this._calendarCache.set(day.date, day); + } + } + + // ── Private helpers ────────────────────────────────────────────────── + + private async _fetchAsset(tickerId: string): Promise { + if (this._assetCache.has(tickerId)) { + return this._assetCache.get(tickerId)!; + } + + const encodedSymbol = encodeURIComponent(tickerId); + const url = `${this._tradingUrl}/assets/${encodedSymbol}`; + const response = await fetch(url, { headers: this._headers() }); + if (!response.ok) return null; + + const asset: AlpacaAsset = await response.json(); + this._assetCache.set(tickerId, asset); + return asset; + } + + /** + * Parse an Alpaca timeframe string (e.g., '1Min', '4Hour', '1Month') + * into a PeriodType and multiplier for calendar-aware date math. + */ + private _parseAlpacaTimeframe(alpacaTf: string): { periodType: PeriodType; multiplier: number } { + const match = alpacaTf.match(/^(\d+)(Min|Hour|Day|Week|Month)$/); + if (match) { + const multiplier = parseInt(match[1], 10); + const periodType = ALPACA_UNIT_TO_PERIOD[match[2]]; + if (periodType) return { periodType, multiplier }; + } + return { periodType: 'day', multiplier: 1 }; + } + + /** Resolve PineTS timeframe to Alpaca timeframe string. */ + private _resolveTimeframe(timeframe: string): string | null { + return TIMEFRAME_TO_ALPACA[timeframe.toUpperCase()] + || TIMEFRAME_TO_ALPACA[timeframe] + || null; + } + + /** Heuristic: crypto tickers contain '/'. */ + private _isCrypto(tickerId: string): boolean { + return tickerId.includes('/'); + } + + /** Add N days to a "YYYY-MM-DD" date string. */ + private _addDaysToDate(dateStr: string, days: number): string { + const d = new Date(dateStr + 'T00:00:00Z'); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().split('T')[0]; + } +} diff --git a/src/marketData/BaseProvider.ts b/src/marketData/BaseProvider.ts new file mode 100644 index 0000000..3ea2a05 --- /dev/null +++ b/src/marketData/BaseProvider.ts @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { IProvider, ISymbolInfo, BaseProviderConfig } from './IProvider'; +import { Kline, normalizeCloseTime } from './types'; +import { selectSubTimeframe, aggregateCandles, getAggregationRatio } from './aggregation'; + +/** + * Normalize a user-supplied timeframe key to the canonical form used + * by `getSupportedTimeframes()` and `TIMEFRAME_SECONDS`. + * + * Canonical forms: seconds as 'NS', minutes as plain integers, + * calendar periods as D/W/M. + */ +const TF_NORMALIZE: Record = { + // Lowercase / Binance-style aliases + '1s': '1S', '5s': '5S', '10s': '10S', '15s': '15S', '30s': '30S', + '1m': '1', '3m': '3', '5m': '5', '15m': '15', '30m': '30', '45m': '45', + '1h': '60', '2h': '120', '3h': '180', '4h': '240', + '1d': 'D', '1w': 'W', + // Uppercase aliases + '1D': 'D', '1W': 'W', '1M': 'M', '4H': '240', + // Pass-through canonical keys + 'D': 'D', 'W': 'W', 'M': 'M', +}; + +function normalizeTimeframeKey(timeframe: string): string { + // Direct match (case-sensitive for '1M' vs '1m') + if (TF_NORMALIZE[timeframe] !== undefined) return TF_NORMALIZE[timeframe]; + // Try lowercase + const lower = timeframe.toLowerCase(); + if (TF_NORMALIZE[lower] !== undefined) return TF_NORMALIZE[lower]; + // Already a canonical number ('1', '60', '240', etc.) + if (/^\d+$/.test(timeframe)) return timeframe; + // Second-based ('30S', etc.) + if (/^\d+S$/i.test(timeframe)) return timeframe.toUpperCase(); + return timeframe; +} + +/** + * Abstract base class for market data providers. + * + * Provides shared logic: closeTime normalization, fail-early API key + * validation, and **automatic candle aggregation** for unsupported + * timeframes. + * + * ## Aggregation + * + * When a provider doesn't natively support a timeframe, `getMarketData()` + * automatically: + * 1. Selects the best sub-timeframe the provider supports + * 2. Fetches sub-candles via `_getMarketDataNative()` + * 3. Aggregates them into the requested timeframe + * + * Providers declare native support via `getSupportedTimeframes()` and + * implement `_getMarketDataNative()` for the actual API call. + * + * ## Usage + * + * ```typescript + * class MyProvider extends BaseProvider { + * protected getSupportedTimeframes() { + * return new Set(['1', '5', '15', '60', 'D']); + * } + * protected async _getMarketDataNative(...) { ... } + * } + * ``` + */ +export abstract class BaseProvider implements IProvider { + private _configured: boolean; + private _requiresApiKey: boolean; + private _providerName: string; + private _aggregationSubTimeframe: string | null = null; + + constructor(options: { requiresApiKey: boolean; providerName: string }) { + this._requiresApiKey = options.requiresApiKey; + this._providerName = options.providerName; + this._configured = !options.requiresApiKey; + } + + /** + * Fail-early check — call at the top of `_getMarketDataNative()` / `getSymbolInfo()` + * in providers that require an API key. + */ + protected ensureConfigured(): void { + if (!this._configured) { + throw new Error( + `${this._providerName} requires configuration before use. ` + + `Call Provider.${this._providerName}.configure({ apiKey: '...' }) ` + + `or instantiate directly: new ${this._providerName}Provider({ apiKey: '...' })` + ); + } + } + + /** + * Base configure — marks the provider as configured. + * Subclasses override to store their specific config, and must call `super.configure(config)`. + */ + configure(config: TConfig): void { + this._configured = true; + } + + /** Whether this provider has been configured (always true for keyless providers). */ + get isConfigured(): boolean { + return this._configured; + } + + /** + * Shared closeTime normalization utility. + * Delegates to the standalone `normalizeCloseTime()` from `types.ts`. + */ + protected normalizeCloseTime(data: Kline[]): void { + normalizeCloseTime(data); + } + + /** + * Override the sub-timeframe used for aggregation. + * When set, this timeframe is used instead of auto-selecting the best divisor. + * Set to `null` to re-enable automatic selection. + */ + setAggregationSubTimeframe(subTimeframe: string | null): void { + this._aggregationSubTimeframe = subTimeframe; + } + + // ── Timeframe support ─────────────────────────────────────────────── + + /** + * Return the set of timeframes this provider supports natively. + * + * Override in subclasses. Default: all canonical timeframes (no aggregation). + * Use canonical keys: '1','3','5','15','30','45','60','120','180','240','D','W','M' + * and optionally second-based: '1S','5S','10S','15S','30S'. + */ + protected getSupportedTimeframes(): Set { + return new Set([ + '1S', '5S', '10S', '15S', '30S', + '1', '3', '5', '15', '30', '45', '60', '120', '180', '240', + 'D', 'W', 'M', + ]); + } + + // ── Market data orchestrator ──────────────────────────────────────── + + /** + * Fetch market data — delegates to native fetch or aggregates from sub-candles. + * + * 1. If the timeframe is natively supported, delegates to `_getMarketDataNative()`. + * 2. Otherwise, selects the best sub-timeframe, fetches sub-candles, and aggregates. + */ + async getMarketData( + tickerId: string, + timeframe: string, + limit?: number, + sDate?: number, + eDate?: number, + ): Promise { + const normalizedTf = normalizeTimeframeKey(timeframe); + const supported = this.getSupportedTimeframes(); + + // Fast path: natively supported + if (supported.has(normalizedTf)) { + return this._getMarketDataNative(tickerId, normalizedTf, limit, sDate, eDate); + } + + // Aggregation path + const forcedSub = this._aggregationSubTimeframe; + const subTimeframe = (forcedSub && supported.has(forcedSub)) + ? forcedSub + : selectSubTimeframe(normalizedTf, supported); + + if (!subTimeframe) { + console.error( + `${this._providerName}: Timeframe '${timeframe}' is not supported ` + + `and no valid sub-timeframe found for aggregation.`, + ); + return []; + } + + // Inflate limit to account for aggregation ratio + const subLimit = this._computeSubLimit(normalizedTf, subTimeframe, limit); + + // Fetch sub-candles + const subCandles = await this._getMarketDataNative( + tickerId, subTimeframe, subLimit, sDate, eDate, + ); + + if (subCandles.length === 0) return []; + + // Aggregate + const aggregated = aggregateCandles(subCandles, normalizedTf, subTimeframe); + + // Apply limit to the aggregated result + if (limit && limit > 0 && aggregated.length > limit) { + return aggregated.slice(aggregated.length - limit); + } + + return aggregated; + } + + // ── Abstract methods for subclasses ───────────────────────────────── + + /** + * Fetch market data natively from the provider's API. + * + * Subclasses MUST implement this. It is called by the BaseProvider + * orchestrator either for the requested timeframe (if natively supported) + * or for a sub-candle timeframe (if aggregation is needed). + */ + protected abstract _getMarketDataNative( + tickerId: string, + timeframe: string, + limit?: number, + sDate?: number, + eDate?: number, + ): Promise; + + abstract getSymbolInfo(tickerId: string): Promise; + + // ── Private helpers ───────────────────────────────────────────────── + + /** + * Compute how many sub-candles to fetch to produce `limit` aggregated candles. + */ + private _computeSubLimit( + targetTf: string, + subTf: string, + limit?: number, + ): number | undefined { + if (!limit) return undefined; // No limit → fetch all + + const ratio = getAggregationRatio(targetTf, subTf); + if (ratio === Infinity) { + // Calendar-based: generous estimate + if (targetTf === 'W') return limit * 7 + 14; + if (targetTf === 'M') return limit * 31 + 31; + return limit * 30; + } + + // Fixed ratio + small buffer for alignment edge cases + return Math.ceil(limit * ratio) + Math.ceil(ratio); + } +} diff --git a/src/marketData/Binance/BinanceProvider.class.ts b/src/marketData/Binance/BinanceProvider.class.ts index 24ca5d9..d3dc0e0 100644 --- a/src/marketData/Binance/BinanceProvider.class.ts +++ b/src/marketData/Binance/BinanceProvider.class.ts @@ -23,13 +23,18 @@ const timeframe_to_binance = { M: '1M', // 1 month }; -import { IProvider, ISymbolInfo } from '@pinets/marketData/IProvider'; +import { ISymbolInfo } from '@pinets/marketData/IProvider'; +import { BaseProvider } from '@pinets/marketData/BaseProvider'; +import { Kline, INTERVAL_DURATION_MS } from '@pinets/marketData/types'; interface CacheEntry { data: T; timestamp: number; } +/** Config for BinanceProvider (no API key needed). */ +export interface BinanceProviderConfig {} + class CacheManager { private cache: Map>; private readonly cacheDuration: number; @@ -84,29 +89,15 @@ class CacheManager { } } -export class BinanceProvider implements IProvider { - private cacheManager: CacheManager; +export class BinanceProvider extends BaseProvider { + private cacheManager: CacheManager; private activeApiUrl: string | null = null; // Persist the working endpoint constructor() { + super({ requiresApiKey: false, providerName: 'Binance' }); this.cacheManager = new CacheManager(5 * 60 * 1000); // 5 minutes cache duration } - /** - * Normalize closeTime to TradingView convention: closeTime = next bar's openTime. - * Binance raw API returns closeTime as (nextBarOpen - 1ms). For all bars except the - * last, we use the next bar's actual openTime (exact). For the last bar, we add 1ms - * to the raw value. - */ - private _normalizeCloseTime(data: any[]): void { - for (let i = 0; i < data.length - 1; i++) { - data[i].closeTime = data[i + 1].openTime; - } - if (data.length > 0) { - data[data.length - 1].closeTime = data[data.length - 1].closeTime + 1; - } - } - /** * Resolves the working Binance API endpoint. * Tries default first, then falls back to US endpoint. @@ -154,7 +145,7 @@ export class BinanceProvider implements IProvider { * Fetch a single chunk of raw kline data from the Binance API (no closeTime normalization). * Used internally by pagination methods that assemble chunks before normalizing. */ - private async _fetchRawChunk(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise { + private async _fetchRawChunk(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise { const interval = timeframe_to_binance[timeframe.toUpperCase()]; if (!interval) { console.error(`Unsupported timeframe: ${timeframe}`); @@ -196,7 +187,7 @@ export class BinanceProvider implements IProvider { })); } - async getMarketDataInterval(tickerId: string, timeframe: string, sDate: number, eDate: number): Promise { + async getMarketDataInterval(tickerId: string, timeframe: string, sDate: number, eDate: number): Promise { try { const interval = timeframe_to_binance[timeframe.toUpperCase()]; if (!interval) { @@ -204,24 +195,10 @@ export class BinanceProvider implements IProvider { return []; } - const timeframeDurations = { - '1m': 60 * 1000, - '3m': 3 * 60 * 1000, - '5m': 5 * 60 * 1000, - '15m': 15 * 60 * 1000, - '30m': 30 * 60 * 1000, - '1h': 60 * 60 * 1000, - '2h': 2 * 60 * 60 * 1000, - '4h': 4 * 60 * 60 * 1000, - '1d': 24 * 60 * 60 * 1000, - '1w': 7 * 24 * 60 * 60 * 1000, - '1M': 30 * 24 * 60 * 60 * 1000, - }; - - let allData = []; + let allData: Kline[] = []; let currentStart = sDate; const endTime = eDate; - const intervalDuration = timeframeDurations[interval]; + const intervalDuration = INTERVAL_DURATION_MS[interval]; if (!intervalDuration) { console.error(`Duration not defined for interval: ${interval}`); @@ -242,7 +219,7 @@ export class BinanceProvider implements IProvider { } // Normalize closeTime on the fully assembled data - this._normalizeCloseTime(allData); + this.normalizeCloseTime(allData); return allData; } catch (error) { console.error('Error in getMarketDataInterval:', error); @@ -250,9 +227,9 @@ export class BinanceProvider implements IProvider { } } - private async getMarketDataBackwards(tickerId: string, timeframe: string, limit: number, endTime?: number): Promise { + private async getMarketDataBackwards(tickerId: string, timeframe: string, limit: number, endTime?: number): Promise { let remaining = limit; - let allData: any[] = []; + let allData: Kline[] = []; let currentEndTime = endTime; // Safety break to prevent infinite loops @@ -281,11 +258,15 @@ export class BinanceProvider implements IProvider { } // Normalize closeTime on the fully assembled data - this._normalizeCloseTime(allData); + this.normalizeCloseTime(allData); return allData; } - async getMarketData(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise { + protected getSupportedTimeframes(): Set { + return new Set(['1', '3', '5', '15', '30', '60', '120', '240', 'D', 'W', 'M']); + } + + protected async _getMarketDataNative(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise { try { // Check cache first // Skip cache if eDate is undefined (live request) to ensure we get fresh data @@ -327,7 +308,7 @@ export class BinanceProvider implements IProvider { // Single chunk — fetch raw, then normalize const data = await this._fetchRawChunk(tickerId, timeframe, limit, sDate, eDate); - this._normalizeCloseTime(data); + this.normalizeCloseTime(data); if (shouldCache) { this.cacheManager.set(cacheParams, data); @@ -352,21 +333,8 @@ export class BinanceProvider implements IProvider { // If we have both start and end dates, calculate required candles if (sDate && eDate) { const interval = timeframe_to_binance[timeframe.toUpperCase()]; - const timeframeDurations = { - '1m': 60 * 1000, - '3m': 3 * 60 * 1000, - '5m': 5 * 60 * 1000, - '15m': 15 * 60 * 1000, - '30m': 30 * 60 * 1000, - '1h': 60 * 60 * 1000, - '2h': 2 * 60 * 60 * 1000, - '4h': 4 * 60 * 60 * 1000, - '1d': 24 * 60 * 60 * 1000, - '1w': 7 * 24 * 60 * 60 * 1000, - '1M': 30 * 24 * 60 * 60 * 1000, - }; - const intervalDuration = timeframeDurations[interval]; + const intervalDuration = INTERVAL_DURATION_MS[interval]; if (intervalDuration) { const requiredCandles = Math.ceil((eDate - sDate) / intervalDuration); // Need pagination if date range requires more than 1000 candles @@ -506,7 +474,4 @@ export class BinanceProvider implements IProvider { } } - configure(config: any): void { - // Nothing to configure in BinanceProvider - } } diff --git a/src/marketData/FMP/FMPProvider.class.ts b/src/marketData/FMP/FMPProvider.class.ts new file mode 100644 index 0000000..da4d720 --- /dev/null +++ b/src/marketData/FMP/FMPProvider.class.ts @@ -0,0 +1,531 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { ISymbolInfo, ApiKeyProviderConfig } from '@pinets/marketData/IProvider'; +import { BaseProvider } from '@pinets/marketData/BaseProvider'; +import { Kline, PeriodType, computeNextPeriodStart, computeSessionClose } from '@pinets/marketData/types'; + +// ── Constants ──────────────────────────────────────────────────────────── + +const FMP_BASE_URL = 'https://financialmodelingprep.com'; + +/** + * Maps PineTS timeframes to FMP endpoint paths. + * + * Daily data: `/stable/historical-price-eod/full` + * Intraday: `/stable/historical-chart/{interval}` (paid plans only) + */ +const TIMEFRAME_TO_FMP: Record = { + // Daily — uses the EOD endpoint + '1D': { endpoint: '/stable/historical-price-eod/full', type: 'daily' }, + 'D': { endpoint: '/stable/historical-price-eod/full', type: 'daily' }, + + // Intraday — uses the chart endpoint (paid plans) + '1': { endpoint: '/stable/historical-chart/1min', interval: '1min', type: 'intraday' }, + '5': { endpoint: '/stable/historical-chart/5min', interval: '5min', type: 'intraday' }, + '15': { endpoint: '/stable/historical-chart/15min', interval: '15min', type: 'intraday' }, + '30': { endpoint: '/stable/historical-chart/30min', interval: '30min', type: 'intraday' }, + '60': { endpoint: '/stable/historical-chart/1hour', interval: '1hour', type: 'intraday' }, + '240': { endpoint: '/stable/historical-chart/4hour', interval: '4hour', type: 'intraday' }, + '4H': { endpoint: '/stable/historical-chart/4hour', interval: '4hour', type: 'intraday' }, +}; + +/** + * Maps exchange names returned by FMP to IANA timezones. + * Falls back to 'America/New_York' for unknown US exchanges. + */ +const EXCHANGE_TIMEZONE: Record = { + 'NASDAQ': 'America/New_York', + 'NYSE': 'America/New_York', + 'AMEX': 'America/New_York', + 'NYSEArca': 'America/New_York', + 'BATS': 'America/New_York', + 'OTC': 'America/New_York', + 'PNK': 'America/New_York', + 'TSX': 'America/Toronto', + 'TSXV': 'America/Toronto', + 'LSE': 'Europe/London', + 'EURONEXT': 'Europe/Paris', + 'XETRA': 'Europe/Berlin', + 'JPX': 'Asia/Tokyo', + 'HKSE': 'Asia/Hong_Kong', + 'SSE': 'Asia/Shanghai', + 'SHZ': 'Asia/Shanghai', + 'ASX': 'Australia/Sydney', + 'NSE': 'Asia/Kolkata', + 'BSE': 'Asia/Kolkata', + 'KRX': 'Asia/Seoul', + 'CRYPTO': 'Etc/UTC', +}; + +const EXCHANGE_SESSION: Record = { + 'NASDAQ': '0930-1600', + 'NYSE': '0930-1600', + 'AMEX': '0930-1600', + 'NYSEArca': '0930-1600', + 'BATS': '0930-1600', + 'OTC': '0930-1600', + 'PNK': '0930-1600', + 'TSX': '0930-1600', + 'TSXV': '0930-1600', + 'LSE': '0800-1630', + 'EURONEXT': '0900-1730', + 'XETRA': '0900-1730', + 'JPX': '0900-1530', + 'HKSE': '0930-1600', + 'SSE': '0930-1500', + 'SHZ': '0930-1500', + 'ASX': '1000-1600', + 'NSE': '0915-1530', + 'BSE': '0915-1530', + 'KRX': '0900-1530', + 'CRYPTO': '24x7', +}; + +/** + * Maps FMP interval strings to { periodType, multiplier } for calendar-aware closeTime. + */ +const FMP_INTERVAL_PERIOD: Record = { + 'daily': { periodType: 'day', multiplier: 1 }, + '1min': { periodType: 'minute', multiplier: 1 }, + '5min': { periodType: 'minute', multiplier: 5 }, + '15min': { periodType: 'minute', multiplier: 15 }, + '30min': { periodType: 'minute', multiplier: 30 }, + '1hour': { periodType: 'hour', multiplier: 1 }, + '4hour': { periodType: 'hour', multiplier: 4 }, +}; + +// ── Config ─────────────────────────────────────────────────────────────── + +/** Configuration for FMPProvider — requires an API key. */ +export interface FMPProviderConfig extends ApiKeyProviderConfig { + /** Optional: override the base URL (e.g. for proxy or self-hosted). */ + baseUrl?: string; +} + +// ── FMP API response shapes ───────────────────────────────────────────── + +interface FMPDailyBar { + symbol: string; + date: string; // "2025-01-10" + open: number; + high: number; + low: number; + close: number; + volume: number; + change: number; + changePercent: number; + vwap: number; +} + +interface FMPIntradayBar { + date: string; // "2025-01-10 09:30:00" + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +interface FMPProfile { + symbol: string; + price: number; + marketCap: number; + beta: number; + lastDividend: number; + range: string; + change: number; + changePercentage: number; + volume: number; + averageVolume: number; + companyName: string; + currency: string; + cik: string; + isin: string; + cusip: string; + exchangeFullName: string; + exchange: string; + industry: string; + website: string; + description: string; + ceo: string; + sector: string; + country: string; + fullTimeEmployees: string; + phone: string; + address: string; + city: string; + state: string; + zip: string; + image: string; + ipoDate: string; + defaultImage: boolean; + isEtf: boolean; + isActivelyTrading: boolean; + isAdr: boolean; + isFund: boolean; +} + +// ── Provider ───────────────────────────────────────────────────────────── + +/** + * Financial Modeling Prep (FMP) market data provider. + * + * Supports stocks, ETFs, crypto, and forex via FMP's stable API. + * + * ## Usage + * + * ### Direct instantiation: + * ```typescript + * const fmp = new FMPProvider({ apiKey: 'your-key' }); + * const pineTS = new PineTS(fmp, 'AAPL', 'D', null, sDate, eDate); + * ``` + * + * ### Via Provider registry: + * ```typescript + * Provider.FMP.configure({ apiKey: 'your-key' }); + * const pineTS = new PineTS(Provider.FMP, 'AAPL', 'D', null, sDate, eDate); + * ``` + * + * ## API Key + * Get a free API key (250 req/day) at https://financialmodelingprep.com/ + * Intraday data (1min, 5min, 15min, 30min, 1h, 4h) requires a paid plan. + * + * ## Symbol Format + * Use standard ticker symbols: `AAPL`, `MSFT`, `SPY`, `BTCUSD`, `EURUSD` + */ +export class FMPProvider extends BaseProvider { + private _apiKey: string | null = null; + private _baseUrl: string = FMP_BASE_URL; + private _profileCache: Map = new Map(); + + constructor(config?: FMPProviderConfig) { + super({ requiresApiKey: true, providerName: 'FMP' }); + if (config?.apiKey) { + this.configure(config); + } + } + + configure(config: FMPProviderConfig): void { + super.configure(config); + this._apiKey = config.apiKey; + if (config.baseUrl) { + this._baseUrl = config.baseUrl; + } + } + + // ── Market Data ────────────────────────────────────────────────────── + + protected getSupportedTimeframes(): Set { + return new Set(['1', '5', '15', '30', '60', '240', 'D']); + } + + protected async _getMarketDataNative( + tickerId: string, + timeframe: string, + limit?: number, + sDate?: number, + eDate?: number, + ): Promise { + this.ensureConfigured(); + + try { + const tfKey = timeframe.toUpperCase(); + const mapping = TIMEFRAME_TO_FMP[tfKey] || TIMEFRAME_TO_FMP[timeframe]; + + if (!mapping) { + console.error(`FMP: Unsupported timeframe: ${timeframe}`); + return []; + } + + if (mapping.type === 'intraday') { + return this._fetchIntradayData(tickerId, mapping.endpoint, mapping.interval!, sDate, eDate, limit); + } + + return this._fetchDailyData(tickerId, sDate, eDate, limit); + } catch (error) { + console.error('Error in FMPProvider.getMarketData:', error); + return []; + } + } + + /** + * Fetch daily EOD data from FMP and convert to Kline format. + */ + private async _fetchDailyData( + tickerId: string, + sDate?: number, + eDate?: number, + limit?: number, + ): Promise { + let url = `${this._baseUrl}/stable/historical-price-eod/full?symbol=${tickerId}&apikey=${this._apiKey}`; + + if (sDate) url += `&from=${this._msToDateStr(sDate)}`; + if (eDate) url += `&to=${this._msToDateStr(eDate)}`; + + const response = await fetch(url); + if (!response.ok) { + const text = await response.text(); + throw new Error(`FMP HTTP ${response.status}: ${text}`); + } + + const data: FMPDailyBar[] = await response.json(); + + if (!Array.isArray(data) || data.length === 0) return []; + + // FMP returns data in DESCENDING order (newest first) — reverse to oldest first + data.reverse(); + + // Apply limit (take the last N bars = most recent) + const bars = limit && limit > 0 && limit < data.length + ? data.slice(data.length - limit) + : data; + + // Resolve session info for closeTime computation + const { session, timezone } = await this._resolveSessionInfo(tickerId); + + // Convert to Kline + const klines: Kline[] = bars.map((bar) => { + const openTime = this._dateStrToMs(bar.date); + const closeTime = computeSessionClose(openTime, session, timezone, 'day'); + + return { + openTime, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + closeTime, + quoteAssetVolume: 0, + numberOfTrades: 0, + takerBuyBaseAssetVolume: 0, + takerBuyQuoteAssetVolume: 0, + ignore: 0, + }; + }); + + return klines; + } + + /** + * Fetch intraday chart data from FMP and convert to Kline format. + * Note: Requires a paid FMP plan. + */ + private async _fetchIntradayData( + tickerId: string, + endpoint: string, + interval: string, + sDate?: number, + eDate?: number, + limit?: number, + ): Promise { + let url = `${this._baseUrl}${endpoint}?symbol=${tickerId}&apikey=${this._apiKey}`; + + if (sDate) url += `&from=${this._msToDateStr(sDate)}`; + if (eDate) url += `&to=${this._msToDateStr(eDate)}`; + + const response = await fetch(url); + if (!response.ok) { + const text = await response.text(); + // Check for restricted endpoint error (free plan) + if (response.status === 403 || text.includes('Restricted Endpoint')) { + console.error( + `FMP: Intraday data (${interval}) requires a paid plan. ` + + `Use daily timeframe ('D') with a free API key, or upgrade at https://financialmodelingprep.com/` + ); + return []; + } + throw new Error(`FMP HTTP ${response.status}: ${text}`); + } + + const data: FMPIntradayBar[] = await response.json(); + + if (!Array.isArray(data) || data.length === 0) return []; + + // FMP returns intraday data in DESCENDING order — reverse + data.reverse(); + + const bars = limit && limit > 0 && limit < data.length + ? data.slice(data.length - limit) + : data; + + const { periodType, multiplier } = FMP_INTERVAL_PERIOD[interval] || { periodType: 'minute' as const, multiplier: 1 }; + + // Resolve session info for closeTime computation + const { session, timezone } = await this._resolveSessionInfo(tickerId); + + const klines: Kline[] = bars.map((bar) => { + const openTime = this._dateTimeStrToMs(bar.date); + const closeTime = computeSessionClose(openTime, session, timezone, periodType, multiplier); + + return { + openTime, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + closeTime, + quoteAssetVolume: 0, + numberOfTrades: 0, + takerBuyBaseAssetVolume: 0, + takerBuyQuoteAssetVolume: 0, + ignore: 0, + }; + }); + + return klines; + } + + // ── Symbol Info ────────────────────────────────────────────────────── + + async getSymbolInfo(tickerId: string): Promise { + this.ensureConfigured(); + + try { + const profile = await this._fetchProfile(tickerId); + if (!profile) { + console.error(`FMP: Symbol ${tickerId} not found`); + return null; + } + + const exchange = profile.exchange || ''; + const timezone = EXCHANGE_TIMEZONE[exchange] || 'America/New_York'; + const session = EXCHANGE_SESSION[exchange] || '0930-1600'; + + // Determine asset type + let type = 'stock'; + if (profile.isEtf) type = 'etf'; + else if (profile.isFund) type = 'fund'; + else if (this._isCrypto(tickerId)) type = 'crypto'; + else if (this._isForex(tickerId)) type = 'forex'; + + const symbolInfo: ISymbolInfo = { + // Symbol Identification + ticker: profile.symbol, + tickerid: `${exchange}:${profile.symbol}`, + prefix: exchange, + root: profile.symbol, + description: profile.companyName || profile.symbol, + type, + main_tickerid: `${exchange}:${profile.symbol}`, + current_contract: '', + isin: profile.isin || '', + + // Currency & Location + basecurrency: profile.currency || 'USD', + currency: profile.currency || 'USD', + timezone: type === 'crypto' ? 'Etc/UTC' : timezone, + country: profile.country || '', + + // Price & Contract Info + mintick: 0.01, + pricescale: 100, + minmove: 1, + pointvalue: 1, + mincontract: 0, + + // Session & Market + session: type === 'crypto' ? '24x7' : session, + volumetype: 'base', + expiration_date: 0, + + // Company Data + employees: parseInt(profile.fullTimeEmployees) || 0, + industry: profile.industry || '', + sector: profile.sector || '', + shareholders: 0, + shares_outstanding_float: 0, + shares_outstanding_total: 0, + + // Analyst Ratings (not provided by FMP profile) + recommendations_buy: 0, + recommendations_buy_strong: 0, + recommendations_date: 0, + recommendations_hold: 0, + recommendations_sell: 0, + recommendations_sell_strong: 0, + recommendations_total: 0, + + // Price Targets (not provided by FMP profile) + target_price_average: 0, + target_price_date: 0, + target_price_estimates: 0, + target_price_high: 0, + target_price_low: 0, + target_price_median: 0, + }; + + return symbolInfo; + } catch (error) { + console.error('Error in FMPProvider.getSymbolInfo:', error); + return null; + } + } + + // ── Private helpers ────────────────────────────────────────────────── + + private async _fetchProfile(tickerId: string): Promise { + // Check cache + if (this._profileCache.has(tickerId)) { + return this._profileCache.get(tickerId)!; + } + + const url = `${this._baseUrl}/stable/profile?symbol=${tickerId}&apikey=${this._apiKey}`; + const response = await fetch(url); + if (!response.ok) return null; + + const data = await response.json(); + if (!Array.isArray(data) || data.length === 0) return null; + + const profile = data[0] as FMPProfile; + this._profileCache.set(tickerId, profile); + return profile; + } + + /** + * Resolve session string and timezone for a ticker by fetching its profile. + * Falls back to NYSE defaults if profile is unavailable. + */ + private async _resolveSessionInfo(tickerId: string): Promise<{ session: string; timezone: string }> { + try { + const profile = await this._fetchProfile(tickerId); + if (profile) { + const exchange = profile.exchange || ''; + const isCrypto = this._isCrypto(tickerId); + const timezone = isCrypto ? 'Etc/UTC' : (EXCHANGE_TIMEZONE[exchange] || 'America/New_York'); + const session = isCrypto ? '24x7' : (EXCHANGE_SESSION[exchange] || '0930-1600'); + return { session, timezone }; + } + } catch { + // Ignore — fall through to defaults + } + return { session: '0930-1600', timezone: 'America/New_York' }; + } + + /** Convert ms timestamp to FMP date string "YYYY-MM-DD". */ + private _msToDateStr(ms: number): string { + return new Date(ms).toISOString().split('T')[0]; + } + + /** Convert FMP date string "YYYY-MM-DD" to ms timestamp (UTC midnight). */ + private _dateStrToMs(dateStr: string): number { + return new Date(dateStr + 'T00:00:00Z').getTime(); + } + + /** Convert FMP datetime string "YYYY-MM-DD HH:MM:SS" to ms timestamp. */ + private _dateTimeStrToMs(dateTimeStr: string): number { + // FMP intraday dates are in exchange local time; treat as UTC for consistency + return new Date(dateTimeStr.replace(' ', 'T') + 'Z').getTime(); + } + + /** Heuristic: crypto tickers end with USD/USDT/BTC/ETH. */ + private _isCrypto(tickerId: string): boolean { + return /^[A-Z]+(USD|USDT|BTC|ETH)$/.test(tickerId) && tickerId.length <= 10; + } + + /** Heuristic: forex pairs are 6 chars, two 3-letter currency codes. */ + private _isForex(tickerId: string): boolean { + return /^[A-Z]{6}$/.test(tickerId) && !this._isCrypto(tickerId); + } +} diff --git a/src/marketData/IProvider.ts b/src/marketData/IProvider.ts index cf95d7b..adf4130 100644 --- a/src/marketData/IProvider.ts +++ b/src/marketData/IProvider.ts @@ -1,5 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-only +import type { Kline } from './types'; + +// ── Provider configuration types ──────────────────────────────────────── + +/** Base config — all providers extend this (may be empty for keyless providers). */ +export interface BaseProviderConfig {} + +/** Config for providers that require an API key (FMP, Alpaca, etc.). */ +export interface ApiKeyProviderConfig extends BaseProviderConfig { + apiKey: string; +} + +// ── Symbol info ───────────────────────────────────────────────────────── + export type ISymbolInfo = { //Symbol Identification current_contract: string; @@ -59,16 +73,21 @@ export type ISymbolInfo = { * Market data provider interface. * * ## closeTime convention - * Providers MUST return `closeTime` following the TradingView convention: - * `closeTime` = the timestamp of the **start of the next bar** (not the last - * millisecond of the current bar). For example, a weekly bar opening on - * Monday 2019-01-07T00:00Z should have `closeTime = 2019-01-14T00:00Z`. + * Providers MUST return `closeTime` as the **session close time** for the bar, + * mirroring TradingView's `time_close` built-in variable. + * + * - **Stocks / regulated markets**: `closeTime` = the session close time on + * the bar's trading day (e.g., 16:00 ET for NYSE daily bars, 13:00 ET for + * early-close days). For weekly/monthly bars, use the session close of the + * last trading day in the period. + * - **24/7 markets (crypto)**: `closeTime` = the start of the next bar + * (equivalent to `openTime + barDuration`), since there are no session gaps. * - * If a provider's raw data uses a different convention (e.g., Binance returns - * `nextBarOpen - 1ms`), the provider must normalize before returning. + * Use `computeSessionClose()` from `types.ts` for session-aware computation, + * or the Alpaca Calendar API for exact per-day close times including early closes. */ export interface IProvider { - getMarketData(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise; + getMarketData(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise; getSymbolInfo(tickerId: string): Promise; configure(config: any): void; } diff --git a/src/marketData/Mock/MockProvider.class.ts b/src/marketData/Mock/MockProvider.class.ts index 5a466ea..cac2966 100644 --- a/src/marketData/Mock/MockProvider.class.ts +++ b/src/marketData/Mock/MockProvider.class.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { IProvider, ISymbolInfo } from '@pinets/marketData/IProvider'; +import { ISymbolInfo } from '@pinets/marketData/IProvider'; +import { BaseProvider } from '@pinets/marketData/BaseProvider'; +import { Kline } from '@pinets/marketData/types'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; @@ -8,19 +10,9 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -interface Kline { - openTime: number; - open: number; - high: number; - low: number; - close: number; - volume: number; - closeTime: number; - quoteAssetVolume: number; - numberOfTrades: number; - takerBuyBaseAssetVolume: number; - takerBuyQuoteAssetVolume: number; - ignore: number | string; +/** Config for MockProvider. */ +export interface MockProviderConfig { + dataDirectory?: string; } /** @@ -40,25 +32,31 @@ interface Kline { * * Example: BTCUSDC-1h-1704067200000-1763683199000.json */ -export class MockProvider implements IProvider { +export class MockProvider extends BaseProvider { private dataCache: Map = new Map(); private exchangeInfoCache: { spot?: any; futures?: any } = {}; private dataDirectory: string; - constructor(dataDirectory?: string) { + constructor(dataDirectoryOrConfig?: string | MockProviderConfig) { + super({ requiresApiKey: false, providerName: 'Mock' }); // Default to tests/compatibility/_data directory // Calculate path relative to this file's location - if (dataDirectory) { - this.dataDirectory = dataDirectory; + const dir = typeof dataDirectoryOrConfig === 'string' + ? dataDirectoryOrConfig + : dataDirectoryOrConfig?.dataDirectory; + if (dir) { + this.dataDirectory = dir; } else { // Navigate from src/marketData/Mock to tests/compatibility/_data const projectRoot = path.resolve(__dirname, '../../../'); this.dataDirectory = path.join(projectRoot, 'tests', 'compatibility', '_data'); } } - public configure({ dataDirectory }: { dataDirectory?: string }): void { - if (dataDirectory) { - this.dataDirectory = dataDirectory; + + public configure(config: MockProviderConfig): void { + super.configure(config); + if (config.dataDirectory) { + this.dataDirectory = config.dataDirectory; } } @@ -201,8 +199,12 @@ export class MockProvider implements IProvider { return timeframeMap[timeframe.toUpperCase()] || timeframe.toLowerCase(); } + protected getSupportedTimeframes(): Set { + return new Set(['1', '3', '5', '15', '30', '45', '60', '120', '180', '240', 'D', 'W', 'M']); + } + /** - * Implements IProvider.getMarketData + * Implements _getMarketDataNative * * @param tickerId - Symbol (e.g., 'BTCUSDC') * @param timeframe - Timeframe (e.g., '1h', '60', 'D') @@ -211,7 +213,7 @@ export class MockProvider implements IProvider { * @param eDate - Optional end date (timestamp in milliseconds) * @returns Promise - Array of candle data */ - async getMarketData(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise { + protected async _getMarketDataNative(tickerId: string, timeframe: string, limit?: number, sDate?: number, eDate?: number): Promise { try { // Normalize timeframe const normalizedTimeframe = this.normalizeTimeframe(timeframe); @@ -231,7 +233,7 @@ export class MockProvider implements IProvider { const filteredData = this.filterData(allData, sDate, eDate, limit); // Normalize closeTime to TV convention (nextBar.openTime) - this._normalizeCloseTime(filteredData); + this.normalizeCloseTime(filteredData); return filteredData; } catch (error) { @@ -392,21 +394,6 @@ export class MockProvider implements IProvider { } } - /** - * Normalize closeTime to TradingView convention: closeTime = next bar's openTime. - * Mock data files contain raw Binance data where closeTime = (nextBarOpen - 1ms). - * For all bars except the last, we use the next bar's actual openTime. For the - * last bar, we add 1ms to the raw value. - */ - private _normalizeCloseTime(data: Kline[]): void { - for (let i = 0; i < data.length - 1; i++) { - data[i].closeTime = data[i + 1].openTime; - } - if (data.length > 0) { - data[data.length - 1].closeTime = data[data.length - 1].closeTime + 1; - } - } - /** * Clears the data cache */ diff --git a/src/marketData/Provider.class.ts b/src/marketData/Provider.class.ts index 1684f1d..151553f 100644 --- a/src/marketData/Provider.class.ts +++ b/src/marketData/Provider.class.ts @@ -1,11 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-only import { BinanceProvider } from './Binance/BinanceProvider.class'; +import { FMPProvider } from './FMP/FMPProvider.class'; +import { AlpacaProvider } from './Alpaca/AlpacaProvider.class'; import { IProvider } from './IProvider'; +import { BaseProvider } from './BaseProvider'; // MockProvider is conditionally imported - excluded from browser builds via rollup plugin // In browser builds, it will be replaced with a stub import { MockProvider } from './Mock/MockProvider.class'; +// Re-export provider classes for direct instantiation +export { BinanceProvider } from './Binance/BinanceProvider.class'; +export { FMPProvider } from './FMP/FMPProvider.class'; +export { AlpacaProvider } from './Alpaca/AlpacaProvider.class'; +export { BaseProvider } from './BaseProvider'; + type TProvider = { [key: string]: IProvider; }; @@ -29,9 +38,10 @@ if (isNodeEnvironment) { export const Provider: TProvider = { Binance: new BinanceProvider(), + FMP: new FMPProvider(), + Alpaca: new AlpacaProvider(), // Only include Mock provider in Node.js environments (excluded from browser builds) ...(MockProviderInstance ? { Mock: MockProviderInstance } : {}), - //TODO : add other providers (polygon, etc.) }; export function registerProvider(name: string, provider: IProvider) { diff --git a/src/marketData/aggregation.ts b/src/marketData/aggregation.ts new file mode 100644 index 0000000..556fadc --- /dev/null +++ b/src/marketData/aggregation.ts @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { Kline, TIMEFRAME_SECONDS } from './types'; + +// ── Ordered list of all canonical timeframes (ascending by duration) ──── + +const ORDERED_TIMEFRAMES = [ + '1S', '5S', '10S', '15S', '30S', + '1', '3', '5', '15', '30', '45', + '60', '120', '180', '240', + 'D', 'W', 'M', +]; + +// ── Public API ────────────────────────────────────────────────────────── + +/** + * Given a target timeframe and a set of supported timeframes, select the + * best sub-timeframe to aggregate from. + * + * Strategy: + * - **W/M targets**: always use `'D'` (calendar-based grouping). + * - **All others**: pick the largest supported timeframe whose duration + * evenly divides the target duration (using `TIMEFRAME_SECONDS`). + * + * @returns The best sub-timeframe, or `null` if none found. + */ +export function selectSubTimeframe( + targetTimeframe: string, + supportedTimeframes: Set, +): string | null { + // Weekly and Monthly always aggregate from Daily + if (targetTimeframe === 'W' || targetTimeframe === 'M') { + return supportedTimeframes.has('D') ? 'D' : null; + } + + const targetSeconds = TIMEFRAME_SECONDS[targetTimeframe]; + if (!targetSeconds) return null; + + // Consider only timeframes strictly smaller than the target + const candidates = ORDERED_TIMEFRAMES.filter(tf => + tf !== 'W' && tf !== 'M' && + supportedTimeframes.has(tf) && + TIMEFRAME_SECONDS[tf] < targetSeconds && + targetSeconds % TIMEFRAME_SECONDS[tf] === 0, + ); + + if (candidates.length === 0) return null; + + // Pick the largest (last in ascending order) — fewest API calls + return candidates[candidates.length - 1]; +} + +/** + * Compute how many sub-candles fit into one aggregated candle. + * + * For fixed-duration aggregation: `targetSeconds / subSeconds`. + * For calendar-based (W/M from D): returns `Infinity` to signal variable grouping. + */ +export function getAggregationRatio(targetTimeframe: string, subTimeframe: string): number { + if (targetTimeframe === 'W' || targetTimeframe === 'M') { + return Infinity; // Calendar-based grouping — variable bars per group + } + const targetSec = TIMEFRAME_SECONDS[targetTimeframe]; + const subSec = TIMEFRAME_SECONDS[subTimeframe]; + if (!targetSec || !subSec || subSec === 0) return Infinity; + return targetSec / subSec; +} + +/** + * Aggregate sub-candles into higher-timeframe candles. + * + * Three modes: + * 1. **Fixed-ratio** (intraday → higher intraday): groups every N consecutive + * sub-candles, with session-boundary detection to avoid cross-session merging. + * 2. **Weekly from daily**: groups daily bars by ISO week number. + * 3. **Monthly from daily**: groups daily bars by calendar year+month. + * + * OHLCV merge: + * - `open` = first sub-candle's open + * - `high` = max of all highs + * - `low` = min of all lows + * - `close` = last sub-candle's close + * - `volume` = sum + * - `closeTime` = last sub-candle's closeTime (preserves session-aware close) + */ +export function aggregateCandles( + subCandles: Kline[], + targetTimeframe: string, + subTimeframe: string, +): Kline[] { + if (subCandles.length === 0) return []; + + if (targetTimeframe === 'W') { + return _aggregateByWeek(subCandles); + } + if (targetTimeframe === 'M') { + return _aggregateByMonth(subCandles); + } + + // Fixed-ratio aggregation with session-boundary detection + const ratio = getAggregationRatio(targetTimeframe, subTimeframe); + return _aggregateByRatio(subCandles, ratio); +} + +// ── Internal helpers ──────────────────────────────────────────────────── + +/** + * Fixed-ratio aggregation with session-boundary detection. + * + * Starts a new group whenever: + * - The current group has `ratio` bars, OR + * - The time gap between consecutive bars exceeds 1.5× the expected sub-candle + * duration (indicates an overnight/weekend/holiday gap for stocks; + * transparent for 24/7 crypto where gaps don't occur). + */ +function _aggregateByRatio(candles: Kline[], ratio: number): Kline[] { + if (candles.length === 0) return []; + + const result: Kline[] = []; + let group: Kline[] = [candles[0]]; + + // Expected gap between consecutive sub-candles (ms). + // Use the median of the first few gaps to be robust against a single outlier. + const expectedGapMs = _estimateExpectedGap(candles); + const maxGapMs = expectedGapMs > 0 ? expectedGapMs * 1.5 : 0; + + for (let i = 1; i < candles.length; i++) { + const gap = candles[i].openTime - candles[i - 1].openTime; + const isSessionBreak = maxGapMs > 0 && gap > maxGapMs; + + if (isSessionBreak || group.length >= ratio) { + result.push(_mergeGroup(group)); + group = []; + } + group.push(candles[i]); + } + + if (group.length > 0) { + result.push(_mergeGroup(group)); + } + + return result; +} + +/** Group daily candles by ISO week. */ +function _aggregateByWeek(dailyCandles: Kline[]): Kline[] { + const groups: Kline[][] = []; + let currentGroup: Kline[] = []; + let currentWeekKey = ''; + + for (const candle of dailyCandles) { + const weekKey = _getISOWeekKey(candle.openTime); + if (weekKey !== currentWeekKey && currentGroup.length > 0) { + groups.push(currentGroup); + currentGroup = []; + } + currentWeekKey = weekKey; + currentGroup.push(candle); + } + if (currentGroup.length > 0) groups.push(currentGroup); + + return groups.map(_mergeGroup); +} + +/** Group daily candles by calendar month. */ +function _aggregateByMonth(dailyCandles: Kline[]): Kline[] { + const groups: Kline[][] = []; + let currentGroup: Kline[] = []; + let currentMonthKey = ''; + + for (const candle of dailyCandles) { + const d = new Date(candle.openTime); + const monthKey = `${d.getUTCFullYear()}-${d.getUTCMonth()}`; + if (monthKey !== currentMonthKey && currentGroup.length > 0) { + groups.push(currentGroup); + currentGroup = []; + } + currentMonthKey = monthKey; + currentGroup.push(candle); + } + if (currentGroup.length > 0) groups.push(currentGroup); + + return groups.map(_mergeGroup); +} + +/** Merge a group of candles into a single aggregated candle. */ +function _mergeGroup(group: Kline[]): Kline { + const first = group[0]; + const last = group[group.length - 1]; + + let high = first.high; + let low = first.low; + let volume = 0; + let quoteAssetVolume = 0; + let numberOfTrades = 0; + let takerBuyBaseAssetVolume = 0; + let takerBuyQuoteAssetVolume = 0; + + for (let i = 0; i < group.length; i++) { + const c = group[i]; + if (c.high > high) high = c.high; + if (c.low < low) low = c.low; + volume += c.volume; + quoteAssetVolume += c.quoteAssetVolume; + numberOfTrades += c.numberOfTrades; + takerBuyBaseAssetVolume += c.takerBuyBaseAssetVolume; + takerBuyQuoteAssetVolume += c.takerBuyQuoteAssetVolume; + } + + return { + openTime: first.openTime, + open: first.open, + high, + low, + close: last.close, + volume, + closeTime: last.closeTime, + quoteAssetVolume, + numberOfTrades, + takerBuyBaseAssetVolume, + takerBuyQuoteAssetVolume, + ignore: 0, + }; +} + +/** + * Estimate the expected gap (ms) between consecutive sub-candles. + * Uses the minimum gap among the first few pairs — this naturally + * picks the intra-session gap and ignores overnight/weekend gaps. + */ +function _estimateExpectedGap(candles: Kline[]): number { + if (candles.length < 2) return 0; + + const samplesToCheck = Math.min(candles.length - 1, 20); + let minGap = Infinity; + + for (let i = 0; i < samplesToCheck; i++) { + const gap = candles[i + 1].openTime - candles[i].openTime; + if (gap > 0 && gap < minGap) minGap = gap; + } + + return minGap === Infinity ? 0 : minGap; +} + +/** Return "YYYY-WNN" ISO week key for a UTC timestamp. */ +function _getISOWeekKey(timestampMs: number): string { + const d = new Date(timestampMs); + const dayNum = d.getUTCDay() || 7; // Make Sunday = 7 + d.setUTCDate(d.getUTCDate() + 4 - dayNum); // Set to nearest Thursday + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86_400_000) + 1) / 7); + return `${d.getUTCFullYear()}-W${weekNo}`; +} diff --git a/src/marketData/types.ts b/src/marketData/types.ts new file mode 100644 index 0000000..a3b5a8d --- /dev/null +++ b/src/marketData/types.ts @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * Standardized candlestick / kline data shape used by all providers. + */ +export interface Kline { + openTime: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + closeTime: number; + quoteAssetVolume: number; + numberOfTrades: number; + takerBuyBaseAssetVolume: number; + takerBuyQuoteAssetVolume: number; + ignore: number | string; +} + +/** + * Interval duration in milliseconds, keyed by normalized interval strings. + * Used by providers for pagination and date-range estimation. + * + * These use Binance-style interval keys ('1m', '1h', '1d', etc.) + * which are also the de-facto standard across most market data APIs. + */ +//prettier-ignore +export const INTERVAL_DURATION_MS: Record = { + '1m': 60 * 1000, + '3m': 3 * 60 * 1000, + '5m': 5 * 60 * 1000, + '15m': 15 * 60 * 1000, + '30m': 30 * 60 * 1000, + '1h': 60 * 60 * 1000, + '2h': 2 * 60 * 60 * 1000, + '4h': 4 * 60 * 60 * 1000, + '1d': 24 * 60 * 60 * 1000, + '1w': 7 * 24 * 60 * 60 * 1000, + '1M': 30 * 24 * 60 * 60 * 1000, +}; + +/** + * Period types for timeframe-aware date arithmetic. + */ +export type PeriodType = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month'; + +/** + * Duration in seconds for each canonical timeframe. + * + * Uses seconds (not minutes) to naturally accommodate TradingView's + * sub-minute timeframes ('1S', '5S', etc.) without fractional values. + * D/W/M values are approximate — used for ratio math, not calendar grouping. + */ +//prettier-ignore +export const TIMEFRAME_SECONDS: Record = { + // Seconds (TradingView format: "NS") + '1S': 1, '5S': 5, '10S': 10, '15S': 15, '30S': 30, + // Minutes (Pine canonical: plain integers) + '1': 60, '3': 180, '5': 300, '15': 900, '30': 1800, '45': 2700, + // Hours + '60': 3600, '120': 7200, '180': 10800, '240': 14400, + // Calendar periods (approximate, for ratio math only) + 'D': 86400, 'W': 604800, 'M': 2592000, +}; + +/** + * Map from canonical timeframe to { periodType, multiplier }. + * Used by aggregation to determine grouping strategy. + */ +//prettier-ignore +export const TIMEFRAME_PERIOD_INFO: Record = { + '1S': { periodType: 'second', multiplier: 1 }, + '5S': { periodType: 'second', multiplier: 5 }, + '10S': { periodType: 'second', multiplier: 10 }, + '15S': { periodType: 'second', multiplier: 15 }, + '30S': { periodType: 'second', multiplier: 30 }, + '1': { periodType: 'minute', multiplier: 1 }, + '3': { periodType: 'minute', multiplier: 3 }, + '5': { periodType: 'minute', multiplier: 5 }, + '15': { periodType: 'minute', multiplier: 15 }, + '30': { periodType: 'minute', multiplier: 30 }, + '45': { periodType: 'minute', multiplier: 45 }, + '60': { periodType: 'hour', multiplier: 1 }, + '120': { periodType: 'hour', multiplier: 2 }, + '180': { periodType: 'hour', multiplier: 3 }, + '240': { periodType: 'hour', multiplier: 4 }, + 'D': { periodType: 'day', multiplier: 1 }, + 'W': { periodType: 'week', multiplier: 1 }, + 'M': { periodType: 'month', multiplier: 1 }, +}; + +/** + * Compute the start of the next period given an openTime (fixed duration math). + * + * For intraday / daily / weekly: adds fixed duration. + * For monthly: uses calendar math to land on the 1st of the next month. + * + * **Suitable for 24/7 crypto markets** where there are no session gaps. + * For stock/regulated markets, prefer `computeSessionClose()` or + * the Alpaca Calendar API which account for session boundaries, + * early closes, and holidays. + * + * @param openTimeMs - The bar's open time in epoch milliseconds + * @param periodType - The period unit ('minute', 'hour', 'day', 'week', 'month') + * @param multiplier - How many units per bar (e.g., 3 for 3Min, 4 for 4Hour). Default: 1 + */ +export function computeNextPeriodStart( + openTimeMs: number, + periodType: PeriodType, + multiplier: number = 1, +): number { + if (periodType === 'month') { + // Calendar math: advance N months, land on the 1st, keep time-of-day + const d = new Date(openTimeMs); + return Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth() + multiplier, + 1, + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds(), + d.getUTCMilliseconds(), + ); + } + + // Fixed-duration periods + const MS_PER_UNIT: Record = { + second: 1_000, + minute: 60_000, + hour: 3_600_000, + day: 86_400_000, + week: 7 * 86_400_000, + }; + return openTimeMs + multiplier * MS_PER_UNIT[periodType]; +} + +/** + * Convert a local date + time string in a given IANA timezone to UTC milliseconds. + * + * Uses `Intl.DateTimeFormat` for DST-correct conversion — no external dependencies. + * + * @param dateStr - Date in "YYYY-MM-DD" format + * @param timeStr - Time in "HH:MM" format (24h) + * @param timezone - IANA timezone name (e.g. "America/New_York", "Etc/UTC") + * @returns UTC epoch milliseconds + */ +export function localTimeToUTC(dateStr: string, timeStr: string, timezone: string): number { + const [year, month, day] = dateStr.split('-').map(Number); + const [hour, minute] = timeStr.split(':').map(Number); + + // Build a UTC timestamp for the naive date+time + const naiveUtcMs = Date.UTC(year, month - 1, day, hour, minute, 0, 0); + + // Use Intl to find the UTC offset for this timezone at this moment. + // Strategy: format the naive UTC time in the target timezone, parse back, + // compute the difference = the timezone's UTC offset. + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }); + + const parts = formatter.formatToParts(new Date(naiveUtcMs)); + const get = (type: string) => parseInt(parts.find(p => p.type === type)!.value, 10); + + let fYear = get('year'); + let fMonth = get('month'); + let fDay = get('day'); + let fHour = get('hour'); + // Intl may return hour=24 for midnight — normalize to 0 + if (fHour === 24) fHour = 0; + let fMinute = get('minute'); + + // What the timezone shows when we feed it naiveUtcMs + const formattedAsUtcMs = Date.UTC(fYear, fMonth - 1, fDay, fHour, fMinute, 0, 0); + + // offset = how far ahead the timezone is from UTC + const offsetMs = formattedAsUtcMs - naiveUtcMs; + + // The local time we want is (year, month, day, hour, minute) in that timezone. + // So the UTC equivalent is: naiveUtcMs - offsetMs + return naiveUtcMs - offsetMs; +} + +/** + * Compute the session close time for a bar. + * + * For providers without a per-day calendar API (like FMP), this computes + * closeTime from the session string and exchange timezone. + * + * Logic by period type: + * - **24x7 session**: closeTime = openTime + barDuration (no gaps) + * - **Intraday** (minute, hour): min(openTime + barDuration, sessionEndOnThatDay) + * - **Daily** (day): same date at session end time in timezone + * - **Weekly** (week): Friday of that week at session end (approximation: no holiday calendar) + * - **Monthly** (month): last weekday of month at session end (approximation: no holiday calendar) + * + * @param openTimeMs - Bar open time in UTC epoch milliseconds + * @param session - Session string, e.g. "0930-1600" or "24x7" + * @param timezone - IANA timezone name, e.g. "America/New_York" + * @param periodType - The period unit + * @param multiplier - How many units per bar (default: 1) + */ +export function computeSessionClose( + openTimeMs: number, + session: string, + timezone: string, + periodType: PeriodType, + multiplier: number = 1, +): number { + // 24x7 markets (crypto): no session boundaries + if (session === '24x7') { + return computeNextPeriodStart(openTimeMs, periodType, multiplier); + } + + // Parse session end time (e.g. "0930-1600" → "16:00") + const sessionEnd = session.split('-')[1]; + if (!sessionEnd || sessionEnd.length !== 4) { + // Fallback if session format is unexpected + return computeNextPeriodStart(openTimeMs, periodType, multiplier); + } + const endHH = sessionEnd.slice(0, 2); + const endMM = sessionEnd.slice(2, 4); + const endTimeStr = `${endHH}:${endMM}`; + + // Get the bar's date in the exchange timezone + const barDate = _utcMsToLocalDate(openTimeMs, timezone); + + if (periodType === 'second' || periodType === 'minute' || periodType === 'hour') { + // Intraday: min(openTime + barDuration, session end on that day) + const MS_PER_UNIT: Record = { + second: 1_000, + minute: 60_000, + hour: 3_600_000, + }; + const barEndMs = openTimeMs + multiplier * MS_PER_UNIT[periodType]; + const sessionEndMs = localTimeToUTC(barDate, endTimeStr, timezone); + return Math.min(barEndMs, sessionEndMs); + } + + if (periodType === 'day') { + // Daily: session end on the bar's date + return localTimeToUTC(barDate, endTimeStr, timezone); + } + + if (periodType === 'week') { + // Weekly: Friday of that week at session end + const d = new Date(openTimeMs); + const dayOfWeek = d.getUTCDay(); // 0=Sun, 5=Fri + const daysToFriday = (5 - dayOfWeek + 7) % 7 || 7; + // If bar opens on Friday (dayOfWeek=5), daysToFriday would be 7, but we want 0 + const fridayOffset = dayOfWeek <= 5 ? (5 - dayOfWeek) : (5 - dayOfWeek + 7); + const fridayMs = openTimeMs + fridayOffset * 86_400_000; + const fridayDate = _utcMsToLocalDate(fridayMs, timezone); + return localTimeToUTC(fridayDate, endTimeStr, timezone); + } + + if (periodType === 'month') { + // Monthly: last weekday of the month at session end + const d = new Date(openTimeMs); + const year = d.getUTCFullYear(); + const month = d.getUTCMonth() + multiplier; + // First day of next month, then go back to find last weekday + const firstOfNext = new Date(Date.UTC(year, month, 1)); + const lastDay = new Date(firstOfNext.getTime() - 86_400_000); + // Walk back from last day of month to find a weekday (Mon-Fri) + while (lastDay.getUTCDay() === 0 || lastDay.getUTCDay() === 6) { + lastDay.setUTCDate(lastDay.getUTCDate() - 1); + } + const lastWeekdayDate = lastDay.toISOString().split('T')[0]; + return localTimeToUTC(lastWeekdayDate, endTimeStr, timezone); + } + + // Fallback + return computeNextPeriodStart(openTimeMs, periodType, multiplier); +} + +/** + * Convert UTC ms to a local date string "YYYY-MM-DD" in the given timezone. + * @internal + */ +function _utcMsToLocalDate(utcMs: number, timezone: string): string { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', month: '2-digit', day: '2-digit', + }); + return formatter.format(new Date(utcMs)); +} + +/** + * Normalize closeTime for 24/7 crypto providers (Binance, Mock). + * + * Many crypto APIs (e.g. Binance) return closeTime as `nextBarOpen - 1ms`. + * For 24/7 markets, `nextBar.openTime == sessionClose`, so this is correct: + * - For bars 0..N-2: closeTime = next bar's openTime (exact for 24/7) + * - For the last bar: closeTime = raw closeTime + 1ms (best estimate) + * + * **Not suitable for stock/regulated market providers** — those must use + * `computeSessionClose()` or the Alpaca Calendar API for session-aware close times. + * + * Mutates the array in place. + */ +export function normalizeCloseTime(data: Kline[]): void { + for (let i = 0; i < data.length - 1; i++) { + data[i].closeTime = data[i + 1].openTime; + } + if (data.length > 0) { + data[data.length - 1].closeTime = data[data.length - 1].closeTime + 1; + } +} diff --git a/tests/marketData/aggregation.test.ts b/tests/marketData/aggregation.test.ts new file mode 100644 index 0000000..0bf7f50 --- /dev/null +++ b/tests/marketData/aggregation.test.ts @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, it, expect } from 'vitest'; +import { selectSubTimeframe, aggregateCandles, getAggregationRatio } from '@pinets/marketData/aggregation'; +import { Kline } from '@pinets/marketData/types'; + +// ── Helper to create test candles ────────────────────────────────────── + +function makeCandle(openTime: number, close: number, opts?: Partial): Kline { + return { + openTime, + open: opts?.open ?? close, + high: opts?.high ?? close + 1, + low: opts?.low ?? close - 1, + close, + volume: opts?.volume ?? 100, + closeTime: opts?.closeTime ?? (openTime + 60_000 - 1), // default 1min candle + quoteAssetVolume: opts?.quoteAssetVolume ?? 0, + numberOfTrades: opts?.numberOfTrades ?? 10, + takerBuyBaseAssetVolume: opts?.takerBuyBaseAssetVolume ?? 0, + takerBuyQuoteAssetVolume: opts?.takerBuyQuoteAssetVolume ?? 0, + ignore: 0, + }; +} + +/** Create N consecutive 1-minute candles starting at baseTime. */ +function make1minCandles(count: number, baseTime: number = Date.UTC(2024, 0, 2, 9, 30)): Kline[] { + const candles: Kline[] = []; + for (let i = 0; i < count; i++) { + const openTime = baseTime + i * 60_000; + candles.push(makeCandle(openTime, 100 + i, { + open: 100 + i, + high: 101 + i, + low: 99 + i, + volume: 100 + i * 10, + closeTime: openTime + 60_000, + })); + } + return candles; +} + +/** Create N consecutive 15-minute candles. */ +function make15minCandles(count: number, baseTime: number = Date.UTC(2024, 0, 2, 9, 30)): Kline[] { + const candles: Kline[] = []; + for (let i = 0; i < count; i++) { + const openTime = baseTime + i * 15 * 60_000; + candles.push(makeCandle(openTime, 100 + i, { + open: 100 + i, + high: 105 + i, + low: 95 + i, + volume: 1000 + i * 100, + closeTime: openTime + 15 * 60_000, + })); + } + return candles; +} + +/** Create N consecutive 1-hour candles. */ +function make1hCandles(count: number, baseTime: number = Date.UTC(2024, 0, 2, 9, 0)): Kline[] { + const candles: Kline[] = []; + for (let i = 0; i < count; i++) { + const openTime = baseTime + i * 3600_000; + candles.push(makeCandle(openTime, 100 + i * 5, { + open: 100 + i * 5, + high: 110 + i * 5, + low: 90 + i * 5, + volume: 5000 + i * 500, + closeTime: openTime + 3600_000, + })); + } + return candles; +} + +/** Create N consecutive daily candles. */ +function makeDailyCandles(count: number, baseTime: number = Date.UTC(2024, 0, 1)): Kline[] { + const candles: Kline[] = []; + for (let i = 0; i < count; i++) { + const openTime = baseTime + i * 86_400_000; + candles.push(makeCandle(openTime, 100 + i, { + open: 100 + i, + high: 105 + i, + low: 95 + i, + volume: 10000 + i * 1000, + closeTime: openTime + 86_400_000, + })); + } + return candles; +} + +// ── selectSubTimeframe ──────────────────────────────────────────────── + +describe('selectSubTimeframe', () => { + const binanceSupported = new Set(['1', '3', '5', '15', '30', '60', '120', '240', 'D', 'W', 'M']); + const fmpSupported = new Set(['1', '5', '15', '30', '60', '240', 'D']); + + it('should return null for natively supported timeframes (no aggregation needed)', () => { + // selectSubTimeframe is only called for unsupported TFs, but if called + // with a supported one, it should still return a valid sub-TF + const sub = selectSubTimeframe('60', binanceSupported); + expect(sub).toBe('30'); // 30min divides 60min evenly + }); + + it('should select 15min for 45min on Binance (3×15=45)', () => { + expect(selectSubTimeframe('45', binanceSupported)).toBe('15'); + }); + + it('should select 60min for 180min on Binance (3×60=180)', () => { + expect(selectSubTimeframe('180', binanceSupported)).toBe('60'); + }); + + it('should select 60min for 120min on FMP (2×60=120)', () => { + expect(selectSubTimeframe('120', fmpSupported)).toBe('60'); + }); + + it('should select 60min for 180min on FMP (3×60=180)', () => { + expect(selectSubTimeframe('180', fmpSupported)).toBe('60'); + }); + + it('should select D for W aggregation', () => { + expect(selectSubTimeframe('W', fmpSupported)).toBe('D'); + }); + + it('should select D for M aggregation', () => { + expect(selectSubTimeframe('M', fmpSupported)).toBe('D'); + }); + + it('should return null if no valid sub-timeframe exists', () => { + const limited = new Set(['240', 'D']); + expect(selectSubTimeframe('45', limited)).toBeNull(); + }); + + it('should return null for W if D is not supported', () => { + const noDaySupport = new Set(['1', '5', '15', '60']); + expect(selectSubTimeframe('W', noDaySupport)).toBeNull(); + }); + + it('should pick the largest valid divisor (fewest API calls)', () => { + // For target '240' (4h), both '60' (4×) and '1' (240×) divide evenly + // Should pick '60' as the largest + const sub = selectSubTimeframe('240', fmpSupported); + expect(sub).toBe('60'); + }); + + it('should handle second-based sub-timeframes', () => { + const withSeconds = new Set(['1S', '5S', '15S', '1', '5']); + // 1min = 60s, 15S divides evenly (4×15=60) + expect(selectSubTimeframe('1', withSeconds)).toBe('15S'); + }); + + it('should select 1min for 3min on FMP (3×1=3)', () => { + expect(selectSubTimeframe('3', fmpSupported)).toBe('1'); + }); +}); + +// ── getAggregationRatio ─────────────────────────────────────────────── + +describe('getAggregationRatio', () => { + it('should return correct fixed ratio for intraday', () => { + expect(getAggregationRatio('60', '15')).toBe(4); // 3600/900 + expect(getAggregationRatio('240', '60')).toBe(4); // 14400/3600 + expect(getAggregationRatio('45', '15')).toBe(3); // 2700/900 + expect(getAggregationRatio('180', '60')).toBe(3); // 10800/3600 + expect(getAggregationRatio('120', '60')).toBe(2); // 7200/3600 + }); + + it('should return Infinity for W/M (calendar-based)', () => { + expect(getAggregationRatio('W', 'D')).toBe(Infinity); + expect(getAggregationRatio('M', 'D')).toBe(Infinity); + }); + + it('should handle second-to-minute ratio', () => { + expect(getAggregationRatio('1', '15S')).toBe(4); // 60/15 + expect(getAggregationRatio('1', '1S')).toBe(60); // 60/1 + }); +}); + +// ── aggregateCandles — fixed ratio ──────────────────────────────────── + +describe('aggregateCandles — fixed ratio', () => { + it('should aggregate 4×15min into 1×60min', () => { + const sub = make15minCandles(4); + const result = aggregateCandles(sub, '60', '15'); + + expect(result).toHaveLength(1); + const bar = result[0]; + expect(bar.openTime).toBe(sub[0].openTime); + expect(bar.open).toBe(sub[0].open); + expect(bar.close).toBe(sub[3].close); + expect(bar.closeTime).toBe(sub[3].closeTime); + expect(bar.high).toBe(Math.max(...sub.map(c => c.high))); + expect(bar.low).toBe(Math.min(...sub.map(c => c.low))); + expect(bar.volume).toBe(sub.reduce((s, c) => s + c.volume, 0)); + }); + + it('should aggregate 8×15min into 2×60min', () => { + const sub = make15minCandles(8); + const result = aggregateCandles(sub, '60', '15'); + expect(result).toHaveLength(2); + + // First bar: candles 0-3 + expect(result[0].openTime).toBe(sub[0].openTime); + expect(result[0].close).toBe(sub[3].close); + + // Second bar: candles 4-7 + expect(result[1].openTime).toBe(sub[4].openTime); + expect(result[1].close).toBe(sub[7].close); + }); + + it('should handle partial last group', () => { + const sub = make15minCandles(6); // 4+2 → 1 full + 1 partial + const result = aggregateCandles(sub, '60', '15'); + expect(result).toHaveLength(2); + expect(result[1].openTime).toBe(sub[4].openTime); + expect(result[1].close).toBe(sub[5].close); + }); + + it('should aggregate 3×15min into 1×45min', () => { + const sub = make15minCandles(6); + const result = aggregateCandles(sub, '45', '15'); + expect(result).toHaveLength(2); + }); + + it('should aggregate 2×1h into 1×2h', () => { + const sub = make1hCandles(4); + const result = aggregateCandles(sub, '120', '60'); + expect(result).toHaveLength(2); + }); + + it('should aggregate 3×1h into 1×3h', () => { + const sub = make1hCandles(6); + const result = aggregateCandles(sub, '180', '60'); + expect(result).toHaveLength(2); + }); + + it('should return empty for empty input', () => { + expect(aggregateCandles([], '60', '15')).toEqual([]); + }); + + it('should preserve OHLCV merge rules', () => { + const sub: Kline[] = [ + makeCandle(1000, 50, { open: 40, high: 60, low: 30, volume: 100, closeTime: 2000 }), + makeCandle(2000, 55, { open: 50, high: 70, low: 35, volume: 200, closeTime: 3000 }), + makeCandle(3000, 45, { open: 55, high: 65, low: 25, volume: 150, closeTime: 4000 }), + ]; + const result = aggregateCandles(sub, '180', '60'); // 3:1 ratio + expect(result).toHaveLength(1); + const bar = result[0]; + expect(bar.open).toBe(40); // first open + expect(bar.close).toBe(45); // last close + expect(bar.high).toBe(70); // max high + expect(bar.low).toBe(25); // min low + expect(bar.volume).toBe(450); // sum + expect(bar.openTime).toBe(1000); // first openTime + expect(bar.closeTime).toBe(4000);// last closeTime + }); +}); + +// ── aggregateCandles — session boundary detection ───────────────────── + +describe('aggregateCandles — session boundary detection', () => { + it('should split groups at overnight gaps for stocks', () => { + // Simulate 2 days of 1h bars with a gap between sessions + // Day 1: 4 bars (9:00-12:00) + // Gap: overnight (~17 hours) + // Day 2: 4 bars (9:00-12:00) + const day1Start = Date.UTC(2024, 0, 2, 14, 30); // 9:30 ET = 14:30 UTC + const day2Start = Date.UTC(2024, 0, 3, 14, 30); // next day + + const candles: Kline[] = []; + // Day 1: 4 bars + for (let i = 0; i < 4; i++) { + const ot = day1Start + i * 3600_000; + candles.push(makeCandle(ot, 100 + i, { + closeTime: ot + 3600_000, + volume: 1000, + })); + } + // Day 2: 4 bars (17h gap from last Day 1 bar) + for (let i = 0; i < 4; i++) { + const ot = day2Start + i * 3600_000; + candles.push(makeCandle(ot, 200 + i, { + closeTime: ot + 3600_000, + volume: 2000, + })); + } + + // Aggregate to 4h (ratio=4). Without session detection, we'd get 2 bars. + // With session detection, the overnight gap splits into: + // Day1: 4 bars → 1×4h, Day2: 4 bars → 1×4h = still 2 bars + // But the key is they don't cross-merge day boundaries + const result = aggregateCandles(candles, '240', '60'); + expect(result).toHaveLength(2); + expect(result[0].close).toBe(103); // Day 1 last close + expect(result[1].close).toBe(203); // Day 2 last close + }); + + it('should NOT split for 24/7 crypto (no gaps)', () => { + // 8 consecutive 1h candles with no gaps + const sub = make1hCandles(8); + const result = aggregateCandles(sub, '240', '60'); + expect(result).toHaveLength(2); + }); +}); + +// ── aggregateCandles — weekly from daily ────────────────────────────── + +describe('aggregateCandles — weekly from daily', () => { + it('should group daily candles by ISO week', () => { + // 2024-01-01 is Monday → first week has 7 bars + const candles = makeDailyCandles(14, Date.UTC(2024, 0, 1)); // 2 weeks + const result = aggregateCandles(candles, 'W', 'D'); + expect(result).toHaveLength(2); + }); + + it('should handle partial first/last week', () => { + // Start on Wednesday → first week has 5 bars (Wed-Sun) + const candles = makeDailyCandles(10, Date.UTC(2024, 0, 3)); // Wed Jan 3, 2024 + const result = aggregateCandles(candles, 'W', 'D'); + expect(result.length).toBeGreaterThanOrEqual(2); + }); + + it('should preserve OHLCV for weekly bars', () => { + // 5 daily bars = 1 trading week (Mon-Fri for stocks, but for crypto all 7) + const candles = makeDailyCandles(7, Date.UTC(2024, 0, 1)); // Mon Jan 1 + const result = aggregateCandles(candles, 'W', 'D'); + expect(result).toHaveLength(1); + expect(result[0].open).toBe(candles[0].open); + expect(result[0].close).toBe(candles[6].close); + expect(result[0].volume).toBe(candles.reduce((s, c) => s + c.volume, 0)); + expect(result[0].closeTime).toBe(candles[6].closeTime); + }); +}); + +// ── aggregateCandles — monthly from daily ───────────────────────────── + +describe('aggregateCandles — monthly from daily', () => { + it('should group daily candles by calendar month', () => { + // Jan 2024 = 31 days, Feb 2024 = 29 days (leap year) + const candles = makeDailyCandles(60, Date.UTC(2024, 0, 1)); + const result = aggregateCandles(candles, 'M', 'D'); + expect(result).toHaveLength(2); // Jan + Feb + }); + + it('should handle single month', () => { + const candles = makeDailyCandles(31, Date.UTC(2024, 0, 1)); + const result = aggregateCandles(candles, 'M', 'D'); + expect(result).toHaveLength(1); + expect(result[0].open).toBe(candles[0].open); + expect(result[0].close).toBe(candles[30].close); + }); + + it('should handle partial month', () => { + // Start mid-January + const candles = makeDailyCandles(45, Date.UTC(2024, 0, 15)); + const result = aggregateCandles(candles, 'M', 'D'); + // Jan 15-31 (17 days) + Feb 1-28 (28 days) = 2 months + expect(result).toHaveLength(2); + }); + + it('should return empty for empty input', () => { + expect(aggregateCandles([], 'M', 'D')).toEqual([]); + }); +}); diff --git a/tests/marketData/baseProviderAggregation.test.ts b/tests/marketData/baseProviderAggregation.test.ts new file mode 100644 index 0000000..e8f98b0 --- /dev/null +++ b/tests/marketData/baseProviderAggregation.test.ts @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, it, expect } from 'vitest'; +import { BaseProvider } from '@pinets/marketData/BaseProvider'; +import { ISymbolInfo } from '@pinets/marketData/IProvider'; +import { Kline } from '@pinets/marketData/types'; + +// ── Test provider with limited timeframe support ────────────────────── + +/** A test provider that only supports 1min, 15min, 60min, and D. */ +class LimitedProvider extends BaseProvider { + private _fakeData: Map = new Map(); + + constructor() { + super({ requiresApiKey: false, providerName: 'TestLimited' }); + } + + protected getSupportedTimeframes(): Set { + return new Set(['1', '15', '60', 'D']); + } + + /** Inject fake data for a given timeframe. */ + setFakeData(timeframe: string, data: Kline[]): void { + this._fakeData.set(timeframe, data); + } + + protected async _getMarketDataNative( + tickerId: string, + timeframe: string, + limit?: number, + sDate?: number, + eDate?: number, + ): Promise { + const data = this._fakeData.get(timeframe) ?? []; + if (limit && limit > 0) return data.slice(0, limit); + return data; + } + + async getSymbolInfo(tickerId: string): Promise { + return null as any; + } +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +function make15minCandles(count: number, baseTime: number = Date.UTC(2024, 0, 2, 9, 30)): Kline[] { + const candles: Kline[] = []; + for (let i = 0; i < count; i++) { + const openTime = baseTime + i * 15 * 60_000; + candles.push({ + openTime, + open: 100 + i, + high: 105 + i, + low: 95 + i, + close: 102 + i, + volume: 1000 + i * 100, + closeTime: openTime + 15 * 60_000, + quoteAssetVolume: 0, + numberOfTrades: 10, + takerBuyBaseAssetVolume: 0, + takerBuyQuoteAssetVolume: 0, + ignore: 0, + }); + } + return candles; +} + +function make1hCandles(count: number, baseTime: number = Date.UTC(2024, 0, 2, 9, 0)): Kline[] { + const candles: Kline[] = []; + for (let i = 0; i < count; i++) { + const openTime = baseTime + i * 3600_000; + candles.push({ + openTime, + open: 100 + i * 5, + high: 110 + i * 5, + low: 90 + i * 5, + close: 105 + i * 5, + volume: 5000 + i * 500, + closeTime: openTime + 3600_000, + quoteAssetVolume: 0, + numberOfTrades: 50, + takerBuyBaseAssetVolume: 0, + takerBuyQuoteAssetVolume: 0, + ignore: 0, + }); + } + return candles; +} + +function makeDailyCandles(count: number, baseTime: number = Date.UTC(2024, 0, 1)): Kline[] { + const candles: Kline[] = []; + for (let i = 0; i < count; i++) { + const openTime = baseTime + i * 86_400_000; + candles.push({ + openTime, + open: 100 + i, + high: 105 + i, + low: 95 + i, + close: 102 + i, + volume: 10000 + i * 1000, + closeTime: openTime + 86_400_000, + quoteAssetVolume: 0, + numberOfTrades: 100, + takerBuyBaseAssetVolume: 0, + takerBuyQuoteAssetVolume: 0, + ignore: 0, + }); + } + return candles; +} + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('BaseProvider aggregation integration', () => { + it('should delegate natively supported timeframes directly', async () => { + const provider = new LimitedProvider(); + const data15 = make15minCandles(4); + provider.setFakeData('15', data15); + + const result = await provider.getMarketData('TEST', '15'); + expect(result).toEqual(data15); + }); + + it('should aggregate 15min → 45min (3:1 ratio)', async () => { + const provider = new LimitedProvider(); + provider.setFakeData('15', make15minCandles(12)); // 4 × 45min bars + + const result = await provider.getMarketData('TEST', '45'); + expect(result).toHaveLength(4); + // First 45min bar uses candles 0-2 + expect(result[0].open).toBe(100); + expect(result[0].close).toBe(104); + }); + + it('should aggregate 60min → 240min (4:1 ratio)', async () => { + const provider = new LimitedProvider(); + provider.setFakeData('60', make1hCandles(8)); // 2 × 4h bars + + const result = await provider.getMarketData('TEST', '240'); + expect(result).toHaveLength(2); + }); + + it('should aggregate D → W (weekly from daily)', async () => { + const provider = new LimitedProvider(); + // 2024-01-01 is Monday, 14 days = 2 complete weeks + provider.setFakeData('D', makeDailyCandles(14, Date.UTC(2024, 0, 1))); + + const result = await provider.getMarketData('TEST', 'W'); + expect(result).toHaveLength(2); + }); + + it('should aggregate D → M (monthly from daily)', async () => { + const provider = new LimitedProvider(); + // Jan 2024 (31d) + Feb 2024 (29d) = 60 days + provider.setFakeData('D', makeDailyCandles(60, Date.UTC(2024, 0, 1))); + + const result = await provider.getMarketData('TEST', 'M'); + expect(result).toHaveLength(2); + }); + + it('should respect limit parameter with aggregation', async () => { + const provider = new LimitedProvider(); + provider.setFakeData('15', make15minCandles(24)); // 8 × 45min bars + + const result = await provider.getMarketData('TEST', '45', 3); + expect(result).toHaveLength(3); + // Should return the LAST 3 bars (most recent) + }); + + it('should normalize timeframe aliases', async () => { + const provider = new LimitedProvider(); + const data = make1hCandles(4); + provider.setFakeData('60', data); + + // '1h' should normalize to '60' and be treated as native + const result = await provider.getMarketData('TEST', '1h'); + expect(result).toEqual(data); + }); + + it('should return empty for completely unsupported timeframe', async () => { + const provider = new LimitedProvider(); + // Provider supports {1, 15, 60, D} — '5S' has no valid sub-TF + const result = await provider.getMarketData('TEST', '5S'); + expect(result).toEqual([]); + }); + + it('should use forced sub-timeframe when set', async () => { + const provider = new LimitedProvider(); + const data1min = make15minCandles(12); // actually 15min spaced, but labeled as '1' data + provider.setFakeData('1', data1min); + provider.setFakeData('15', make15minCandles(4)); // different data + + // Force use of 1min instead of auto-selected 15min + provider.setAggregationSubTimeframe('1'); + const result = await provider.getMarketData('TEST', '45'); + // Should use the '1' data, not '15' data + expect(result.length).toBeGreaterThan(0); + }); + + it('should fall back to auto-selection if forced sub-TF is not supported', async () => { + const provider = new LimitedProvider(); + provider.setFakeData('15', make15minCandles(6)); + + // Force '5' which is not in LimitedProvider's supported set + provider.setAggregationSubTimeframe('5'); + const result = await provider.getMarketData('TEST', '45'); + // Should fall back to '15' (auto-selected) and aggregate + expect(result).toHaveLength(2); + }); +}); From e3085706b1f210c3cd89ec9d18b59e24e07991d3 Mon Sep 17 00:00:00 2001 From: alaa-eddine Date: Fri, 13 Mar 2026 13:43:30 +0100 Subject: [PATCH 2/2] update vitest package --- package-lock.json | 2276 +++++++-------------------------------------- package.json | 6 +- 2 files changed, 341 insertions(+), 1941 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed0deae..2aeb9e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "pinets", - "version": "0.8.10", + "version": "0.9.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pinets", - "version": "0.8.10", - "license": "AGPL-3.0", + "version": "0.9.5", + "license": "AGPL-3.0-only", "dependencies": { "acorn": "^8.14.0", "acorn-walk": "^8.3.4", @@ -20,7 +20,7 @@ "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", - "@vitest/coverage-v8": "^2.0.0", + "@vitest/coverage-v8": "^4.1.0", "autoprefixer": "^10.4.23", "badgen": "^3.2.3", "cross-env": "^7.0.3", @@ -31,23 +31,10 @@ "rollup-plugin-typescript-paths": "^1.5.0", "tailwindcss": "^4.1.18", "tsx": "^4.21.0", + "typescript": "~5.9.3", "vite": "^7.3.1", "vite-tsconfig-paths": "^4.3.2", - "vitest": "^2.0.0" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" + "vitest": "^4.1.0" } }, "node_modules/@babel/code-frame": { @@ -333,11 +320,14 @@ } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/@emnapi/core": { "version": "1.8.1", @@ -815,34 +805,6 @@ "node": ">=18" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1231,17 +1193,6 @@ "win32" ] }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1695,6 +1646,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1751,6 +1709,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1818,31 +1794,29 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", - "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.7", + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.0", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.12", - "magicast": "^0.3.5", - "std-env": "^3.8.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.9", - "vitest": "2.1.9" + "@vitest/browser": "4.1.0", + "vitest": "4.1.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1851,100 +1825,123 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1974,32 +1971,6 @@ "node": ">=0.4.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2017,6 +1988,35 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -2083,13 +2083,6 @@ "dev": true, "license": "MIT" }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -2103,16 +2096,6 @@ "node": ">=6.0.0" } }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2160,16 +2143,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001770", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", @@ -2192,52 +2165,15 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -2321,16 +2257,6 @@ "node": ">=0.10" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -2341,13 +2267,6 @@ "node": ">=0.10.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.302", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", @@ -2355,13 +2274,6 @@ "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2506,23 +2418,6 @@ "node": ">=8" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/formatly": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", @@ -2601,28 +2496,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -2699,16 +2572,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2781,21 +2644,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -2810,22 +2658,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2924,13 +2756,6 @@ "typescript": ">=5.0.4 <7" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2952,15 +2777,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -3029,22 +2854,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -3055,16 +2864,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3098,6 +2897,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/oxc-resolver": { "version": "11.18.0", "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.18.0.tgz", @@ -3130,13 +2940,6 @@ "@oxc-resolver/binding-win32-x64-msvc": "11.18.0" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3154,47 +2957,13 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3503,19 +3272,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/smol-toml": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", @@ -3559,116 +3315,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -3715,21 +3367,6 @@ "dev": true, "license": "MIT" }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3738,11 +3375,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -3761,30 +3401,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -3859,7 +3479,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3999,1295 +3618,174 @@ } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "node_modules/vite-tsconfig-paths": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", + "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" }, "bin": { - "vite-node": "vite-node.mjs" + "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } } }, - "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } + "license": "MIT" }, - "node_modules/vite-node/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "license": "ISC", "engines": { - "node": ">=12" + "node": "20 || >=22" } }, - "node_modules/vite-node/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite-node/node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/vite-node/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-tsconfig-paths": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", - "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "globrex": "^0.1.2", - "tsconfck": "^3.0.3" - }, - "peerDependencies": { - "vite": "*" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vitest/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vitest/node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/vitest/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/walk-up-path": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", - "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -5317,104 +3815,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 4447230..07ec14d 100644 --- a/package.json +++ b/package.json @@ -81,14 +81,13 @@ "astring": "^1.9.0" }, "devDependencies": { - "typescript": "~5.9.3", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", - "@vitest/coverage-v8": "^2.0.0", + "@vitest/coverage-v8": "^4.1.0", "autoprefixer": "^10.4.23", "badgen": "^3.2.3", "cross-env": "^7.0.3", @@ -99,9 +98,10 @@ "rollup-plugin-typescript-paths": "^1.5.0", "tailwindcss": "^4.1.18", "tsx": "^4.21.0", + "typescript": "~5.9.3", "vite": "^7.3.1", "vite-tsconfig-paths": "^4.3.2", - "vitest": "^2.0.0" + "vitest": "^4.1.0" }, "repository": { "type": "git",