diff --git a/apps/backend/src/config/configuration.ts b/apps/backend/src/config/configuration.ts index 028b210..be1ca82 100644 --- a/apps/backend/src/config/configuration.ts +++ b/apps/backend/src/config/configuration.ts @@ -14,6 +14,13 @@ export default registerAs('app', () => ({ 10, ), }, + tbank: { + token: process.env.T_BANK_TOKEN || '', + baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443', + appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe', + rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10), + requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10), + }, cache: { marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10), historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10), @@ -21,6 +28,10 @@ export default registerAs('app', () => ({ securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10), searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), + tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10), + tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10), + tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10), + tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10), }, auth: { jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', diff --git a/apps/backend/src/modules/tbank/tbank.config.spec.ts b/apps/backend/src/modules/tbank/tbank.config.spec.ts new file mode 100644 index 0000000..a3ae781 --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.config.spec.ts @@ -0,0 +1,39 @@ +import configuration from '../../config/configuration'; + +describe('T-Bank configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('uses conservative defaults for T-Bank integration', () => { + delete process.env.T_BANK_BASE_URL; + delete process.env.T_BANK_RATE_LIMIT_PER_SECOND; + delete process.env.CACHE_TBANK_PORTFOLIO_TTL; + + const config = configuration(); + + expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443'); + expect(config.tbank.rateLimitPerSecond).toBe(5); + expect(config.cache.tbankPortfolioTtl).toBe(60); + }); + + it('reads T-Bank token and TTL overrides from environment', () => { + process.env.T_BANK_TOKEN = 'secret-token'; + process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443'; + process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2'; + process.env.CACHE_TBANK_ACCOUNTS_TTL = '120'; + + const config = configuration(); + + expect(config.tbank.token).toBe('secret-token'); + expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443'); + expect(config.tbank.rateLimitPerSecond).toBe(2); + expect(config.cache.tbankAccountsTtl).toBe(120); + }); +}); diff --git a/apps/backend/src/modules/tbank/tbank.config.ts b/apps/backend/src/modules/tbank/tbank.config.ts new file mode 100644 index 0000000..803376f --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.config.ts @@ -0,0 +1,22 @@ +export const TBANK_PROTO_PACKAGE = 'tinkoff.public.invest.api.contract.v1'; + +export const TBANK_PROTO_FILES = { + users: 'users.proto', + operations: 'operations.proto', + instruments: 'instruments.proto', +} as const; + +export const TBANK_ACCOUNT_TYPES = { + brokerage: 'ACCOUNT_TYPE_TINKOFF', + iis: 'ACCOUNT_TYPE_TINKOFF_IIS', +} as const; + +export const TBANK_OPEN_ACCOUNT_STATUS = 'ACCOUNT_STATUS_OPEN'; + +export const TBANK_CACHE_KEYS = { + accounts: 'tbank:accounts', + portfolio: 'tbank:portfolio', + positions: 'tbank:positions', + operations: 'tbank:operations', + instrument: 'tbank:instrument', +} as const; diff --git a/apps/backend/src/modules/tbank/types/broker.types.ts b/apps/backend/src/modules/tbank/types/broker.types.ts new file mode 100644 index 0000000..113a77e --- /dev/null +++ b/apps/backend/src/modules/tbank/types/broker.types.ts @@ -0,0 +1,90 @@ +export type BrokerMoney = { + currency: string; + units: string; + nano: number; + value: number; +}; + +export type BrokerAccount = { + id: string; + type: 'brokerage' | 'iis'; + name: string; + status: string; + openedAt: string | null; + accessLevel: string | null; +}; + +export type BrokerPosition = { + figi: string | null; + instrumentUid: string | null; + positionUid: string | null; + ticker: string | null; + classCode: string | null; + instrumentType: string | null; + name: string | null; + quantity: number | null; + blockedLots: number | null; + currentPrice: BrokerMoney | null; + currentValue: BrokerMoney | null; + averagePositionPrice: BrokerMoney | null; + expectedYieldPercent: number | null; + dailyYield: BrokerMoney | null; +}; + +export type BrokerPortfolio = { + account: BrokerAccount; + totals: { + shares: BrokerMoney | null; + bonds: BrokerMoney | null; + etf: BrokerMoney | null; + currencies: BrokerMoney | null; + futures: BrokerMoney | null; + options: BrokerMoney | null; + structuredProducts: BrokerMoney | null; + dfa: BrokerMoney | null; + portfolio: BrokerMoney | null; + }; + yields: { + expectedPercent: number | null; + daily: BrokerMoney | null; + dailyPercent: number | null; + }; + cash: BrokerMoney[]; + blockedCash: BrokerMoney[]; + positions: BrokerPosition[]; + asOf: string; +}; + +export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'; + +export type BrokerOperation = { + cursor: string | null; + accountId: string; + id: string | null; + parentOperationId: string | null; + date: string | null; + type: string; + category: BrokerOperationCategory; + description: string | null; + state: string | null; + instrumentUid: string | null; + figi: string | null; + ticker: string | null; + classCode: string | null; + instrumentType: string | null; + payment: BrokerMoney | null; + price: BrokerMoney | null; + commission: BrokerMoney | null; + yield: BrokerMoney | null; + accruedInt: BrokerMoney | null; + quantity: number | null; + quantityDone: number | null; +}; + +export type BrokerOperationsPage = { + accountId: string; + items: BrokerOperation[]; + nextCursor: string | null; + hasNext: boolean; + asOf: string; +}; diff --git a/apps/backend/src/modules/tbank/types/tbank-proto.types.ts b/apps/backend/src/modules/tbank/types/tbank-proto.types.ts new file mode 100644 index 0000000..c6abb88 --- /dev/null +++ b/apps/backend/src/modules/tbank/types/tbank-proto.types.ts @@ -0,0 +1,148 @@ +export type TBankTimestamp = { + seconds?: number | string; + nanos?: number; +}; + +export type TBankMoneyValue = { + currency?: string; + units?: number | string; + nano?: number; +}; + +export type TBankQuotation = { + units?: number | string; + nano?: number; +}; + +export type TBankAccount = { + id: string; + type: string; + name?: string; + status: string; + openedDate?: TBankTimestamp; + closedDate?: TBankTimestamp; + accessLevel?: string; +}; + +export type TBankAccountsResponse = { + accounts?: TBankAccount[]; +}; + +export type TBankPortfolioPosition = { + figi?: string; + instrumentType?: string; + quantity?: TBankQuotation; + averagePositionPrice?: TBankMoneyValue; + expectedYield?: TBankQuotation; + currentNkd?: TBankMoneyValue; + currentPrice?: TBankMoneyValue; + averagePositionPriceFifo?: TBankMoneyValue; + blocked?: boolean; + blockedLots?: TBankQuotation; + positionUid?: string; + instrumentUid?: string; + expectedYieldFifo?: TBankQuotation; + dailyYield?: TBankMoneyValue; + ticker?: string; + classCode?: string; +}; + +export type TBankPortfolioResponse = { + accountId?: string; + totalAmountShares?: TBankMoneyValue; + totalAmountBonds?: TBankMoneyValue; + totalAmountEtf?: TBankMoneyValue; + totalAmountCurrencies?: TBankMoneyValue; + totalAmountFutures?: TBankMoneyValue; + expectedYield?: TBankQuotation; + positions?: TBankPortfolioPosition[]; + totalAmountOptions?: TBankMoneyValue; + totalAmountSp?: TBankMoneyValue; + totalAmountPortfolio?: TBankMoneyValue; + dailyYield?: TBankMoneyValue; + dailyYieldRelative?: TBankQuotation; + totalAmountDfa?: TBankMoneyValue; +}; + +export type TBankPositionsSecurity = { + figi?: string; + blocked?: string | number; + balance?: string | number; + positionUid?: string; + instrumentUid?: string; + ticker?: string; + classCode?: string; + exchangeBlocked?: boolean; + instrumentType?: string; +}; + +export type TBankPositionsResponse = { + accountId?: string; + money?: TBankMoneyValue[]; + blocked?: TBankMoneyValue[]; + securities?: TBankPositionsSecurity[]; +}; + +export type TBankOperationTrade = { + num?: string; + date?: TBankTimestamp; + quantity?: string | number; + price?: TBankMoneyValue; + yield?: TBankMoneyValue; + yieldRelative?: TBankQuotation; +}; + +export type TBankOperationItem = { + cursor?: string; + brokerAccountId?: string; + id?: string; + parentOperationId?: string; + name?: string; + date?: TBankTimestamp; + type?: string; + description?: string; + state?: string; + instrumentUid?: string; + figi?: string; + instrumentType?: string; + instrumentKind?: string; + positionUid?: string; + ticker?: string; + classCode?: string; + payment?: TBankMoneyValue; + price?: TBankMoneyValue; + commission?: TBankMoneyValue; + yield?: TBankMoneyValue; + yieldRelative?: TBankQuotation; + accruedInt?: TBankMoneyValue; + quantity?: string | number; + quantityRest?: string | number; + quantityDone?: string | number; + tradesInfo?: { trades?: TBankOperationTrade[] }; +}; + +export type TBankOperationsByCursorResponse = { + hasNext?: boolean; + nextCursor?: string; + items?: TBankOperationItem[]; +}; + +export type TBankInstrument = { + figi?: string; + ticker?: string; + classCode?: string; + isin?: string; + lot?: number; + currency?: string; + name?: string; + exchange?: string; + instrumentType?: string; + uid?: string; + positionUid?: string; + assetUid?: string; + instrumentKind?: string; +}; + +export type TBankInstrumentResponse = { + instrument?: TBankInstrument; +};