From 5ad22d68ec525093eca86f58169e7783ba64d247 Mon Sep 17 00:00:00 2001 From: Anatoly Popov Date: Sun, 18 May 2025 15:27:57 +0300 Subject: [PATCH] feat: support security from MOEX via MOEX api **Justification** My main portfolio is on MOEX, so I need a way to manage it. Ghostfolio looks cool but there's no way to proper manage assets on MOEX. So, first I've found `moex-iss-api` npm package. Turns out it needed some help, so couple of PR there. After that I was ready to work on ghostfolio. This commit implements full `DataProviderInterface` spec: - Disabled by default, so only those who need it would enabled it. - We `canHandle` all symbols - We aren't premium feature - `getQuotes`, `getHistorical` and `search` were pretty straightforward - `getTestSymbol` return `SBER` because if something happens to `SBER` stocks then MOEX would definetely doesn't matter anymore for sometime. - `getAssetProfile` and `getDividends` proved to be tricky to implement, so I'll cover them below separately. **getAssetProfile** **Currency** This is main method to get info about assets. Unfortunately, due to fall of USSR and ruble denomination in 1998, we have three currency tickers: 'RUR', 'SUR', 'RUB' For convenience we use 'RUB' instead of all of them. I don't see practical value to differentiate between them here, but I'm open to suggestions. Also, some of the tickers do not return currency in which they're listed on MOEX. Assumed that it's also 'RUB'. **Name** Every asset can have several things to identify it. And all of them are optional in MOEX API, except `secid` which is `Security ID`. So we use them for name in this order of preference: 1. Latin (usually English) name. 2. Latin short name. 3. Russian name. 4. Security ID. **Country** I try to detect country, parsing ISIN: first two letters should be country code. **Sectors** MOEX supports some industry related indices, so when we first encounter some symbol, I check whether it's in those indices and assign sectors accordingly. **AssetClass and AssetSubClass** At first, I was tempted to leave them empty, but finally decided to look into. I downloaded all asset types from MOEX and tried to best of my knowledge assign asset classes and subclasses. If I wasn't able to find proper relation, I left the cell empty. After that I took the table (you can check it in the comments in the code) and made `SecurityTypeMap` interface. **getDividends** MOEX API for dividends isn't documented at all (or probably I didn't find proper docs) and sometimes it doesn't return the newest dividends. Surprisingly, you can get dividends for MOEX-related assets from YAHOO, but the date can differ. So, there is heurestic implemented: if those date are no more than two days apart and payout is the same, then it's the exact same payout and we merge them. Signed-off-by: Anatoly Popov --- .../data-provider/data-provider.module.ts | 18 +- .../data-provider/moex/moex.service.ts | 539 ++++++++++++++++++ package-lock.json | 25 +- package.json | 1 + .../migration.sql | 2 + prisma/schema.prisma | 1 + 6 files changed, 578 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/services/data-provider/moex/moex.service.ts create mode 100644 prisma/migrations/20240501071657_added_moex_to_data_sources/migration.sql diff --git a/apps/api/src/services/data-provider/data-provider.module.ts b/apps/api/src/services/data-provider/data-provider.module.ts index 71b54f01e..0881fd036 100644 --- a/apps/api/src/services/data-provider/data-provider.module.ts +++ b/apps/api/src/services/data-provider/data-provider.module.ts @@ -8,6 +8,7 @@ import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-prov import { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; +import { MoexService } from '@ghostfolio/api/services/data-provider/moex/moex.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; @@ -43,6 +44,7 @@ import { DataProviderService } from './data-provider.service'; ManualService, RapidApiService, YahooFinanceService, + MoexService, { inject: [ AlphaVantageService, @@ -53,7 +55,8 @@ import { DataProviderService } from './data-provider.service'; GoogleSheetsService, ManualService, RapidApiService, - YahooFinanceService + YahooFinanceService, + MoexService ], provide: 'DataProviderInterfaces', useFactory: ( @@ -65,7 +68,8 @@ import { DataProviderService } from './data-provider.service'; googleSheetsService, manualService, rapidApiService, - yahooFinanceService + yahooFinanceService, + moexService ) => [ alphaVantageService, coinGeckoService, @@ -75,11 +79,17 @@ import { DataProviderService } from './data-provider.service'; googleSheetsService, manualService, rapidApiService, - yahooFinanceService + yahooFinanceService, + moexService ] }, YahooFinanceDataEnhancerService ], - exports: [DataProviderService, ManualService, YahooFinanceService] + exports: [ + DataProviderService, + ManualService, + YahooFinanceService, + MoexService + ] }) export class DataProviderModule {} diff --git a/apps/api/src/services/data-provider/moex/moex.service.ts b/apps/api/src/services/data-provider/moex/moex.service.ts new file mode 100644 index 000000000..6f100c7a8 --- /dev/null +++ b/apps/api/src/services/data-provider/moex/moex.service.ts @@ -0,0 +1,539 @@ +import { + DataProviderInterface, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; +import { + IDataProviderHistoricalResponse, + IDataProviderResponse +} from '@ghostfolio/api/services/interfaces/interfaces'; +import { + DataProviderInfo, + LookupResponse +} from '@ghostfolio/common/interfaces'; + +import { Injectable, Logger } from '@nestjs/common'; +import { $Enums, SymbolProfile } from '@prisma/client'; +import { + subYears, + format, + startOfYesterday, + subDays, + differenceInDays, + addYears, + differenceInMilliseconds +} from 'date-fns'; +import { createMoexCLient } from 'moex-iss-api-client'; +import { + IGetSecuritiesParams, + TGetSecuritiesParamsGroupBy +} from 'moex-iss-api-client/dist/client/security/requestTypes'; +import { ISecuritiesResponse } from 'moex-iss-api-client/dist/client/security/responseTypes'; + +const moexClient = createMoexCLient(); + +declare interface ResponseData { + columns: string[]; + data: (string | number | null)[][]; +} + +declare interface Response { + data: T; + issError: string; +} + +function response_data_to_map( + response: ResponseData, + keyColumnName: string +): Map> { + const result = new Map>(); + + response.data.forEach((x) => { + const item = new Map(); + response.columns.forEach((c, i) => item.set(c, x[i])); + result.set(item.get(keyColumnName), item); + }); + + return result; +} + +function getCurrency(currency: string): string { + if (currency === 'SUR' || currency === 'RUR') return 'RUB'; + + return currency; +} + +/// So, we try to guess sectors of security by looking into indexes, +/// in which this security was put by MOEX +const indexToSectorMapping = new Map([ + ['MOEXOG', ['Energy']], // MOEX Oil and Gas Index + ['MOEXEU', ['Utilities']], // MOEX Electric Utilities Index + ['MOEXTL', ['Communication Services']], // MOEX Telecommunication Index + ['MOEXMM', ['Basic Materials', 'Industrial']], // MOEX Metals and Mining Index + ['MOEXFN', ['Financial Services']], // MOEX Financials Index + ['MOEXCN', ['Consumer Defensive', 'Consumer Cyclical', 'Healthcare']], // MOEX Consumer Index + ['MOEXCH', ['Basic Materials']], // MOEX Chemicals Index + ['MOEXIT', ['Technology']], // MOEX Information Technologies Index + ['MOEXRE', ['Real Estate']], // MOEX Real Estate Index + ['MOEXTN', ['Consumer Cyclical', 'Industrial']] // MOEX Transportation Index +]); + +async function getSectors( + symbol: string +): Promise<{ name: string; weight: number }[]> { + const indicesResponse: Response<{ indices: ResponseData }> = + await moexClient.security.getSecurityIndexes({ security: symbol }); + const errorMessage = indicesResponse.issError; + if (errorMessage) { + Logger.warn(errorMessage, 'MoexService.getSectors'); + return []; + } + + const indices = response_data_to_map(indicesResponse.data.indices, 'SECID'); + + const sectorIncluded = new Set(); + const sectors = new Array<{ name: string; weight: number }>(); + + for (const [indexCode, indexSectors] of indexToSectorMapping.entries()) { + const index = indices.get(indexCode); + if (!index) { + continue; + } + + if (sectorIncluded.has(indexCode)) { + continue; + } + + sectorIncluded.add(indexCode); + indexSectors.forEach((x) => sectors.push({ name: x, weight: 1.0 })); + } + + return sectors; +} + +async function getDividendsFromMoex({ + from, + symbol, + to +}: GetDividendsParams): Promise<{ + [date: string]: IDataProviderHistoricalResponse; +}> { + const response: Response<{ dividends: ResponseData }> = + await moexClient.request(`securities/${symbol}/dividends`); + if (response.issError) { + Logger.warn(response.issError, 'MoexService.getDividends'); + return {}; + } + + const dividends = response_data_to_map( + response.data.dividends, + 'registryclosedate' + ); + const result: { + [date: string]: IDataProviderHistoricalResponse; + } = {}; + + for (const [key, value] of dividends.entries()) { + const date = new Date(key); + + if (date < from || date > to) { + continue; + } + + const price = value.get('value'); + if (typeof price === 'number') result[key] = { marketPrice: price }; + } + + return result; +} + +async function readBatchedResponse( + getNextBatch: (start: number) => Promise>, + extractor: (batch: Response) => ResponseData +): Promise { + let batch: ResponseData; + const wholeResponse: ResponseData = { columns: [], data: [] }; + + do { + const response: Response = await getNextBatch(wholeResponse.data.length); + if (response === undefined) { + break; + } + if (response.issError) { + Logger.warn(response.issError, 'MoexService.readBatchedResponse'); + break; + } + + batch = extractor(response); + if (wholeResponse.columns.length === 0) { + wholeResponse.columns = batch.columns; + } + + wholeResponse.data = [...wholeResponse.data, ...batch.data]; + } while (batch.data.length > 0); + + return wholeResponse; +} + +function getYahooSymbolFromMoex(symbol: string): string { + return `${symbol}.ME`; +} + +interface SecurityTypeMap { + [key: string]: [$Enums.AssetClass?, $Enums.AssetSubClass?]; +} + +/// MOEX security types were obtained here: https://iss.moex.com/iss/index.json (add `?lang=en` for english) +/// | id | security_type_name | ghostfolio_assetclass | ghostfolio_assetsubclass | +/// | ---- | --------------------- | --------------------- | ------------------------ | +/// | 1 | preferred_share | EQUITY | STOCK | +/// | 2 | corporate_bond | FIXED_INCOME | BOND | +/// | 3 | common_share | EQUITY | STOCK | +/// | 4 | cb_bond | FIXED_INCOME | BOND | +/// | 5 | currency | LIQUIDITY | CASH | +/// | 6 | futures | COMMODITY | | +/// | 7 | public_ppif | EQUITY | MUTUALFUND | +/// | 8 | interval_ppif | EQUITY | MUTUALFUND | +/// | 9 | private_ppif | EQUITY | MUTUALFUND | +/// | 10 | state_bond | FIXED_INCOME | BOND | +/// | 41 | subfederal_bond | FIXED_INCOME | BOND | +/// | 42 | ifi_bond | FIXED_INCOME | BOND | +/// | 43 | exchange_bond | FIXED_INCOME | BOND | +/// | 44 | stock_index | | | +/// | 45 | municipal_bond | FIXED_INCOME | BOND | +/// | 51 | depositary_receipt | EQUITY | STOCK | +/// | 52 | option | COMMODITY | | +/// | 53 | rts_index | | | +/// | 54 | ofz_bond | FIXED_INCOME | BOND | +/// | 55 | etf_ppif | EQUITY | ETF | +/// | 57 | stock_mortgage | REAL_ESTATE | | +/// | 58 | gold_metal | LIQUIDITY | PRECIOUS_METAL | +/// | 59 | silver_metal | LIQUIDITY | PRECIOUS_METAL | +/// | 60 | euro_bond | FIXED_INCOME | BOND | +/// | 62 | currency_futures | LIQUIDITY | CASH | +/// | 63 | stock_deposit | LIQUIDITY | CASH | +/// | 73 | currency_fixing | LIQUIDITY | CASH | +/// | 74 | exchange_ppif | EQUITY | ETF | +/// | 75 | currency_index | LIQUIDITY | CASH | +/// | 76 | currency_wap | LIQUIDITY | CASH | +/// | 78 | non_exchange_bond | FIXED_INCOME | BOND | +/// | 84 | stock_index_eq | | | +/// | 85 | stock_index_fi | | | +/// | 86 | stock_index_mx | | | +/// | 87 | stock_index_ie | | | +/// | 88 | stock_index_if | | | +/// | 89 | stock_index_ci | | | +/// | 90 | stock_index_im | | | +/// | 1030 | stock_index_namex | | | +/// | 1031 | option_on_shares | EQUITY | STOCK | +/// | 1034 | stock_index_rusfar | | | +/// | 1155 | stock_index_pf | | | +/// | 1291 | option_on_currency | | | +/// | 1293 | option_on_indices | | | +/// | 1295 | option_on_commodities | COMMODITY | | +/// | 1337 | futures_spread | COMMODITY | | +/// | 1338 | futures_collateral | COMMODITY | | +/// | 1347 | currency_otcindices | LIQUIDITY | CASH | +/// | 1352 | other_metal | COMMODITY | PRECIOUS_METAL | +/// | 1403 | stock_index_ri | | | +const securityTypeMap: SecurityTypeMap = { + preferred_share: [$Enums.AssetClass.EQUITY, $Enums.AssetSubClass.STOCK], + corporate_bond: [$Enums.AssetClass.FIXED_INCOME, $Enums.AssetSubClass.BOND], + common_share: [$Enums.AssetClass.EQUITY, $Enums.AssetSubClass.STOCK], + cb_bond: [$Enums.AssetClass.FIXED_INCOME, $Enums.AssetSubClass.BOND], + currency: [$Enums.AssetClass.LIQUIDITY, $Enums.AssetSubClass.CASH], + futures: [$Enums.AssetClass.COMMODITY], + public_ppif: [$Enums.AssetClass.EQUITY, $Enums.AssetSubClass.MUTUALFUND], + interval_ppif: [$Enums.AssetClass.EQUITY, $Enums.AssetSubClass.MUTUALFUND], + private_ppif: [$Enums.AssetClass.EQUITY, $Enums.AssetSubClass.MUTUALFUND], + state_bond: [$Enums.AssetClass.FIXED_INCOME, $Enums.AssetSubClass.BOND], + subfederal_bond: [$Enums.AssetClass.FIXED_INCOME, $Enums.AssetSubClass.BOND], + ifi_bond: [$Enums.AssetClass.FIXED_INCOME, $Enums.AssetSubClass.BOND], + exchange_bond: [$Enums.AssetClass.FIXED_INCOME, $Enums.AssetSubClass.BOND], + municipal_bond: [$Enums.AssetClass.FIXED_INCOME, $Enums.AssetSubClass.BOND], + depositary_receipt: [$Enums.AssetClass.EQUITY, $Enums.AssetSubClass.STOCK], + option: [$Enums.AssetClass.COMMODITY], + etf_ppif: [$Enums.AssetClass.EQUITY, $Enums.AssetSubClass.ETF], + stock_mortgage: [$Enums.AssetClass.REAL_ESTATE], + gold_metal: [ + $Enums.AssetClass.LIQUIDITY, + $Enums.AssetSubClass.PRECIOUS_METAL + ], + silver_metal: [ + $Enums.AssetClass.LIQUIDITY, + $Enums.AssetSubClass.PRECIOUS_METAL + ], + currency_futures: [$Enums.AssetClass.LIQUIDITY, $Enums.AssetSubClass.CASH], + stock_deposit: [$Enums.AssetClass.LIQUIDITY, $Enums.AssetSubClass.CASH], + currency_fixing: [$Enums.AssetClass.LIQUIDITY, $Enums.AssetSubClass.CASH], + exchange_ppif: [$Enums.AssetClass.EQUITY, $Enums.AssetSubClass.ETF], + currency_index: [$Enums.AssetClass.LIQUIDITY, $Enums.AssetSubClass.CASH], + currency_wap: [$Enums.AssetClass.LIQUIDITY, $Enums.AssetSubClass.CASH], + non_exchange_bond: [ + $Enums.AssetClass.FIXED_INCOME, + $Enums.AssetSubClass.BOND + ], + option_on_shares: [$Enums.AssetClass.EQUITY, $Enums.AssetSubClass.STOCK], + option_on_commodities: [$Enums.AssetClass.COMMODITY], + futures_spread: [$Enums.AssetClass.COMMODITY], + futures_collateral: [$Enums.AssetClass.COMMODITY], + currency_otcindices: [$Enums.AssetClass.LIQUIDITY, $Enums.AssetSubClass.CASH], + other_metal: [ + $Enums.AssetClass.COMMODITY, + $Enums.AssetSubClass.PRECIOUS_METAL + ] +}; + +@Injectable() +export class MoexService implements DataProviderInterface { + public constructor( + private readonly yahooFinanceService: YahooFinanceService + ) {} + + canHandle(): boolean { + return true; + } + + async getAssetProfile({ + symbol + }: { + symbol: string; + }): Promise> { + const securitySpecificationResponse = + await moexClient.security.getSecuritySpecification({ security: symbol }); + const errorMessage = securitySpecificationResponse.issError; + if (errorMessage) { + Logger.warn(errorMessage, 'MoexService.getAssetProfile'); + return {}; + } + + const securitySpecification = response_data_to_map( + securitySpecificationResponse.data.description, + 'name' + ); + + const issueDate = securitySpecification.get('ISSUEDATE'); + const faceunit = securitySpecification.get('FACEUNIT'); + const isin = securitySpecification.get('ISIN'); + const latname = securitySpecification.get('LATNAME'); + const shortname = securitySpecification.get('SHORTNAME'); + const name = securitySpecification.get('NAME'); + const secid = securitySpecification.get('SECID'); + const type = securitySpecification.get('TYPE'); + const [assetClass, assetSubClass] = + securityTypeMap[type.get('value').toString()] ?? []; + + return { + assetClass: assetClass, + assetSubClass: assetSubClass, + createdAt: issueDate ? new Date(issueDate.get('value')) : new Date(), + currency: faceunit + ? getCurrency(faceunit.get('value').toString()) + : 'RUB', + dataSource: this.getName(), + id: symbol, + isin: isin ? isin.get('value').toString() : null, + name: (latname ?? shortname ?? name ?? secid).get('value').toString(), + sectors: await getSectors(symbol), + symbol: symbol, + countries: isin + ? [ + { + code: isin.get('value').toString().substring(0, 2), + weight: 1 + } + ] + : null + }; + } + + getDataProviderInfo(): DataProviderInfo { + return { + isPremium: false + }; + } + + // MOEX endpoint for dividends isn't documented and sometimes doesn't return newer dividends. + // YAHOO endpoint for dividends sometimes doesn't respect date filters. + // So, we'll requests dividends for 2 years more from both providers and merge data. + // If dividends date from MOEX and YAHOO differs for 2 days or less, we'll assume it's the same payout given amount is the same. + // Payouts considered the same if they differ less that 1/100 of the currency. + async getDividends({ from, symbol, to }: GetDividendsParams): Promise<{ + [date: string]: IDataProviderHistoricalResponse; + }> { + const twoYearsAgo = subYears(from, 2); + const twoYearsAhead = addYears(to, 2); + const [dividends, dividendsFromYahoo] = await Promise.all([ + getDividendsFromMoex({ from: twoYearsAgo, symbol, to: twoYearsAhead }), + this.yahooFinanceService.getDividends({ + from: twoYearsAgo, + symbol: getYahooSymbolFromMoex(symbol), + to: twoYearsAhead + }) + ]); + + const dateAlmostTheSame = (x: Date, y: Date) => + Math.abs(differenceInDays(x, y)) <= 2; + const payoutsAlmostTheSame = (x: number, y: number) => + 100 * Math.abs(x - y) < 1; + + for (const [yahooDateStr, yahooDividends] of Object.entries( + dividendsFromYahoo + )) { + const yahooDate = new Date(yahooDateStr); + const sameDividendIndex = Object.entries(dividends).findIndex( + (x) => + dateAlmostTheSame(new Date(x[0]), yahooDate) && + payoutsAlmostTheSame(x[1].marketPrice, yahooDividends.marketPrice) + ); + if (sameDividendIndex === -1) { + dividends[yahooDateStr] = yahooDividends; + } + } + + const result: { [date: string]: IDataProviderHistoricalResponse } = {}; + + Object.entries(dividends) + .map( + ([dateStr, dividends]) => + [dateStr, dividends, new Date(dateStr)] as [ + string, + IDataProviderHistoricalResponse, + Date + ] + ) + .filter(([, , date]) => date >= from) + .filter(([, , date]) => date <= to) + .sort(([, , a], [, , b]) => differenceInMilliseconds(a, b)) + .forEach(([dateStr, dividends]) => (result[dateStr] = dividends)); + + return result; + } + + async getHistorical({ from, symbol, to }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + }> { + const params: Record = { + sort_order: 'desc', + marketprice_board: '1', + from: format(from, 'yyyy-MM-dd'), + to: format(to, 'yyyy-MM-dd') + }; + + const historyResponse = await readBatchedResponse<{ + history: ResponseData; + }>( + async (x) => { + params['start'] = x; + return await moexClient.request( + `history/engines/stock/markets/shares/securities/${symbol}`, + params + ); + }, + (x) => x.data.history + ); + + const history = response_data_to_map(historyResponse, 'TRADEDATE'); + + const result: { + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + } = {}; + result[symbol] = {}; + + for (const [key, value] of history.entries()) { + const price = value.get('LEGALCLOSEPRICE'); + if (typeof price === 'number') + result[symbol][key] = { marketPrice: price }; + } + + return result; + } + + getMaxNumberOfSymbolsPerRequest?(): number { + return 1; + } + + getName(): $Enums.DataSource { + return $Enums.DataSource.MOEX; + } + + async getQuotes({ + symbols + }: GetQuotesParams): Promise<{ [symbol: string]: IDataProviderResponse }> { + const result: { [symbol: string]: IDataProviderResponse } = {}; + + for (const symbol of symbols) { + const profile = await this.getAssetProfile({ symbol: symbol }); + + for ( + let date = startOfYesterday(); + !result[symbol]; + date = subDays(date, 1) + ) { + const history = await this.getHistorical({ + from: date, + to: date, + symbol: symbol + }); + + for (const [, v] of Object.entries(history[symbol])) { + result[symbol] = { + currency: profile.currency, + dataSource: profile.dataSource, + marketPrice: v.marketPrice, + marketState: 'closed' + }; + } + } + } + + return result; + } + + getTestSymbol(): string { + return 'SBER'; + } + + async search({ query }: GetSearchParams): Promise { + // MOEX doesn't support search for queries less than 3 symbols + if (query.length < 3) { + return { items: [] }; + } + + const params: IGetSecuritiesParams & TGetSecuritiesParamsGroupBy = { + q: query + }; + + const searchResponse = await readBatchedResponse( + async (x) => { + params['start'] = x; + return await moexClient.security.getSecurities(params); + }, + (x) => x.data.securities + ); + + const search = response_data_to_map(searchResponse, 'secid'); + + const result: LookupResponse = { items: [] }; + for (const k of search.keys()) { + if (typeof k != 'string') { + continue; + } + const profile = await this.getAssetProfile({ symbol: k }); + result.items.push({ + assetClass: profile.assetClass, + assetSubClass: profile.assetSubClass, + currency: profile.currency, + dataProviderInfo: this.getDataProviderInfo(), + dataSource: this.getName(), + name: profile.name, + symbol: k + }); + } + + return result; + } +} diff --git a/package-lock.json b/package-lock.json index 58aa36721..086480f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "jsonpath": "1.1.1", "lodash": "4.17.21", "marked": "15.0.4", + "moex-iss-api-client": "0.4.2", "ms": "3.0.0-canary.1", "ng-extract-i18n-merge": "2.15.1", "ngx-device-detector": "9.0.0", @@ -16129,7 +16130,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "devOptional": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -17871,7 +17871,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -20325,7 +20324,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "devOptional": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -26489,6 +26487,26 @@ "pathe": "^2.0.1" } }, + "node_modules/moex-iss-api-client": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/moex-iss-api-client/-/moex-iss-api-client-0.4.2.tgz", + "integrity": "sha512-gm0nI/d0aTprLhX5K8+3CD/S741juBSsG3ZlU1fkofOxPLyyGnIjrxxmHaCopmwoXG7OCeyP7Qm7QkImbKh3Ew==", + "license": "MIT", + "dependencies": { + "axios": "^0.28.0" + } + }, + "node_modules/moex-iss-api-client/node_modules/axios": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.28.1.tgz", + "integrity": "sha512-iUcGA5a7p0mVb4Gm/sy+FSECNkPFT4y7wt6OM/CDpO/OnNCvSs3PoMG8ibrC9jRoGYU0gUK5pXVC4NPXq6lHRQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -29436,7 +29454,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, "license": "MIT" }, "node_modules/prr": { diff --git a/package.json b/package.json index c3ed868f8..16acee491 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "jsonpath": "1.1.1", "lodash": "4.17.21", "marked": "15.0.4", + "moex-iss-api-client": "0.4.2", "ms": "3.0.0-canary.1", "ng-extract-i18n-merge": "2.15.1", "ngx-device-detector": "9.0.0", diff --git a/prisma/migrations/20240501071657_added_moex_to_data_sources/migration.sql b/prisma/migrations/20240501071657_added_moex_to_data_sources/migration.sql new file mode 100644 index 000000000..639e7aa9b --- /dev/null +++ b/prisma/migrations/20240501071657_added_moex_to_data_sources/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "DataSource" ADD VALUE 'MOEX'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b063b1d89..6376215dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -307,6 +307,7 @@ enum DataSource { MANUAL RAPID_API YAHOO + MOEX } enum MarketDataState {