diff --git a/src/__mocks__/api/v3/client.ts b/src/__mocks__/api/v3/client.ts index 78ef5f0..7481ffc 100644 --- a/src/__mocks__/api/v3/client.ts +++ b/src/__mocks__/api/v3/client.ts @@ -2,7 +2,7 @@ import { default as company } from '~/__mocks__/fixtures/v3/company.js' import { default as daily } from '~/__mocks__/fixtures/v3/daily.js' import { default as quarter } from '~/__mocks__/fixtures/v3/quarter.js' -export class BuffettCodeApiClientV2 { +export class BuffettCodeApiClientV3 { public mockCompany = jest.fn() public mockDaily = jest.fn() public mockQuarter = jest.fn() @@ -13,23 +13,28 @@ export class BuffettCodeApiClientV2 { this.mockQuarter.mockReturnValue(quarter) } - company(ticker): object | null { - const company = this.mockCompany()[ticker] - return company ? company[0] : null + company(): object | null { + const company = this.mockCompany()['data'] + return company ? company : null } - quarter(ticker): object | null { - const quarter = this.mockQuarter()[ticker] - return quarter ? quarter[0] : null + quarter(): object | null { + const quarter = this.mockQuarter()['data'] + return quarter ? quarter : null } - daily(ticker): object | null { - const daily = this.mockDaily()[ticker] - return daily ? daily[0] : null + daily(): object | null { + const daily = this.mockDaily()['data'] + return daily ? daily : null } - ondemandQuarter(ticker): object | null { - const quarter = this.mockQuarter()[ticker] - return quarter ? quarter[0] : null + ondemandDaily(): object | null { + const daily = this.mockDaily()['data'] + return daily ? daily : null + } + + ondemandQuarter(): object | null { + const quarter = this.mockQuarter()['data'] + return quarter ? quarter : null } } diff --git a/src/__mocks__/fixtures/v3/daily.js b/src/__mocks__/fixtures/v3/daily.js index 497fe6f..66ecc5c 100644 --- a/src/__mocks__/fixtures/v3/daily.js +++ b/src/__mocks__/fixtures/v3/daily.js @@ -82,6 +82,7 @@ module.exports = { } }, data: { + day: '2020-09-06', cash_market_capital_ratio: 4.98944759676063, debt_market_capital_ratio: 1.48298604408628, dividend_yield_actual: 1.40154169586545, diff --git a/src/__mocks__/services/daily-cache.ts b/src/__mocks__/services/daily-cache.ts new file mode 100644 index 0000000..e4922a3 --- /dev/null +++ b/src/__mocks__/services/daily-cache.ts @@ -0,0 +1,23 @@ +import { DateParam } from '~/fiscal-periods/date-param' + +export class DailyCache { + static readonly cache = {} + + static get(ticker: string, date: Date | DateParam): object | null { + if (date instanceof Date) { + date = new DateParam(date) + } + + const cached = DailyCache.cache[`${ticker}-${date}`] + return cached === undefined ? null : cached + } + + static put(ticker: string, daily: object): void { + DailyCache.cache[`${ticker}-${daily['day']}`] = daily + } + + // for testing + static clearAll(): void { + Object.keys(this.cache).forEach(key => delete this.cache[key]) + } +} diff --git a/src/api/v3/caching-client.test.ts b/src/api/v3/caching-client.test.ts new file mode 100644 index 0000000..a832b72 --- /dev/null +++ b/src/api/v3/caching-client.test.ts @@ -0,0 +1,182 @@ +import { CompanyCache } from '~/__mocks__/services/company-cache' +import { QuarterCache } from '~/__mocks__/services/quarter-cache' +import { CachingBuffettCodeApiClientV3 } from '~/api/v3/caching-client' +import { DateParam } from '~/fiscal-periods/date-param' +import { YearQuarter } from '~/fiscal-periods/year-quarter' +import { YearQuarterParam } from '~/fiscal-periods/year-quarter-param' +import { DailyCache } from '~/services/daily-cache' + +jest.mock('~/api/v3/client', () => + jest.requireActual('~/__mocks__/api/v3/client') +) +jest.mock('~/services/company-cache', () => + jest.requireActual('~/__mocks__/services/company-cache') +) +jest.mock('~/services/daily-cache', () => + jest.requireActual('~/__mocks__/services/daily-cache') +) +jest.mock('~/services/quarter-cache', () => + jest.requireActual('~/__mocks__/services/quarter-cache') +) + +describe('company', () => { + const ticker = '2371' + + test('(uncached)', () => { + expect(CompanyCache.get(ticker)).toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.company(ticker) + expect(res).not.toBeNull() + + expect(CompanyCache.get(ticker)).toEqual(res) + }) + + test('(cached)', () => { + const cached = CompanyCache.get(ticker) + expect(cached).not.toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.company(ticker) + expect(res).toEqual(cached) + + expect(CompanyCache.get(ticker)).toEqual(cached) + }) +}) + +describe('daily', () => { + const ticker = '2371' + + test('(uncached)', () => { + const date = new DateParam(new Date('2020-09-06')) + expect(DailyCache.get(ticker, date)).toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.daily(ticker, date) + expect(res).not.toBeNull() + + expect(DailyCache.get(ticker, date)).toEqual(res) + }) + + test('(cached)', () => { + const date = new DateParam(new Date('2020-09-06')) + const cached = DailyCache.get(ticker, date) + expect(cached).not.toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.daily(ticker, date) + expect(res).toEqual(cached) + + expect(DailyCache.get(ticker, date)).toEqual(cached) + }) +}) + +describe('quarter', () => { + const ticker = '2371' + + describe('(FY, FQ)', () => { + beforeAll(() => { + QuarterCache.clearAll() + }) + + const period = new YearQuarterParam(2018, 1) + + test('(uncached)', () => { + expect(QuarterCache.get(ticker, period.toYearQuarter())).toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.quarter(ticker, period) + expect(res).not.toBeNull() + expect(res['fiscal_year']).toBe(period.year) + expect(res['fiscal_quarter']).toBe(period.quarter) + + expect(QuarterCache.get(ticker, period.toYearQuarter())).toEqual(res) + }) + + test('(cached)', () => { + const cached = QuarterCache.get(ticker, period.toYearQuarter()) + expect(cached).not.toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.quarter(ticker, period) + expect(res).toEqual(cached) + expect(res['fiscal_year']).toBe(period.year) + expect(res['fiscal_quarter']).toBe(period.quarter) + + expect(QuarterCache.get(ticker, period.toYearQuarter())).toEqual(cached) + }) + }) + + describe('(LY, LQ)', () => { + beforeAll(() => { + QuarterCache.clearAll() + }) + + const period = new YearQuarterParam('LY', 'LQ') + + test('(uncached)', () => { + expect(QuarterCache.get(ticker, new YearQuarter(2018, 1))).toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.quarter(ticker, period) + expect(res).not.toBeNull() + expect(res['fiscal_year']).toBe(2018) + expect(res['fiscal_quarter']).toBe(1) + + expect(QuarterCache.get(ticker, new YearQuarter(2018, 1))).toEqual(res) + }) + }) +}) + +describe('ondemandQuarter', () => { + const ticker = '2371' + + describe('(FY, FQ)', () => { + beforeAll(() => { + QuarterCache.clearAll() + }) + + const period = new YearQuarterParam(2018, 1) + + test('(uncached)', () => { + expect(QuarterCache.get(ticker, period.toYearQuarter())).toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.ondemandQuarter(ticker, period) + expect(res).not.toBeNull() + + expect(QuarterCache.get(ticker, period.toYearQuarter())).toEqual(res) + }) + + test('(cached)', () => { + const cached = QuarterCache.get(ticker, period.toYearQuarter()) + expect(cached).not.toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.ondemandQuarter(ticker, period) + expect(res).toEqual(cached) + + expect(QuarterCache.get(ticker, period.toYearQuarter())).toEqual(cached) + }) + }) + + describe('(LY, LQ)', () => { + beforeAll(() => { + QuarterCache.clearAll() + }) + + const period = new YearQuarterParam('LY', 'LQ') + + test('(uncached)', () => { + expect(QuarterCache.get(ticker, new YearQuarter(2018, 1))).toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.ondemandQuarter(ticker, period) + expect(res).not.toBeNull() + expect(res['fiscal_year']).toBe(2018) + expect(res['fiscal_quarter']).toBe(1) + + expect(QuarterCache.get(ticker, new YearQuarter(2018, 1))).toEqual(res) + }) + }) +}) diff --git a/src/api/v3/caching-client.ts b/src/api/v3/caching-client.ts new file mode 100644 index 0000000..362a526 --- /dev/null +++ b/src/api/v3/caching-client.ts @@ -0,0 +1,94 @@ +import { BuffettCodeApiClientV3 } from '~/api/v3/client' +import { DateParam } from '~/fiscal-periods/date-param' +import { YearQuarterParam } from '~/fiscal-periods/year-quarter-param' +import { CompanyCache } from '~/services/company-cache' +import { DailyCache } from '~/services/daily-cache' +import { QuarterCache } from '~/services/quarter-cache' + +export class CachingBuffettCodeApiClientV3 extends BuffettCodeApiClientV3 { + constructor(private token: string) { + super(token) + } + + company(ticker: string): object { + const cached = CompanyCache.get(ticker) + if (cached) { + return cached + } + + const company = super.company(ticker) + CompanyCache.put(ticker, company) + + return company + } + + daily(ticker: string, date: DateParam): object | null { + const cached = DailyCache.get(ticker, date) + if (cached) { + return cached + } + + const daily = super.daily(ticker, date) + if (!daily) { + return null + } + + DailyCache.put(ticker, daily) + + return daily + } + + quarter(ticker: string, period: YearQuarterParam): object | null { + if (period.convertibleToYearQuarter()) { + const cached = QuarterCache.get(ticker, period.toYearQuarter()) + if (cached) { + return cached + } + } + + const quarter = super.quarter(ticker, period) + if (!quarter) { + return null + } + + QuarterCache.put(ticker, quarter) + + return quarter + } + + ondemandDaily(ticker: string, date: DateParam): object | null { + const cached = DailyCache.get(ticker, date) + if (cached) { + return cached + } + + const daily = super.ondemandDaily(ticker, date) + if (!daily) { + return null + } + + DailyCache.put(ticker, daily) + + return daily + } + + ondemandQuarter(ticker: string, period: YearQuarterParam): object | null { + if (period.convertibleToYearQuarter()) { + const cached = QuarterCache.get(ticker, period.toYearQuarter()) + if (cached) { + return cached + } + } + + const quarter = super.ondemandQuarter(ticker, period) + if (!quarter) { + return null + } + + QuarterCache.put(ticker, quarter) + + return quarter + } + + // TODO: Add bulkDaily and bulkQuarter support +} diff --git a/src/services/daily-cache.test.ts b/src/services/daily-cache.test.ts new file mode 100644 index 0000000..6adf9cc --- /dev/null +++ b/src/services/daily-cache.test.ts @@ -0,0 +1,47 @@ +import * as dailyFixture from '~/__mocks__/fixtures/v3/daily' +import { DateParam } from '~/fiscal-periods/date-param' +import { getMock, putMock } from '~/services/cache-test-helper' +import { DailyCache } from '~/services/daily-cache' + +test('key', () => { + expect(DailyCache.key('6501', new DateParam('latest'))).toBe( + `daily-6501-${new Date().toISOString().substring(0, 10)}` + ) + expect(DailyCache.key('6501', new Date())).toBe( + `daily-6501-${new Date().toISOString().substring(0, 10)}` + ) + expect(DailyCache.key('6501', new DateParam(new Date('2020-09-06')))).toBe( + `daily-6501-2020-09-06` + ) + expect(DailyCache.key('6501', new Date('2020-09-06'))).toBe( + `daily-6501-2020-09-06` + ) +}) + +const daily = dailyFixture['data'] +const date = new Date('2020-09-06') + +beforeEach(() => { + jest.clearAllMocks() +}) + +test('get', () => { + getMock.mockReturnValueOnce(JSON.stringify(daily)) + expect(DailyCache.get('2371', date)).toEqual(daily) + expect(DailyCache.get('9999', date)).toBeNull() + + expect(getMock).toBeCalledTimes(2) + expect(getMock).nthCalledWith(1, 'daily-2371-2020-09-06') + expect(getMock).nthCalledWith(2, 'daily-9999-2020-09-06') +}) + +test('put', () => { + DailyCache.put('2371', daily) + + expect(putMock).toBeCalledTimes(1) + expect(putMock).toBeCalledWith( + 'daily-2371-2020-09-06', + JSON.stringify(daily), + 21600 + ) +}) diff --git a/src/services/daily-cache.ts b/src/services/daily-cache.ts new file mode 100644 index 0000000..36cce4d --- /dev/null +++ b/src/services/daily-cache.ts @@ -0,0 +1,37 @@ +import { DateParam } from '~/fiscal-periods/date-param' + +export class DailyCache { + static readonly prefix = 'daily' + + private constructor() { + // + } + + static key(ticker: string, date: Date | DateParam): string { + if (date instanceof Date) { + date = new DateParam(date) + } else if (date.isLatest()) { + date = new DateParam(new Date()) + } + + return `${this.prefix}-${ticker}-${date}` + } + + static get(ticker: string, date: Date | DateParam): object | null { + const cache = CacheService.getUserCache() + const key = this.key(ticker, date) + const cached = cache.get(key) + if (!cached) { + return null + } + + return JSON.parse(cached) + } + + static put(ticker: string, daily: object, expirationInSeconds = 21600): void { + const cache = CacheService.getUserCache() + const date = new Date(daily['day']) + const key = this.key(ticker, date) + cache.put(key, JSON.stringify(daily), expirationInSeconds) + } +}