UNPKG

@atomic-utils/time

Version:
772 lines (661 loc) 21.8 kB
export type CsoEvent = | 'dlcExpiry' | 'dlcAttestation' | 'rolloverOpen' | 'newEntryOpen' | 'newEntryClosed' | 'tradingOpen' | 'halfMonthEntryClosed' | 'tradingOpenHalfMonth'; export type CsoPeriod = 'weekly' | 'monthly' | 'bimonthly'; export type CsoLength = | 'full-month' | 'half-month' | 'one-and-a-half-months' | 'two-months'; export type CsoEventIdType = 'period' | 'split' | 'unsplit'; export const DLC_EXPIRY_LEN = 7; export const DLC_ATTESTATION_LEN = 1; export const ROLLOVER_OPEN_LEN = 24; export const NEW_ENTRY_OPEN_LEN = 36; export const NEW_ENTRY_CLOSED_LEN = 8; export const HALF_MONTH_ENTRY_CLOSED_LEN = 6; export const TRADING_OPEN_HALF_MONTH_LEN = 334; import { getStrDate } from '@atomic-utils/deribit'; import assert from 'assert'; export const STR_DATE_REGEX = /(\d{1,2})([A-Z]+)(\d{1,2})/; /** * getLastFridayInMonth * * Pass in year and month and return Date object with * last friday of the month * * @param {number} y Year in full format (i.e. 2022) * @param {number} m Month NOT 0-indexed (i.e. 1 => January) * @returns {Date} last friday of month */ export const getLastFridayInMonth = (y: number, m: number): Date => { const lastDay = new Date(Date.UTC(y, m, 0, 8, 0, 0)); if (lastDay.getUTCDay() < 5) { lastDay.setDate(lastDay.getDate() - 7); } lastDay.setDate(lastDay.getDate() - (lastDay.getUTCDay() - 5)); return lastDay; }; /** * getCurrentCycleMaturityDate * * @param {Date} t_ current time * @returns {Date} last friday in current cycle */ export const getCurrentCycleMaturityDate = (t_: Date): Date => { const t = new Date(t_.getTime()); // clone to avoid mutation let y = t.getUTCFullYear(); assert( y >= 2009, // since cycle maturity cannot be before Bitcoin was created `Invalid date provided ${t} with timestamp ${t.getTime()}, you may have used seconds instead of milliseconds`, ); assert( y <= 2100, // reasonable upper bound `Invalid date provided ${t} with timestamp ${t.getTime()}, you may have used microseconds instead of milliseconds`, ); let m = t.getUTCMonth() + 1; let lastFriday = getLastFridayInMonth(y, m); if (t >= lastFriday) { t.setUTCDate(1); t.setUTCMonth(t.getUTCMonth() + 1); y = t.getUTCFullYear(); m = t.getUTCMonth() + 1; lastFriday = getLastFridayInMonth(y, m); } return lastFriday; }; /** * getNextCycleMaturityDate * * @param {Date} t_ current time * @returns {Date} last friday in next cycle */ export const getNextCycleMaturityDate = (t_: Date): Date => { const t = new Date(t_.getTime()); // clone to avoid mutation const currentLastFriday = getCurrentCycleMaturityDate(t); currentLastFriday.setUTCSeconds(currentLastFriday.getUTCSeconds() + 1); const nextLastFriday = getCurrentCycleMaturityDate(currentLastFriday); return nextLastFriday; }; /** * getPreviousCycleMaturityDate * * @param {Date} t_ current time * @returns {Date} last friday in previous cycle */ export const getPreviousCycleMaturityDate = (t_: Date): Date => { const t = new Date(t_.getTime()); // clone to avoid mutation const currentLastFriday = getCurrentCycleMaturityDate(t); currentLastFriday.setUTCMonth(currentLastFriday.getUTCMonth() - 1); currentLastFriday.setUTCDate(currentLastFriday.getDate() - 14); const previousLastFriday = getCurrentCycleMaturityDate(currentLastFriday); return previousLastFriday; }; export const getCsoEventDates = (t_: Date): CsoEventDates => { const t = new Date(t_.getTime()); const upcomingDlcExpiry = getCurrentCycleMaturityDate(t); const previousDlcExpiry = getPreviousCycleMaturityDate(t); const dlcAttestation = new Date(previousDlcExpiry.getTime()); dlcAttestation.setUTCHours(dlcAttestation.getUTCHours() + DLC_EXPIRY_LEN); const rolloverOpen = new Date(dlcAttestation.getTime()); rolloverOpen.setUTCHours(rolloverOpen.getUTCHours() + DLC_ATTESTATION_LEN); const newEntryOpen = new Date(rolloverOpen.getTime()); newEntryOpen.setUTCHours(newEntryOpen.getUTCHours() + ROLLOVER_OPEN_LEN); const newEntryClosed = new Date(newEntryOpen.getTime()); newEntryClosed.setUTCHours(newEntryClosed.getUTCHours() + NEW_ENTRY_OPEN_LEN); const tradingOpen = new Date(newEntryClosed.getTime()); tradingOpen.setUTCHours(tradingOpen.getUTCHours() + NEW_ENTRY_CLOSED_LEN); const tradingOpenHalfMonth = new Date(upcomingDlcExpiry.getTime()); tradingOpenHalfMonth.setUTCHours( tradingOpenHalfMonth.getUTCHours() - TRADING_OPEN_HALF_MONTH_LEN, ); const halfMonthEntryClosed = new Date(tradingOpenHalfMonth.getTime()); halfMonthEntryClosed.setUTCHours( halfMonthEntryClosed.getUTCHours() - HALF_MONTH_ENTRY_CLOSED_LEN, ); return { previousDlcExpiry, dlcAttestation, rolloverOpen, newEntryOpen, newEntryClosed, tradingOpen, halfMonthEntryClosed, tradingOpenHalfMonth, upcomingDlcExpiry, }; }; /** * getCsoEvent * * @param {Date} t_ current time * @returns {CsoEvent} which cso event the date provided is within */ export const getCsoEvent = (t_: Date): CsoEvent => { const t = new Date(t_.getTime()); const { previousDlcExpiry, dlcAttestation, rolloverOpen, newEntryOpen, newEntryClosed, tradingOpen, halfMonthEntryClosed, tradingOpenHalfMonth, upcomingDlcExpiry, } = getCsoEventDates(t); switch (true) { case t >= previousDlcExpiry && t < dlcAttestation: return 'dlcExpiry'; case t >= dlcAttestation && t < rolloverOpen: return 'dlcAttestation'; case t >= rolloverOpen && t < newEntryOpen: return 'rolloverOpen'; case t >= newEntryOpen && t < newEntryClosed: return 'newEntryOpen'; case t >= newEntryClosed && t < tradingOpen: return 'newEntryClosed'; case t >= tradingOpen && t < halfMonthEntryClosed: return 'tradingOpen'; case t >= halfMonthEntryClosed && t < tradingOpenHalfMonth: return 'halfMonthEntryClosed'; case t >= tradingOpenHalfMonth && t < upcomingDlcExpiry: return 'tradingOpenHalfMonth'; case t.getTime() === upcomingDlcExpiry.getTime(): return 'dlcExpiry'; } }; /** * getCsoStartAndEndDate * * Pass in current time and get start and end date of event that user can enter into * immediately * * @param t_ current time * @returns {StartEndDates} start and end dates of the CSO event */ export const getCsoStartAndEndDate = ( t_: Date, forceExtendedPeriod = false, ): StartEndDates => { const t = new Date(t_.getTime()); const csoEvent = getCsoEvent(t); const { newEntryClosed, halfMonthEntryClosed, upcomingDlcExpiry } = getCsoEventDates(t); const { upcomingDlcExpiry: followingDlcExpiry } = getCsoEventDates( new Date(upcomingDlcExpiry.getTime() + 1), ); if ( csoEvent === 'halfMonthEntryClosed' || csoEvent === 'tradingOpenHalfMonth' ) { // Create full month for next month const nextT = new Date(upcomingDlcExpiry.getTime() + 1); const { newEntryClosed: nextNewEntryClosed, upcomingDlcExpiry: nextDlcExpiry, } = getCsoEventDates(nextT); const { upcomingDlcExpiry: followingDlcExpiry } = getCsoEventDates( new Date(nextDlcExpiry.getTime() + 1), ); return { startDate: nextNewEntryClosed, endDate: forceExtendedPeriod ? followingDlcExpiry : nextDlcExpiry, }; } else if (csoEvent === 'newEntryClosed' || csoEvent === 'tradingOpen') { // Create half month event ID return { startDate: halfMonthEntryClosed, endDate: forceExtendedPeriod ? followingDlcExpiry : upcomingDlcExpiry, }; } else { // Create full month for current month return { startDate: newEntryClosed, endDate: forceExtendedPeriod ? followingDlcExpiry : upcomingDlcExpiry, }; } }; /** * getUpcomingFriday * * From the current time, get the upcoming Friday. * If the current time is Friday and is after 8am UTC, then return the next week's Friday * * @param {Date} t_ current time * @returns */ export const getUpcomingFriday = (t_: Date): Date => { const t = new Date(t_.getTime()); let dayDelta = (5 - t.getUTCDay()) % 7; if (dayDelta === 0 && t.getUTCHours() >= 8) { dayDelta = 7; } t.setUTCDate(t.getUTCDate() + dayDelta); return new Date(t.setUTCHours(8, 0, 0, 0)); }; /** * getPreviousFriday * * From the current time, get the previous Friday. * If the current time is Friday and is before 8am UTC, then return the previous week's Friday * * @param {Date} t_ current time * @returns */ export const getPreviousFriday = (t_: Date): Date => { const t = new Date(t_.getTime()); let dayDelta = (5 - t.getUTCDay() + 7) % 7; if ( dayDelta === 0 && (t.getUTCHours() > 8 || (t.getUTCHours() === 8 && t.getUTCMinutes() === 0 && t.getUTCSeconds() > 0) || (t.getUTCHours() === 8 && t.getUTCMinutes() > 0)) ) { dayDelta = 7; } t.setUTCDate(t.getUTCDate() - (7 - dayDelta)); return new Date(t.setUTCHours(8, 0, 0, 0)); }; /** * getCsoEventId * * Pass in Date and return event ID of announcement that user can enter into immediately * * @param {Date} t_ current time * @param {string} provider company or trader providing strategy * @param {string} strategyId unique identifier for strategy * @param {CsoPeriod} period i.e. monthly * @returns {string} event ID string i.e. atomic-call_spread_v1-monthly-27JUN22-29JUL22 */ export const getCsoEventId = ( t_: Date, provider: string, strategyId: string, period: CsoPeriod, forceExtendedPeriod = false, ): string => { const t = new Date(t_.getTime()); const { startDate, endDate } = getCsoStartAndEndDate(t, forceExtendedPeriod); return [ provider, strategyId, period, getStrDate(startDate), getStrDate(endDate), ].join('-'); }; /** * getCsoTradeSplitEventId * * This function generates an event ID for a split trade. * * @param {string} provider - The company or trader providing the strategy. * @param {string} strategyId - The unique identifier for the strategy. * @param {number} tradeIndex - The index of the trade. * @returns {string} - The event ID string in the format [provider]-[strategyId]-trade-[tradeIndex]. * i.e. atomic-oyster-trade-84 */ export const getCsoTradeSplitEventId = ( provider: string, strategyId: string, tradeIndex: number, ): string => { return [provider, strategyId, 'trade', tradeIndex].join('-'); }; /** * getCsoTradeUnsplitEventId * * This function generates an event ID for an unsplit trade. * * @param {Date} t_ - The current time. * @param {string} provider - The company or trader providing the strategy. * @param {string} strategyId - The unique identifier for the strategy. * @param {number} numTrades - The number of trades. * @returns {string} - The event ID string in the format [provider]-[strategyId]-[numTrades]-trades-[startDate]. * i.e. atomic-oyster-5-trades-1JAN24 */ export const getCsoTradeUnsplitEventId = ( t_: Date, provider: string, strategyId: string, numTrades: number, ): string => { const t = new Date(t_.getTime()); return [provider, strategyId, numTrades, 'trades', getStrDate(t)].join('-'); }; export const getManualEventId = ( provider: string, source: string, maturity: Date, symbol = 'BTC', ): string => { const month = maturity .toLocaleString('default', { month: 'short' }) .toUpperCase() .split('.')[0]; const year = maturity.getFullYear().toString().slice(-2); const day = maturity.getDate().toString(); return `${provider}-${source}-${symbol}-${day}${month}${year}`; }; /** * getStartAndEndDateFromCsoEventId * * Pass in eventId and return start and end date by checking if date is * equal to tradingOpen, tradingOpenHalfMonth or dlcExpiry, else just * output date inside cso params for eventId * * @param {string} eventId format [provider]-[strategyId]-[period]-[startDate]-[endDate] * @returns {CsoParams} provider, strategyId, period, and start and end dates */ export const getParamsFromCsoEventId = (eventId: string): CsoParams => { const eventParams = eventId.split('-'); if (eventParams.length !== 5) throw Error( `Invalid eventId provided: ${eventId}. Expected format [provider]-[strategyId]-[period]-[startDate]-[endDate]`, ); const [provider, strategyId, period, startDateStr, endDateStr] = eventParams; if (!STR_DATE_REGEX.test(startDateStr) || !STR_DATE_REGEX.test(endDateStr)) throw new Error( `Invalid start or end date provided. Start Date: ${startDateStr}. End Date: ${endDateStr}`, ); const startDate = extractCsoEventIdDateFromStr(startDateStr); const endDate = extractCsoEventIdDateFromStr(endDateStr); return { provider, strategyId, period, startDate, endDate, }; }; export const getParamsFromCsoTradeSplitEventId = ( eventId: string, ): CsoSplitParams => { const eventParams = eventId.split('-'); if (eventParams.length !== 4) throw Error( `Invalid eventId provided: ${eventId}. Expected format [provider]-[strategyId]-[trade]-[tradeIndex]`, ); const [provider, strategyId, , tradeIndexStr] = eventParams; const tradeIndex = parseInt(tradeIndexStr); if (isNaN(tradeIndex)) throw Error( `Invalid tradeIndex provided: ${tradeIndexStr}. Expected integer`, ); return { provider, strategyId, tradeIndex, }; }; export const getParamsFromCsoTradeUnsplitEventId = ( eventId: string, ): CsoUnsplitParams => { const eventParams = eventId.split('-'); if (eventParams.length !== 5) throw Error( `Invalid eventId provided: ${eventId}. Expected format [provider]-[strategyId]-[numTrades]-[trades]-[startDate]`, ); const [provider, strategyId, numTradesStr, , startDateStr] = eventParams; const numTrades = parseInt(numTradesStr); if (isNaN(numTrades)) throw Error( `Invalid numTrades provided: ${numTradesStr}. Expected integer`, ); if (!STR_DATE_REGEX.test(startDateStr)) throw new Error(`Invalid start date provided: ${startDateStr}`); const startDate = extractCsoEventIdDateFromStr(startDateStr); return { provider, strategyId, numTrades, startDate, }; }; /** * getParamsFromManualEventId * * Pass in eventId and return provider, source, maturity, and symbol * * @param {string} eventId format [provider]-[source]-[symbol]-[maturity] * @returns {ManualEventParams} provider, source, maturity, and symbol */ export const getParamsFromManualEventId = ( eventId: string, ): ManualEventParams => { const eventParams = eventId.split('-'); if (eventParams.length !== 4) throw Error( `Invalid eventId provided: ${eventId}. Expected format [provider]-[source]-[symbol]-[maturity]`, ); const [provider, source, symbol, maturityStr] = eventParams; if (!STR_DATE_REGEX.test(maturityStr)) throw new Error(`Invalid maturity date provided: ${maturityStr}`); const maturity = extractCsoEventIdDateFromStr(maturityStr); return { provider, source, symbol, maturity, }; }; export const getCsoEventIdType = (eventId: string): CsoEventIdType => { const eventParams = eventId.split('-'); // Check the length and specific parts of the split eventId to determine its type switch (eventParams.length) { case 5: // If the eventId splits into 5 parts, it could be 'period' or 'unsplit' // Check the fourth part to differentiate return eventParams[3] === 'trades' ? 'unsplit' : 'period'; case 4: // If the eventId splits into 4 parts, it's a 'split' return 'split'; default: // If the eventId doesn't match the above cases, throw an error throw new Error(`Invalid eventId provided: ${eventId}`); } }; export const getEventIdType = (eventId: string): CsoEventIdType | 'manual' => { const eventParams = eventId.split('-'); // Check if the eventId is of type 'manual' if (eventParams.length === 4 && eventParams[2] !== 'trade') { return 'manual'; } // If not 'manual', use the existing function to determine the type return getCsoEventIdType(eventId); }; /** * extractCsoEventIdDateFromStr * * Get Date from string date while checking if date matches tradingOpen, * tradingOpenHalfMonth or dlcExpiry * * @param {string} dateStr string date in format [day][month][year] i.e. 21AUG22 * @returns {Date} */ export const extractCsoEventIdDateFromStr = (dateStr: string): Date => { const [, day, month, year] = dateStr.match(STR_DATE_REGEX); // Create a more reliable date parsing that works across all JS environments // Map month abbreviations to month numbers (0-indexed) const monthMap: { [key: string]: number } = { JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11, }; const monthNum = monthMap[month]; if (monthNum === undefined) { throw new Error(`Invalid month abbreviation: ${month}`); } // Convert 2-digit year to 4-digit year (assuming 20xx for years 00-99) const fullYear = parseInt(year) < 50 ? 2000 + parseInt(year) : 1900 + parseInt(year); const dayNum = parseInt(day); // Create date using UTC constructor for consistency const date = new Date(Date.UTC(fullYear, monthNum, dayNum, 12, 0, 0, 0)); // Validate the date was created successfully if (isNaN(date.getTime())) { throw new Error(`Invalid date created from: ${dateStr}`); } const csoEvent = getCsoEvent(date); const { previousDlcExpiry, newEntryClosed, tradingOpen, tradingOpenHalfMonth, upcomingDlcExpiry, } = getCsoEventDates(date); if (csoEvent === 'tradingOpen') { if (date.getUTCDate() === tradingOpen.getUTCDate()) return newEntryClosed; } else if (csoEvent === 'tradingOpenHalfMonth') { if (date.getUTCDate() === tradingOpenHalfMonth.getUTCDate()) return tradingOpenHalfMonth; } else if (csoEvent === 'dlcExpiry') { if (date.getUTCDate() === upcomingDlcExpiry.getUTCDate()) { return upcomingDlcExpiry; } else if (date.getUTCDate() === previousDlcExpiry.getUTCDate()) { return previousDlcExpiry; } } else if (csoEvent === 'newEntryClosed') { return newEntryClosed; } return date; }; /** * findCycleMaturityMonthsInPast * * Enter number of cycles to look in past and get cycle maturity * i.e. passing in numMonths 3 will get the cycle maturity 3 months ago * * @param {Date} t_ current time * @param {number} numMonths number of months to go back * @returns {Date} */ export const findCycleMaturityMonthsInPast = ( t_: Date, numMonths: number, ): Date => { let t = new Date(t_.getTime()); if (numMonths === 0) throw Error('numMonths must be at least 1'); for (let i = 0; i < numMonths; i++) { t = getPreviousCycleMaturityDate(new Date(t.getTime() - 1)); } return t; }; /** * findNumCyclesInPastMaturityExists * * Enter previousExpiry and find out how many months ago this expiry was * * @param {Date} t_ current time * @param {Date} previousExpiry previous cycle expiry * @returns {number} */ export const findNumCyclesInPastMaturityExists = ( t_: Date, previousExpiry_: Date, maxTries = 1000, ): number => { let t = new Date(t_.getTime()); if (previousExpiry_.getTime() >= t.getTime()) throw Error('Previous Expiry should be less than current date'); if (getCsoEvent(previousExpiry_) !== 'dlcExpiry') throw Error('Previous Expiry should be in time period dlcExpiry'); const { previousDlcExpiry } = getCsoEventDates(previousExpiry_); const previousExpiry = previousDlcExpiry; if (t.getTime() === previousExpiry.getTime()) return 0; for (let i = 0; i < maxTries; i++) { t = getPreviousCycleMaturityDate(new Date(t.getTime() - 1)); if (t.getTime() === previousExpiry.getTime()) return i + 1; } throw Error( `Could not find cycle maturity in the past after checking ${maxTries} months`, ); }; /** * isHalfMonth * * Determine if the current cycle is a half month cycle * @param {string} eventId current time * @returns {boolean} whether the current cycle is a half month */ export const isHalfMonth = (eventId: string): boolean => { const startDate = getParamsFromCsoEventId(eventId).startDate; return ( startDate.getTime() === getCsoEventDates(startDate).tradingOpenHalfMonth.getTime() || startDate.getTime() === getCsoEventDates(startDate).halfMonthEntryClosed.getTime() ); }; export const getCsoLength = (eventId: string): CsoLength => { const { startDate, endDate } = getParamsFromCsoEventId(eventId); const containsHalfMonth = isHalfMonth(eventId); // Get the difference in days const diffInDays = Math.floor( (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24), ); if (containsHalfMonth) { if (diffInDays > 30) return 'one-and-a-half-months'; else return 'half-month'; } else { if (diffInDays > 45) return 'two-months'; else return 'full-month'; } }; export interface StartEndDates { startDate: Date; endDate: Date; } export interface CsoParams { provider: string; strategyId: string; period: string; startDate: Date; endDate: Date; } export interface CsoSplitParams { provider: string; strategyId: string; tradeIndex: number; } export interface CsoUnsplitParams { provider: string; strategyId: string; numTrades: number; startDate: Date; } export interface ManualEventParams { provider: string; source: string; symbol: string; maturity: Date; } export interface CsoEventDates { previousDlcExpiry: Date; dlcAttestation: Date; rolloverOpen: Date; newEntryOpen: Date; newEntryClosed: Date; tradingOpen: Date; halfMonthEntryClosed: Date; tradingOpenHalfMonth: Date; upcomingDlcExpiry: Date; }