From 12e1435c3ed9d44205640e25974b5dc85b3472cf Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Fri, 23 Dec 2022 16:16:59 +0900 Subject: [PATCH 1/8] Add YearMonth --- src/fiscal-periods/error.ts | 9 ++++++++ src/fiscal-periods/year-month.test.ts | 31 +++++++++++++++++++++++++++ src/fiscal-periods/year-month.ts | 29 +++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 src/fiscal-periods/year-month.test.ts create mode 100644 src/fiscal-periods/year-month.ts diff --git a/src/fiscal-periods/error.ts b/src/fiscal-periods/error.ts index 0166913..9eb6370 100644 --- a/src/fiscal-periods/error.ts +++ b/src/fiscal-periods/error.ts @@ -16,6 +16,15 @@ export class InvalidQuarterError implements Error { } } +export class InvalidMonthError implements Error { + public name = 'InvalidMonthError' + public message: string + + constructor(message = '') { + this.message = message + } +} + export class InvalidLYLQError implements Error { public name = 'InvalidLYLQError' public message: string diff --git a/src/fiscal-periods/year-month.test.ts b/src/fiscal-periods/year-month.test.ts new file mode 100644 index 0000000..8e7722c --- /dev/null +++ b/src/fiscal-periods/year-month.test.ts @@ -0,0 +1,31 @@ +import { InvalidYearError, InvalidMonthError, ParseError } from '~/fiscal-periods/error' +import { YearMonth } from '~/fiscal-periods/year-month' + +test.each([ + { year: 1, month: 12 }, + { year: 2018, month: 1 }, + { year: 2018, month: 12 } +])('constructor($year, $month) (valid)', ({ year, month }) => { + expect(() => new YearMonth(year, month)).not.toThrow(Error) +}) + +test.each([ + { year: -1, month: -1, expected: InvalidYearError }, + { year: 0, month: 12, expected: InvalidYearError }, + { year: 2018, month: 0, expect: InvalidMonthError }, + { year: 2018, month: 13, expect: InvalidMonthError } +])('constructor($year, $month) (error)', ({ year, month, expected }) => { + expect(() => new YearMonth(year, month)).toThrow(expected) +}) + +test('toString', () => { + expect(new YearMonth(2018, 3).toString()).toBe('2018-03') +}) + +test('parse (valid)', () => { + expect(YearMonth.parse('2018-03')).toEqual(new YearMonth(2018, 3)) +}) + +test('parse (error)', () => { + expect(() => YearMonth.parse('foobar')).toThrow(ParseError) +}) diff --git a/src/fiscal-periods/year-month.ts b/src/fiscal-periods/year-month.ts new file mode 100644 index 0000000..a6ffb90 --- /dev/null +++ b/src/fiscal-periods/year-month.ts @@ -0,0 +1,29 @@ +import { InvalidYearError, InvalidMonthError, ParseError } from '~/fiscal-periods/error' + +export class YearMonth { + constructor(public year: number, public month: number) { + if (year < 1) { + throw new InvalidYearError(`Invalid year value: ${year}`) + } + + if (month < 1 || month > 12) { + throw new InvalidMonthError(`Invalid month value: ${month}`) + } + } + + public toString(): string { + return this.year + '-' + this.month.toString().padStart(2, '0') + } + + static parse(str: string): YearMonth { + const matches = str.match(/^\d{4}-\d{2}$/) + if (matches == undefined) { + throw new ParseError(`Invalid year-month format: ${str}`) + } + + const [yearString, monthString] = str.split('-') + const year = parseInt(yearString, 10) + const month = parseInt(monthString, 10) + return new YearMonth(year, month) + } +} From 5f03defee17b3f3b374a52f155318339db210856 Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Sat, 24 Dec 2022 11:33:42 +0900 Subject: [PATCH 2/8] Add type-helper --- src/services/type-helper.test.ts | 25 +++++++++++++++++++++++++ src/services/type-helper.ts | 8 ++++++++ 2 files changed, 33 insertions(+) create mode 100644 src/services/type-helper.test.ts create mode 100644 src/services/type-helper.ts diff --git a/src/services/type-helper.test.ts b/src/services/type-helper.test.ts new file mode 100644 index 0000000..00f1370 --- /dev/null +++ b/src/services/type-helper.test.ts @@ -0,0 +1,25 @@ +import { isObject, isColumnDescription } from '~/services/type-helper' + +test.each([ + { obj: {}, expected: true }, + { obj: new Error('foo'), expected: true }, + { obj: 1, expected: false }, + { obj: '', expected: false }, + { obj: null, expected: false }, + { obj: undefined, expected: false }, + { obj: [], expected: false }, + { obj: (): string => 'foo', expected: false } +])('isObject($obj)', ({ obj, expected }) => { + expect(isObject(obj)).toBe(expected) +}) + +describe.each([ + // eslint-disable-next-line @typescript-eslint/camelcase + { obj: { unit: '', name_jp: '' }, expected: true }, + { obj: { foo: '', bar: '' }, expected: false }, + { obj: {}, expected: false }, + { obj: undefined, expected: false }, + { obj: null, expected: false } +])('isObject($obj)', ({ obj, expected }) => { + expect(isColumnDescription(obj)).toBe(expected) +}) diff --git a/src/services/type-helper.ts b/src/services/type-helper.ts new file mode 100644 index 0000000..b5f5603 --- /dev/null +++ b/src/services/type-helper.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isObject(obj: any): obj is object { + return obj !== null && typeof obj === 'object' && !Array.isArray(obj) +} + +export function isColumnDescription(obj: object): boolean { + return obj != undefined && 'name_jp' in obj && 'unit' in obj +} From 7136c16144b535678109c51004da8306b4a747b7 Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Sat, 24 Dec 2022 10:24:01 +0900 Subject: [PATCH 3/8] Add PropertyPathResolver --- src/services/error.ts | 8 +++ src/services/property-path-resolver.test.ts | 54 ++++++++++++++++++++ src/services/property-path-resolver.ts | 56 +++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 src/services/error.ts create mode 100644 src/services/property-path-resolver.test.ts create mode 100644 src/services/property-path-resolver.ts diff --git a/src/services/error.ts b/src/services/error.ts new file mode 100644 index 0000000..5f8e4bd --- /dev/null +++ b/src/services/error.ts @@ -0,0 +1,8 @@ +export class KeyNotFoundError implements Error { + public name = 'KeyNotFoundError' + public message: string + + constructor(message = '') { + this.message = message + } +} diff --git a/src/services/property-path-resolver.test.ts b/src/services/property-path-resolver.test.ts new file mode 100644 index 0000000..44f0a4e --- /dev/null +++ b/src/services/property-path-resolver.test.ts @@ -0,0 +1,54 @@ +import * as monthly from '~/__mocks__/fixtures/v3/monthly' +import { KeyNotFoundError } from '~/services/error' +import { PropertyPathResolver } from '~/services/property-path-resolver' + +test('listPathsOf', () => { + expect(PropertyPathResolver.listPathsOf(monthly.data)).toEqual([ + 'ticker', + 'year', + 'month', + 'beta.years_2.start_date', + 'beta.years_2.end_date', + 'beta.years_2.beta', + 'beta.years_2.alpha', + 'beta.years_2.r', + 'beta.years_2.r_squared', + 'beta.years_2.count', + 'beta.years_3.start_date', + 'beta.years_3.end_date', + 'beta.years_3.beta', + 'beta.years_3.alpha', + 'beta.years_3.r', + 'beta.years_3.r_squared', + 'beta.years_3.count', + 'beta.years_5.start_date', + 'beta.years_5.end_date', + 'beta.years_5.beta', + 'beta.years_5.alpha', + 'beta.years_5.r', + 'beta.years_5.r_squared', + 'beta.years_5.count' + ]) +}) + +test.each([ + { name: 'ticker', expected: monthly.data.ticker }, + { name: 'year', expected: monthly.data.year }, + { name: 'month', expected: monthly.data.month }, + { name: 'beta', expected: monthly.data.beta }, + { name: 'beta.years_2', expected: monthly.data.beta.years_2 }, + { name: 'beta.years_2.beta', expected: monthly.data.beta.years_2.beta }, + { name: 'kpi', expected: monthly.data.kpi } +])('getPropertyOf($name) (valid)', ({ name, expected }) => { + expect(PropertyPathResolver.getPropertyOf(monthly.data, name)).toEqual(expected) +}) + +test.each([ + { name: 'foo', expected: KeyNotFoundError }, + { name: 'ticker.foo', expected: TypeError }, + { name: 'beta.foo', expected: KeyNotFoundError }, + { name: 'beta.years_2.foo', expected: KeyNotFoundError }, + { name: 'kpi.0', expected: TypeError } +])('getPropertyOf($name) (error)', ({ name, expected }) => { + expect(() => PropertyPathResolver.getPropertyOf(monthly.data, name)).toThrow(expected) +}) diff --git a/src/services/property-path-resolver.ts b/src/services/property-path-resolver.ts new file mode 100644 index 0000000..d604059 --- /dev/null +++ b/src/services/property-path-resolver.ts @@ -0,0 +1,56 @@ +import { KeyNotFoundError } from '~/services/error' +import { isObject } from '~/services/type-helper' + +export class PropertyPathResolver { + private constructor() { + // + } + + private static joinPath(...components: string[]): string { + return components.join('.') + } + + private static splitPath(path: string): string[] { + return path.split('.') + } + + static listPathsOf(data: object, prefix: string | null = null): string[] { + const paths = Object.keys(data).map(key => { + const value = data[key] + const path = prefix === null ? key : PropertyPathResolver.joinPath(prefix, key) + + if (Array.isArray(value)) { + // NOTE: nested array property is currently not supported + return [] + } else if (isObject(value)) { + return PropertyPathResolver.listPathsOf(value, path) + } else { + return path + } + }) + + return [].concat(...paths) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static getPropertyOf(data: object, path: string): any { + const [key, ...components] = PropertyPathResolver.splitPath(path) + + if (!(key in data)) { + throw new KeyNotFoundError(`${key} does not exist in data`) + } + + const value = data[key] + if (components.length === 0) { + return value + } + + if (Array.isArray(value)) { + throw new TypeError(`An array property is currently not supported`) + } else if (isObject(value)) { + return PropertyPathResolver.getPropertyOf(value, PropertyPathResolver.joinPath(...components)) + } else { + throw new TypeError(`Can't access to a non-object property`) + } + } +} From 99dddd2768a83041fef1378d4724263dcd12f86d Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Fri, 23 Dec 2022 16:32:01 +0900 Subject: [PATCH 4/8] Add Monthly --- src/entities/v3/interface.ts | 7 +++ src/entities/v3/monthly.test.ts | 96 +++++++++++++++++++++++++++++++++ src/entities/v3/monthly.ts | 59 ++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 src/entities/v3/monthly.test.ts create mode 100644 src/entities/v3/monthly.ts diff --git a/src/entities/v3/interface.ts b/src/entities/v3/interface.ts index a339a69..cae55b4 100644 --- a/src/entities/v3/interface.ts +++ b/src/entities/v3/interface.ts @@ -1,3 +1,10 @@ +export interface HasData { + propertyNames(): string[] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + valueOf(propertyName: string): any +} + export interface HasColumnDescription { propertyNames(): string[] diff --git a/src/entities/v3/monthly.test.ts b/src/entities/v3/monthly.test.ts new file mode 100644 index 0000000..dcac4a3 --- /dev/null +++ b/src/entities/v3/monthly.test.ts @@ -0,0 +1,96 @@ +import * as response from '~/__mocks__/fixtures/v3/monthly' +import { Monthly } from '~/entities/v3/monthly' +import { YearMonth } from '~/fiscal-periods/year-month' +import { KeyNotFoundError } from '~/services/error' + +const monthly = Monthly.fromResponse(response) + +test('period', () => { + expect(monthly.period()).toEqual(new YearMonth(2022, 5)) +}) + +test('propertyNames', () => { + expect(monthly.propertyNames()).toEqual([ + 'ticker', + 'year', + 'month', + 'beta.years_2.start_date', + 'beta.years_2.end_date', + 'beta.years_2.beta', + 'beta.years_2.alpha', + 'beta.years_2.r', + 'beta.years_2.r_squared', + 'beta.years_2.count', + 'beta.years_3.start_date', + 'beta.years_3.end_date', + 'beta.years_3.beta', + 'beta.years_3.alpha', + 'beta.years_3.r', + 'beta.years_3.r_squared', + 'beta.years_3.count', + 'beta.years_5.start_date', + 'beta.years_5.end_date', + 'beta.years_5.beta', + 'beta.years_5.alpha', + 'beta.years_5.r', + 'beta.years_5.r_squared', + 'beta.years_5.count' + ]) +}) + +test.each([ + { name: 'ticker', expected: '2371' }, + { name: 'year', expected: 2022 }, + { name: 'beta.years_2.count', expected: 24 } +])('valueOf($name) (normal)', ({ name, expected }) => { + expect(monthly.valueOf(name)).toEqual(expected) +}) + +test.each([ + { name: 'foo', expected: KeyNotFoundError }, + { name: 'year.foo', expected: TypeError }, + { name: 'beta', expected: TypeError }, + { name: 'beta.foo', expected: KeyNotFoundError }, + { name: 'beta.years_2', expected: TypeError }, + { name: 'kpi', expected: TypeError } +])('valueOf($name) (error)', ({ name, expected }) => { + expect(() => monthly.valueOf(name)).toThrow(expected) +}) + +test.each([ + { name: 'ticker', expected: 'ティッカー' }, + { name: 'year', expected: '年' }, + { name: 'beta.years_2.count', expected: '利用データ数' } +])('labelOf($name) (normal)', ({ name, expected }) => { + expect(monthly.labelOf(name)).toEqual(expected) +}) + +test.each([ + { name: 'foo', expected: KeyNotFoundError }, + { name: 'year.foo', expected: KeyNotFoundError }, + { name: 'beta', expected: TypeError }, + { name: 'beta.foo', expected: KeyNotFoundError }, + { name: 'beta.years_2', expected: TypeError }, + { name: 'kpi', expected: TypeError } +])('labelOf($name) (error)', ({ name, expected }) => { + expect(() => monthly.labelOf(name)).toThrow(expected) +}) + +test.each([ + { name: 'ticker', expected: 'なし' }, + { name: 'year', expected: 'なし' }, + { name: 'beta.years_2.count', expected: '個' } +])('unitOf($name) (normal)', ({ name, expected }) => { + expect(monthly.unitOf(name)).toEqual(expected) +}) + +test.each([ + { name: 'foo', expected: KeyNotFoundError }, + { name: 'year.foo', expected: KeyNotFoundError }, + { name: 'beta', expected: TypeError }, + { name: 'beta.foo', expected: KeyNotFoundError }, + { name: 'beta.years_2', expected: TypeError }, + { name: 'kpi', expected: TypeError } +])('unitOf($name) (error)', ({ name, expected }) => { + expect(() => monthly.unitOf(name)).toThrow(expected) +}) diff --git a/src/entities/v3/monthly.ts b/src/entities/v3/monthly.ts new file mode 100644 index 0000000..ac5a608 --- /dev/null +++ b/src/entities/v3/monthly.ts @@ -0,0 +1,59 @@ +import { HasColumnDescription, HasData, HasPeriod } from '~/entities/v3/interface' +import { YearMonth } from '~/fiscal-periods/year-month' +import { PropertyPathResolver } from '~/services/property-path-resolver' +import { isObject, isColumnDescription } from '~/services/type-helper' + +export class Monthly implements HasData, HasColumnDescription, HasPeriod { + constructor(readonly data: object, readonly columnDescription: object) { + // noop + } + + period(): YearMonth { + return new YearMonth(this.data['year'], this.data['month']) + } + + propertyNames(): string[] { + return PropertyPathResolver.listPathsOf(this.data) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + valueOf(propertyName: string): string | number | null { + const value = PropertyPathResolver.getPropertyOf(this.data, propertyName) + if (isObject(value) || Array.isArray(value)) { + throw new TypeError(`Can't access to a non-primitive type`) + } + + return value + } + + labelOf(propertyName: string): string | null { + const desc = this.columnDescriptionOf(propertyName) + if (desc) { + return desc['name_jp'] + } else { + return null + } + } + + unitOf(propertyName: string): string | null { + const desc = this.columnDescriptionOf(propertyName) + if (desc) { + return desc['unit'] + } else { + return null + } + } + + private columnDescriptionOf(propertyName: string): object | null { + const desc = PropertyPathResolver.getPropertyOf(this.columnDescription, propertyName) + if (isObject(desc) && isColumnDescription(desc)) { + return desc + } + + throw new TypeError(`Can't access to non-column-description object`) + } + + static fromResponse(response: object): Monthly { + return new Monthly(response['data'], response['column_description']) + } +} From df09869d7206316d8b209c43824edd7cbebf91e1 Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Fri, 23 Dec 2022 16:32:25 +0900 Subject: [PATCH 5/8] Add BuffettCodeApiClientV3#monthly --- src/__mocks__/fixtures/v3/monthly.js | 155 +++++++++++++++++++++++++++ src/api/v3/client.test.ts | 19 ++++ src/api/v3/client.ts | 16 +++ 3 files changed, 190 insertions(+) create mode 100644 src/__mocks__/fixtures/v3/monthly.js diff --git a/src/__mocks__/fixtures/v3/monthly.js b/src/__mocks__/fixtures/v3/monthly.js new file mode 100644 index 0000000..70f8508 --- /dev/null +++ b/src/__mocks__/fixtures/v3/monthly.js @@ -0,0 +1,155 @@ +module.exports = { + data: { + ticker: "2371", + year: 2022, + month: 5, + beta: { + years_2: { + start_date: "2020-06-01", + end_date: "2022-05-31", + beta: -0.16, + alpha: -0.01, + r: -0.19, + r_squared: 0.04, + count: 24 + }, + years_3: { + start_date: "2019-06-01", + end_date: "2022-05-31", + beta: 0.07, + alpha: 0.0, + r: 0.11, + r_squared: 0.01, + count: 36 + }, + years_5: { + start_date: "2017-06-01", + end_date: "2022-05-31", + beta: 0.12, + alpha: 0.0, + r: 0.19, + r_squared: 0.04, + count: 60 + } + }, + kpi: [{ + name: "全店 売上(円)", + value: 3477000000.0 + }] + }, + column_description: { + ticker: { + name_jp: "ティッカー", + unit: "なし" + }, + year: { + name_jp: "年", + unit: "なし" + }, + month: { + name_jp: "月", + unit: "なし" + }, + beta: { + years_2: { + start_date: { + name_jp: "開始日", + unit: "日付" + }, + end_date: { + name_jp: "終了日", + unit: "日付" + }, + beta: { + name_jp: "β", + unit: "なし" + }, + alpha: { + name_jp: "α", + unit: "なし" + }, + r: { + name_jp: "相関係数", + unit: "なし" + }, + r_squared: { + name_jp: "決定係数", + unit: "なし" + }, + count: { + name_jp: "利用データ数", + unit: "個"} + }, + years_3: { + start_date: { + name_jp: "開始日", + unit: "日付" + }, + end_date: { + name_jp: "終了日", + unit: "日付" + }, + beta: { + name_jp: "β", + unit: "なし" + }, + alpha: { + name_jp: "α", + unit: "なし" + }, + r: { + name_jp: "相関係数", + unit: "なし" + }, + r_squared: { + name_jp: "決定係数", + unit: "なし" + }, + count: { + name_jp: "利用データ数", + unit: "個" + } + }, + years_5: { + start_date: { + name_jp: "開始日", + unit: "日付" + }, + end_date: { + name_jp: "終了日", + unit: "日付" + }, + beta: { + name_jp: "β", + unit: "なし" + }, + alpha: { + name_jp: "α", + unit: "なし" + }, + r: { + name_jp: "相関係数", + unit: "なし" + }, + r_squared: { + name_jp: "決定係数", + unit: "なし" + }, + count: { + name_jp: "利用データ数", + unit: "個" + } + } + }, + kpi: { + name: { + name_jp: "指標", + unit: "なし" + }, + value: { + name_jp: "値", + unit: "指標による" + } + } + } +} diff --git a/src/api/v3/client.test.ts b/src/api/v3/client.test.ts index 9dc14b6..66486f3 100644 --- a/src/api/v3/client.test.ts +++ b/src/api/v3/client.test.ts @@ -1,16 +1,19 @@ import * as company from '~/__mocks__/fixtures/v3/company' import * as daily from '~/__mocks__/fixtures/v3/daily' +import * as monthly from '~/__mocks__/fixtures/v3/monthly' import * as quarter from '~/__mocks__/fixtures/v3/quarter' import { HttpError } from '~/api/http-error' import { useMockedUrlFetchApp } from '~/api/test-helper' import { BuffettCodeApiClientV3 } from '~/api/v3/client' import { Company } from '~/entities/v3/company' import { Daily } from '~/entities/v3/daily' +import { Monthly } from '~/entities/v3/monthly' import { Quarter } from '~/entities/v3/quarter' import { DateParam } from '~/fiscal-periods/date-param' import { DateRange } from '~/fiscal-periods/date-range' import { LqWithOffset } from '~/fiscal-periods/lq-with-offset' import { LyWithOffset } from '~/fiscal-periods/ly-with-offset' +import { YearMonth } from '~/fiscal-periods/year-month' import { YearQuarter } from '~/fiscal-periods/year-quarter' import { YearQuarterParam } from '~/fiscal-periods/year-quarter-param' import { YearQuarterRange } from '~/fiscal-periods/year-quarter-range' @@ -210,4 +213,20 @@ describe('BuffettCodeApiClientV3', () => { muteHttpExceptions: true }) }) + + test('monthly', () => { + const mockFetch = useMockedUrlFetchApp(200, JSON.stringify(monthly)) + + const client = new BuffettCodeApiClientV3('foo') + const ticker = '2371' + const period = new YearMonth(2018, 1) + expect(client.monthly(ticker, period)).toEqual(Monthly.fromResponse(monthly)) + expect(mockFetch.mock.calls.length).toBe(1) + expect(mockFetch.mock.calls[0].length).toBe(2) + expect(mockFetch.mock.calls[0][0]).toBe('https://api.buffett-code.com/api/v3/monthly?ticker=2371&year=2018&month=1') + expect(mockFetch.mock.calls[0][1]).toEqual({ + headers: { 'x-api-key': 'foo' }, + muteHttpExceptions: true + }) + }) }) diff --git a/src/api/v3/client.ts b/src/api/v3/client.ts index a2fcae3..8c8a9f7 100644 --- a/src/api/v3/client.ts +++ b/src/api/v3/client.ts @@ -2,9 +2,11 @@ import { HttpError } from '~/api/http-error' import { UrlBuilder } from '~/api/url-builder' import { Company } from '~/entities/v3/company' import { Daily } from '~/entities/v3/daily' +import { Monthly } from '~/entities/v3/monthly' import { Quarter } from '~/entities/v3/quarter' import { DateParam } from '~/fiscal-periods/date-param' import { DateRange } from '~/fiscal-periods/date-range' +import { YearMonth } from '~/fiscal-periods/year-month' import { YearQuarterParam } from '~/fiscal-periods/year-quarter-param' import { YearQuarterRange } from '~/fiscal-periods/year-quarter-range' @@ -137,4 +139,18 @@ export class BuffettCodeApiClientV3 { const res = BuffettCodeApiClientV3.request(url, options) return Daily.fromResponse(res) } + + public monthly(ticker: string, period: YearMonth): Monthly { + const endpoint = BuffettCodeApiClientV3.baseUrl + '/monthly' + const builder = new UrlBuilder(endpoint, { + ticker, + year: period.year, + month: period.month + }) + const url = builder.toString() + const options = this.defaultOptions() + + const res = BuffettCodeApiClientV3.request(url, options) + return Monthly.fromResponse(res) + } } From cde911e6591ad8a6d93c947fd460171076b6a14b Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Fri, 23 Dec 2022 16:52:09 +0900 Subject: [PATCH 6/8] Add MonthlyCache --- src/services/monthly-cache.test.ts | 34 ++++++++++++++++ src/services/monthly-cache.ts | 65 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/services/monthly-cache.test.ts create mode 100644 src/services/monthly-cache.ts diff --git a/src/services/monthly-cache.test.ts b/src/services/monthly-cache.test.ts new file mode 100644 index 0000000..f4e968a --- /dev/null +++ b/src/services/monthly-cache.test.ts @@ -0,0 +1,34 @@ +import * as monthlyFixture from '~/__mocks__/fixtures/v3/monthly' +import { Monthly } from '~/entities/v3/monthly' +import { YearMonth } from '~/fiscal-periods/year-month' +import { getMock, putMock } from '~/services/cache-test-helper' +import { MonthlyCache } from '~/services/monthly-cache' + +test('key', () => { + expect(MonthlyCache.key('6501', new YearMonth(2019, 4))).toBe('monthly-6501-2019-04') +}) + +const monthly = Monthly.fromResponse(monthlyFixture) +const yearMonth = new YearMonth(2022, 5) + +beforeEach(() => { + jest.clearAllMocks() +}) + +test('get', () => { + getMock.mockReturnValueOnce(JSON.stringify(monthly.data)) + getMock.mockReturnValueOnce(JSON.stringify(monthly.columnDescription)) + expect(MonthlyCache.get('2371', yearMonth)).toEqual(monthly) + + expect(getMock).toBeCalledTimes(2) + expect(getMock).nthCalledWith(1, 'monthly-2371-2022-05') + expect(getMock).nthCalledWith(2, 'monthly-column-description') +}) + +test('put', () => { + MonthlyCache.put('2371', monthly) + + expect(putMock).toBeCalledTimes(2) + expect(putMock).nthCalledWith(1, 'monthly-2371-2022-05', JSON.stringify(monthly.data), 21600) + expect(putMock).nthCalledWith(2, 'monthly-column-description', JSON.stringify(monthly.columnDescription), 21600) +}) diff --git a/src/services/monthly-cache.ts b/src/services/monthly-cache.ts new file mode 100644 index 0000000..dcaf845 --- /dev/null +++ b/src/services/monthly-cache.ts @@ -0,0 +1,65 @@ +import { Monthly } from '~/entities/v3/monthly' +import { YearMonth } from '~/fiscal-periods/year-month' + +export class MonthlyCache { + static readonly prefix = 'monthly' + + private constructor() { + // + } + + static key(ticker: string, yearMonth: YearMonth): string { + return `${this.prefix}-${ticker}-${yearMonth}` + } + + static columnDescriptionKey(): string { + return `${this.prefix}-column-description` + } + + private static getData(ticker: string, yearMonth: YearMonth): object | null { + const cache = CacheService.getUserCache() + const key = this.key(ticker, yearMonth) + const cached = cache.get(key) + if (!cached) { + return null + } + + return JSON.parse(cached) + } + + private static getColumnDescription(): object | null { + const cache = CacheService.getUserCache() + const cached = cache.get(this.columnDescriptionKey()) + if (!cached) { + return null + } + + return JSON.parse(cached) + } + + static get(ticker: string, yearMonth: YearMonth): Monthly | null { + const cachedData = this.getData(ticker, yearMonth) + const cachedColumnDescription = this.getColumnDescription() + if (!cachedData || !cachedColumnDescription) { + return null + } + + return new Monthly(cachedData, cachedColumnDescription) + } + + private static putData(ticker: string, monthly: Monthly, expirationInSeconds = 21600): void { + const cache = CacheService.getUserCache() + const key = this.key(ticker, monthly.period()) + cache.put(key, JSON.stringify(monthly.data), expirationInSeconds) + } + + private static putColumnDescription(columnDescription: object, expirationInSeconds = 21600): void { + const cache = CacheService.getUserCache() + cache.put(this.columnDescriptionKey(), JSON.stringify(columnDescription), expirationInSeconds) + } + + static put(ticker: string, monthly: Monthly, expirationInSeconds = 21600): void { + this.putData(ticker, monthly, expirationInSeconds) + this.putColumnDescription(monthly.columnDescription, expirationInSeconds) + } +} From 3129592832660067df4f7f2d922610b2c0117ac5 Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Fri, 23 Dec 2022 17:13:17 +0900 Subject: [PATCH 7/8] Add CachingBuffettCodeApiClientV3#monthly --- src/__mocks__/api/v3/client.ts | 13 ++++++++ src/__mocks__/services/monthly-cache.ts | 44 +++++++++++++++++++++++++ src/api/v3/caching-client.test.ts | 36 ++++++++++++++++++++ src/api/v3/caching-client.ts | 15 +++++++++ 4 files changed, 108 insertions(+) create mode 100644 src/__mocks__/services/monthly-cache.ts diff --git a/src/__mocks__/api/v3/client.ts b/src/__mocks__/api/v3/client.ts index f3dd955..adf85a2 100644 --- a/src/__mocks__/api/v3/client.ts +++ b/src/__mocks__/api/v3/client.ts @@ -1,20 +1,24 @@ import { HTTPResnpose } from '~/__mocks__/api/v3/http-response' import { default as company } from '~/__mocks__/fixtures/v3/company.js' import { default as daily } from '~/__mocks__/fixtures/v3/daily.js' +import { default as monthly } from '~/__mocks__/fixtures/v3/monthly.js' import { default as quarter } from '~/__mocks__/fixtures/v3/quarter.js' import { HttpError } from '~/api/http-error' import { Company } from '~/entities/v3/company' import { Daily } from '~/entities/v3/daily' +import { Monthly } from '~/entities/v3/monthly' import { Quarter } from '~/entities/v3/quarter' export class BuffettCodeApiClientV3 { public mockCompany = jest.fn() public mockDaily = jest.fn() + public mockMonthly = jest.fn() public mockQuarter = jest.fn() constructor(readonly token: string) { this.mockCompany.mockReturnValue(company) this.mockDaily.mockReturnValue(daily) + this.mockMonthly.mockReturnValue(monthly) this.mockQuarter.mockReturnValue(quarter) } @@ -62,4 +66,13 @@ export class BuffettCodeApiClientV3 { return Quarter.fromResponse(this.mockQuarter()) } + + monthly(ticker: string): Monthly { + if (ticker !== '2371') { + const res = new HTTPResnpose() + throw new HttpError('/v3/company', res) + } + + return Monthly.fromResponse(this.mockMonthly()) + } } diff --git a/src/__mocks__/services/monthly-cache.ts b/src/__mocks__/services/monthly-cache.ts new file mode 100644 index 0000000..a07d8e6 --- /dev/null +++ b/src/__mocks__/services/monthly-cache.ts @@ -0,0 +1,44 @@ +import { Monthly } from '~/entities/v3/monthly' +import { YearMonth } from '~/fiscal-periods/year-month' + +export class MonthlyCache { + static readonly cache = {} + + static get(ticker: string, yearMonth: YearMonth): Monthly | null { + const cachedData = this.getData(ticker, yearMonth) + const cachedColumnDescription = this.getColumnDescription() + if (cachedData == undefined || cachedColumnDescription == undefined) { + return null + } + + return new Monthly(cachedData, cachedColumnDescription) + } + + private static getData(ticker: string, yearMonth: YearMonth): object | null { + const cached = this.cache[`${ticker}-${yearMonth}`] + return cached === undefined ? null : cached + } + + private static getColumnDescription(): object | null { + const cached = this.cache['column-description'] + return cached === undefined ? null : cached + } + + static put(ticker: string, monthly: Monthly): void { + this.putData(ticker, monthly) + this.putColumnDescription(monthly.columnDescription) + } + + private static putData(ticker: string, monthly: Monthly): void { + this.cache[`${ticker}-${monthly.period()}`] = monthly.data + } + + private static putColumnDescription(columnDescription: object): void { + this.cache['column-description'] = columnDescription + } + + // 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 index 9006d70..d9fbcda 100644 --- a/src/api/v3/caching-client.test.ts +++ b/src/api/v3/caching-client.test.ts @@ -1,16 +1,19 @@ import { CompanyCache } from '~/__mocks__/services/company-cache' import { DailyCache } from '~/__mocks__/services/daily-cache' +import { MonthlyCache } from '~/__mocks__/services/monthly-cache' import { QuarterCache } from '~/__mocks__/services/quarter-cache' import { CachingBuffettCodeApiClientV3 } from '~/api/v3/caching-client' import { DateParam } from '~/fiscal-periods/date-param' import { LqWithOffset } from '~/fiscal-periods/lq-with-offset' import { LyWithOffset } from '~/fiscal-periods/ly-with-offset' +import { YearMonth } from '~/fiscal-periods/year-month' import { YearQuarter } from '~/fiscal-periods/year-quarter' import { YearQuarterParam } from '~/fiscal-periods/year-quarter-param' 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/monthly-cache', () => jest.requireActual('~/__mocks__/services/monthly-cache')) jest.mock('~/services/quarter-cache', () => jest.requireActual('~/__mocks__/services/quarter-cache')) const LY = new LyWithOffset() @@ -208,3 +211,36 @@ describe('ondemandDaily', () => { expect(DailyCache.get(ticker, period)).toEqual(cached) }) }) + +describe('monthly', () => { + const ticker = '2371' + + beforeAll(() => { + MonthlyCache.clearAll() + }) + + const period = new YearMonth(2022, 5) + + test('(uncached)', () => { + expect(MonthlyCache.get(ticker, period)).toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.monthly(ticker, period) + expect(res).not.toBeNull() + expect(res.period()).toEqual(period) + + expect(MonthlyCache.get(ticker, period)).toEqual(res) + }) + + test('(cached)', () => { + const cached = MonthlyCache.get(ticker, period) + expect(cached).not.toBeNull() + + const client = new CachingBuffettCodeApiClientV3('token') + const res = client.monthly(ticker, period) + expect(res).toEqual(cached) + expect(res.period()).toEqual(period) + + expect(MonthlyCache.get(ticker, period)).toEqual(cached) + }) +}) diff --git a/src/api/v3/caching-client.ts b/src/api/v3/caching-client.ts index a7d5c60..28c7106 100644 --- a/src/api/v3/caching-client.ts +++ b/src/api/v3/caching-client.ts @@ -1,11 +1,14 @@ import { BuffettCodeApiClientV3 } from '~/api/v3/client' import { Company } from '~/entities/v3/company' import { Daily } from '~/entities/v3/daily' +import { Monthly } from '~/entities/v3/monthly' import { Quarter } from '~/entities/v3/quarter' import { DateParam } from '~/fiscal-periods/date-param' +import { YearMonth } from '~/fiscal-periods/year-month' import { YearQuarterParam } from '~/fiscal-periods/year-quarter-param' import { CompanyCache } from '~/services/company-cache' import { DailyCache } from '~/services/daily-cache' +import { MonthlyCache } from '~/services/monthly-cache' import { QuarterCache } from '~/services/quarter-cache' export class CachingBuffettCodeApiClientV3 extends BuffettCodeApiClientV3 { @@ -77,5 +80,17 @@ export class CachingBuffettCodeApiClientV3 extends BuffettCodeApiClientV3 { return quarter } + monthly(ticker: string, period: YearMonth): Monthly { + const cached = MonthlyCache.get(ticker, period) + if (cached) { + return cached + } + + const monthly = super.monthly(ticker, period) + MonthlyCache.put(ticker, monthly) + + return monthly + } + // TODO: Add bulkDaily and bulkQuarter support } From 281e8c613dfdb1495219ce1c811dd10c00d3861c Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Sat, 24 Dec 2022 14:22:20 +0900 Subject: [PATCH 8/8] Add bcode-monthly --- src/custom-functions/v3/bcode-monthly.ts | 28 +++++++++++ src/custom-functions/v3/bcode.ts | 6 ++- src/fiscal-periods/period-parser.test.ts | 59 +++++++++++++++--------- src/fiscal-periods/period-parser.ts | 11 ++++- src/main.ts | 2 +- 5 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 src/custom-functions/v3/bcode-monthly.ts diff --git a/src/custom-functions/v3/bcode-monthly.ts b/src/custom-functions/v3/bcode-monthly.ts new file mode 100644 index 0000000..bde2102 --- /dev/null +++ b/src/custom-functions/v3/bcode-monthly.ts @@ -0,0 +1,28 @@ +import { BuffettCodeApiClientV3 } from '~/api/v3/client' +import { ApiResponseError, PropertyNotFoundError } from '~/custom-functions/error' +import { BcodeResult } from '~/custom-functions/v3/bcode-result' +import { YearMonth } from '~/fiscal-periods/year-month' + +export function bcodeMonthly( + client: BuffettCodeApiClientV3, + ticker: string, + period: YearMonth, + propertyName: string +): BcodeResult { + const monthly = client.monthly(ticker, period) + + if (monthly == undefined) { + throw new ApiResponseError() + } + + let value: string | number | null + let unit: string + try { + value = monthly.valueOf(propertyName) + unit = monthly.unitOf(propertyName) + } catch (e) { + throw new PropertyNotFoundError(`propetyName '${propertyName}' is not found.`) + } + + return new BcodeResult(propertyName, value, unit) +} diff --git a/src/custom-functions/v3/bcode.ts b/src/custom-functions/v3/bcode.ts index 41edc97..1f8e030 100644 --- a/src/custom-functions/v3/bcode.ts +++ b/src/custom-functions/v3/bcode.ts @@ -1,9 +1,11 @@ import { bcodeCompany } from './bcode-company' import { CachingBuffettCodeApiClientV3 } from '~/api/v3/caching-client' import { bcodeDaily } from '~/custom-functions/v3/bcode-daily' +import { bcodeMonthly } from '~/custom-functions/v3/bcode-monthly' import { bcodeQuarter } from '~/custom-functions/v3/bcode-quarter' import { BcodeResult } from '~/custom-functions/v3/bcode-result' import { PeriodParser } from '~/fiscal-periods/period-parser' +import { YearQuarterParam } from '~/fiscal-periods/year-quarter-param' import { ErrorHandler } from '~/services/error-handler' import { Setting } from '~/setting' @@ -58,7 +60,7 @@ export function bcode( setting.ondemandApiEnabled, setting.isOndemandApiCallModeForce() ) - } else { + } else if (parsedPeriod instanceof YearQuarterParam) { result = bcodeQuarter( client, ticker, @@ -67,6 +69,8 @@ export function bcode( setting.ondemandApiEnabled, setting.isOndemandApiCallModeForce() ) + } else { + result = bcodeMonthly(client, ticker, parsedPeriod, propertyName) } return result.format(isRawValue, isWithUnits) diff --git a/src/fiscal-periods/period-parser.test.ts b/src/fiscal-periods/period-parser.test.ts index 66875ef..bcd3aa7 100644 --- a/src/fiscal-periods/period-parser.test.ts +++ b/src/fiscal-periods/period-parser.test.ts @@ -3,34 +3,47 @@ import { ParseError } from '~/fiscal-periods/error' import { LqWithOffset } from '~/fiscal-periods/lq-with-offset' import { LyWithOffset } from '~/fiscal-periods/ly-with-offset' import { PeriodParser } from '~/fiscal-periods/period-parser' +import { YearMonth } from '~/fiscal-periods/year-month' import { YearQuarterParam } from '~/fiscal-periods/year-quarter-param' const LY = new LyWithOffset() const LQ = new LqWithOffset() -test('PeriodParser.parse', () => { - expect(PeriodParser.parse('2020Q3')).toEqual(new YearQuarterParam(2020, 3)) - expect(PeriodParser.parse('2020LQ')).toEqual(new YearQuarterParam(2020, LQ)) - expect(PeriodParser.parse('LYQ3')).toEqual(new YearQuarterParam(LY, 3)) - expect(PeriodParser.parse('LYLQ')).toEqual(new YearQuarterParam(LY, LQ)) - expect(PeriodParser.parse('lylq')).toEqual(new YearQuarterParam(LY, LQ)) - expect(PeriodParser.parse('LY-1Q4')).toEqual(new YearQuarterParam(new LyWithOffset(-1), 4)) - expect(PeriodParser.parse('2020LQ-1')).toEqual(new YearQuarterParam(2020, new LqWithOffset(-1))) - expect(PeriodParser.parse('LY-1LQ-1')).toEqual(new YearQuarterParam(new LyWithOffset(-1), new LqWithOffset(-1))) - expect(PeriodParser.parse('ly-1lq-1')).toEqual(new YearQuarterParam(new LyWithOffset(-1), new LqWithOffset(-1))) - expect(PeriodParser.parse('2020-09-06')).toEqual(DateParam.from(new Date('2020-09-06'))) - expect(PeriodParser.parse('latest')).toEqual(DateParam.from('latest')) - expect(PeriodParser.parse('Latest')).toEqual(DateParam.from('latest')) - expect(() => PeriodParser.parse('foo')).toThrow(ParseError) - expect(() => PeriodParser.parse('2020/09/06')).toThrow(ParseError) - expect(() => PeriodParser.parse('0Q1')).toThrow(ParseError) +test.each([ + { period: '2020Q3', expected: new YearQuarterParam(2020, 3) }, + { period: '2020LQ', expected: new YearQuarterParam(2020, LQ) }, + { period: 'LYQ3', expected: new YearQuarterParam(LY, 3) }, + { period: 'LYLQ', expected: new YearQuarterParam(LY, LQ) }, + { period: 'lylq', expected: new YearQuarterParam(LY, LQ) }, + { period: 'LY-1Q4', expected: new YearQuarterParam(new LyWithOffset(-1), 4) }, + { period: '2020LQ-1', expected: new YearQuarterParam(2020, new LqWithOffset(-1)) }, + { period: 'LY-1LQ-1', expected: new YearQuarterParam(new LyWithOffset(-1), new LqWithOffset(-1)) }, + { period: 'ly-1lq-1', expected: new YearQuarterParam(new LyWithOffset(-1), new LqWithOffset(-1)) }, + { period: '2020-09-06', expected: DateParam.from(new Date('2020-09-06')) }, + { period: 'latest', expected: DateParam.from('latest') }, + { period: 'Latest', expected: DateParam.from('latest') }, + { period: '2022-01', expected: new YearMonth(2022, 1) } +])('PeriodParser.parse($period) (valid)', ({ period, expected }) => { + expect(PeriodParser.parse(period)).toEqual(expected) }) -test('PeriodParser.isDateParam', () => { - expect(PeriodParser.isDateParam(DateParam.from('latest'))).toBeTruthy() - expect(PeriodParser.isDateParam(DateParam.from(new Date()))).toBeTruthy() - expect(PeriodParser.isDateParam(new YearQuarterParam(2020, 3))).toBeFalsy() - expect(PeriodParser.isDateParam(new YearQuarterParam(2020, LQ))).toBeFalsy() - expect(PeriodParser.isDateParam(new YearQuarterParam(LY, 3))).toBeFalsy() - expect(PeriodParser.isDateParam(new YearQuarterParam(LY, LQ))).toBeFalsy() +test.each([ + { period: 'foo', expected: ParseError }, + { period: '2020/09/06', expected: ParseError }, + { period: '0Q1', expected: ParseError }, + { period: '2022.01', expected: ParseError } +])('PeriodParser.parse($period) (error)', ({ period, expected }) => { + expect(() => PeriodParser.parse(period)).toThrow(expected) +}) + +test.each([ + { period: DateParam.from('latest'), expected: true }, + { period: DateParam.from(new Date()), expected: true }, + { period: new YearQuarterParam(2020, 3), expected: false }, + { period: new YearQuarterParam(2020, LQ), expected: false }, + { period: new YearQuarterParam(LY, 3), expected: false }, + { period: new YearQuarterParam(LY, LQ), expected: false }, + { period: new YearMonth(2022, 1), expected: false } +])('PeriodParser.isDateParam($period)', ({ period, expected }) => { + expect(PeriodParser.isDateParam(period)).toBe(expected) }) diff --git a/src/fiscal-periods/period-parser.ts b/src/fiscal-periods/period-parser.ts index 98e63e9..8aa8e94 100644 --- a/src/fiscal-periods/period-parser.ts +++ b/src/fiscal-periods/period-parser.ts @@ -1,5 +1,6 @@ import { DateParam, DateParamDate, DateParamLatest } from '~/fiscal-periods/date-param' import { ParseError } from '~/fiscal-periods/error' +import { YearMonth } from '~/fiscal-periods/year-month' import { YearQuarterParam } from '~/fiscal-periods/year-quarter-param' export class PeriodParser { @@ -7,7 +8,7 @@ export class PeriodParser { // noop } - static parse(str: string): DateParam | YearQuarterParam { + static parse(str: string): DateParam | YearQuarterParam | YearMonth { try { return YearQuarterParam.parse(str) } catch { @@ -20,10 +21,16 @@ export class PeriodParser { // noop } + try { + return YearMonth.parse(str) + } catch { + // noop + } + throw new ParseError(`Invalid period format: ${str}`) } - static isDateParam(period: DateParam | YearQuarterParam): period is DateParam { + static isDateParam(period: DateParam | YearQuarterParam | YearMonth): period is DateParam { return period instanceof DateParamDate || period instanceof DateParamLatest } } diff --git a/src/main.ts b/src/main.ts index 53f1e5e..4d38ad5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,7 +37,7 @@ global.exportCsv = exportCsv * 指定した銘柄の財務数字や指標を取得します。 * * @param {"6501"} ticker 銘柄コード - * @param {"2017Q4"} intent 会計期間または識別子 (例: 四半期 '2017Q4', 日付 '2020-09-06', 企業情報 'COMPANY') + * @param {"2017Q4"} intent 会計期間または識別子 (例: 四半期 '2017Q4', 日付 '2020-09-06', 年月 '2022-05', 企業情報 'COMPANY') * @param {"net_sales"} propertyName 項目名 * @param {TRUE} isRawValue (オプション) 数値をRAWデータで表示するかどうか (デフォルト値: FALSE) * @param {TRUE} isWithUnits (オプション) 単位を末尾に付加するかどうか (デフォルト値: FALSE)