Compare commits

...

188 Commits

Author SHA1 Message Date
40ef3e9059 docs: update tasks.md with skeleton parity and analytics rewrite summary
All checks were successful
CI / ci (pull_request) Successful in 15m26s
CI / ci (push) Successful in 15m33s
2026-06-27 20:56:58 +03:00
d8070209ed feat(frontend): add structural skeletons matching reference loading states
- Portfolio history: chart-loading skeleton with shimmer area and dashed guide
- Analytics: 2 summary cards + 6 detail grid cards with skel bars
- Title/yield: 3 skeleton bars (label, value, daily) when portfolio loading
- Events card already had skeleton table (no change needed)
- Fix test expectation for operations limit (7 -> 100)
2026-06-27 20:47:04 +03:00
3281866c43 refactor(tbank): rewrite BrokerAnalyticsService to use T-Bank API directly
Replace Prisma-based analytics with direct T-Bank API calls:
- GetPortfolio for expectedYield (real portfolio return)
- GetOperationsByCursor with pagination for full operation history
- Remove incorrect totalReturnPercent formula, use T-Bank expectedYield instead
2026-06-27 20:39:32 +03:00
8b24d8b82b fix(frontend): align badges, pagination, chart labels with reference
- Replace MUI Chip in events card with custom Badge matching reference
  colors (blue coupon, green dividend, amber maturity, red tax/fee)
- Add working pagination (useState, 7 per page, ←/→ handlers)
- Increase events API limit to 100 for multi-page support
- Fix chart month labels: left-align first, right-align last
  to prevent overflow beyond container edges
2026-06-27 20:16:40 +03:00
d21e2411b8 feat(frontend): complete broker account overview HTML parity
- Remove BrokerDashboardHero and BrokerDashboardIncomeCard from overview
- Reorder blocks to spec: PortfolioHistory → Analytics → Allocation → Events
- Add BrokerPortfolioHistoryCard with SVG line/area chart (6 months)
- Switch EventsCard from calendar events to executed operations
- Update AnalyticsCard: add totalFees/totalTaxesPaid, remove netInvested
- Update AllocationCard: title 'Структура', only shares/bonds/cash
- Add inline yield display in BrokerAccountLayout
- Clean up unused income filter state and dashboardFilters lib
- Update tests to match new component structure
- Visual QA screenshots (desktop + mobile)
2026-06-27 19:54:03 +03:00
ff0e11b303 feat(tbank): add totalFees/totalTaxesPaid, portfolio history endpoint, and category filtering
- Add totalFees and totalTaxesPaid to BrokerAnalyticsDto and service
- Add GET /accounts/:accountId/portfolio/history endpoint with estimated v1 read-model
- Add categories query param to operations endpoint for filtering by category
- Update tests and module registration
2026-06-27 19:30:32 +03:00
c4b93f9f0c docs: add broker overview html parity spec 2026-06-27 19:22:54 +03:00
3fdceb9438 feat: add example.html 2026-06-27 18:39:02 +03:00
fee179d8e9 fix(frontend): align broker dashboard HTML parity
All checks were successful
CI / ci (pull_request) Successful in 15m41s
CI / ci (push) Successful in 13m50s
2026-06-27 15:23:55 +03:00
58b8075583 refactor(frontend): deduplicate analytics metric cells and restore text states 2026-06-27 13:52:37 +03:00
d7b65e256f docs: mark HTML parity analytics task and DoD verified
- Flip BrokerDashboardAnalyticsCard + analytics component tests
  checkboxes for the parity iteration.
- Mark verified DoD items (dashboard tests 49/49, full frontend tests
  175/175, lint, build).
- Add code-to-spec mapping table linking the analytics card to the
  HTML reference and spec §6 for the next agent / reviewer.
- Be explicit that live visual + mobile-overflow checks still need
  manual verification with a running backend.
2026-06-27 13:45:48 +03:00
b12c2741cd feat(frontend): align analytics card with HTML parity
- Render RUB values via formatDashboardCurrency so RUB shows as ₽
  instead of 'RUB', matching the hero, events and income cards.
- Apply semantic color tones per spec §6:
  * Пополнения/Дивиденды/Купоны/Всего получено → positive when > 0,
    neutral when 0.
  * Выводы → prefix value with '−' and render with negative tone
    whenever the underlying amount is positive.
  * Нетто → sign-based tone.
- Add data-testid and data-tone attributes on each analytics value so
  tests and downstream styling can address each metric.
- Update the analytics mock in BrokerDashboard.test.tsx to cover
  positive, negative (net invested + withdrawals) and zero values, and
  add two new tests asserting ₽ rendering and the tone data attributes.
2026-06-27 13:44:31 +03:00
a1377fa5c2 feat(frontend): allow aria-label on BrokerDashboardCard
Support setting an accessible name on the dashboard card section so
consumers can query sections via getByLabelText in tests and assistive
tech, matching the hero pattern in BrokerDashboardHero.
2026-06-27 13:44:25 +03:00
27fb6e77be refactor(frontend): polish dashboard tables per code review
- Drop unused `letterSpacing: 0.2` from TH_SX in both table cards
  (HTML mockup has no letter-spacing on headers)
- Move `eventStatusTone` from EventsCard to dashboardVisual.ts
  for symmetry with `eventTypeTone` (single source of tone semantics)
- Drop unused `columnWidths` prop and `widthsFor` override branch
  in BrokerDashboardTableSkeleton (no caller passes it)
- Drop redundant `role="presentation"` on skeleton wrapper
  (rows already carry `aria-hidden="true"`)
- Drop unused `TypeTone` import from EventsCard after moving
  eventStatusTone out
2026-06-27 13:39:23 +03:00
94f8bb876d fix(frontend): correct events amount sign for actual-negative rows
Spec review of Task 9 caught that the events amount prefix used '+'
unconditionally for any actual source, producing '+-87,00 ₽' on negative
actual amounts. Mirror the income card sign handling: '+' for positive,
Unicode '−' (U+2212) for negative, '~' for forecast (regardless of sign).

Extract a small eventAmountDisplay helper and tighten the corresponding
test assertion to require exact equality instead of substring match, so
this regression class is caught next time.
2026-06-27 13:35:48 +03:00
3bad694dce feat(frontend): align dashboard tables with HTML parity
- Add thead with semantic column headers to events and income tables
- Render instruments as main (ticker/ISIN) + subtitle (name) via instrumentDisplay
- Type column uses compact Chip with eventTypeTone/incomeTypeTone; DIV_EXT
  shows distinct label 'Дивиденд (внешний)'
- Amount column applies moneyTone (positive=green, negative=red, planned=neutral)
  via moneyToneToColor; sign prefixes (+/-/~) preserved per spec
- Status column: 'Поступило' (success) vs 'Ожидается' (neutral)
- Income sum semantics preserved (sum of current page rows)
- Skeleton: unified column-width and variant pattern for both tables
- Drop legacy DashboardIncomeRow.instrument alias (now uses instrumentMain/Subtitle)
- Extract moneyToneToColor helper from hero (single source of MUI color mapping)
- Dashboard tests cover thead, badges, subtitles, signed tones, and that
  tables show ₽ instead of RUB for RUB amounts
2026-06-27 13:33:02 +03:00
21df354df6 fix(frontend): align dashboard card heading hierarchy
BrokerDashboardCard previously rendered <Heading level={3} size="section">,
which produced an <h3> under the <h1> account name in BrokerAccountLayout,
skipping the <h2> level. Use level={2} so the semantic HTML is a proper
<h2> while keeping the compact "section" visual size.

Also extract the byte-identical toolbar wrapper (grid + chips + date filter
slot) shared by BrokerDashboardEventsCard and BrokerDashboardIncomeCard
into BrokerDashboardTableToolbar to remove duplicated sx config.
2026-06-27 13:21:15 +03:00
0a1ae0552c feat(frontend): apply hero, card and toolbar HTML parity 2026-06-27 13:07:06 +03:00
1a2937ad58 refactor(frontend): tighten dashboard visual helpers per code review 2026-06-27 12:57:03 +03:00
af0aaeda9d feat(frontend): add dashboard visual helpers for HTML parity
Introduce dashboardVisual.ts with moneyTone, formatDashboardCurrency,
eventTypeTone, incomeTypeTone, and instrumentDisplay helpers used by
the broker dashboard to match the HTML reference prototype.

Extend dashboardIncome.ts: typeLabel now distinguishes DIV_EXT
('Дивиденд (внешний)'), and rows expose instrumentMain/instrumentSubtitle
via instrumentDisplay while keeping instrument as a derived alias for
existing callers.

Add resetDashboardFilters factory in dashboardFilters.ts so Task 3 can
wire the reset action without re-churning filter types.
2026-06-27 12:46:39 +03:00
ae207b0cb3 feat: improove design, add change spec 2026-06-27 12:33:58 +03:00
d4d1dd980e feat: replace native date inputs with single DateCalendar field in broker dashboard
- Replace two MUI DatePicker components with a single visual range field
- Click opens Popover with community DateCalendar (no DateRangePicker/pro)
- First click sets start, second sets end; if end < start, range resets
- Remove isOpen/onToggle props — Popover managed locally
- Add BrokerDashboardTableSkeleton for events/income loading
- Set default weekly range (today-7d / today) for both events and income
- Remove 'Фильтр' label, rename 'Показать' → 'Применить период'
- Install @mui/x-date-pickers@7
- Update tests for new UI and skeleton loading states
- Align spec.md, plan.md, tasks.md with implementation
2026-06-27 11:48:10 +03:00
0116c34a9f feat: improove design 2026-06-27 11:11:25 +03:00
d804d43616 docs: align broker dashboard redesign docs 2026-06-27 10:45:20 +03:00
8df94a51dd feat: broker dashboard redesign with top tabs, single-column layout, and clickable chip filters
- Move navigation from left sidebar to horizontal top tabs in BrokerAccountLayout
- Change dashboard blocks to single-column layout (Events, Income, Analytics, Allocation)
- Extend DS Chip with onClick/selected/disabled/aria-pressed
- Add clickable chip filters to Events and Income cards with immediate filtering
- Disable backend query when all types deselected (show validation message)
- Add useBrokerEvents/useBrokerOperations options.enabled param
- Update dashboard skeleton to single-column shape
- Add userEvent tests for chip filter interactions
2026-06-26 10:47:08 +03:00
c429d6a43a feat(frontend): redesign broker account overview as investment dashboard
Replace vertical overview with dashboard composition:
- BrokerDashboardHero with portfolio KPI, return, daily change, total income
- BrokerDashboardEventsCard with compact events table and local pagination
- BrokerDashboardIncomeCard with income operations table and cursor pagination
- BrokerDashboardAnalyticsCard with analytics Metric grid
- BrokerDashboardAllocationCard wrapping existing donut chart
- BrokerDashboardSkeleton matching dashboard shape
- BrokerDashboardCard local card pattern
- Pure lib helpers: dashboardIncome, dashboardFilters, dashboardFormatters
- Unit tests for income helpers (6) and dashboard composition (1)
2026-06-26 10:18:57 +03:00
49dac140ff docs: plan broker dashboard redesign 2026-06-26 09:45:04 +03:00
8dab915edd fix(backend): isolate health service unit test
All checks were successful
CI / ci (push) Successful in 15m16s
2026-06-26 07:58:31 +03:00
bbbdca30f6 docs: merge architecture backlog branch 2026-06-26 07:54:16 +03:00
99a917392a docs: publish architecture quality backlog 2026-06-26 07:50:04 +03:00
47e65c9b46 docs: add architecture quality backlog 2026-06-25 22:53:22 +03:00
7af8691e0c docs: clarify graphify usage for tracing 2026-06-25 22:53:06 +03:00
4cb0b8d450 docs: clarify graphify usage for tracing 2026-06-25 22:51:01 +03:00
293838ba68 docs: clarify graphify usage for tracing 2026-06-25 22:26:08 +03:00
b27d6ad836 chore: remove redundant express dependency and fix docs
Some checks failed
CI / ci (pull_request) Failing after 2m58s
CI / ci (push) Failing after 2m53s
2026-06-25 22:13:11 +03:00
53bab79c5f docs: mark all tasks complete in backend-architecture-refactor task list 2026-06-25 22:01:23 +03:00
0e7ecbb1ef docs: sync backend docs with refactored MOEX, health envelope, and operations/sync endpoint 2026-06-25 22:00:44 +03:00
b87ed761ed fix: preserve cachedAt metadata on cache hits 2026-06-25 21:58:05 +03:00
fe938b2706 refactor: localize T-Bank gRPC as any casts behind typed facade methods 2026-06-25 21:55:23 +03:00
af0c93fd34 fix: harden portfolio DTO validation for quantity and date fields 2026-06-25 21:51:08 +03:00
db28f46481 feat: add production config hardening for JWT secrets and CORS origins 2026-06-25 21:50:07 +03:00
0e8f65c457 fix: mask internal Error.message in unhandled 500 responses 2026-06-25 21:44:35 +03:00
120db3c2ab docs: add backend-architecture-refactor SDD artifacts and update epic/roadmap/inbox 2026-06-25 21:41:57 +03:00
58ddb10a92 docs: update inbox and roadmap with completed backend architecture improvements
Some checks failed
CI / ci (pull_request) Failing after 2m56s
CI / ci (push) Failing after 2m54s
2026-06-25 21:05:01 +03:00
4ed6e0ce20 docs: mark all tasks as completed in moex-client-split tasks.md 2026-06-25 20:54:06 +03:00
75fead68b8 refactor: split MoexClientService into domain-specific clients
- MoexHttpClient: infrastructure (axios, rate limiter, circuit breaker)
- MoexSecuritiesClient: search and security descriptions
- MoexMarketDataClient: share/bond market data and batch queries
- MoexCandlesClient: candle data
- MoexHistoryClient: share/bond history
- MoexDividendsClient: dividend data
- Removed @Global() from MoexClientModule
- Updated all 7 consumers with explicit DI
- All 141 tests passing
2026-06-25 20:49:17 +03:00
d2457d13af docs: add ADR-020 and feature docs for MoexClientService split 2026-06-25 20:42:28 +03:00
238836c850 refactor(backend): connect RequestLoggingMiddleware through DI
- AppModule implements NestModule with configure() for middleware
- Remove manual middleware instantiation from main.ts
- 120 tests pass, build succeeds
2026-06-25 20:37:10 +03:00
6e8efd2b80 feat(backend): improve health check with dependency probes 2026-06-25 20:34:37 +03:00
9f85dc5dde refactor(backend): introduce domain exception hierarchy
- Add DomainException base class extending HttpException
- Add EntityNotFoundException, PortfolioAccessDeniedException,
  MoexApiException, TBankApiException, TBankNotConfiguredException
- Update HttpExceptionFilter with unhandled error logging
- Replace generic NestJS exceptions in services with domain exceptions
- Update all affected tests
- 117 tests pass, build succeeds
2026-06-25 20:30:45 +03:00
3b919ecdc6 perf(backend): add dedicated screenerTtl config for screener caching
- Add CACHE_SCREENER_TTL env var (default 900s) to configuration
- Move screener from marketDataTtl to dedicated screenerTtl
- Add test verifying cache key prefix, parts, and TTL config key
- 117 tests pass, build succeeds
2026-06-25 20:25:53 +03:00
ccb1082535 refactor(backend): unify envelope DTOs and fix shares/bonds inconsistency
- Replace 4 duplicate meta DTOs (AuthResponseMetaDto, PortfolioResponseMetaDto,
  BrokerResponseMetaDto, ScreenerResponseMetaDto) with shared ApiResponseMeta
- Wrap shares getShare() in ApiEnvelopePayload (was raw object, unlike bonds)
- Remove unnecessary CacheModule import from securities module
- Update portfolio controller nullDataEnvelopeSchema to use shared ApiResponseMeta
- All 116 tests pass
2026-06-25 20:20:59 +03:00
8d6410b37b docs: mark pagination-loading-overlay and broker-account-analytics as implemented
All checks were successful
CI / ci (push) Successful in 13m33s
2026-06-25 06:44:31 +03:00
1fc7386568 Merge branch 'codex/api-envelope-contract'
All checks were successful
CI / ci (push) Successful in 13m12s
2026-06-25 06:15:38 +03:00
e69f183372 feat: finalize api-envelope-contract — tests, frontend simplify, contract tests, docs 2026-06-25 06:15:26 +03:00
b092caf8d6 feat: migrate services to ApiEnvelopePayload, controllers to plain returns 2026-06-24 19:39:30 +03:00
3a89cef76c feat: add ApiEnvelopePayload carrier, update TransformInterceptor to handle it 2026-06-24 19:36:28 +03:00
659be4636c docs: mark portfolio-analytics and quality-gate-contract-docs as completed in spec/plan
All checks were successful
CI / ci (pull_request) Successful in 12m44s
CI / ci (push) Successful in 14m21s
2026-06-24 19:06:24 +03:00
de776f477c docs: update roadmap — mark portfolio-analytics and quality-gate-contract-docs as completed 2026-06-24 19:04:28 +03:00
194a8be3e4 fix: remove invalid vitest workspace config, fix test:storybook script
vitest v4 removed defineWorkspace API, causing CI failure.
No .stories.test.* files exist, so test:storybook is a no-op.
2026-06-24 19:00:37 +03:00
95e9e7f71f feat: complete portfolio analytics Phases 2-3 and sync OpenAPI artifacts
- Phase 2: dividend income — batch MOEX dividend fetching with buyDate filtering, UI cards
- Phase 3: target allocation — backend targets parsing + deviation calc, frontend inputs + display
- Add openapi-artifacts.spec.ts for checked-in contract verification
- Regenerate frontend types from Swagger (auth/screener/portfolio paths)
2026-06-24 18:53:46 +03:00
ba0a4cbfef docs: mark broker-operations-ui-improvements and broker-positions-pagination as completed
Some checks failed
CI / ci (pull_request) Failing after 13m15s
CI / ci (push) Failing after 12m58s
2026-06-24 17:55:22 +03:00
f2fe6a4829 chore: untrack graphify-out generated artifacts
Some checks failed
CI / ci (push) Has been cancelled
CI / ci (pull_request) Failing after 14m29s
2026-06-24 17:49:40 +03:00
f0b6785540 feat: finalize broker-events — visual separation, docs update, roadmap 2026-06-24 17:47:06 +03:00
2af2ff32c1 feat: finalize table-migration — fix DataTable types, remove legacy helpers, update docs
Some checks failed
CI / ci (pull_request) Failing after 12m13s
CI / ci (push) Failing after 11m35s
2026-06-24 15:24:56 +03:00
2ed356fbcd docs: document MCP tools setup (code-index-mcp, serena, graphify)
- Add code-index-mcp and serena sections to AGENTS.md
- Update AGENTS.md MCP tools description in obligatory approach
- Add graphify hooks (post-checkout, post-commit) for auto graph rebuild
- Add serena project config
- Add graphify-out knowledge graph artifacts
- Ignore dev.db and graphify-out/cache/ in .gitignore
2026-06-24 13:32:10 +03:00
a72f951033 fix(ci): build design-system before frontend tests to resolve @moex-vibe/design-system import
Some checks failed
CI / ci (pull_request) Failing after 16m40s
CI / ci (push) Failing after 4m16s
2026-06-24 12:36:20 +03:00
3dfcf5aaa8 feat: complete broker account analytics with sync button, tests, and state filter
Some checks failed
CI / ci (pull_request) Failing after 3m39s
CI / ci (push) Failing after 3m10s
- Add state filter (EXECUTED/null) to analytics query (spec compliance)
- Add service unit tests (11 tests) and controller test
- Add sync button to operations page with mutation hook
- Regenerate frontend types via codegen
- Update tasks.md marking all items complete

Backend: 114 tests, Frontend: 116 tests — all pass
2026-06-24 11:29:09 +03:00
ef4bc48e50 feat: add analytics route and tab navigation for broker account 2026-06-24 09:46:38 +03:00
a1e68fb13a feat: add BrokerAnalyticsPage component with loading/error/empty states 2026-06-24 09:45:59 +03:00
3f0fb42d99 feat: add BrokerAnalyticsDto type, API function, and hook 2026-06-24 09:44:35 +03:00
abf3676ba1 fix(backend): export BrokerAnalyticsService from TBankModule 2026-06-24 09:42:35 +03:00
5cd56b239a feat: wire up broker analytics endpoint, cache config, and module registration
- Add analytics cache key to TBANK_CACHE_KEYS
- Add tbankAnalyticsTtl to cache configuration
- Register and export BrokerAnalyticsService in TBankModule
- Add BrokerAnalyticsEnvelopeDto to broker envelope DTOs
- Add GET /accounts/:accountId/analytics endpoint to TBankController
- Fix TBankController spec to pass new constructor dependency
2026-06-24 09:41:57 +03:00
5184d34262 fix(backend): handle malformed payment JSON in analytics service 2026-06-24 09:40:41 +03:00
61e3398c71 feat(backend): add broker analytics DTO and service 2026-06-24 09:38:25 +03:00
15427be384 docs: add spec for broker account analytics 2026-06-24 09:30:03 +03:00
0c4250fa6c feat(frontend): complete frontend debt audit with reconciled backlog
Some checks failed
CI / ci (pull_request) Failing after 2m54s
CI / ci (push) Failing after 3m9s
2026-06-24 09:09:50 +03:00
d529d51612 docs(frontend): complete debt backlog features with test conventions 2026-06-24 08:51:42 +03:00
9ad0f571d5 docs(frontend): mark docs sync done 2026-06-24 08:21:57 +03:00
410f35bbd7 docs(frontend): harden infra docs 2026-06-24 08:09:05 +03:00
afa0ec05e7 docs(frontend): sync frontend docs with current state 2026-06-24 08:00:23 +03:00
41668ea452 docs(frontend): add test hygiene plan 2026-06-24 07:19:22 +03:00
2a6cb1cec9 docs(frontend): add shared boundary cleanup plan 2026-06-24 07:16:39 +03:00
5b8bf1c4b4 docs(frontend): add infrastructure hardening plan 2026-06-24 07:14:32 +03:00
3e054ee6c4 docs(frontend): add docs sync plan 2026-06-24 07:10:40 +03:00
204152907c docs(frontend): add debt backlog epic and specs 2026-06-23 22:00:09 +03:00
a13b5145f7 docs(frontend): align tooling spec with implementation
Some checks failed
CI / ci (pull_request) Failing after 3m32s
CI / ci (push) Failing after 2m50s
2026-06-23 21:30:11 +03:00
b26021016c fix(frontend): clean up mock setup and route params 2026-06-23 21:22:37 +03:00
76cffc061d fix: provide portfolio via BrokerAccountContext, fix Обзор link adding trailing dot 2026-06-23 20:41:23 +03:00
aea74bda3b fix: sync SessionProvider auth state to Zustand store for router guard
requireAuth() in routeTree.tsx reads isAuthenticated from useSessionStore
(Zustand), but SessionProvider only managed state via React context.
After login, the Zustand store stayed false, causing protected route guards
to redirect to /login even when authenticated.
2026-06-23 20:27:50 +03:00
9932278b64 feat: complete Phase 3 API type unification, delete stale test file
- Delete shared/api/responses.ts, move type re-exports to index.ts
- Update all 55+ imports from shared/api/responses to shared/api
- Delete stale client.test.ts (tested deleted client.ts)
- Run biome checks and fix import ordering
- Update tasks.md and plan.md to reflect actual approach
2026-06-23 20:24:16 +03:00
063e80c375 feat(openapi): unify frontend types with codegen, fix backend nullable DTOs
- Fix ApiResponseMeta nullable property (add type: String) to prevent Record<string, never> in codegen
- Fix broker events DTO nullable fields with proper type annotations
- Regenerate frontend types.ts from updated Swagger schema
- Replace all hand-written types in responses.ts with codegen aliases
- Remove stale BrokerPortfolioEvent/BrokerEventsSummary/BrokerEventsData interfaces
- Fix codegen output path in frontend package.json
2026-06-23 19:58:23 +03:00
5b794c0419 feat: add Swagger response DTOs for all missing endpoints
- Shares: ShareEnvelopeDto, ShareMarketDataEnvelopeDto, DividendsEnvelopeDto,
  ShareHistoryEnvelopeDto, DividendItemDto, HistoryItemDto
- Bonds: BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto,
  BondHistoryItemDto
- Candles: CandleItemDto, CandleEnvelopeDto
- Securities: SearchResultItemDto, SearchEnvelopeDto
- Health: HealthResponseDto, HealthEnvelopeDto
- Add @ApiOkResponse decorators to all previously undocumented endpoints
- Reuse ApiResponseMeta from common for all envelope DTOs
2026-06-23 07:17:30 +03:00
cbb5d09bc3 docs: update tasks.md — mark Phase 2 prettier removal as complete 2026-06-23 06:58:55 +03:00
c7a8993b4a chore: remove prettier, consolidate formatting under biome
- Remove prettier dependency and config files (.prettierrc, .prettierignore)
- Update root format/format:check scripts to use biome only (via frontend)
- Update lint-staged: remove prettier --check, keep biome + eslint
- Update CI: remove redundant format:check step
- Update ADR-018 with code-first TanStack Router approach note
2026-06-23 06:58:35 +03:00
7e10b4b8aa docs: update tasks and plan to reflect actual implementation progress
Mark completed tasks across all 6 phases, document code-first
router approach deviation, update Phase 6 plan with actual steps
2026-06-23 06:54:29 +03:00
cfadb2adbe feat: migrate from react-router-dom to @tanstack/react-router (code-first)
- Create code-first route tree in src/app/routing/routeTree.tsx
- Replace ProtectedRoute with beforeLoad auth guards
- Add useSearchParamsCompat for URLSearchParams access
- Update App.tsx, layouts, and all page/widget imports
- Add frontend tooling: biome, prettier, env config
- Update all tests for TanStack Router compatibility
- Remove react-router-dom dependency, @tanstack/router-plugin
- Consolidate biome config at root level
2026-06-23 06:51:48 +03:00
6d3601a8f4 docs: add plan, tasks, research, and ADRs for frontend infrastructure tooling 2026-06-23 06:07:23 +03:00
203b7cbf20 docs: add spec for frontend infrastructure tooling 2026-06-23 06:04:25 +03:00
462212c95e docs: update apps/docs — fix inconsistencies, add broker events diagrams
Some checks failed
CI / ci (pull_request) Failing after 3m18s
CI / ci (push) Failing after 3m16s
2026-06-22 22:15:51 +03:00
62a8cffc96 fix: resolve unhandled promise rejection in broker events test; update docs: roadmap and epics status 2026-06-22 22:02:43 +03:00
f7dc338719 feat: add actual payouts and filter-as-draft UX to broker events calendar
Some checks failed
CI / ci (pull_request) Failing after 3m9s
CI / ci (push) Failing after 3m9s
- Backend: actual events from T-Bank operations, types filter, split forecast/actual summary
- Frontend: draft/applied filters with multi-select types, status column (Факт/Прогноз), green actual amounts
- Docs: update spec, plan, tasks
2026-06-22 21:48:03 +03:00
c71ba090ad test: remove unused test
Some checks failed
CI / ci (push) Failing after 3m15s
2026-06-22 20:21:20 +03:00
6f4b46c964 fix: flatten queryKey to primitives for reliable TanStack Query key comparison
Some checks failed
CI / ci (pull_request) Failing after 3m12s
CI / ci (push) Failing after 2m53s
2026-06-22 19:56:53 +03:00
0ddd9b5f80 feat: add date range selection to broker events page 2026-06-22 06:58:59 +03:00
3027b4f0f0 docs: update plan and tasks for broker events date range selection 2026-06-22 06:56:18 +03:00
595d059151 feat: add broker events calendar and payout projections for T-Bank accounts
Some checks failed
CI / ci (push) Failing after 3m1s
2026-06-22 06:41:37 +03:00
7b1d649853 docs: add broker events and payouts feature docs
Some checks failed
CI / ci (pull_request) Failing after 2m48s
CI / ci (push) Failing after 2m44s
2026-06-21 21:22:56 +03:00
723250f0b2 docs: add table migration planning docs
Some checks are pending
CI / ci (push) Has started running
2026-06-21 21:14:30 +03:00
6c768ef6a9 docs: mark pilot-migration and broker-accounts-page-migration as implemented
Some checks failed
CI / ci (pull_request) Failing after 3m9s
CI / ci (push) Failing after 3m8s
2026-06-21 20:51:46 +03:00
5ea1fcfe14 docs: mark broker-account-sections-ds-migration spec as implemented 2026-06-21 20:47:01 +03:00
1cabe2d71e feat: migrate BrokerAllocationChart wrappers to design system 2026-06-21 20:38:01 +03:00
6debf66ac8 docs: mark broker account sections DS migration tasks complete 2026-06-21 20:31:52 +03:00
b41ee9fce4 chore: remove all orphaned broker-* CSS classes and variables 2026-06-21 20:30:39 +03:00
85fbfcdb0c chore: remove SkeletonBlock from shared/ui exports 2026-06-21 20:24:03 +03:00
aa56c8a48d chore: remove legacy SkeletonBlock component 2026-06-21 20:21:15 +03:00
8ad5f3c70c feat: migrate BrokerPositionsPage and BrokerOperationsPage 2026-06-21 20:21:00 +03:00
e044b64de4 feat: migrate BrokerOperationsTable to design system 2026-06-21 18:59:58 +03:00
da656330b3 feat: migrate BrokerPositionTable, PositionTicker and TableSkeleton 2026-06-21 18:41:53 +03:00
9a94f4e3a9 chore: remove orphaned broker-overview CSS classes 2026-06-21 18:20:03 +03:00
a9a95dae69 feat: migrate BrokerOverviewSkeleton to design system Skeleton 2026-06-21 18:16:47 +03:00
ef97fd7136 feat: migrate BrokerSummary, BrokerAssetCards and BrokerAccountOverviewPage 2026-06-21 18:14:49 +03:00
3682f43aa6 feat: migrate BrokerAccountLayout to design system 2026-06-21 18:06:03 +03:00
bd27bba66d docs(sdd): add broker account sections DS migration spec/plan/tasks 2026-06-21 18:00:39 +03:00
860bed159c feat(frontend): migrate BrokerAccountsPage and widgets to design system 2026-06-21 17:42:05 +03:00
62a3389bfb feat(frontend): migrate LoginPage, RegisterPage, ProfilePage to design system
- LoginPage: <input>/<label> → <TextField>, <button> → <Button>,
  <h1> → <Heading>, inline error → <Text tone="negative">
- RegisterPage: same pattern, 4 fields, minLength via slotProps.htmlInput
- ProfilePage: <div> inline styles → <Surface>, <input> → <TextField>,
  <button> → <Button>, email/role → <Text>+<Text variant="label">
- Tests: loading state checks updated from text change to
  toBeDisabled() (DS Button keeps text, shows spinner)
- 264 lines removed, 134 net reduction
2026-06-21 17:23:04 +03:00
a8741cfeab fix: resolve Vite MUI deep import by moving to barrel import + updated ESLint allowlist
- Changed @mui/material/Box deep imports to { Box } from '@mui/material'
- Updated no-restricted-imports to only restrict DS-covered components,
  allowing Box/Stack/Grid for layout
2026-06-21 17:15:45 +03:00
bd33412691 feat(frontend): migrate HomePage and SearchBar to design system components
- HomePage: replace inline styles with <Heading>, <Text>, <Box>
- SearchBar: replace <input> with <TextField>, <ul> with <Surface>,
  <li> with <Box>+<Text>+<Chip>, hardcoded hex with theme tokens
- ESLint no-restricted-imports respected via @mui/material/Box deep import
- Logic unchanged, all 111 tests pass
2026-06-21 16:57:19 +03:00
3534ec4f77 fix(design-system): add @storybook/test and @testing-library/dom deps
Some checks failed
CI / ci (pull_request) Failing after 3m26s
CI / ci (push) Failing after 2m49s
- @storybook/test needed by stories (import { fn })
- npm overrides resolves peer dep conflict with storybook 10
- @testing-library/dom explicit dep (peer of @testing-library/react)
- 157 design-system tests + 111 frontend tests all pass
2026-06-21 15:49:59 +03:00
339845dd57 ci(design-system): enforce foundation quality gates 2026-06-21 15:40:21 +03:00
e6cf852741 docs(design-system): publish usage guidelines and ADR-016 2026-06-21 15:40:17 +03:00
53659a36b1 feat(design-system): add data components, form/page-state patterns, and frontend integration
- Inputs: TextField, Select, Checkbox
- Surfaces: Surface, Card, Chip, Badge
- Feedback: Alert, Dialog, Skeleton, Progress
- Financial data: DataTable, Metric, Money, PriceChange
- Form & page-state: FormField, FilterBar, EmptyState, ErrorState, LoadingState
- MUI CssVarsProvider -> ThemeProvider deprecation fix
- Inter font bundled via @fontsource/inter
- ESLint no-restricted-imports (warn) for gradual migration
- storybook-static gitignored

All 268 tests pass (157 design-system + 111 frontend)
2026-06-21 09:49:18 +03:00
55221dbf22 feat(design-system): add form and page-state patterns
- FormField: label, htmlFor, helperText, error, required, children
- FilterBar: responsive layout container with children and actions
- EmptyState: semantic heading with optional description and action
- ErrorState: semantic heading with optional description and action
- LoadingState: aria-live polite, section/page sizes, reduced-motion
2026-06-21 09:44:12 +03:00
74ca576310 feat(design-system): add typography and actions
Implement Text, Heading, Link, Button, and IconButton components with TDD and Storybook stories. Each component wraps MUI with restricted props for visual consistency.
2026-06-21 09:32:58 +03:00
886ac7b87a build(design-system): configure Storybook workbench 2026-06-21 09:29:26 +03:00
1107a1fc8b feat(design-system): add MUI theme adapter 2026-06-21 09:28:38 +03:00
fdffe204e4 feat(design-system): define three-level tokens 2026-06-21 09:27:16 +03:00
129282d634 feat(design-system): add token schema and resolver 2026-06-21 09:25:18 +03:00
ec13fa8ca2 docs(design-system): add foundation implementation plan 2026-06-21 09:13:19 +03:00
e8dcb27a03 docs(design-system): add foundation specification 2026-06-21 08:56:32 +03:00
1b6fd39be3 feat: add frontend infrastructure libraries (ky, Zustand, MUI, dayjs, clsx, tanstack-table, rhf, zod)
Some checks failed
CI / ci (pull_request) Failing after 2m55s
CI / ci (push) Failing after 2m53s
- install ky, zustand, @mui/material, @fontsource/roboto, dayjs, clsx
- install @tanstack/react-table, react-hook-form, zod, @hookform/resolvers
- create kyClient.ts with auth interceptors
- create Zustand store for session (useSessionStore.ts)
- add MUI theming (theme.ts, ThemeProvider in AppProviders)
- add dayjs utils with ru locale (formatDate, formatRelative)
- add clsx cn() utility
- add base Table component using @tanstack/react-table
- create ADR-015 documenting architectural decisions
- create SDD artifacts: spec.md, plan.md, tasks.md
- build, lint, and 111 tests passing
2026-06-21 08:26:27 +03:00
2744608d06 chore: add @conarti/eslint-plugin-feature-sliced for FSD layer enforcement
Some checks failed
CI / ci (pull_request) Failing after 2m23s
CI / ci (push) Failing after 2m19s
2026-06-20 23:21:14 +03:00
ad196164ee refactor: extract AddPositionForm to features layer 2026-06-20 23:16:20 +03:00
d1f9128441 refactor: extract BrokerOverview components to widgets layer 2026-06-20 23:14:24 +03:00
8e70691bde refactor: extract BrokerPositionTable to widgets layer 2026-06-20 23:12:28 +03:00
943a6aec2d fix: move test SessionProvider and SessionContext to shared layer for FSD compliance 2026-06-20 23:10:56 +03:00
79011c779f fix: move test SessionProvider to shared layer for FSD compliance 2026-06-20 23:04:34 +03:00
1774c7ffd6 refactor(frontend): bring FSD architecture into compliance
Some checks failed
CI / ci (pull_request) Failing after 2m32s
CI / ci (push) Failing after 2m17s
- Remove cross-entity import (broker-position -> broker-operation)
- Extract duplicated formatters to shared/lib/formatters.ts
- Flatten shared/ui/broker-allocation-bar nesting
- Clean up entities/stock/index.ts public API

All 111 tests pass, build clean.
2026-06-20 22:43:44 +03:00
1f426e9734 refactor(frontend): complete FSD compliance - barrel imports, test/ move, auth extraction, delete App.tsx and stale dir
Some checks failed
CI / ci (pull_request) Failing after 2m37s
CI / ci (push) Failing after 2m30s
2026-06-20 22:31:34 +03:00
2fd2f0611b refactor(frontend): move BrokerAllocationBar to shared/ui, fix cross-entity imports and missing barrel exports 2026-06-20 22:07:25 +03:00
b9d7a6ad69 refactor(frontend): complete FSD migration - import aliases, BrokerAccountLayout move, search api/ layer 2026-06-20 21:57:04 +03:00
055bc23097 refactor(frontend): migrate auth/portfolio pages to FSD, add layer boundaries
Some checks failed
CI / ci (pull_request) Failing after 2m45s
CI / ci (push) Failing after 2m41s
- Convert LoginPage, RegisterPage, ProfilePage to FSD structure (index.ts + ui/)
- Convert PortfoliosListPage, PortfolioDetailPage to FSD structure
- Replace relative imports with @/ aliases in portfolio pages
- Add ESLint import/no-restricted-paths for FSD layer boundaries
- Refactor useSession test to mock context instead of import from app/
- Mark frontend-fsd-screener tasks as complete
2026-06-20 21:42:33 +03:00
9b208481dd feat: migrate screener to FSD with first feature-slice (features/screener)
Some checks failed
CI / ci (pull_request) Failing after 2m49s
CI / ci (push) Failing after 2m36s
2026-06-20 21:23:08 +03:00
2fc98341a6 docs: add FSD screener feature spec, plan, and tasks 2026-06-20 21:23:01 +03:00
2619a27b80 docs: mark portfolio FSD migration tasks complete
Some checks failed
CI / ci (pull_request) Failing after 2m38s
CI / ci (push) Failing after 2m56s
2026-06-20 21:11:54 +03:00
605a4e8d00 docs: update frontend overview for portfolio FSD migration 2026-06-20 21:11:54 +03:00
16f5e1752c refactor(frontend): remove flat components/portfolios 2026-06-20 21:11:30 +03:00
1ef1a3c902 refactor(frontend): switch pages/portfolios to widget imports 2026-06-20 21:11:30 +03:00
cfa890fdbe feat: add portfolio widgets with barrel files (FSD migration) 2026-06-20 21:11:30 +03:00
36f39d2d3f docs: add frontend fsd portfolios plan and tasks 2026-06-20 21:11:30 +03:00
49dcbb4e33 docs: define frontend fsd portfolios spec 2026-06-20 21:11:30 +03:00
b069575fbb refactor(frontend): remove FSD shim files and dead code
Some checks failed
CI / ci (pull_request) Failing after 2m51s
CI / ci (push) Failing after 2m38s
- Break entity shim chain: brokerPositionApi, brokerOperationApi now use shared/api/client directly
- Remove all 43 shim re-export files (api/, hooks/, context/, components/, pages/ flat shims, routes.tsx)
- Remove entire pages/broker/ dead code directory
- Remove api/broker.ts after breaking the shim chain
- Switch auth consumers from AuthProvider (shim) to SessionProvider (FSD)
- Fix api/screener.ts imports to use @/shared/api
- Update frontend hooks.md and routes.md documentation
- 2840 lines removed, 71 lines added
2026-06-20 20:42:44 +03:00
6f9e368126 refactor(frontend): refactoring fsd
Some checks failed
CI / ci (pull_request) Failing after 3m2s
CI / ci (push) Failing after 2m58s
2026-06-20 19:58:49 +03:00
40d7792b9e docs: add specification for frontend FSD market 2026-06-20 19:57:37 +03:00
20f0efa083 docs: update spec status, tasks, and frontend overview for FSD app+auth migration
Some checks failed
CI / ci (push) Failing after 6m18s
2026-06-20 14:54:53 +03:00
8e9fdbe70a refactor(frontend): migrate app layer and auth to FSD
- Create app/ layer: App.tsx, providers, routing, layouts
- Create entities/session/ for auth domain
- Extract SessionProvider + AppProviders composition
- Add ProtectedRoute, AppRoutes to app/routing/
- Add AppLayout to app/layouts/
- Convert legacy files to re-export shims
- Update Login/Register/Profile pages to useSession
2026-06-20 14:54:53 +03:00
af36d2e7cf docs: define frontend fsd app+auth spec, plan, tasks 2026-06-20 14:54:53 +03:00
ad8ff6a875 docs: mark fsd entities migration tasks complete 2026-06-20 14:54:52 +03:00
946383bde0 refactor(frontend): migrate entities/portfolio to FSD 2026-06-20 14:54:52 +03:00
9c115f0e16 refactor(frontend): migrate entities/bond to FSD 2026-06-20 14:54:52 +03:00
dd727f9fb2 refactor(frontend): migrate entities/stock to FSD 2026-06-20 14:54:52 +03:00
93d18f1218 docs: add pre-flight checklist to AGENTS.md, commit missing plan.md 2026-06-20 14:54:52 +03:00
23a077536c docs: mark frontend fsd shared layer tasks complete 2026-06-20 14:54:52 +03:00
d95b2df460 refactor(frontend): update legacy imports to use @/shared/ 2026-06-20 14:54:52 +03:00
9816bc6b96 refactor(frontend): update fsd entities imports to use @/shared/ 2026-06-20 14:54:52 +03:00
5355451e35 refactor(frontend): create shared/ui layer with skeleton components 2026-06-20 14:54:52 +03:00
a9132109da refactor(frontend): create shared/api layer with re-export shims 2026-06-20 14:54:52 +03:00
df4b778d95 docs: define frontend fsd shared layer spec 2026-06-20 14:54:52 +03:00
4db95146a7 docs: finalize sdd artifacts — mark checkboxes, update status, document cross-slice export 2026-06-20 14:54:52 +03:00
b02ee9cd74 docs: document broker fsd pilot completion 2026-06-20 14:54:52 +03:00
00f01fae4f refactor(frontend): move broker operations and layout to fsd 2026-06-20 14:54:52 +03:00
09213624c3 test: remove legacy broker position duplicates 2026-06-20 14:54:51 +03:00
8e578e7a91 refactor(frontend): move broker position slice to fsd 2026-06-20 14:54:51 +03:00
1db1763b36 refactor(frontend): align broker account shims 2026-06-20 14:54:51 +03:00
55cc479abb refactor(frontend): move broker account slice to fsd 2026-06-20 14:54:51 +03:00
a03e3c0240 refactor(frontend): add broker fsd page entrypoints 2026-06-20 14:54:51 +03:00
9287b97880 docs: prefer subagent-driven execution 2026-06-20 14:54:51 +03:00
3b618d3da0 docs: define frontend fsd broker pilot 2026-06-20 14:54:51 +03:00
696 changed files with 46503 additions and 12424 deletions

View File

@ -23,12 +23,12 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build design system
run: npm run build:design-system
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Format check
run: npm run format:check
- name: Test backend - name: Test backend
run: npm run test:backend run: npm run test:backend
@ -40,3 +40,26 @@ jobs:
- name: Build frontend - name: Build frontend
run: npm run build:frontend run: npm run build:frontend
- name: Setup Playwright browsers
run: npx playwright install --with-deps chromium
- name: Test design system
run: npm run test:design-system
- name: Build Storybook
run: npm run build:storybook
- name: Test Storybook (browser)
run: npm run test:storybook
- name: Build docs
run: npm run build:docs
- name: Upload Storybook build (if failed)
if: failure()
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: packages/design-system/storybook-static
retention-days: 3

4
.gitignore vendored
View File

@ -11,3 +11,7 @@ vite.config.js
apps/docs/.docusaurus/ apps/docs/.docusaurus/
apps/docs/build/ apps/docs/build/
.idea .idea
.playwright-mcp
.opencode
dev.db
graphify-out/

139
.husky/post-checkout Executable file
View File

@ -0,0 +1,139 @@
#!/bin/sh
# graphify-checkout-hook-start
# Auto-rebuilds the knowledge graph (code only) when switching branches.
# Installed by: graphify hook install
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
# order is randomized per-process by PYTHONHASHSEED, so community assignments
# churn run-to-run. Pinning it makes graphify-out reproducible.
export PYTHONHASHSEED=0
PREV_HEAD=$1
NEW_HEAD=$2
BRANCH_SWITCH=$3
# Only run on branch switches, not file checkouts
if [ "$BRANCH_SWITCH" != "1" ]; then
exit 0
fi
# Only run if graphify-out/ exists (graph has been built before)
if [ ! -d "graphify-out" ]; then
exit 0
fi
# Skip during rebase/merge/cherry-pick
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
# _PINNED was recorded at hook-install time; tried first so the hook works even
# when the graphify launcher is not on PATH (common in GUI clients and CI).
GRAPHIFY_PYTHON=""
_PINNED='/Users/ksv741/.local/share/uv/tools/graphifyy/bin/python'
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="$_PINNED"
fi
# Second probe: read graphify-out/.graphify_python (written by the skill and
# CLI; survives uv-tool reinstalls and is the same source the README documents).
if [ -z "$GRAPHIFY_PYTHON" ]; then
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
if [ -f "$_GFY_PYTHON_FILE" ]; then
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
case "$_FROM_FILE" in
*[!a-zA-Z0-9/_.@:\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
esac
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="$_FROM_FILE"
fi
fi
fi
# Third probe: resolve via the graphify launcher on PATH (shebang probe).
if [ -z "$GRAPHIFY_PYTHON" ]; then
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
case "$GRAPHIFY_BIN" in
*.exe) _SHEBANG="" ;;
*) _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | sed 's/^#![[:space:]]*//') ;;
esac
case "$_SHEBANG" in
*/env\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
esac
# Allowlist: only keep characters valid in a filesystem path to prevent
# injection if the shebang contains shell metacharacters.
case "$GRAPHIFY_PYTHON" in
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
esac
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON=""
fi
fi
fi
# Last resort: try python3 / python (works for system/venv installs on PATH).
if [ -z "$GRAPHIFY_PYTHON" ]; then
if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="python3"
elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="python"
else
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
exit 0
fi
fi
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)"
"$GRAPHIFY_PYTHON" -c "import os, subprocess, sys
_src = '''
from graphify.watch import _rebuild_code, _apply_resource_limits
from pathlib import Path
import os, signal, sys
try:
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
# post-checkout: branch switch can touch arbitrary files; full rebuild path
# (no changed_paths) is correct here. The flock inside _rebuild_code still
# prevents pile-ups when commit + checkout fire back-to-back.
_root = Path('.')
_saved = Path('graphify-out/.graphify_root')
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8').strip()
if _txt:
_root = Path(_txt)
_rebuild_code(_root, force=_force)
except TimeoutError as exc:
print(f'[graphify] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify] Rebuild failed: {exc}')
sys.exit(1)
'''
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
try:
os.makedirs(os.path.dirname(_log), exist_ok=True)
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
except OSError:
_out = subprocess.DEVNULL
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
_cmd = [sys.executable, '-c', _src]
if os.name == 'nt':
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
try:
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
except OSError:
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
else:
subprocess.Popen(_cmd, start_new_session=True, **_kw)
"
# graphify-checkout-hook-end

150
.husky/post-commit Executable file
View File

@ -0,0 +1,150 @@
#!/bin/sh
# graphify-hook-start
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
# Installed by: graphify hook install
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
# order is randomized per-process by PYTHONHASHSEED, so community assignments
# churn run-to-run. Pinning it makes graphify-out reproducible.
export PYTHONHASHSEED=0
# Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
if [ -z "$CHANGED" ]; then
exit 0
fi
# Skip when only graphify-out/ artifacts changed (avoids rebuild loop when graph outputs are tracked in git)
_NON_GRAPH=$(echo "$CHANGED" | grep -v '^graphify-out/' || true)
if [ -z "$_NON_GRAPH" ]; then
exit 0
fi
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
# _PINNED was recorded at hook-install time; tried first so the hook works even
# when the graphify launcher is not on PATH (common in GUI clients and CI).
GRAPHIFY_PYTHON=""
_PINNED='/Users/ksv741/.local/share/uv/tools/graphifyy/bin/python'
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="$_PINNED"
fi
# Second probe: read graphify-out/.graphify_python (written by the skill and
# CLI; survives uv-tool reinstalls and is the same source the README documents).
if [ -z "$GRAPHIFY_PYTHON" ]; then
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
if [ -f "$_GFY_PYTHON_FILE" ]; then
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
case "$_FROM_FILE" in
*[!a-zA-Z0-9/_.@:\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
esac
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="$_FROM_FILE"
fi
fi
fi
# Third probe: resolve via the graphify launcher on PATH (shebang probe).
if [ -z "$GRAPHIFY_PYTHON" ]; then
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
case "$GRAPHIFY_BIN" in
*.exe) _SHEBANG="" ;;
*) _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | sed 's/^#![[:space:]]*//') ;;
esac
case "$_SHEBANG" in
*/env\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
esac
# Allowlist: only keep characters valid in a filesystem path to prevent
# injection if the shebang contains shell metacharacters.
case "$GRAPHIFY_PYTHON" in
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
esac
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON=""
fi
fi
fi
# Last resort: try python3 / python (works for system/venv installs on PATH).
if [ -z "$GRAPHIFY_PYTHON" ]; then
if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="python3"
elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="python"
else
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
exit 0
fi
fi
export GRAPHIFY_CHANGED="$CHANGED"
# Run the rebuild detached so git commit returns immediately. Full-repo rebuilds
# can take hours; blocking the post-commit hook stalls the shell. The Python
# launcher below detaches the child cross-platform, so it works on Git for
# Windows' shell too (which lacks the coreutils backgrounding tools) (#1161).
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
"$GRAPHIFY_PYTHON" -c "import os, subprocess, sys
_src = '''
import os, signal, sys
from pathlib import Path
changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
if not changed:
sys.exit(0)
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
try:
from graphify.watch import _rebuild_code, _apply_resource_limits
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
_root = Path('.')
_saved = Path('graphify-out/.graphify_root')
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8').strip()
if _txt:
_root = Path(_txt)
_rebuild_code(_root, changed_paths=changed, force=_force)
except TimeoutError as exc:
print(f'[graphify hook] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify hook] Rebuild failed: {exc}')
sys.exit(1)
'''
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
try:
os.makedirs(os.path.dirname(_log), exist_ok=True)
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
except OSError:
_out = subprocess.DEVNULL
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
_cmd = [sys.executable, '-c', _src]
if os.name == 'nt':
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
try:
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
except OSError:
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
else:
subprocess.Popen(_cmd, start_new_session=True, **_kw)
"
# graphify-hook-end

View File

@ -1,6 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": true
}

2
.serena/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/cache
/project.local.yml

View File

@ -0,0 +1,33 @@
# Memory Maintenance
## Discovery Model
- Core principle: progressive discovery through references, building a graph of memories.
- Initially, agents are provided with the list of all memories (names only).
- Agents should read `mem:core` as the top-level entry point (graph root).
This memory should contain references to other memories covering major project domains.
The referenced memories shall, in turn, shall contain references to even more specific memories, and so on.
The depth of the graph shall depend on the project complexity.
- Use topics/folders to group related memories in order to make the content structure explicit.
Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc.
- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`.
The surrounding text should clearly indicate when to read the memory/which content to expect.
The text should provide more precise guidance than the memory name alone,
i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered.
- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory.
## Style
Dense agent notes, not prose docs. Prefer invariants, terse bullets.
Avoid obvious context, rationale, and examples unless they prevent likely mistakes.
Keep guidance durable and generalizable, not task-local.
## Add/update threshold
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
## Maintenance Actions
- Renaming memories: References are updated automatically if handled via Serena's memory rename tool.
- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report.

133
.serena/project.yml Normal file
View File

@ -0,0 +1,133 @@
# the name by which the project can be referenced within Serena
project_name: "moex-vibe"
# list of languages for which language servers are started; choose from:
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# svelte swift systemverilog terraform toml
# typescript typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []

View File

@ -40,8 +40,8 @@
## Обязательный подход к разработке ## Обязательный подход к разработке
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria. - **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений. - **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans для крупных многошаговых работ, subagent-driven-development как предпочтительный способ исполнения плана, executing-plans как fallback для явно связанных inline-задач, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче. - **MCP-инструменты**: в проекте настроены `code-index-mcp` (файловый поиск/индексация), `serena` (LSP-символьный анализ кода) и `graphify` (knowledge graph). Использовать для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
- **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы. - **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы.
--- ---
@ -204,6 +204,11 @@ docs/
Для исторической фичи сначала прочитать все имеющиеся артефакты. Отсутствие старого `plan.md` или `tasks.md` само по себе не блокирует maintenance или исправление бага и не требует создавать их задним числом. Для нового расширения такой фичи сначала подготовить недостающие артефакты в объёме текущего изменения. Для исторической фичи сначала прочитать все имеющиеся артефакты. Отсутствие старого `plan.md` или `tasks.md` само по себе не блокирует maintenance или исправление бага и не требует создавать их задним числом. Для нового расширения такой фичи сначала подготовить недостающие артефакты в объёме текущего изменения.
Если есть согласованный `plan.md` для многошаговой реализации, агент по умолчанию должен
предпочитать `superpowers:subagent-driven-development`. `superpowers:executing-plans` использовать
только когда пользователь явно просит inline-исполнение или когда задачи настолько тесно связаны,
что разбиение по subagent-циклам ухудшит надёжность и скорость.
### Работа с новыми идеями ### Работа с новыми идеями
Если во время реализации появилась новая идея: Если во время реализации появилась новая идея:
@ -264,6 +269,18 @@ Portfolio Dashboard
Если информации недостаточно — остановиться и запросить уточнение вместо того, чтобы делать предположения. Если информации недостаточно — остановиться и запросить уточнение вместо того, чтобы делать предположения.
### Pre-flight checklist (обязателен перед реализацией любой фичи)
Агент не имеет права начать реализацию, пока не выполнены все пункты:
- [ ] Feature branch создана: `codex/<feature-name>`
- [ ] spec.md написана и утверждена пользователем
- [ ] plan.md написан и утверждён пользователем
- [ ] tasks.md создан с чекбоксами до начала работы
- [ ] Все тесты проходят на текущем состоянии
Нарушение любого пункта = остановиться и вернуться к пропущенному шагу.
### Anti-Loop: лимит на итерации ### Anti-Loop: лимит на итерации
Если после 3 последовательных неудачных попыток исправить одну и ту же проблему в рамках одной гипотезы симптом не изменился — остановиться и запросить помощь у пользователя. Если после 3 последовательных неудачных попыток исправить одну и ту же проблему в рамках одной гипотезы симптом не изменился — остановиться и запросить помощь у пользователя.
@ -442,3 +459,67 @@ roadmap.md и inbox.md никогда не являются основанием
- Тесты фронтенда есть: Vitest + Testing Library + MSW. - Тесты фронтенда есть: Vitest + Testing Library + MSW.
- CI находится в `.gitea/workflows/ci.yml`. - CI находится в `.gitea/workflows/ci.yml`.
- Pre-commit checks настроены через Husky и lint-staged. - Pre-commit checks настроены через Husky и lint-staged.
## code-index-mcp
В проекте настроен `code-index-mcp` — MCP-сервер для быстрого поиска файлов и кода.
**Инструменты:**
- `find_files(pattern)` — поиск файлов по glob-паттерну через in-memory индекс
- `search_code_advanced(pattern)` — поиск кода с поддержкой regex, контекста, фильтрации по типу файла
- `get_file_summary(path)` — сводка по файлу (строки, функции, классы, импорты)
- `get_symbol_body(path, symbol_name)` — получить тело символа (функции/класса)
- `find_implementations(name_path, relative_path)` — найти реализации символа
- `find_referencing_symbols(name_path, relative_path)` — найти ссылки на символ
**Когда использовать:**
- Поиск файлов по имени или паттерну (glob)
- Быстрый grep по коду с контекстом
- Получение только тела функции/класса без всего файла
---
## serena
В проекте настроена `serena` — MCP-сервер с LSP-символьным анализом кода. Предоставляет symbol-aware инструменты поверх TypeScript LSP.
**Инструменты:**
- `find_symbol(name_path_pattern)` — поиск символов (классы, функции, методы) по всему проекту
- `get_symbols_overview(relative_path)` — обзор символов в файле (группировка по типу)
- `find_referencing_symbols(name_path, relative_path)` — где используется символ
- `find_implementations(name_path, relative_path)` — реализации интерфейса/класса
- `find_declaration(relative_path, regex)` — найти объявление по вызову
- `replace_symbol_body(name_path, relative_path, body)` — заменить тело метода
- `rename_symbol(name_path, relative_path, new_name)` — рефакторинг-переименование
- `replace_content(relative_path, needle, repl, mode)` — regex-замена в файле
- `safe_delete_symbol(name_path, relative_path)` — удалить неиспользуемый символ
- `get_diagnostics_for_file(relative_path)` — ошибки/предупреждения в файле
- `write_memory/read_memory/list_memories` — сохранение контекста между сессиями
**Когда использовать:**
- Найти все использования функции/метода в коде
- Получить структуру файла (классы, методы)
- Безопасный рефакторинг (переименование, удаление)
- Получить LSP-диагностику (ошибки компиляции)
- Запомнить что-то между сессиями (memories)
---
## graphify
This project has a knowledge graph in `graphify-out/` with god nodes, community structure, and cross-file relationships. The graph is a local artifact, not a tracked repo asset.
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
Rules:
- Используй `graphify` в первую очередь, когда задача связана с архитектурой, границами модулей, кросс-файловым влиянием или трассировкой потока данных.
- Для таких вопросов сначала запускай `graphify query "<question>"`, если существует `graphify-out/graph.json`. Для связей используй `graphify path "<A>" "<B>"`, для точечных концептов — `graphify explain "<concept>"`. Обычно это даёт гораздо более узкий подграф, чем `GRAPH_REPORT.md` или raw grep.
- Предпочитай `graphify query` перед raw grep, когда нужен кратчайший путь между концептами, мост между комьюнити или трассировка того, как один подсистемный блок достигает другого.
- Для отладки багов начинай с симптома и спрашивай у graphify путь зависимости, bridge nodes или модули, которые могут объяснить неожиданное поведение.
- Если `graphify` возвращает только общую структуру, переходи к `serena` за символ-уровневыми фактами и затем повторяй `graphify` с более узким вопросом, где названы конкретные файлы, модули или сервисы.
- Dirty `graphify-out/` после хуков или инкрементальных обновлений считаются нормой; грязные файлы графа не повод пропускать `graphify`. Пропускать его можно только если задача именно про устаревший или некорректный граф, либо если пользователь прямо попросил не использовать его.
- В новом `worktree` сначала заново создай локальный граф командой `graphify extract .`.
- После первой сборки в этом `worktree` обновляй граф командой `graphify update .`.
- Если существует `graphify-out/wiki/index.md`, используй его для широкого обзора вместо ручного просмотра исходников.
- `graphify-out/GRAPH_REPORT.md` читай только для широкого архитектурного обзора или когда `query/path/explain` не дают достаточно контекста.
- После изменений в коде запускай `graphify update .`, чтобы держать граф актуальным (только AST, без затрат на LLM).

View File

@ -24,6 +24,7 @@ npm workspaces монорепозиторий:
| `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) | | `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) |
| `apps/frontend` | React SPA на Vite | | `apps/frontend` | React SPA на Vite |
| `apps/docs` | Сайт документации Docusaurus | | `apps/docs` | Сайт документации Docusaurus |
| `packages/design-system` | Дизайн-система (Storybook, MUI-адаптер, UI-компоненты) |
--- ---
@ -31,6 +32,7 @@ npm workspaces монорепозиторий:
- **Бэкенд:** NestJS, TypeScript, OpenAPI (Swagger) - **Бэкенд:** NestJS, TypeScript, OpenAPI (Swagger)
- **Фронтенд:** React, TypeScript, Vite, TanStack Query, lightweight-charts - **Фронтенд:** React, TypeScript, Vite, TanStack Query, lightweight-charts
- **Дизайн-система:** MUI v7, Storybook 10, lightweight-charts
- **Документация:** Docusaurus - **Документация:** Docusaurus
- **Инфраструктура:** Docker, docker-compose - **Инфраструктура:** Docker, docker-compose
@ -90,6 +92,8 @@ apps/
backend/ — NestJS API, единая точка доступа к MOEX ISS backend/ — NestJS API, единая точка доступа к MOEX ISS
frontend/ — React SPA на Vite frontend/ — React SPA на Vite
docs/ — сайт документации Docusaurus docs/ — сайт документации Docusaurus
packages/
design-system/ — дизайн-система (MUI-адаптер, UI-компоненты, Storybook)
docs/ docs/
features/ — спецификации и планы реализации (SDD) features/ — спецификации и планы реализации (SDD)
epics/ — продуктовые эпики epics/ — продуктовые эпики
@ -105,16 +109,26 @@ docs/
| ---------------------------------- | --------------------------------------------------------------------------- | | ---------------------------------- | --------------------------------------------------------------------------- |
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 | | `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 | | `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
| `npm run dev:docs` | Docusaurus dev-сервер | | `npm run dev:docs` | Docusaurus dev-сервер (опубликованная документация) |
| `npm run build:backend` | `nest build` | | `npm run build:backend` | `nest build` |
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) | | `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
| `npm run build:docs` | `docusaurus build` | | `npm run build:docs` | `docusaurus build` |
| `npm run build:design-system` | Сборка дизайн-системы (`tsc`) |
| `npm run build:storybook` | Статическая сборка Storybook |
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) | | `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
| `npm run test:frontend` | Frontend Vitest suite | | `npm run test:frontend` | Frontend Vitest suite |
| `npm run lint` | ESLint для backend и frontend | | `npm run test:design-system` | Unit-тесты дизайн-системы (Vitest) |
| `npm run test:storybook` | Браузерные тесты Storybook (Vitest browser mode + Playwright) |
| `npm run storybook` | Storybook dev-сервер на :6006 (инженерный workbench, не docs) |
| `npm run lint` | ESLint для backend, frontend и design-system |
| `npm run lint:design-system` | ESLint для дизайн-системы |
| `npm run format` | Prettier для всех `*.{ts,tsx}` | | `npm run format` | Prettier для всех `*.{ts,tsx}` |
| `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` | | `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` |
`graphify-out/` — локальный артефакт знания, он не хранится в git. В новом `worktree` сначала собери его заново: `graphify extract .`; дальше обновляй инкрементально: `graphify update .`. Для вопросов по коду используй `graphify query "..."`.
Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов.
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`. Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.
Один backend-тест: `npm exec -w apps/backend -- vitest run src/path/to/test.spec.ts` Один backend-тест: `npm exec -w apps/backend -- vitest run src/path/to/test.spec.ts`

View File

@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { CacheModule } from './modules/cache/cache.module'; import { CacheModule } from './modules/cache/cache.module';
import { MoexClientModule } from './modules/moex-client/moex-client.module'; import { MoexClientModule } from './modules/moex-client/moex-client.module';
@ -11,6 +11,7 @@ import { PortfolioModule } from './modules/portfolio/portfolio.module';
import { PrismaModule } from './modules/prisma/prisma.module'; import { PrismaModule } from './modules/prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { TBankModule } from './modules/tbank/tbank.module'; import { TBankModule } from './modules/tbank/tbank.module';
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
import configuration from './config/configuration'; import configuration from './config/configuration';
@Module({ @Module({
@ -29,4 +30,8 @@ import configuration from './config/configuration';
TBankModule, TBankModule,
], ],
}) })
export class AppModule {} export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestLoggingMiddleware).forRoutes('*');
}
}

View File

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class ApiResponseMeta { export class ApiResponseMeta {
@ApiProperty({ nullable: true }) @ApiProperty({ type: String, nullable: true })
cachedAt: string | null; cachedAt: string | null;
@ApiProperty() @ApiProperty()
@ -13,6 +13,14 @@ export class ApiResponseMeta {
} }
} }
export class ApiEnvelopePayload<T> {
constructor(
public readonly data: T,
public readonly fromCache: boolean,
public readonly cachedAt: string | null,
) {}
}
export class ApiResponse<T> { export class ApiResponse<T> {
data: T; data: T;
meta: ApiResponseMeta; meta: ApiResponseMeta;

View File

@ -0,0 +1,8 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export abstract class DomainException extends HttpException {
constructor(message: string, status: HttpStatus) {
super(message, status);
this.name = this.constructor.name;
}
}

View File

@ -0,0 +1,8 @@
import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';
export class EntityNotFoundException extends DomainException {
constructor(entity: string, id: string | number) {
super(`${entity} ${id} not found`, HttpStatus.NOT_FOUND);
}
}

View File

@ -0,0 +1,8 @@
import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';
export class MoexApiException extends DomainException {
constructor(message: string) {
super(`MOEX API error: ${message}`, HttpStatus.BAD_GATEWAY);
}
}

View File

@ -0,0 +1,8 @@
import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';
export class PortfolioAccessDeniedException extends DomainException {
constructor(portfolioId: number) {
super(`Access denied to portfolio ${portfolioId}`, HttpStatus.FORBIDDEN);
}
}

View File

@ -0,0 +1,14 @@
import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';
export class TBankApiException extends DomainException {
constructor(message: string) {
super(`T-Bank API error: ${message}`, HttpStatus.BAD_GATEWAY);
}
}
export class TBankNotConfiguredException extends DomainException {
constructor() {
super('T-Bank integration is not configured', HttpStatus.SERVICE_UNAVAILABLE);
}
}

View File

@ -0,0 +1,67 @@
import { ArgumentsHost, BadRequestException, HttpStatus } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => {
const createHost = () => {
const json = vi.fn();
const status = vi.fn(() => ({ json }));
const host = {
switchToHttp: () => ({
getResponse: () => ({ status }),
getRequest: () => ({ url: '/api/v1/test' }),
}),
} as unknown as ArgumentsHost;
return { host, status, json };
};
it('does not expose internal Error.message for unhandled exceptions', () => {
const filter = new HttpExceptionFilter();
const { host, status, json } = createHost();
filter.catch(new Error('Prisma failed at file:///secret/path'), host);
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
error: 'Internal Server Error',
path: '/api/v1/test',
}),
);
expect(json.mock.calls[0][0].message).not.toContain('Prisma failed');
});
it('returns safe defaults for non-Error thrown values', () => {
const filter = new HttpExceptionFilter();
const { host, status, json } = createHost();
filter.catch('some string error', host);
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
error: 'Internal Server Error',
}),
);
});
it('keeps HttpException response messages intact', () => {
const filter = new HttpExceptionFilter();
const { host, status, json } = createHost();
filter.catch(new BadRequestException('Invalid request'), host);
expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: HttpStatus.BAD_REQUEST,
message: 'Invalid request',
error: 'Bad Request',
}),
);
});
});

View File

@ -1,8 +1,10 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) { catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
@ -24,7 +26,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
error = (r.error as string) || exception.name; error = (r.error as string) || exception.name;
} }
} else if (exception instanceof Error) { } else if (exception instanceof Error) {
message = exception.message; this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
} else {
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
} }
response.status(status).json({ response.status(status).json({

View File

@ -1,7 +1,7 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiResponse } from '../dto/api-response.dto'; import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
@Injectable() @Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> { export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
@ -9,6 +9,9 @@ export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T
return next.handle().pipe( return next.handle().pipe(
map((data) => { map((data) => {
if (data instanceof ApiResponse) return data; if (data instanceof ApiResponse) return data;
if (data instanceof ApiEnvelopePayload) {
return new ApiResponse(data.data, data.fromCache, data.cachedAt);
}
return new ApiResponse(data, false, null); return new ApiResponse(data, false, null);
}), }),
); );

View File

@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('backend runtime configuration', () => {
const OLD_ENV = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...OLD_ENV };
delete process.env.JWT_SECRET;
delete process.env.JWT_REFRESH_SECRET;
delete process.env.BACKEND_CORS_ORIGINS;
});
afterEach(() => {
process.env = OLD_ENV;
});
it('keeps dev auth defaults outside production', async () => {
const configuration = (await import('./configuration')).default;
expect(configuration().auth).toMatchObject({
jwtSecret: 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: 'dev-refresh-secret-change-in-production',
});
});
it('parses backend CORS origins from comma-separated env', async () => {
process.env.BACKEND_CORS_ORIGINS = 'https://app.example.com, http://localhost:5173 ';
const configuration = (await import('./configuration')).default;
expect(configuration().cors.origins).toEqual([
'https://app.example.com',
'http://localhost:5173',
]);
});
it('rejects production defaults for JWT secrets', async () => {
const { assertSafeProductionConfig } = await import('../main');
expect(() =>
assertSafeProductionConfig({
nodeEnv: 'production',
jwtSecret: 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: 'custom-refresh-secret',
corsOrigins: ['https://app.example.com'],
}),
).toThrow('JWT_SECRET must be set to a non-default value in production');
});
it('rejects production credentialed CORS without explicit origins', async () => {
const { assertSafeProductionConfig } = await import('../main');
expect(() =>
assertSafeProductionConfig({
nodeEnv: 'production',
jwtSecret: 'custom-access-secret',
jwtRefreshSecret: 'custom-refresh-secret',
corsOrigins: [],
}),
).toThrow('BACKEND_CORS_ORIGINS must contain at least one origin in production');
});
it('allows development with reflected CORS', async () => {
const { buildCorsOrigin } = await import('../main');
expect(buildCorsOrigin('development', [])).toBe(true);
});
it('uses explicit production CORS origins', async () => {
const { buildCorsOrigin } = await import('../main');
expect(buildCorsOrigin('production', ['https://app.example.com'])).toEqual([
'https://app.example.com',
]);
});
});

View File

@ -1,5 +1,14 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export const DEV_JWT_SECRET = 'dev-jwt-secret-change-in-production';
export const DEV_JWT_REFRESH_SECRET = 'dev-refresh-secret-change-in-production';
const parseCsv = (value: string | undefined): string[] =>
(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
export default registerAs('app', () => ({ export default registerAs('app', () => ({
port: parseInt(process.env.PORT || '3000', 10), port: parseInt(process.env.PORT || '3000', 10),
database: { database: {
@ -29,12 +38,17 @@ export default registerAs('app', () => ({
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10), candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10), securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
screenerTtl: parseInt(process.env.CACHE_SCREENER_TTL || '900', 10),
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10), tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10), tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10), tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10), tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10),
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10), tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
},
cors: {
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
}, },
auth: { auth: {
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',

View File

@ -0,0 +1,63 @@
import 'reflect-metadata'
import { Test, type TestingModule } from '@nestjs/testing'
import type { INestApplication } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { HealthModule } from './modules/health/health.module'
import { PrismaModule } from './modules/prisma/prisma.module'
import { TransformInterceptor } from './common/interceptors/transform.interceptor'
import configuration from './config/configuration'
describe('API envelope contract', () => {
let app: INestApplication
let baseUrl: string
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }), PrismaModule, HealthModule],
}).compile()
app = module.createNestApplication()
app.setGlobalPrefix('api/v1')
app.useGlobalInterceptors(new TransformInterceptor())
await app.init()
await app.listen(0)
const address = app.getHttpServer().address()
if (typeof address === 'object' && address && 'port' in address) {
baseUrl = `http://127.0.0.1:${address.port}`
}
})
afterAll(async () => {
await app.close()
})
it('returns a proper envelope with checks from the public health endpoint', async () => {
const response = await fetch(`${baseUrl}/api/v1/health`)
expect(response.status).toBe(200)
const body = (await response.json()) as {
data: { status: string; timestamp: string; uptime: number; checks: Array<{ name: string; status: string }> }
meta: { fromCache: boolean; cachedAt: string | null }
}
expect(body).toMatchObject({
data: {
status: expect.any(String),
timestamp: expect.any(String),
uptime: expect.any(Number),
checks: expect.arrayContaining([
expect.objectContaining({ name: 'prisma', status: expect.any(String) }),
expect.objectContaining({ name: 'moex', status: expect.any(String) }),
expect.objectContaining({ name: 'tbank', status: expect.any(String) }),
]),
},
meta: {
fromCache: false,
cachedAt: null,
},
})
expect(body.data).not.toHaveProperty('data')
expect(body.data).not.toHaveProperty('meta')
})
})

View File

@ -4,9 +4,37 @@ import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { DEV_JWT_SECRET, DEV_JWT_REFRESH_SECRET } from './config/configuration';
export type BackendRuntimeConfig = {
nodeEnv: string;
jwtSecret: string;
jwtRefreshSecret: string;
corsOrigins: string[];
};
export function assertSafeProductionConfig(config: BackendRuntimeConfig): void {
if (config.nodeEnv !== 'production') return;
if (!config.jwtSecret || config.jwtSecret === DEV_JWT_SECRET) {
throw new Error('JWT_SECRET must be set to a non-default value in production');
}
if (!config.jwtRefreshSecret || config.jwtRefreshSecret === DEV_JWT_REFRESH_SECRET) {
throw new Error('JWT_REFRESH_SECRET must be set to a non-default value in production');
}
if (config.corsOrigins.length === 0) {
throw new Error('BACKEND_CORS_ORIGINS must contain at least one origin in production');
}
}
export function buildCorsOrigin(nodeEnv: string, corsOrigins: string[]): boolean | string[] {
return nodeEnv === 'production' ? corsOrigins : true;
}
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@ -18,10 +46,20 @@ async function bootstrap() {
app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalInterceptors(new TransformInterceptor());
app.use(cookieParser()); app.use(cookieParser());
const reqLogMiddleware = new RequestLoggingMiddleware(); const configService = app.get(ConfigService);
app.use(reqLogMiddleware.use.bind(reqLogMiddleware)); const runtimeConfig: BackendRuntimeConfig = {
nodeEnv: process.env.NODE_ENV || 'development',
jwtSecret: configService.get<string>('app.auth.jwtSecret', ''),
jwtRefreshSecret: configService.get<string>('app.auth.jwtRefreshSecret', ''),
corsOrigins: configService.get<string[]>('app.cors.origins', []),
};
app.enableCors({ origin: true, credentials: true }); assertSafeProductionConfig(runtimeConfig);
app.enableCors({
origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins),
credentials: true,
});
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('MoexVibe API') .setTitle('MoexVibe API')
@ -36,4 +74,7 @@ async function bootstrap() {
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`); console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`); console.log(`Swagger docs: http://localhost:${port}/api/docs`);
} }
bootstrap();
if (process.env.NODE_ENV !== 'test') {
void bootstrap();
}

View File

@ -41,13 +41,7 @@ export class AuthController {
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.register(dto); const result = await this.authService.register(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { return { user: result.user, accessToken: result.accessToken };
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
} }
@Public() @Public()
@ -57,13 +51,7 @@ export class AuthController {
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) { async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.login(dto); const result = await this.authService.login(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { return { user: result.user, accessToken: result.accessToken };
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
} }
@Public() @Public()
@ -75,13 +63,7 @@ export class AuthController {
const token = req.cookies?.[REFRESH_COOKIE]; const token = req.cookies?.[REFRESH_COOKIE];
const result = await this.authService.refresh(token); const result = await this.authService.refresh(token);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { return { user: result.user, accessToken: result.accessToken };
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
} }
@Post('logout') @Post('logout')
@ -92,10 +74,7 @@ export class AuthController {
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) { async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
await this.authService.logout(user.sub); await this.authService.logout(user.sub);
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }); res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
return { return { message: 'Logged out successfully' };
data: { message: 'Logged out successfully' },
meta: { fromCache: false, cachedAt: null },
};
} }
@Get('me') @Get('me')
@ -103,11 +82,7 @@ export class AuthController {
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto }) @ApiOkResponse({ type: AuthProfileResponseDto })
async getProfile(@CurrentUser() user: JwtPayload) { async getProfile(@CurrentUser() user: JwtPayload) {
const profile = await this.authService.getProfile(user.sub); return this.authService.getProfile(user.sub);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
} }
@Patch('me') @Patch('me')
@ -115,10 +90,6 @@ export class AuthController {
@ApiOperation({ summary: 'Update current user profile' }) @ApiOperation({ summary: 'Update current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto }) @ApiOkResponse({ type: AuthProfileResponseDto })
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) { async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
const profile = await this.authService.updateProfile(user.sub, dto); return this.authService.updateProfile(user.sub, dto);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
} }
} }

View File

@ -1,12 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
class AuthResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
class AuthUserDto { class AuthUserDto {
@ApiProperty() @ApiProperty()
@ -39,22 +32,22 @@ export class AuthTokenResponseDto {
@ApiProperty({ type: AuthTokenDataDto }) @ApiProperty({ type: AuthTokenDataDto })
data!: AuthTokenDataDto; data!: AuthTokenDataDto;
@ApiProperty({ type: AuthResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: AuthResponseMetaDto; meta!: ApiResponseMeta;
} }
export class AuthProfileResponseDto { export class AuthProfileResponseDto {
@ApiProperty({ type: AuthUserDto }) @ApiProperty({ type: AuthUserDto })
data!: AuthUserDto; data!: AuthUserDto;
@ApiProperty({ type: AuthResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: AuthResponseMetaDto; meta!: ApiResponseMeta;
} }
export class AuthLogoutResponseDto { export class AuthLogoutResponseDto {
@ApiProperty({ type: LogoutDataDto }) @ApiProperty({ type: LogoutDataDto })
data!: LogoutDataDto; data!: LogoutDataDto;
@ApiProperty({ type: AuthResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: AuthResponseMetaDto; meta!: ApiResponseMeta;
} }

View File

@ -1,26 +1,32 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
import { BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto } from './dto/bonds-envelope.dto';
@ApiTags('Bonds') @ApiTags('Bonds')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities/bonds') @Controller('securities/bonds')
export class BondsController { export class BondsController {
constructor(private readonly bondsService: BondsService) {} constructor(private readonly bondsService: BondsService) {}
@Get(':secid') @Get(':secid')
@ApiOperation({ summary: 'Получить спецификацию облигации' }) @ApiOperation({ summary: 'Получить спецификацию облигации' })
@ApiOkResponse({ type: BondEnvelopeDto })
async getBond(@Param('secid') secid: string) { async getBond(@Param('secid') secid: string) {
return this.bondsService.getBond(secid); return this.bondsService.getBond(secid);
} }
@Get(':secid/marketdata') @Get(':secid/marketdata')
@ApiOperation({ summary: 'Получить рыночные данные облигации' }) @ApiOperation({ summary: 'Получить рыночные данные облигации' })
@ApiOkResponse({ type: BondMarketDataEnvelopeDto })
async getMarketData(@Param('secid') secid: string) { async getMarketData(@Param('secid') secid: string) {
return this.bondsService.getMarketData(secid); return this.bondsService.getMarketData(secid);
} }
@Get(':secid/history') @Get(':secid/history')
@ApiOperation({ summary: 'Получить дневную историю торгов облигации' }) @ApiOperation({ summary: 'Получить дневную историю торгов облигации' })
@ApiOkResponse({ type: BondHistoryEnvelopeDto })
async getHistory( async getHistory(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query('from') from: string, @Query('from') from: string,

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module';
import { BondsController } from './bonds.controller'; import { BondsController } from './bonds.controller';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
@Module({ @Module({
imports: [MoexClientModule],
controllers: [BondsController], controllers: [BondsController],
providers: [BondsService], providers: [BondsService],
exports: [BondsService], exports: [BondsService],

View File

@ -1,16 +1,17 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
describe('BondsService', () => { describe('BondsService', () => {
let service: BondsService; let service: BondsService;
let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>; let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexClient = { moexMarketData = {
getBondData: vi.fn(), getBondData: vi.fn(),
getBondMarketData: vi.fn(), getBondMarketData: vi.fn(),
}; };
@ -25,7 +26,8 @@ describe('BondsService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
BondsService, BondsService,
{ provide: MoexClientService, useValue: moexClient }, { provide: MoexMarketDataClient, useValue: moexMarketData },
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -34,7 +36,7 @@ describe('BondsService', () => {
}); });
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => { it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
vi.mocked(moexClient.getBondData).mockResolvedValue({ vi.mocked(moexMarketData.getBondData).mockResolvedValue({
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
boardid: 'TQCB', boardid: 'TQCB',
shortName: 'ОФЗ 26238', shortName: 'ОФЗ 26238',
@ -57,7 +59,7 @@ describe('BondsService', () => {
bondSubType: 'fixed', bondSubType: 'fixed',
listLevel: 1, listLevel: 1,
}); });
vi.mocked(moexClient.getBondMarketData).mockResolvedValue({ vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
bid: 72.9, bid: 72.9,
offer: 73.1, offer: 73.1,
@ -92,8 +94,8 @@ describe('BondsService', () => {
expect.any(Function), expect.any(Function),
'marketDataTtl', 'marketDataTtl',
); );
expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5'); expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5'); expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
expect(result).toMatchObject({ expect(result).toMatchObject({
data: { data: {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
@ -129,19 +131,17 @@ describe('BondsService', () => {
volume: 10000, volume: 10000,
}, },
}, },
meta: {
fromCache: false, fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z', cachedAt: '2026-06-15T00:00:00.000Z',
},
}); });
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
}); });
it('throws NotFoundException when bond data is missing', async () => { it('throws EntityNotFoundException when bond data is missing', async () => {
vi.mocked(moexClient.getBondData).mockResolvedValue(null); vi.mocked(moexMarketData.getBondData).mockResolvedValue(null);
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException); await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException);
expect(cache.getOrFetch).toHaveBeenCalledTimes(1); expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
expect(moexClient.getBondMarketData).not.toHaveBeenCalled(); expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled();
}); });
}); });

View File

@ -1,11 +1,15 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
@Injectable() @Injectable()
export class BondsService { export class BondsService {
constructor( constructor(
private readonly moexClient: MoexClientService, private readonly moexMarketData: MoexMarketDataClient,
private readonly moexHistory: MoexHistoryClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
@ -17,23 +21,23 @@ export class BondsService {
} = await this.cache.getOrFetch( } = await this.cache.getOrFetch(
'bond', 'bond',
[secid], [secid],
() => this.moexClient.getBondData(secid), () => this.moexMarketData.getBondData(secid),
'securityTtl', 'securityTtl',
); );
if (!bond) { if (!bond) {
throw new NotFoundException(`Bond ${secid} not found`); throw new EntityNotFoundException('Bond', secid);
} }
const { data: mkt } = await this.cache.getOrFetch( const { data: mkt } = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['bonds', secid], ['bonds', secid],
() => this.moexClient.getBondMarketData(secid), () => this.moexMarketData.getBondMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
return { return new ApiEnvelopePayload(
data: { {
secid: bond.secid, secid: bond.secid,
isin: bond.isin, isin: bond.isin,
name: bond.shortName, name: bond.shortName,
@ -70,8 +74,9 @@ export class BondsService {
: new Date().toISOString(), : new Date().toISOString(),
}, },
}, },
meta: { fromCache, cachedAt }, fromCache,
}; cachedAt,
);
} }
async getMarketData(secid: string) { async getMarketData(secid: string) {
@ -82,16 +87,16 @@ export class BondsService {
} = await this.cache.getOrFetch( } = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['bonds', secid], ['bonds', secid],
() => this.moexClient.getBondMarketData(secid), () => this.moexMarketData.getBondMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
if (!mkt) { if (!mkt) {
throw new NotFoundException(`Market data for bond ${secid} not found`); throw new EntityNotFoundException('MarketData', `bond ${secid}`);
} }
return { return new ApiEnvelopePayload(
data: { {
price: mkt.last ?? 0, price: mkt.last ?? 0,
yieldToMaturity: mkt.yield ?? null, yieldToMaturity: mkt.yield ?? null,
duration: mkt.duration ?? null, duration: mkt.duration ?? null,
@ -107,26 +112,28 @@ export class BondsService {
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
: new Date().toISOString(), : new Date().toISOString(),
}, },
meta: { fromCache, cachedAt }, fromCache,
}; cachedAt,
);
} }
async getHistory(secid: string, from: string, till: string) { async getHistory(secid: string, from: string, till: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'history', 'history',
['bonds', secid, from, till], ['bonds', secid, from, till],
() => this.moexClient.getBondHistory(secid, from, till), () => this.moexHistory.getBondHistory(secid, from, till),
'historyTtl', 'historyTtl',
); );
return { return new ApiEnvelopePayload(
data: data.map((h) => ({ data.map((h) => ({
date: h.tradeDate, date: h.tradeDate,
closePrice: h.legalClosePrice ?? h.close ?? 0, closePrice: h.legalClosePrice ?? h.close ?? 0,
yieldClose: h.yieldClose ?? null, yieldClose: h.yieldClose ?? null,
duration: h.duration ?? null, duration: h.duration ?? null,
})), })),
meta: { fromCache, cachedAt }, fromCache,
}; cachedAt,
);
} }
} }

View File

@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { BondMarketDataDto, BondResponseDto } from './bond-response.dto';
import { BondHistoryItemDto } from './history-item.dto';
export class BondEnvelopeDto {
@ApiProperty({ type: BondResponseDto })
data!: BondResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BondMarketDataEnvelopeDto {
@ApiProperty({ type: BondMarketDataDto })
data!: BondMarketDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BondHistoryEnvelopeDto {
@ApiProperty({ type: [BondHistoryItemDto] })
data!: BondHistoryItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BondHistoryItemDto {
@ApiProperty({ example: '2026-06-01' })
date!: string;
@ApiProperty({ example: 100.45 })
closePrice!: number;
@ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 })
yieldClose!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 })
duration!: number | null;
}

View File

@ -0,0 +1,65 @@
import { ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';
describe('CacheService', () => {
const configService = {
get: vi.fn((_key: string, fallback?: unknown) => fallback),
} as unknown as ConfigService;
const createCache = () => ({
get: vi.fn(),
set: vi.fn(),
});
it('stores data with cachedAt metadata on cache miss', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-25T10:00:00.000Z'));
const cache = createCache();
cache.get.mockResolvedValue(undefined);
const service = new CacheService(cache as never, configService);
try {
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 1 }), 'ttlKey');
expect(result).toEqual({
data: { value: 1 },
fromCache: false,
cachedAt: '2026-06-25T10:00:00.000Z',
});
expect(cache.set).toHaveBeenCalledWith(
'prefix:a',
{ data: { value: 1 }, cachedAt: '2026-06-25T10:00:00.000Z' },
900,
);
} finally {
vi.useRealTimers();
}
});
it('returns cachedAt metadata on cache hit', async () => {
const cache = createCache();
cache.get.mockResolvedValue({
data: { value: 1 },
cachedAt: '2026-06-25T10:00:00.000Z',
});
const service = new CacheService(cache as never, configService);
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
expect(result).toEqual({
data: { value: 1 },
fromCache: true,
cachedAt: '2026-06-25T10:00:00.000Z',
});
});
it('supports legacy raw cache values during rollout', async () => {
const cache = createCache();
cache.get.mockResolvedValue({ value: 1 });
const service = new CacheService(cache as never, configService);
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
expect(result).toEqual({ data: { value: 1 }, fromCache: true, cachedAt: null });
});
});

View File

@ -3,6 +3,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager'; import { Cache } from 'cache-manager';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
type CacheEntry<T> = {
data: T;
cachedAt: string;
};
@Injectable() @Injectable()
export class CacheService { export class CacheService {
constructor( constructor(
@ -18,6 +23,16 @@ export class CacheService {
await this.cacheManager.set(key, value, ttl); await this.cacheManager.set(key, value, ttl);
} }
private isCacheEntry<T>(value: unknown): value is CacheEntry<T> {
return (
typeof value === 'object' &&
value !== null &&
'data' in value &&
'cachedAt' in value &&
typeof (value as { cachedAt?: unknown }).cachedAt === 'string'
);
}
private buildKey(...parts: string[]): string { private buildKey(...parts: string[]): string {
return parts.join(':'); return parts.join(':');
} }
@ -31,14 +46,19 @@ export class CacheService {
const key = this.buildKey(keyPrefix, ...keyParts); const key = this.buildKey(keyPrefix, ...keyParts);
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900); const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
const cached = await this.get<T>(key); const cached = await this.get<CacheEntry<T> | T>(key);
if (cached !== undefined) { if (cached !== undefined) {
return { data: cached, fromCache: true, cachedAt: null }; if (this.isCacheEntry<T>(cached)) {
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
}
return { data: cached as T, fromCache: true, cachedAt: null };
} }
const data = await fetchFn(); const data = await fetchFn();
await this.set(key, data, ttl); const cachedAt = new Date().toISOString();
await this.set(key, { data, cachedAt }, ttl);
return { data, fromCache: false, cachedAt: new Date().toISOString() }; return { data, fromCache: false, cachedAt };
} }
} }

View File

@ -1,15 +1,19 @@
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common'; import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { CandlesService } from './candles.service'; import { CandlesService } from './candles.service';
import { CandlesQueryDto } from './dto/candles-query.dto'; import { CandlesQueryDto } from './dto/candles-query.dto';
import { CandleEnvelopeDto } from './dto/candles-envelope.dto';
@ApiTags('Candles') @ApiTags('Candles')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities') @Controller('securities')
export class CandlesController { export class CandlesController {
constructor(private readonly candlesService: CandlesService) {} constructor(private readonly candlesService: CandlesService) {}
@Get('shares/:secid/candles') @Get('shares/:secid/candles')
@ApiOperation({ summary: 'Получить свечи акции' }) @ApiOperation({ summary: 'Получить свечи акции' })
@ApiOkResponse({ type: CandleEnvelopeDto })
async getShareCandles( async getShareCandles(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto, @Query(ValidationPipe) query: CandlesQueryDto,
@ -19,6 +23,7 @@ export class CandlesController {
@Get('bonds/:secid/candles') @Get('bonds/:secid/candles')
@ApiOperation({ summary: 'Получить свечи облигации' }) @ApiOperation({ summary: 'Получить свечи облигации' })
@ApiOkResponse({ type: CandleEnvelopeDto })
async getBondCandles( async getBondCandles(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto, @Query(ValidationPipe) query: CandlesQueryDto,

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module';
import { CandlesController } from './candles.controller'; import { CandlesController } from './candles.controller';
import { CandlesService } from './candles.service'; import { CandlesService } from './candles.service';
@Module({ @Module({
imports: [MoexClientModule],
controllers: [CandlesController], controllers: [CandlesController],
providers: [CandlesService], providers: [CandlesService],
exports: [CandlesService], exports: [CandlesService],

View File

@ -1,16 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { CandlesService } from './candles.service'; import { CandlesService } from './candles.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexCandlesClient } from '../moex-client/moex-candles.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { CandleInterval } from './dto/candles-query.dto'; import { CandleInterval } from './dto/candles-query.dto';
describe('CandlesService', () => { describe('CandlesService', () => {
let service: CandlesService; let service: CandlesService;
let moexClient: Pick<MoexClientService, 'getCandles'>; let moexCandles: Pick<MoexCandlesClient, 'getCandles'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexClient = { moexCandles = {
getCandles: vi.fn(), getCandles: vi.fn(),
}; };
cache = { cache = {
@ -24,7 +24,7 @@ describe('CandlesService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
CandlesService, CandlesService,
{ provide: MoexClientService, useValue: moexClient }, { provide: MoexCandlesClient, useValue: moexCandles },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -33,7 +33,7 @@ describe('CandlesService', () => {
}); });
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => { it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
vi.mocked(moexClient.getCandles).mockResolvedValue([ vi.mocked(moexCandles.getCandles).mockResolvedValue([
{ {
open: 320, open: 320,
high: 325, high: 325,
@ -60,7 +60,7 @@ describe('CandlesService', () => {
expect.any(Function), expect.any(Function),
'candlesTtl', 'candlesTtl',
); );
expect(moexClient.getCandles).toHaveBeenCalledWith( expect(moexCandles.getCandles).toHaveBeenCalledWith(
'stock', 'stock',
'shares', 'shares',
'SBER', 'SBER',
@ -81,15 +81,13 @@ describe('CandlesService', () => {
end: '2026-05-01 23:59:59', end: '2026-05-01 23:59:59',
}, },
], ],
meta: {
fromCache: false, fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z', cachedAt: '2026-06-15T00:00:00.000Z',
},
}); });
}); });
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => { it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
vi.mocked(moexClient.getCandles).mockResolvedValue([]); vi.mocked(moexCandles.getCandles).mockResolvedValue([]);
await service.getCandles( await service.getCandles(
'bonds', 'bonds',
@ -105,7 +103,7 @@ describe('CandlesService', () => {
expect.any(Function), expect.any(Function),
'candlesTtl', 'candlesTtl',
); );
expect(moexClient.getCandles).toHaveBeenCalledWith( expect(moexCandles.getCandles).toHaveBeenCalledWith(
'stock', 'stock',
'bonds', 'bonds',
'SU26238RMFS5', 'SU26238RMFS5',

View File

@ -1,12 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexCandlesClient } from '../moex-client/moex-candles.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { CandleInterval } from './dto/candles-query.dto'; import { CandleInterval } from './dto/candles-query.dto';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
@Injectable() @Injectable()
export class CandlesService { export class CandlesService {
constructor( constructor(
private readonly moexClient: MoexClientService, private readonly moexCandles: MoexCandlesClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
@ -25,12 +26,12 @@ export class CandlesService {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'candles', 'candles',
[market, secid, String(moexInterval), from, till], [market, secid, String(moexInterval), from, till],
() => this.moexClient.getCandles('stock', market, secid, moexInterval, from, till), () => this.moexCandles.getCandles('stock', market, secid, moexInterval, from, till),
'candlesTtl', 'candlesTtl',
); );
return { return new ApiEnvelopePayload(
data: data.map((c) => ({ data.map((c) => ({
open: c.open, open: c.open,
high: c.high, high: c.high,
low: c.low, low: c.low,
@ -40,7 +41,8 @@ export class CandlesService {
begin: c.begin, begin: c.begin,
end: c.end, end: c.end,
})), })),
meta: { fromCache, cachedAt }, fromCache,
}; cachedAt,
);
} }
} }

View File

@ -0,0 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
export class CandleItemDto {
@ApiProperty({ example: 321.3 })
open!: number;
@ApiProperty({ example: 322.66 })
high!: number;
@ApiProperty({ example: 321.2 })
low!: number;
@ApiProperty({ example: 322.35 })
close!: number;
@ApiProperty({ example: 1925163 })
volume!: number;
@ApiProperty({ example: 620184479 })
value!: number;
@ApiProperty({ example: '2026-06-01T10:00:00' })
begin!: string;
@ApiProperty({ example: '2026-06-01T10:59:00' })
end!: string;
}

View File

@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { CandleItemDto } from './candle-item.dto';
export class CandleEnvelopeDto {
@ApiProperty({ type: [CandleItemDto] })
data!: CandleItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { HealthResponseDto } from './health-response.dto';
export class HealthEnvelopeDto {
@ApiProperty({ type: HealthResponseDto })
data!: HealthResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
class HealthCheckResultDto {
@ApiProperty({ example: 'prisma' })
name!: string;
@ApiProperty({ enum: ['ok', 'error'] })
status!: 'ok' | 'error';
@ApiPropertyOptional({ type: String, nullable: true })
error?: string;
}
export class HealthResponseDto {
@ApiProperty({ example: 'ok' })
status!: string;
@ApiProperty({ example: '2026-06-23T06:00:00.000Z' })
timestamp!: string;
@ApiProperty({ example: 12345 })
uptime!: number;
@ApiProperty({ type: [HealthCheckResultDto] })
checks!: HealthCheckResultDto[];
}

View File

@ -1,18 +1,21 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { HealthEnvelopeDto } from './dto/health-envelope.dto';
import { HealthService } from './health.service';
@ApiTags('Health') @ApiTags('Health')
@ApiExtraModels(ApiResponseMeta)
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get() @Get()
@Public() @Public()
@ApiOperation({ summary: 'Проверка состояния сервиса' }) @ApiOperation({ summary: 'Проверка состояния сервиса' })
check() { @ApiOkResponse({ type: HealthEnvelopeDto })
return { async check() {
status: 'ok', return this.healthService.check();
timestamp: new Date().toISOString(),
uptime: process.uptime(),
};
} }
} }

View File

@ -1,7 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({ @Module({
imports: [PrismaModule],
controllers: [HealthController], controllers: [HealthController],
providers: [HealthService],
}) })
export class HealthModule {} export class HealthModule {}

View File

@ -0,0 +1,68 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { HealthService } from './health.service';
import { PrismaService } from '../prisma/prisma.service';
describe('HealthService', () => {
let service: HealthService;
const prisma = { $queryRaw: vi.fn() } as any;
const config = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.moex.baseUrl': 'https://iss.moex.test/iss',
'app.tbank.token': 'token-1',
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
const fetchMock = vi.fn();
beforeEach(async () => {
vi.clearAllMocks();
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockResolvedValue({ ok: true, status: 200 });
const module: TestingModule = await Test.createTestingModule({
providers: [
HealthService,
{ provide: PrismaService, useValue: prisma },
{ provide: ConfigService, useValue: config },
],
}).compile();
service = module.get<HealthService>(HealthService);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns ok when all dependencies are healthy', async () => {
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
const result = await service.check();
expect(result.status).toBe('ok');
expect(result.checks).toHaveLength(3);
expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('ok');
});
it('returns degraded when prisma is down', async () => {
prisma.$queryRaw.mockRejectedValue(new Error('connection refused'));
const result = await service.check();
expect(result.status).toBe('degraded');
expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('error');
});
it('includes timestamp and uptime', async () => {
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
const result = await service.check();
expect(result.timestamp).toEqual(expect.any(String));
expect(result.uptime).toEqual(expect.any(Number));
});
});

View File

@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
export interface HealthCheckResult {
name: string;
status: 'ok' | 'error';
error?: string;
}
@Injectable()
export class HealthService {
private readonly logger = new Logger(HealthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
async check(): Promise<{ status: string; timestamp: string; uptime: number; checks: HealthCheckResult[] }> {
const checks = await Promise.all([
this.checkPrisma(),
this.checkMoex(),
this.checkTBank(),
]);
const allOk = checks.every((c) => c.status === 'ok');
return {
status: allOk ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
checks,
};
}
private async checkPrisma(): Promise<HealthCheckResult> {
try {
await this.prisma.$queryRaw`SELECT 1`;
return { name: 'prisma', status: 'ok' };
} catch {
return { name: 'prisma', status: 'error', error: 'Database unreachable' };
}
}
private async checkMoex(): Promise<HealthCheckResult> {
try {
const baseUrl = this.config.get<string>('app.moex.baseUrl', 'https://iss.moex.com/iss');
const res = await fetch(`${baseUrl}/engines/stock/quotes.json?iss.meta=off&limit=1`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return { name: 'moex', status: 'error', error: `HTTP ${res.status}` };
}
return { name: 'moex', status: 'ok' };
} catch (err) {
return { name: 'moex', status: 'error', error: 'MOEX API unreachable' };
}
}
private async checkTBank(): Promise<HealthCheckResult> {
try {
const token = this.config.get<string>('app.tbank.token', '');
if (!token) {
return { name: 'tbank', status: 'error', error: 'Not configured' };
}
return { name: 'tbank', status: 'ok' };
} catch {
return { name: 'tbank', status: 'error', error: 'T-Bank API unreachable' };
}
}
}

View File

@ -0,0 +1,31 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexCandlesClient } from './moex-candles.client';
describe('MoexCandlesClient', () => {
let client: MoexCandlesClient;
let request: ReturnType<typeof vi.fn>;
let extractTable: ReturnType<typeof vi.fn>;
beforeEach(() => {
request = vi.fn();
extractTable = vi.fn();
client = new MoexCandlesClient({ request, extractTable } as unknown as MoexHttpClient);
});
it('возвращает свечи для заданного инструмента', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValue([
{ open: '320', close: '322', high: '323', low: '319', value: '100000', volume: '3000', begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' },
]);
const result = await client.getCandles('stock', 'shares', 'SBER', 60, '2025-01-10', '2025-01-11');
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER/candles', {
interval: '60', from: '2025-01-10', till: '2025-01-11',
});
expect(result).toEqual([
{ open: 320, close: 322, high: 323, low: 319, value: 100000, volume: 3000, begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' },
]);
});
});

View File

@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import { MoexCandle } from './moex-client.types';
@Injectable()
export class MoexCandlesClient {
constructor(private readonly http: MoexHttpClient) {}
async getCandles(
engine: 'stock',
market: 'shares' | 'bonds',
secid: string,
interval: 1 | 10 | 60 | 24,
from: string,
till: string,
): Promise<MoexCandle[]> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
{
interval: String(interval),
from,
till,
},
);
return this.http.extractTable(data, 'candles').map((c) => ({
open: parseFloat(c.open as string),
close: parseFloat(c.close as string),
high: parseFloat(c.high as string),
low: parseFloat(c.low as string),
value: parseFloat(c.value as string),
volume: parseInt(c.volume as string, 10),
begin: c.begin as string,
end: c.end as string,
}));
}
}

View File

@ -1,9 +1,26 @@
import { Global, Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientService } from './moex-client.service'; import { MoexHttpClient } from './moex-http.client';
import { MoexSecuritiesClient } from './moex-securities.client';
import { MoexMarketDataClient } from './moex-market-data.client';
import { MoexCandlesClient } from './moex-candles.client';
import { MoexHistoryClient } from './moex-history.client';
import { MoexDividendsClient } from './moex-dividends.client';
@Global()
@Module({ @Module({
providers: [MoexClientService], providers: [
exports: [MoexClientService], MoexHttpClient,
MoexSecuritiesClient,
MoexMarketDataClient,
MoexCandlesClient,
MoexHistoryClient,
MoexDividendsClient,
],
exports: [
MoexSecuritiesClient,
MoexMarketDataClient,
MoexCandlesClient,
MoexHistoryClient,
MoexDividendsClient,
],
}) })
export class MoexClientModule {} export class MoexClientModule {}

View File

@ -1,32 +1,35 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { MoexClientService } from './moex-client.service'; import { MoexClientModule } from './moex-client.module';
import { MoexSecuritiesClient } from './moex-securities.client';
import { MoexMarketDataClient } from './moex-market-data.client';
import configuration from '../../config/configuration'; import configuration from '../../config/configuration';
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')( describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
'MoexClientService live MOEX integration', 'MoexClient live MOEX integration',
() => { () => {
let service: MoexClientService; let moexSecurities: MoexSecuritiesClient;
let moexMarketData: MoexMarketDataClient;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })], imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule],
providers: [MoexClientService],
}).compile(); }).compile();
service = module.get<MoexClientService>(MoexClientService); moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient);
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
}); });
it('возвращает результаты поиска для SBER из live MOEX', async () => { it('возвращает результаты поиска для SBER из live MOEX', async () => {
const results = await service.searchSecurities('SBER'); const results = await moexSecurities.searchSecurities('SBER');
expect(results.length).toBeGreaterThan(0); expect(results.length).toBeGreaterThan(0);
expect(results[0].secid).toBeDefined(); expect(results[0].secid).toBeDefined();
}, 15000); }, 15000);
it('возвращает рыночные данные SBER из live MOEX', async () => { it('возвращает рыночные данные SBER из live MOEX', async () => {
const data = await service.getShareMarketData('SBER'); const data = await moexMarketData.getShareMarketData('SBER');
expect(data).toBeDefined(); expect(data).toBeDefined();
expect(data!.secid).toBe('SBER'); expect(data!.secid).toBe('SBER');

View File

@ -1,186 +0,0 @@
import 'reflect-metadata';
import axios from 'axios';
import { ConfigService } from '@nestjs/config';
import { MoexClientService } from './moex-client.service';
vi.mock('axios', () => ({
default: {
create: vi.fn(),
},
}));
describe('MoexClientService', () => {
let service: MoexClientService;
let getMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
getMock = vi.fn();
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
service = new MoexClientService({
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.moex.baseUrl': 'https://iss.moex.test/iss',
'app.moex.circuitBreakerThreshold': 5,
'app.moex.circuitBreakerResetSeconds': 30,
'app.moex.rateLimit': 10,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService);
});
it('создаётся с настроенным MOEX client', () => {
expect(service).toBeDefined();
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://iss.moex.test/iss',
timeout: 10000,
paramsSerializer: { indexes: null },
});
});
it('нормализует результаты поиска из ISS table format', async () => {
getMock.mockResolvedValueOnce({
data: {
securities: {
columns: [
'secid',
'isin',
'name',
'shortName',
'latName',
'listLevel',
'issuesize',
'facevalue',
'faceunit',
'issuedate',
'typename',
'group',
'type',
'isqualifiedinvestors',
'morningsession',
'eveningsession',
],
data: [
[
'SBER',
'RU0009029540',
'Сбербанк России ПАО ао',
'Сбербанк',
'Sberbank',
'1',
'21586948000',
'3',
'SUR',
'2007-07-20',
'Акция обыкновенная',
'stock_shares',
'common_share',
'0',
'1',
'1',
],
],
},
},
});
const results = await service.searchSecurities('SBER');
expect(getMock).toHaveBeenCalledWith('/securities.json', {
params: { q: 'SBER', 'iss.meta': 'off' },
});
expect(results).toEqual([
{
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586948000,
faceValue: 3,
faceUnit: 'SUR',
issueDate: '2007-07-20',
typeName: 'Акция обыкновенная',
group: 'stock_shares',
type: 'common_share',
isQualifiedInvestors: false,
morningSession: true,
eveningSession: true,
},
]);
});
it('нормализует market data акции без live MOEX запроса', async () => {
getMock.mockResolvedValueOnce({
data: {
securities: {
columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'],
data: [['SBER', 'TQBR', 'Сбербанк', '320.10']],
},
marketdata: {
columns: [
'SECID',
'BOARDID',
'BID',
'OFFER',
'OPEN',
'LOW',
'HIGH',
'LAST',
'LASTCHANGE',
'LASTCHANGEPRCNT',
'VOLTODAY',
'VALTODAY',
'WAPRICE',
'NUMTRADES',
'ISSUECAPITALIZATION',
'TRADINGSTATUS',
'UPDATETIME',
],
data: [
[
'SBER',
'TQBR',
'321',
'322',
'320',
'319',
'323',
'322.35',
'1.15',
'0.36',
'1925163',
'620184479',
'321.9',
'12345',
'6958336818320',
'T',
'10:30:00',
],
],
},
},
});
const data = await service.getShareMarketData('SBER');
expect(getMock).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER.json', {
params: { boards: 'TQBR', 'iss.meta': 'off' },
});
expect(data).toMatchObject({
secid: 'SBER',
boardid: 'TQBR',
shortName: 'Сбербанк',
last: 322.35,
lastChange: 1.15,
lastChangePrcnt: 0.36,
volume: 1925163,
value: 620184479,
issueCapitalization: 6958336818320,
tradingStatus: 'T',
updateTime: '10:30:00',
});
});
});

View File

@ -1,423 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import PQueue from 'p-queue';
import {
MoexSecurityDescription,
MoexShareMarketData,
MoexBondData,
MoexBondMarketData,
MoexBondPositionData,
MoexDividend,
MoexCandle,
MoexHistoryEntry,
MoexBondHistoryEntry,
} from './moex-client.types';
@Injectable()
export class MoexClientService {
private readonly logger = new Logger(MoexClientService.name);
private readonly client: AxiosInstance;
private readonly queue: PQueue;
private circuitOpen = false;
private circuitErrorCount = 0;
private readonly threshold: number;
private readonly resetMs: number;
constructor(private configService: ConfigService) {
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
this.client = axios.create({
baseURL: baseUrl,
timeout: 10000,
paramsSerializer: { indexes: null },
});
this.queue = new PQueue({
interval: 1000,
intervalCap: rateLimit,
});
}
private async request<T>(path: string, params?: Record<string, string>): Promise<T> {
if (this.circuitOpen) {
throw new Error('Circuit breaker is open — MOEX requests paused');
}
return this.queue.add(async () => {
try {
const jsonPath = path + '.json';
const response = await this.client.get(jsonPath, {
params: { ...params, 'iss.meta': 'off' },
});
this.circuitErrorCount = 0;
return response.data as T;
} catch (error) {
this.circuitErrorCount++;
if (this.circuitErrorCount >= this.threshold) {
this.circuitOpen = true;
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
setTimeout(() => {
this.circuitOpen = false;
this.circuitErrorCount = 0;
this.logger.log('Circuit breaker reset');
}, this.resetMs);
}
throw error;
}
}) as Promise<T>;
}
private extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
const table = data[name] as Record<string, unknown> | undefined;
if (!table || !table.columns || !table.data) return [];
const columns = table.columns as string[];
const rows = table.data as unknown[][];
return rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
});
}
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
const data = await this.request<Record<string, unknown>>('/securities', {
q: query,
});
return this.extractTable(data, 'securities').map((s) => ({
secid: s.secid as string,
isin: s.isin as string,
name: s.name as string,
shortName: s.shortName as string,
latName: (s.latName as string) || null,
listLevel: parseInt(s.listLevel as string, 10) || 0,
issueSize: parseInt(s.issuesize as string, 10) || 0,
faceValue: parseFloat(s.facevalue as string) || 0,
faceUnit: (s.faceunit as string) || '',
issueDate: (s.issuedate as string) || '',
typeName: (s.typename as string) || '',
group: (s.group as string) || '',
type: (s.type as string) || '',
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
morningSession: (s.morningsession as string) === '1',
eveningSession: (s.eveningsession as string) === '1',
}));
}
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
const data = await this.request<Record<string, unknown>>(`/securities/${secid}`);
const rows = this.extractTable(data, 'description');
if (rows.length === 0) return null;
const map = new Map(rows.map((r) => [r.name, r.value]));
return {
secid,
isin: (map.get('ISIN') as string) || '',
name: (map.get('NAME') as string) || '',
shortName: (map.get('SHORTNAME') as string) || '',
latName: (map.get('LATNAME') as string) || null,
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
faceUnit: (map.get('FACEUNIT') as string) || '',
issueDate: (map.get('ISSUEDATE') as string) || '',
typeName: (map.get('TYPENAME') as string) || '',
group: (map.get('GROUP') as string) || '',
type: (map.get('TYPE') as string) || '',
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
morningSession: (map.get('MORNINGSESSION') as string) === '1',
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
};
}
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`,
{ boards: boardId },
);
const rows = this.extractTable(data, 'securities');
const share = rows.find((r) => r.BOARDID === boardId);
if (!share) return null;
const mktRows = this.extractTable(data, 'marketdata');
const mkt = mktRows.find((r) => r.BOARDID === boardId);
return {
secid,
boardid: boardId,
shortName: (share?.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((share.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
}
async getShareMarketDataBatch(
secids: string[],
boardId = 'TQBR',
): Promise<MoexShareMarketData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities`,
params,
);
const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredSecurities = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
return filteredSecurities.map((sec) => {
const secid = sec.SECID as string;
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: boardId,
shortName: (sec?.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((sec?.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
});
}
async getBondPositionDataBatch(
secids: string[],
boardId = 'TQCB',
): Promise<MoexBondPositionData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities`,
params,
);
const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredSecurities = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
return filteredSecurities.map((bond) => {
const secid = bond.SECID as string;
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
marketdata.find((r) => r.SECID === secid && r.LAST != null) ||
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: (bond.BOARDID as string) || boardId,
shortName: (bond?.SHORTNAME as string) || '',
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
couponPercent:
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
matDate: (bond?.MATDATE as string) || null,
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
bondType: (bond?.BONDTYPE as string) || null,
offerDate: (bond?.OFFERDATE as string) || null,
};
});
}
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ boards: boardId },
);
const rows = this.extractTable(data, 'securities');
const bond =
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
rows.find((r) => r.PREVWAPRICE != null) ||
rows[0];
if (!bond) return null;
return {
secid,
boardid: boardId,
shortName: (bond.SHORTNAME as string) || '',
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
nextCoupon: (bond.NEXTCOUPON as string) || null,
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
matDate: (bond.MATDATE as string) || '',
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
isin: (bond.ISIN as string) || '',
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
offerDate: (bond.OFFERDATE as string) || null,
buybackDate: (bond.BUYBACKDATE as string) || null,
bondType: (bond.BONDTYPE as string) || '',
bondSubType: (bond.BONDSUBTYPE as string) || '',
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
};
}
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ boards: boardId },
);
const mktRows = this.extractTable(data, 'marketdata');
const mkt =
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
mktRows.find((r) => r.LAST != null) ||
mktRows.find((r) => r.SECID === secid);
if (!mkt) return null;
return {
secid,
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
value: parseFloat((mkt.VALTODAY as string) || '0'),
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
updateTime: (mkt.UPDATETIME as string) || '',
};
}
async getDividends(secid: string): Promise<MoexDividend[]> {
const data = await this.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
return this.extractTable(data, 'dividends').map((d) => ({
secid: d.secid as string,
isin: d.isin as string,
registryCloseDate: d.registryclosedate as string,
value: parseFloat(d.value as string),
currencyId: (d.currencyid as string) || 'RUB',
}));
}
async getCandles(
engine: 'stock',
market: 'shares' | 'bonds',
secid: string,
interval: 1 | 10 | 60 | 24,
from: string,
till: string,
): Promise<MoexCandle[]> {
const data = await this.request<Record<string, unknown>>(
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
{
interval: String(interval),
from,
till,
},
);
return this.extractTable(data, 'candles').map((c) => ({
open: parseFloat(c.open as string),
close: parseFloat(c.close as string),
high: parseFloat(c.high as string),
low: parseFloat(c.low as string),
value: parseFloat(c.value as string),
volume: parseInt(c.volume as string, 10),
begin: c.begin as string,
end: c.end as string,
}));
}
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`,
{ from, till },
);
const tableName = Object.keys(data).find(
(k) => k.startsWith('history') && !k.includes('cursor'),
);
if (!tableName) return [];
return this.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string,
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
volume: parseInt((h.VOLUME as string) || '0', 10),
value: parseFloat((h.VALUE as string) || '0'),
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
}));
}
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ from, till },
);
const tableName = Object.keys(data).find(
(k) => k.startsWith('history') && !k.includes('cursor'),
);
if (!tableName) return [];
return this.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
}));
}
}

View File

@ -0,0 +1,29 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexDividendsClient } from './moex-dividends.client';
describe('MoexDividendsClient', () => {
let client: MoexDividendsClient;
let request: ReturnType<typeof vi.fn>;
let extractTable: ReturnType<typeof vi.fn>;
beforeEach(() => {
request = vi.fn();
extractTable = vi.fn();
client = new MoexDividendsClient({ request, extractTable } as unknown as MoexHttpClient);
});
it('возвращает дивиденды для бумаги', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValue([
{ secid: 'SBER', isin: 'RU0009029540', registryclosedate: '2025-07-10', value: '33.3', currencyid: 'RUB' },
]);
const result = await client.getDividends('SBER');
expect(request).toHaveBeenCalledWith('/securities/SBER/dividends');
expect(result).toEqual([
{ secid: 'SBER', isin: 'RU0009029540', registryCloseDate: '2025-07-10', value: 33.3, currencyId: 'RUB' },
]);
});
});

View File

@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import { MoexDividend } from './moex-client.types';
@Injectable()
export class MoexDividendsClient {
constructor(private readonly http: MoexHttpClient) {}
async getDividends(secid: string): Promise<MoexDividend[]> {
const data = await this.http.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
return this.http.extractTable(data, 'dividends').map((d) => ({
secid: d.secid as string,
isin: d.isin as string,
registryCloseDate: d.registryclosedate as string,
value: parseFloat(d.value as string),
currencyId: (d.currencyid as string) || 'RUB',
}));
}
}

View File

@ -0,0 +1,49 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexHistoryClient } from './moex-history.client';
describe('MoexHistoryClient', () => {
let client: MoexHistoryClient;
let request: ReturnType<typeof vi.fn>;
let extractTable: ReturnType<typeof vi.fn>;
beforeEach(() => {
request = vi.fn();
extractTable = vi.fn();
client = new MoexHistoryClient({ request, extractTable } as unknown as MoexHttpClient);
});
describe('getHistory', () => {
it('возвращает историю торгов для акции', async () => {
request.mockResolvedValue({ history: { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '322']] } });
extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '322' }]);
const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11');
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { from: '2025-01-10', till: '2025-01-11' });
expect(result).toEqual([
{ tradeDate: '2025-01-10', open: null, low: null, high: null, close: 322, waprice: null, volume: 0, value: 0, numtrades: 0 },
]);
});
it('возвращает пустой массив если history таблица не найдена', async () => {
request.mockResolvedValue({});
const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11');
expect(result).toEqual([]);
});
});
describe('getBondHistory', () => {
it('возвращает историю торгов для облигации', async () => {
request.mockResolvedValue({ 'history:': { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '98.5']] } });
extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '98.5' }]);
const result = await client.getBondHistory('SU26238RMFS4', '2025-01-10', '2025-01-11');
expect(result).toEqual([
{ tradeDate: '2025-01-10', close: 98.5, legalClosePrice: null, waprice: null, yieldClose: null, duration: null, accruedInt: null },
]);
});
});
});

View File

@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import { MoexHistoryEntry, MoexBondHistoryEntry } from './moex-client.types';
@Injectable()
export class MoexHistoryClient {
constructor(private readonly http: MoexHttpClient) {}
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`,
{ from, till },
);
const tableName = Object.keys(data).find(
(k) => k.startsWith('history') && !k.includes('cursor'),
);
if (!tableName) return [];
return this.http.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string,
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
volume: parseInt((h.VOLUME as string) || '0', 10),
value: parseFloat((h.VALUE as string) || '0'),
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
}));
}
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ from, till },
);
const tableName = Object.keys(data).find(
(k) => k.startsWith('history') && !k.includes('cursor'),
);
if (!tableName) return [];
return this.http.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
}));
}
}

View File

@ -0,0 +1,132 @@
import 'reflect-metadata';
import axios from 'axios';
import { ConfigService } from '@nestjs/config';
import { MoexHttpClient } from './moex-http.client';
vi.mock('axios', () => ({
default: {
create: vi.fn(),
},
}));
describe('MoexHttpClient', () => {
let client: MoexHttpClient;
let getMock: ReturnType<typeof vi.fn>;
const mockConfig = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.moex.baseUrl': 'https://iss.moex.test/iss',
'app.moex.circuitBreakerThreshold': 5,
'app.moex.circuitBreakerResetSeconds': 30,
'app.moex.rateLimit': 10,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
beforeEach(() => {
vi.useFakeTimers();
getMock = vi.fn();
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
client = new MoexHttpClient(mockConfig);
});
afterEach(() => {
vi.useRealTimers();
});
describe('constructor', () => {
it('создаёт axios instance с параметрами из конфига', () => {
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://iss.moex.test/iss',
timeout: 10000,
paramsSerializer: { indexes: null },
});
});
});
describe('request', () => {
it('выполняет GET запрос с .json суффиксом и iss.meta=off', async () => {
getMock.mockResolvedValueOnce({ data: { some: 'data' } });
const result = await client.request<{ some: string }>('/securities', { q: 'SBER' });
expect(getMock).toHaveBeenCalledWith('/securities.json', {
params: { q: 'SBER', 'iss.meta': 'off' },
});
expect(result).toEqual({ some: 'data' });
});
it('открывает circuit breaker после заданного числа ошибок', async () => {
getMock.mockRejectedValue(new Error('Network error'));
for (let i = 0; i < 5; i++) {
await expect(client.request('/test')).rejects.toThrow();
}
await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open');
expect(getMock).toHaveBeenCalledTimes(5);
});
it('закрывает circuit breaker после resetMs', async () => {
getMock.mockRejectedValue(new Error('Network error'));
for (let i = 0; i < 5; i++) {
await expect(client.request('/test')).rejects.toThrow();
}
await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open');
vi.advanceTimersByTime(30000);
getMock.mockResolvedValue({ data: 'ok' });
const result = await client.request('/test');
expect(result).toBe('ok');
});
it('сбрасывает errorCount при успешном запросе', async () => {
getMock
.mockRejectedValueOnce(new Error('fail'))
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce({ data: 'ok' });
await expect(client.request('/test')).rejects.toThrow('fail');
await expect(client.request('/test')).rejects.toThrow('fail');
const result = await client.request('/test');
expect(result).toBe('ok');
expect(getMock).toHaveBeenCalledTimes(3);
});
});
describe('extractTable', () => {
it('преобразует ISS columns/data формат в массив объектов', () => {
const data = {
securities: {
columns: ['secid', 'name'],
data: [
['SBER', 'Сбербанк'],
['VTBR', 'ВТБ'],
],
},
};
const result = client.extractTable(data as Record<string, unknown>, 'securities');
expect(result).toEqual([
{ secid: 'SBER', name: 'Сбербанк' },
{ secid: 'VTBR', name: 'ВТБ' },
]);
});
it('возвращает пустой массив если таблица не найдена', () => {
const result = client.extractTable({}, 'nonexistent');
expect(result).toEqual([]);
});
it('возвращает пустой массив если нет columns', () => {
const result = client.extractTable({ securities: { data: [] } } as unknown as Record<string, unknown>, 'securities');
expect(result).toEqual([]);
});
});
});

View File

@ -0,0 +1,76 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import PQueue from 'p-queue';
@Injectable()
export class MoexHttpClient {
private readonly logger = new Logger(MoexHttpClient.name);
private readonly client: AxiosInstance;
private readonly queue: PQueue;
private circuitOpen = false;
private circuitErrorCount = 0;
private readonly threshold: number;
private readonly resetMs: number;
constructor(private configService: ConfigService) {
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
this.client = axios.create({
baseURL: baseUrl,
timeout: 10000,
paramsSerializer: { indexes: null },
});
this.queue = new PQueue({
interval: 1000,
intervalCap: rateLimit,
});
}
async request<T>(path: string, params?: Record<string, string>): Promise<T> {
if (this.circuitOpen) {
throw new Error('Circuit breaker is open — MOEX requests paused');
}
return this.queue.add(async () => {
try {
const jsonPath = path + '.json';
const response = await this.client.get(jsonPath, {
params: { ...params, 'iss.meta': 'off' },
});
this.circuitErrorCount = 0;
return response.data as T;
} catch (error) {
this.circuitErrorCount++;
if (this.circuitErrorCount >= this.threshold) {
this.circuitOpen = true;
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
setTimeout(() => {
this.circuitOpen = false;
this.circuitErrorCount = 0;
this.logger.log('Circuit breaker reset');
}, this.resetMs);
}
throw error;
}
}) as Promise<T>;
}
extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
const table = data[name] as Record<string, unknown> | undefined;
if (!table || !table.columns || !table.data) return [];
const columns = table.columns as string[];
const rows = table.data as unknown[][];
return rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
});
}
}

View File

@ -0,0 +1,118 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexMarketDataClient } from './moex-market-data.client';
describe('MoexMarketDataClient', () => {
let client: MoexMarketDataClient;
let request: ReturnType<typeof vi.fn>;
let extractTable: ReturnType<typeof vi.fn>;
beforeEach(() => {
request = vi.fn();
extractTable = vi.fn();
client = new MoexMarketDataClient({ request, extractTable } as unknown as MoexHttpClient);
});
describe('getShareMarketData', () => {
it('возвращает рыночные данные акции из securities и marketdata таблиц', async () => {
request.mockResolvedValue({});
extractTable
.mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' }])
.mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', BID: '321', OFFER: '322', OPEN: '320', LOW: '319', HIGH: '323', LAST: '322.35', LASTCHANGE: '1.15', LASTCHANGEPRCNT: '0.36', VOLTODAY: '1925163', VALTODAY: '620184479', WAPRICE: '321.9', NUMTRADES: '12345', ISSUECAPITALIZATION: '6958336818320', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]);
const result = await client.getShareMarketData('SBER');
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { boards: 'TQBR' });
expect(result).toMatchObject({ secid: 'SBER', boardid: 'TQBR', shortName: 'Сбербанк', last: 322.35, bid: 321, offer: 322 });
});
it('возвращает null если бумага не найдена', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([]).mockReturnValueOnce([]);
const result = await client.getShareMarketData('INVALID');
expect(result).toBeNull();
});
});
describe('getShareMarketDataBatch', () => {
it('возвращает массив рыночных данных для нескольких бумаг', async () => {
request.mockResolvedValue({});
extractTable
.mockReturnValueOnce([
{ SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' },
{ SECID: 'VTBR', BOARDID: 'TQBR', SHORTNAME: 'ВТБ', PREVPRICE: '50' },
])
.mockReturnValueOnce([
{ SECID: 'SBER', BOARDID: 'TQBR', LAST: '322', BID: '321', OFFER: '323' },
{ SECID: 'VTBR', BOARDID: 'TQBR', LAST: '50.5', BID: '50.1', OFFER: '50.8' },
]);
const results = await client.getShareMarketDataBatch(['SBER', 'VTBR']);
expect(results).toHaveLength(2);
expect(results[0].secid).toBe('SBER');
expect(results[1].secid).toBe('VTBR');
});
});
describe('getBondData', () => {
it('возвращает данные облигации из securities таблицы', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', PREVWAPRICE: '98.5', COUPONVALUE: '34.5', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' },
]);
const result = await client.getBondData('SU26238RMFS4');
expect(request).toHaveBeenCalledWith('/engines/stock/markets/bonds/securities/SU26238RMFS4', { boards: 'TQCB' });
expect(result).toMatchObject({ secid: 'SU26238RMFS4', shortName: 'ОФЗ 26238' });
});
it('возвращает null если облигация не найдена', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([]);
const result = await client.getBondData('INVALID');
expect(result).toBeNull();
});
});
describe('getBondMarketData', () => {
it('возвращает рыночные данные облигации из marketdata таблицы', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', BID: '98', OFFER: '99', YIELD: '7.5', DURATION: '1500', VOLTODAY: '1000', VALTODAY: '98500', NUMTRADES: '50', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]);
const result = await client.getBondMarketData('SU26238RMFS4');
expect(result).toMatchObject({ secid: 'SU26238RMFS4', last: 98.5, bid: 98, offer: 99, yield: 7.5 });
});
it('возвращает null если marketdata не найдена', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([]);
const result = await client.getBondMarketData('INVALID');
expect(result).toBeNull();
});
});
describe('getBondPositionDataBatch', () => {
it('возвращает массив позиций по облигациям', async () => {
request.mockResolvedValue({});
extractTable
.mockReturnValueOnce([
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', COUPONVALUE: '34.5', COUPONPERCENT: '7', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' },
])
.mockReturnValueOnce([
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', YIELD: '7.5', DURATION: '1500', BID: '98', OFFER: '99' },
]);
const results = await client.getBondPositionDataBatch(['SU26238RMFS4']);
expect(results).toHaveLength(1);
expect(results[0].secid).toBe('SU26238RMFS4');
expect(results[0].price).toBe(98.5);
});
});
});

View File

@ -0,0 +1,219 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import {
MoexShareMarketData,
MoexBondData,
MoexBondMarketData,
MoexBondPositionData,
} from './moex-client.types';
@Injectable()
export class MoexMarketDataClient {
constructor(private readonly http: MoexHttpClient) {}
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`,
{ boards: boardId },
);
const rows = this.http.extractTable(data, 'securities');
const share = rows.find((r) => r.BOARDID === boardId);
if (!share) return null;
const mktRows = this.http.extractTable(data, 'marketdata');
const mkt = mktRows.find((r) => r.BOARDID === boardId);
return {
secid,
boardid: boardId,
shortName: (share?.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((share.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
}
async getShareMarketDataBatch(
secids: string[],
boardId = 'TQBR',
): Promise<MoexShareMarketData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities`,
params,
);
const securities = this.http.extractTable(data, 'securities');
const marketdata = this.http.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredSecurities = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
return filteredSecurities.map((sec) => {
const secid = sec.SECID as string;
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: boardId,
shortName: (sec?.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((sec?.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
});
}
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ boards: boardId },
);
const rows = this.http.extractTable(data, 'securities');
const bond =
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
rows.find((r) => r.PREVWAPRICE != null) ||
rows[0];
if (!bond) return null;
return {
secid,
boardid: boardId,
shortName: (bond.SHORTNAME as string) || '',
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
nextCoupon: (bond.NEXTCOUPON as string) || null,
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
matDate: (bond.MATDATE as string) || '',
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
isin: (bond.ISIN as string) || '',
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
offerDate: (bond.OFFERDATE as string) || null,
buybackDate: (bond.BUYBACKDATE as string) || null,
bondType: (bond.BONDTYPE as string) || '',
bondSubType: (bond.BONDSUBTYPE as string) || '',
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
};
}
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ boards: boardId },
);
const mktRows = this.http.extractTable(data, 'marketdata');
const mkt =
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
mktRows.find((r) => r.LAST != null) ||
mktRows.find((r) => r.SECID === secid);
if (!mkt) return null;
return {
secid,
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
value: parseFloat((mkt.VALTODAY as string) || '0'),
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
updateTime: (mkt.UPDATETIME as string) || '',
};
}
async getBondPositionDataBatch(
secids: string[],
boardId = 'TQCB',
): Promise<MoexBondPositionData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities`,
params,
);
const securities = this.http.extractTable(data, 'securities');
const marketdata = this.http.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredSecurities = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
return filteredSecurities.map((bond) => {
const secid = bond.SECID as string;
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
marketdata.find((r) => r.SECID === secid && r.LAST != null) ||
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: (bond.BOARDID as string) || boardId,
shortName: (bond?.SHORTNAME as string) || '',
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
couponPercent:
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
matDate: (bond?.MATDATE as string) || null,
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
bondType: (bond?.BONDTYPE as string) || null,
offerDate: (bond?.OFFERDATE as string) || null,
};
});
}
}

View File

@ -0,0 +1,67 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexSecuritiesClient } from './moex-securities.client';
describe('MoexSecuritiesClient', () => {
let client: MoexSecuritiesClient;
let httpMock: { request: ReturnType<typeof vi.fn>; extractTable: ReturnType<typeof vi.fn> };
beforeEach(() => {
httpMock = {
request: vi.fn(),
extractTable: vi.fn(),
};
client = new MoexSecuritiesClient(httpMock as unknown as MoexHttpClient);
});
describe('searchSecurities', () => {
it('выполняет поиск по запросу и нормализует результаты', async () => {
httpMock.request.mockResolvedValue({});
httpMock.extractTable.mockReturnValue([
{
secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк', latName: 'Sberbank', listLevel: '1', issuesize: '21586948000',
facevalue: '3', faceunit: 'SUR', issuedate: '2007-07-20', typename: 'Акция обыкновенная',
group: 'stock_shares', type: 'common_share', isqualifiedinvestors: '0',
morningsession: '1', eveningsession: '1',
},
]);
const results = await client.searchSecurities('SBER');
expect(httpMock.request).toHaveBeenCalledWith('/securities', { q: 'SBER' });
expect(results).toEqual([
{
secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк', latName: 'Sberbank', listLevel: 1, issueSize: 21586948000,
faceValue: 3, faceUnit: 'SUR', issueDate: '2007-07-20', typeName: 'Акция обыкновенная',
group: 'stock_shares', type: 'common_share', isQualifiedInvestors: false,
morningSession: true, eveningSession: true,
},
]);
});
});
describe('getSecurityDescription', () => {
it('возвращает описание бумаги из description таблицы', async () => {
httpMock.request.mockResolvedValue({});
httpMock.extractTable.mockReturnValue([
{ name: 'ISIN', value: 'RU0009029540' },
{ name: 'SHORTNAME', value: 'Сбербанк' },
]);
const result = await client.getSecurityDescription('SBER');
expect(httpMock.request).toHaveBeenCalledWith('/securities/SBER');
expect(result).toMatchObject({ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбербанк' });
});
it('возвращает null если description пуст', async () => {
httpMock.request.mockResolvedValue({});
httpMock.extractTable.mockReturnValue([]);
const result = await client.getSecurityDescription('INVALID');
expect(result).toBeNull();
});
});
});

View File

@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import { MoexSecurityDescription } from './moex-client.types';
@Injectable()
export class MoexSecuritiesClient {
constructor(private readonly http: MoexHttpClient) {}
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
const data = await this.http.request<Record<string, unknown>>('/securities', { q: query });
return this.http.extractTable(data, 'securities').map((s) => ({
secid: s.secid as string,
isin: s.isin as string,
name: s.name as string,
shortName: s.shortName as string,
latName: (s.latName as string) || null,
listLevel: parseInt(s.listLevel as string, 10) || 0,
issueSize: parseInt(s.issuesize as string, 10) || 0,
faceValue: parseFloat(s.facevalue as string) || 0,
faceUnit: (s.faceunit as string) || '',
issueDate: (s.issuedate as string) || '',
typeName: (s.typename as string) || '',
group: (s.group as string) || '',
type: (s.type as string) || '',
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
morningSession: (s.morningsession as string) === '1',
eveningSession: (s.eveningsession as string) === '1',
}));
}
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
const data = await this.http.request<Record<string, unknown>>(`/securities/${secid}`);
const rows = this.http.extractTable(data, 'description');
if (rows.length === 0) return null;
const map = new Map(rows.map((r) => [r.name, r.value]));
return {
secid,
isin: (map.get('ISIN') as string) || '',
name: (map.get('NAME') as string) || '',
shortName: (map.get('SHORTNAME') as string) || '',
latName: (map.get('LATNAME') as string) || null,
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
faceUnit: (map.get('FACEUNIT') as string) || '',
issueDate: (map.get('ISSUEDATE') as string) || '',
typeName: (map.get('TYPENAME') as string) || '',
group: (map.get('GROUP') as string) || '',
type: (map.get('TYPE') as string) || '',
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
morningSession: (map.get('MORNINGSESSION') as string) === '1',
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
};
}
}

View File

@ -8,6 +8,7 @@ import {
IsIn, IsIn,
MaxLength, MaxLength,
MinLength, MinLength,
IsDateString,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@ -31,7 +32,7 @@ export class AddPositionDto {
@ApiProperty({ example: 10 }) @ApiProperty({ example: 10 })
@IsInt() @IsInt()
@Min(0) @Min(1)
quantity!: number; quantity!: number;
@ApiPropertyOptional({ example: 250.5 }) @ApiPropertyOptional({ example: 250.5 })
@ -41,7 +42,7 @@ export class AddPositionDto {
buyPrice?: number; buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-01' }) @ApiPropertyOptional({ example: '2026-06-01' })
@IsString() @IsDateString()
@IsOptional() @IsOptional()
buyDate?: string; buyDate?: string;

View File

@ -11,6 +11,24 @@ export class PortfolioSummaryDto {
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null; @ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
@ApiProperty() positionCount!: number; @ApiProperty() positionCount!: number;
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null; @ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
@ApiProperty({ type: Number, nullable: true })
targetSharesPercent?: number | null;
@ApiProperty({ type: Number, nullable: true })
targetBondsPercent?: number | null;
@ApiProperty()
actualSharesPercent!: number;
@ApiProperty()
actualBondsPercent!: number;
@ApiProperty({ type: Number, nullable: true })
sharesDeviation?: number | null;
@ApiProperty({ type: Number, nullable: true })
bondsDeviation?: number | null;
} }
export class AnalyticsResponseDto { export class AnalyticsResponseDto {

View File

@ -1,53 +1,46 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { AnalyticsResponseDto } from './analytics-response.dto'; import { AnalyticsResponseDto } from './analytics-response.dto';
import { PortfolioListResponseDto } from './portfolio-list-response.dto'; import { PortfolioListResponseDto } from './portfolio-list-response.dto';
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto'; import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
import { PositionResponseDto } from './position-response.dto'; import { PositionResponseDto } from './position-response.dto';
export class PortfolioResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class PortfolioListEnvelopeDto { export class PortfolioListEnvelopeDto {
@ApiProperty({ type: [PortfolioListResponseDto] }) @ApiProperty({ type: [PortfolioListResponseDto] })
data!: PortfolioListResponseDto[]; data!: PortfolioListResponseDto[];
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }
export class PortfolioEnvelopeDto { export class PortfolioEnvelopeDto {
@ApiProperty({ type: PortfolioResponseDto }) @ApiProperty({ type: PortfolioResponseDto })
data!: PortfolioResponseDto; data!: PortfolioResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }
export class PortfolioDetailEnvelopeDto { export class PortfolioDetailEnvelopeDto {
@ApiProperty({ type: PortfolioDetailResponseDto }) @ApiProperty({ type: PortfolioDetailResponseDto })
data!: PortfolioDetailResponseDto; data!: PortfolioDetailResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }
export class PositionEnvelopeDto { export class PositionEnvelopeDto {
@ApiProperty({ type: PositionResponseDto }) @ApiProperty({ type: PositionResponseDto })
data!: PositionResponseDto; data!: PositionResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }
export class AnalyticsEnvelopeDto { export class AnalyticsEnvelopeDto {
@ApiProperty({ type: AnalyticsResponseDto }) @ApiProperty({ type: AnalyticsResponseDto })
data!: AnalyticsResponseDto; data!: AnalyticsResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }

View File

@ -9,6 +9,16 @@ export class PortfolioResponseDto {
@ApiProperty({ default: 'RUB' }) currency!: string; @ApiProperty({ default: 'RUB' }) currency!: string;
@ApiProperty() createdAt!: string; @ApiProperty() createdAt!: string;
@ApiProperty() updatedAt!: string; @ApiProperty() updatedAt!: string;
@ApiPropertyOptional({
type: 'object',
properties: {
sharesPercent: { type: 'number' },
bondsPercent: { type: 'number' },
},
nullable: true,
})
targets!: { sharesPercent: number; bondsPercent: number } | null;
} }
export class PortfolioDetailResponseDto extends PortfolioResponseDto { export class PortfolioDetailResponseDto extends PortfolioResponseDto {

View File

@ -0,0 +1,43 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { AddPositionDto } from './add-position.dto';
import { UpdatePositionDto } from './update-position.dto';
describe('position DTO validation', () => {
const validateDto = async <T extends object>(cls: new () => T, payload: Record<string, unknown>) =>
validate(plainToInstance(cls, payload));
it('rejects zero quantity when adding a position', async () => {
const errors = await validateDto(AddPositionDto, { secid: 'SBER', quantity: 0 });
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
});
it('rejects zero quantity when updating a position', async () => {
const errors = await validateDto(UpdatePositionDto, { quantity: 0 });
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
});
it('rejects invalid buyDate values', async () => {
const addErrors = await validateDto(AddPositionDto, {
secid: 'SBER',
quantity: 1,
buyDate: 'not-a-date',
});
const updateErrors = await validateDto(UpdatePositionDto, { buyDate: 'not-a-date' });
expect(addErrors.some((error) => error.property === 'buyDate')).toBe(true);
expect(updateErrors.some((error) => error.property === 'buyDate')).toBe(true);
});
it('accepts valid position payloads', async () => {
await expect(
validateDto(AddPositionDto, { secid: 'SBER', quantity: 1, buyDate: '2026-06-01' }),
).resolves.toHaveLength(0);
await expect(
validateDto(UpdatePositionDto, { quantity: 2, buyDate: '2026-06-15' }),
).resolves.toHaveLength(0);
});
});

View File

@ -1,8 +1,34 @@
import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator'; import {
import { ApiPropertyOptional } from '@nestjs/swagger'; IsString,
IsOptional,
IsIn,
IsObject,
IsNumber,
MaxLength,
MinLength,
Min,
Max,
ValidateNested,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const; const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
export class PortfolioTargetsDto {
@ApiProperty({ example: 70 })
@IsNumber()
@Min(0)
@Max(100)
sharesPercent!: number;
@ApiProperty({ example: 30 })
@IsNumber()
@Min(0)
@Max(100)
bondsPercent!: number;
}
export class UpdatePortfolioDto { export class UpdatePortfolioDto {
@ApiPropertyOptional({ example: 'Мой портфель' }) @ApiPropertyOptional({ example: 'Мой портфель' })
@IsString() @IsString()
@ -22,4 +48,11 @@ export class UpdatePortfolioDto {
@IsIn(CURRENCIES) @IsIn(CURRENCIES)
@IsOptional() @IsOptional()
currency?: string; currency?: string;
@ApiPropertyOptional({ example: { sharesPercent: 70, bondsPercent: 30 } })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => PortfolioTargetsDto)
targets?: PortfolioTargetsDto;
} }

View File

@ -7,6 +7,7 @@ import {
IsArray, IsArray,
IsIn, IsIn,
MaxLength, MaxLength,
IsDateString,
} from 'class-validator'; } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
@ -24,7 +25,7 @@ const TAGS = [
export class UpdatePositionDto { export class UpdatePositionDto {
@ApiPropertyOptional({ example: 15 }) @ApiPropertyOptional({ example: 15 })
@IsInt() @IsInt()
@Min(0) @Min(1)
@IsOptional() @IsOptional()
quantity?: number; quantity?: number;
@ -35,7 +36,7 @@ export class UpdatePositionDto {
buyPrice?: number; buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-15' }) @ApiPropertyOptional({ example: '2026-06-15' })
@IsString() @IsDateString()
@IsOptional() @IsOptional()
buyDate?: string; buyDate?: string;

View File

@ -13,32 +13,28 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto'; import { AddPositionDto } from './dto/add-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { import {
AnalyticsEnvelopeDto, AnalyticsEnvelopeDto,
PortfolioDetailEnvelopeDto, PortfolioDetailEnvelopeDto,
PortfolioEnvelopeDto, PortfolioEnvelopeDto,
PortfolioListEnvelopeDto, PortfolioListEnvelopeDto,
PortfolioResponseMetaDto,
PositionEnvelopeDto, PositionEnvelopeDto,
} from './dto/portfolio-envelope.dto'; } from './dto/portfolio-envelope.dto';
const nullDataEnvelopeSchema = { const nullDataEnvelopeSchema = {
type: 'object', type: 'object',
properties: { properties: {
data: { data: { type: 'null' },
type: 'null', meta: { $ref: getSchemaPath(ApiResponseMeta) },
},
meta: {
$ref: getSchemaPath(PortfolioResponseMetaDto),
},
}, },
required: ['data', 'meta'], required: ['data', 'meta'],
}; };
@ApiTags('Portfolios') @ApiTags('Portfolios')
@ApiBearerAuth() @ApiBearerAuth()
@ApiExtraModels(PortfolioResponseMetaDto) @ApiExtraModels(ApiResponseMeta)
@Controller('portfolios') @Controller('portfolios')
export class PortfolioController { export class PortfolioController {
constructor(private readonly portfolioService: PortfolioService) {} constructor(private readonly portfolioService: PortfolioService) {}
@ -47,24 +43,21 @@ export class PortfolioController {
@ApiOperation({ summary: 'Get all portfolios for current user' }) @ApiOperation({ summary: 'Get all portfolios for current user' })
@ApiOkResponse({ type: PortfolioListEnvelopeDto }) @ApiOkResponse({ type: PortfolioListEnvelopeDto })
async findAll(@CurrentUser() user: { sub: number }) { async findAll(@CurrentUser() user: { sub: number }) {
const portfolios = await this.portfolioService.findAll(user.sub); return this.portfolioService.findAll(user.sub);
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
} }
@Post() @Post()
@ApiOperation({ summary: 'Create a new portfolio' }) @ApiOperation({ summary: 'Create a new portfolio' })
@ApiCreatedResponse({ type: PortfolioEnvelopeDto }) @ApiCreatedResponse({ type: PortfolioEnvelopeDto })
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) { async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
const portfolio = await this.portfolioService.create(user.sub, dto); return this.portfolioService.create(user.sub, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get portfolio details with positions and prices' }) @ApiOperation({ summary: 'Get portfolio details with positions and prices' })
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto }) @ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
const portfolio = await this.portfolioService.findOne(user.sub, id); return this.portfolioService.findOne(user.sub, id);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
} }
@Patch(':id') @Patch(':id')
@ -75,8 +68,7 @@ export class PortfolioController {
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePortfolioDto, @Body() dto: UpdatePortfolioDto,
) { ) {
const portfolio = await this.portfolioService.update(user.sub, id, dto); return this.portfolioService.update(user.sub, id, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
} }
@Delete(':id') @Delete(':id')
@ -84,7 +76,7 @@ export class PortfolioController {
@ApiOkResponse({ schema: nullDataEnvelopeSchema }) @ApiOkResponse({ schema: nullDataEnvelopeSchema })
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
await this.portfolioService.remove(user.sub, id); await this.portfolioService.remove(user.sub, id);
return { data: null, meta: { cachedAt: null, fromCache: false } }; return null;
} }
@Post(':id/positions') @Post(':id/positions')
@ -95,8 +87,7 @@ export class PortfolioController {
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() dto: AddPositionDto, @Body() dto: AddPositionDto,
) { ) {
const position = await this.portfolioService.addPosition(user.sub, id, dto); return this.portfolioService.addPosition(user.sub, id, dto);
return { data: position, meta: { cachedAt: null, fromCache: false } };
} }
@Patch(':id/positions/:positionId') @Patch(':id/positions/:positionId')
@ -108,16 +99,14 @@ export class PortfolioController {
@Param('positionId', ParseIntPipe) positionId: number, @Param('positionId', ParseIntPipe) positionId: number,
@Body() dto: UpdatePositionDto, @Body() dto: UpdatePositionDto,
) { ) {
const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto); return this.portfolioService.updatePosition(user.sub, id, positionId, dto);
return { data: position, meta: { cachedAt: null, fromCache: false } };
} }
@Get(':id/analytics') @Get(':id/analytics')
@ApiOperation({ summary: 'Get portfolio analytics with PnL' }) @ApiOperation({ summary: 'Get portfolio analytics with PnL' })
@ApiOkResponse({ type: AnalyticsEnvelopeDto }) @ApiOkResponse({ type: AnalyticsEnvelopeDto })
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
const result = await this.portfolioService.getAnalytics(user.sub, id); return this.portfolioService.getAnalytics(user.sub, id);
return { data: result, meta: { cachedAt: null, fromCache: false } };
} }
@Delete(':id/positions/:positionId') @Delete(':id/positions/:positionId')
@ -129,6 +118,6 @@ export class PortfolioController {
@Param('positionId', ParseIntPipe) positionId: number, @Param('positionId', ParseIntPipe) positionId: number,
) { ) {
await this.portfolioService.removePosition(user.sub, id, positionId); await this.portfolioService.removePosition(user.sub, id, positionId);
return { data: null, meta: { cachedAt: null, fromCache: false } }; return null;
} }
} }

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@Module({ @Module({
imports: [MoexClientModule],
controllers: [PortfolioController], controllers: [PortfolioController],
providers: [PortfolioService], providers: [PortfolioService],
exports: [PortfolioService], exports: [PortfolioService],

View File

@ -2,15 +2,18 @@ import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration'; import configuration from '../../config/configuration';
import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
describe('PortfolioService', () => { describe('PortfolioService', () => {
let service: PortfolioService; let service: PortfolioService;
let prisma: PrismaService; let prisma: PrismaService;
let moexClient: MoexClientService; let moexMarketData: MoexMarketDataClient;
let module: TestingModule; let module: TestingModule;
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({ const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
@ -65,13 +68,20 @@ describe('PortfolioService', () => {
}, },
}, },
{ {
provide: MoexClientService, provide: MoexSecuritiesClient,
useValue: { getSecurityDescription: vi.fn() },
},
{
provide: MoexMarketDataClient,
useValue: { useValue: {
getShareMarketDataBatch: vi.fn(), getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn(),
getSecurityDescription: vi.fn(),
}, },
}, },
{
provide: MoexDividendsClient,
useValue: { getDividends: vi.fn() },
},
{ {
provide: CacheService, provide: CacheService,
useValue: { useValue: {
@ -83,7 +93,7 @@ describe('PortfolioService', () => {
service = module.get<PortfolioService>(PortfolioService); service = module.get<PortfolioService>(PortfolioService);
prisma = module.get<PrismaService>(PrismaService); prisma = module.get<PrismaService>(PrismaService);
moexClient = module.get<MoexClientService>(MoexClientService); moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
}); });
beforeEach(() => { beforeEach(() => {
@ -137,11 +147,11 @@ describe('PortfolioService', () => {
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any, mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
]); ]);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any); ] as any);
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
shortName: 'OFZ 26238', shortName: 'OFZ 26238',
@ -203,14 +213,14 @@ describe('PortfolioService', () => {
}); });
describe('findOne', () => { describe('findOne', () => {
it('should throw NotFoundException for non-existent portfolio', async () => { it('should throw EntityNotFoundException for non-existent portfolio', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException); await expect(service.findOne(1, 999)).rejects.toThrow(EntityNotFoundException);
}); });
it('should throw ForbiddenException for wrong user', async () => { it('should throw PortfolioAccessDeniedException for wrong user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); await expect(service.findOne(1, 1)).rejects.toThrow(PortfolioAccessDeniedException);
}); });
it('should return portfolio with enriched positions and analytics summary', async () => { it('should return portfolio with enriched positions and analytics summary', async () => {
@ -235,7 +245,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 }, { secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any); ] as any);
@ -281,7 +291,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any); ] as any);
@ -323,7 +333,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
shortName: 'OFZ 26238', shortName: 'OFZ 26238',
@ -367,7 +377,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 }, { secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any); ] as any);
@ -403,7 +413,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any); vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([] as any);
const result = await service.getPositionsWithPrices(1); const result = await service.getPositionsWithPrices(1);
@ -459,7 +469,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 }, { secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
] as any); ] as any);
@ -511,7 +521,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 120 }, { secid: 'SBER', shortName: 'Sberbank', last: 120 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 180 }, { secid: 'GAZP', shortName: 'Gazprom', last: 180 },
] as any); ] as any);
@ -524,16 +534,16 @@ describe('PortfolioService', () => {
expect(result.summary.weightedYield).toBeCloseTo(0, 1); expect(result.summary.weightedYield).toBeCloseTo(0, 1);
}); });
it('should throw ForbiddenException if portfolio belongs to another user', async () => { it('should throw PortfolioAccessDeniedException if portfolio belongs to another user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.getAnalytics(1, 1)).rejects.toThrow(ForbiddenException); await expect(service.getAnalytics(1, 1)).rejects.toThrow(PortfolioAccessDeniedException);
}); });
it('should throw NotFoundException if portfolio does not exist', async () => { it('should throw EntityNotFoundException if portfolio does not exist', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException); await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException);
}); });
}); });
}); });

View File

@ -1,13 +1,19 @@
import { import {
Injectable, Injectable,
NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import type { MoexShareMarketData, MoexBondPositionData } from '../moex-client/moex-client.types'; import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
import type {
MoexShareMarketData,
MoexBondPositionData,
MoexDividend,
} from '../moex-client/moex-client.types';
import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto'; import { AddPositionDto } from './dto/add-position.dto';
@ -54,12 +60,14 @@ export interface EnrichedPosition {
export class PortfolioService { export class PortfolioService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly moexClient: MoexClientService, private readonly moexSecurities: MoexSecuritiesClient,
private readonly moexMarketData: MoexMarketDataClient,
private readonly moexDividends: MoexDividendsClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
async create(userId: number, dto: CreatePortfolioDto) { async create(userId: number, dto: CreatePortfolioDto) {
return this.prisma.portfolio.create({ const portfolio = await this.prisma.portfolio.create({
data: { data: {
userId, userId,
name: dto.name, name: dto.name,
@ -67,6 +75,8 @@ export class PortfolioService {
currency: dto.currency ?? 'RUB', currency: dto.currency ?? 'RUB',
}, },
}); });
return { ...portfolio, targets: null };
} }
async findAll(userId: number) { async findAll(userId: number) {
@ -89,6 +99,7 @@ export class PortfolioService {
positionCount: 0, positionCount: 0,
shareCount: 0, shareCount: 0,
bondCount: 0, bondCount: 0,
targets: p.targets ? JSON.parse(p.targets) : null,
})); }));
} }
@ -117,6 +128,7 @@ export class PortfolioService {
positionCount: positions.length, positionCount: positions.length,
shareCount: positions.filter((pos) => pos.type === 'share').length, shareCount: positions.filter((pos) => pos.type === 'share').length,
bondCount: positions.filter((pos) => pos.type === 'bond').length, bondCount: positions.filter((pos) => pos.type === 'bond').length,
targets: p.targets ? JSON.parse(p.targets) : null,
}; };
}); });
} }
@ -127,8 +139,8 @@ export class PortfolioService {
include: { positions: true }, include: { positions: true },
}); });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id); const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
@ -154,28 +166,35 @@ export class PortfolioService {
positions: positionsWithWeights, positions: positionsWithWeights,
totalValue: Math.round(totalValue * 100) / 100, totalValue: Math.round(totalValue * 100) / 100,
analytics: analytics.summary, analytics: analytics.summary,
targets: portfolio.targets ? JSON.parse(portfolio.targets) : null,
}; };
} }
async update(userId: number, id: number, dto: UpdatePortfolioDto) { async update(userId: number, id: number, dto: UpdatePortfolioDto) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
return this.prisma.portfolio.update({ const updated = await this.prisma.portfolio.update({
where: { id }, where: { id },
data: { data: {
...(dto.name !== undefined && { name: dto.name }), ...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }), ...(dto.description !== undefined && { description: dto.description }),
...(dto.currency !== undefined && { currency: dto.currency }), ...(dto.currency !== undefined && { currency: dto.currency }),
...(dto.targets !== undefined && { targets: JSON.stringify(dto.targets) }),
}, },
}); });
return {
...updated,
targets: updated.targets ? JSON.parse(updated.targets) : null,
};
} }
async remove(userId: number, id: number) { async remove(userId: number, id: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
await this.prisma.portfolio.delete({ where: { id } }); await this.prisma.portfolio.delete({ where: { id } });
} }
@ -185,8 +204,8 @@ export class PortfolioService {
where: { id: portfolioId }, where: { id: portfolioId },
include: { positions: true }, include: { positions: true },
}); });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const exists = portfolio.positions.find((p) => p.secid === dto.secid); const exists = portfolio.positions.find((p) => p.secid === dto.secid);
if (exists) if (exists)
@ -194,7 +213,7 @@ export class PortfolioService {
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0'); if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
const desc = await this.moexClient.getSecurityDescription(dto.secid); const desc = await this.moexSecurities.getSecurityDescription(dto.secid);
if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`); if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`);
const type = desc.group === 'stock_bonds' ? 'bond' : 'share'; const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
@ -220,12 +239,12 @@ export class PortfolioService {
dto: UpdatePositionDto, dto: UpdatePositionDto,
) { ) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const position = await this.prisma.position.findUnique({ where: { id: positionId } }); const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) { if (!position || position.portfolioId !== portfolioId) {
throw new NotFoundException(`Position ${positionId} not found`); throw new EntityNotFoundException('Position', positionId);
} }
return this.prisma.position.update({ return this.prisma.position.update({
@ -242,12 +261,12 @@ export class PortfolioService {
async removePosition(userId: number, portfolioId: number, positionId: number) { async removePosition(userId: number, portfolioId: number, positionId: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const position = await this.prisma.position.findUnique({ where: { id: positionId } }); const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) { if (!position || position.portfolioId !== portfolioId) {
throw new NotFoundException(`Position ${positionId} not found`); throw new EntityNotFoundException('Position', positionId);
} }
await this.prisma.position.delete({ where: { id: positionId } }); await this.prisma.position.delete({ where: { id: positionId } });
@ -272,9 +291,10 @@ export class PortfolioService {
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort(); const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort(); const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
const [shareDataBySecid, bondDataBySecid] = await Promise.all([ const [shareDataBySecid, bondDataBySecid, dividendsBySecid] = await Promise.all([
this.fetchShareBatch(shareSecids, portfolioId), this.fetchShareBatch(shareSecids, portfolioId),
this.fetchBondBatch(bondSecids, portfolioId), this.fetchBondBatch(bondSecids, portfolioId),
this.fetchDividendsBatch(shareSecids, portfolioId),
]); ]);
const enriched: EnrichedPosition[] = []; const enriched: EnrichedPosition[] = [];
@ -305,7 +325,9 @@ export class PortfolioService {
if (pos.type === 'bond') { if (pos.type === 'bond') {
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid))); enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
} else { } else {
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid))); enriched.push(
this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid), dividendsBySecid.get(pos.secid)),
);
} }
} }
@ -321,7 +343,7 @@ export class PortfolioService {
const { data } = await this.cache.getOrFetch( const { data } = await this.cache.getOrFetch(
'batchdata', 'batchdata',
['shares', cacheKey], ['shares', cacheKey],
() => this.moexClient.getShareMarketDataBatch(secids), () => this.moexMarketData.getShareMarketDataBatch(secids),
'marketDataTtl', 'marketDataTtl',
); );
return new Map(data.map((d) => [d.secid, d])); return new Map(data.map((d) => [d.secid, d]));
@ -336,12 +358,32 @@ export class PortfolioService {
const { data } = await this.cache.getOrFetch( const { data } = await this.cache.getOrFetch(
'batchdata', 'batchdata',
['bonds', cacheKey], ['bonds', cacheKey],
() => this.moexClient.getBondPositionDataBatch(secids), () => this.moexMarketData.getBondPositionDataBatch(secids),
'marketDataTtl', 'marketDataTtl',
); );
return new Map(data.map((d) => [d.secid, d])); return new Map(data.map((d) => [d.secid, d]));
} }
private async fetchDividendsBatch(
secids: string[],
portfolioId?: number,
): Promise<Map<string, MoexDividend[]>> {
if (secids.length === 0) return new Map();
const results = await Promise.all(
secids.map(async (secid) => {
const cacheKey = portfolioId ? `pf:${portfolioId}:${secid}` : secid;
const { data } = await this.cache.getOrFetch(
'dividends',
[cacheKey],
() => this.moexDividends.getDividends(secid),
'marketDataTtl',
);
return { secid, dividends: data };
}),
);
return new Map(results.map((r) => [r.secid, r.dividends]));
}
private buildSharePosition( private buildSharePosition(
pos: { pos: {
id: number; id: number;
@ -352,9 +394,16 @@ export class PortfolioService {
}, },
base: EnrichedPosition, base: EnrichedPosition,
data: MoexShareMarketData | undefined, data: MoexShareMarketData | undefined,
dividends?: MoexDividend[],
): EnrichedPosition { ): EnrichedPosition {
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null;
const dividendIncome = 0; let dividendIncome = 0;
if (pos.buyDate && dividends && dividends.length > 0) {
const buyDateStr = pos.buyDate.toISOString().split('T')[0];
dividendIncome = dividends
.filter((d) => d.registryCloseDate >= buyDateStr)
.reduce((sum, d) => sum + d.value * pos.quantity, 0);
}
if (!data) { if (!data) {
return { return {
@ -472,8 +521,8 @@ export class PortfolioService {
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> { async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new ForbiddenException(); if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const enrichedPositions = await this.getPositionsWithPrices(portfolioId); const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
@ -494,6 +543,32 @@ export class PortfolioService {
) )
: null; : null;
let targetSharesPercent: number | null = null;
let targetBondsPercent: number | null = null;
if (portfolio.targets) {
const targets = JSON.parse(portfolio.targets);
targetSharesPercent = targets.sharesPercent;
targetBondsPercent = targets.bondsPercent;
}
let actualSharesPercent = 0;
let actualBondsPercent = 0;
if (totalValue > 0) {
const shareValue = enrichedPositions
.filter((p) => p.type === 'share')
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
const bondValue = enrichedPositions
.filter((p) => p.type === 'bond')
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
actualSharesPercent = Math.round((shareValue / totalValue) * 10000) / 100;
actualBondsPercent = Math.round((bondValue / totalValue) * 10000) / 100;
}
const sharesDeviation =
targetSharesPercent !== null ? Math.round((actualSharesPercent - targetSharesPercent) * 100) / 100 : null;
const bondsDeviation =
targetBondsPercent !== null ? Math.round((actualBondsPercent - targetBondsPercent) * 100) / 100 : null;
const summary = { const summary = {
totalInvested, totalInvested,
totalValue, totalValue,
@ -504,6 +579,12 @@ export class PortfolioService {
totalReturnPercent, totalReturnPercent,
positionCount, positionCount,
weightedYield, weightedYield,
targetSharesPercent,
targetBondsPercent,
actualSharesPercent,
actualBondsPercent,
sharesDeviation,
bondsDeviation,
}; };
return { positions: enrichedPositions, summary }; return { positions: enrichedPositions, summary };

View File

@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
export class ScreenerItemDto { export class ScreenerItemDto {
@ApiProperty({ example: 'SBER' }) @ApiProperty({ example: 'SBER' })
@ -70,18 +71,10 @@ export class ScreenerResultDto {
totalPages!: number; totalPages!: number;
} }
class ScreenerResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class ScreenerResponseDto { export class ScreenerResponseDto {
@ApiProperty({ type: ScreenerResultDto }) @ApiProperty({ type: ScreenerResultDto })
data!: ScreenerResultDto; data!: ScreenerResultDto;
@ApiProperty({ type: ScreenerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: ScreenerResponseMetaDto; meta!: ApiResponseMeta;
} }

View File

@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
export class SearchResultItemDto {
@ApiProperty({ example: 'SBER' })
secid!: string;
@ApiProperty({ example: 'RU0009029540' })
isin!: string;
@ApiProperty({ example: 'Сбербанк' })
shortName!: string;
@ApiProperty({ enum: ['share', 'bond'] })
type!: 'share' | 'bond';
@ApiProperty({ example: 1 })
listLevel!: number;
@ApiPropertyOptional({ type: String, nullable: true, example: 'RUB' })
currency!: string | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 })
price!: number | null;
}
export class SearchEnvelopeDto {
@ApiProperty({ type: [SearchResultItemDto] })
data!: SearchResultItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,30 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ScreenerType } from './dto/screener-query.dto'; import { ScreenerType } from './dto/screener-query.dto';
describe('ScreenerService', () => { describe('ScreenerService', () => {
let service: ScreenerService; let service: ScreenerService;
let cache: CacheService; let cache: CacheService;
const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
ScreenerService, ScreenerService,
{ { provide: MoexMarketDataClient, useValue: moexMarketData },
provide: MoexClientService, { provide: CacheService, useValue: { getOrFetch: vi.fn() } },
useValue: {
getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(),
},
},
{
provide: CacheService,
useValue: {
getOrFetch: vi.fn(),
},
},
], ],
}).compile(); }).compile();
@ -37,6 +28,30 @@ describe('ScreenerService', () => {
}); });
describe('screen', () => { describe('screen', () => {
it('should cache full dataset with screenerTtl config', async () => {
const mockShares = [{
secid: 'SBER', shortName: 'Sberbank', last: 250, volume: 1000000,
lastChange: 5, lastChangePrcnt: 2, issueCapitalization: 1e9,
}];
moexMarketData.getShareMarketDataBatch.mockResolvedValue(mockShares);
vi.mocked(cache.getOrFetch).mockImplementation(async (_prefix, _keys, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}));
await service.screen({ type: ScreenerType.SHARE });
expect(cache.getOrFetch).toHaveBeenCalledWith(
'screener',
[ScreenerType.SHARE],
expect.any(Function),
'screenerTtl',
);
});
it('should filter and sort shares', async () => { it('should filter and sort shares', async () => {
const mockShares = [ const mockShares = [
{ {

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto'; import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'; import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';
@ -7,7 +7,7 @@ import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'
@Injectable() @Injectable()
export class ScreenerService { export class ScreenerService {
constructor( constructor(
private readonly moexClient: MoexClientService, private readonly moexMarketData: MoexMarketDataClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
@ -38,7 +38,7 @@ export class ScreenerService {
[type], [type],
async () => { async () => {
if (type === ScreenerType.SHARE) { if (type === ScreenerType.SHARE) {
const shares = await this.moexClient.getShareMarketDataBatch([]); const shares = await this.moexMarketData.getShareMarketDataBatch([]);
return shares.map( return shares.map(
(s): ScreenerItemDto => ({ (s): ScreenerItemDto => ({
secid: s.secid, secid: s.secid,
@ -61,7 +61,7 @@ export class ScreenerService {
}), }),
); );
} else { } else {
const bonds = await this.moexClient.getBondPositionDataBatch([]); const bonds = await this.moexMarketData.getBondPositionDataBatch([]);
return bonds.map( return bonds.map(
(b): ScreenerItemDto => ({ (b): ScreenerItemDto => ({
secid: b.secid, secid: b.secid,
@ -85,7 +85,7 @@ export class ScreenerService {
); );
} }
}, },
'marketDataTtl', 'screenerTtl',
); );
return data; return data;

View File

@ -47,7 +47,7 @@ describe('SecuritiesController', () => {
it('should return search results', async () => { it('should return search results', async () => {
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 }); const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
expect(result.data).toEqual(mockResults); expect(result).toEqual(mockResults);
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5); expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
}); });
}); });

View File

@ -1,12 +1,15 @@
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
import { ScreenerQueryDto } from './dto/screener-query.dto'; import { ScreenerQueryDto } from './dto/screener-query.dto';
import { ScreenerResponseDto } from './dto/screener-response.dto'; import { ScreenerResponseDto } from './dto/screener-response.dto';
import { SearchEnvelopeDto } from './dto/search-response.dto';
@ApiTags('Securities') @ApiTags('Securities')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities') @Controller('securities')
export class SecuritiesController { export class SecuritiesController {
constructor( constructor(
@ -16,20 +19,19 @@ export class SecuritiesController {
@Get('search') @Get('search')
@ApiOperation({ summary: 'Поиск по инструментам' }) @ApiOperation({ summary: 'Поиск по инструментам' })
@ApiOkResponse({ type: SearchEnvelopeDto })
async search(@Query(ValidationPipe) query: SearchQueryDto) { async search(@Query(ValidationPipe) query: SearchQueryDto) {
const results = await this.securitiesService.search( return this.securitiesService.search(
query.q, query.q,
query.type || SecurityType.ALL, query.type || SecurityType.ALL,
query.limit || 20, query.limit || 20,
); );
return { data: results, meta: { cachedAt: null, fromCache: false } };
} }
@Get('screener') @Get('screener')
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' }) @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
@ApiOkResponse({ type: ScreenerResponseDto }) @ApiOkResponse({ type: ScreenerResponseDto })
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) { async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
const result = await this.screenerService.screen(query); return this.screenerService.screen(query);
return { data: result, meta: { cachedAt: null, fromCache: false } };
} }
} }

View File

@ -1,11 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheModule } from '../cache/cache.module'; import { MoexClientModule } from '../moex-client/moex-client.module';
import { SecuritiesController } from './securities.controller'; import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
@Module({ @Module({
imports: [CacheModule], imports: [MoexClientModule],
controllers: [SecuritiesController], controllers: [SecuritiesController],
providers: [SecuritiesService, ScreenerService], providers: [SecuritiesService, ScreenerService],
exports: [SecuritiesService], exports: [SecuritiesService],

View File

@ -1,16 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { SecurityType } from './dto/search-query.dto'; import { SecurityType } from './dto/search-query.dto';
describe('SecuritiesService', () => { describe('SecuritiesService', () => {
let service: SecuritiesService; let service: SecuritiesService;
let moexClient: Pick<MoexClientService, 'searchSecurities'>; let moexSecurities: Pick<MoexSecuritiesClient, 'searchSecurities'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexClient = { moexSecurities = {
searchSecurities: vi.fn(), searchSecurities: vi.fn(),
}; };
cache = { cache = {
@ -24,7 +24,7 @@ describe('SecuritiesService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
SecuritiesService, SecuritiesService,
{ provide: MoexClientService, useValue: moexClient }, { provide: MoexSecuritiesClient, useValue: moexSecurities },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -33,7 +33,7 @@ describe('SecuritiesService', () => {
}); });
it('returns supported securities only and normalizes SUR currency to RUB', async () => { it('returns supported securities only and normalizes SUR currency to RUB', async () => {
vi.mocked(moexClient.searchSecurities).mockResolvedValue([ vi.mocked(moexSecurities.searchSecurities).mockResolvedValue([
{ {
secid: 'SBER', secid: 'SBER',
isin: 'RU0009029540', isin: 'RU0009029540',
@ -118,7 +118,7 @@ describe('SecuritiesService', () => {
expect.any(Function), expect.any(Function),
'searchTtl', 'searchTtl',
); );
expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr'); expect(moexSecurities.searchSecurities).toHaveBeenCalledWith('SbEr');
}); });
it('filters by type and applies limit without live MOEX dependency', async () => { it('filters by type and applies limit without live MOEX dependency', async () => {
@ -169,6 +169,6 @@ describe('SecuritiesService', () => {
price: null, price: null,
}, },
]); ]);
expect(moexClient.searchSecurities).not.toHaveBeenCalled(); expect(moexSecurities.searchSecurities).not.toHaveBeenCalled();
}); });
}); });

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { SecurityType } from './dto/search-query.dto'; import { SecurityType } from './dto/search-query.dto';
@ -16,7 +16,7 @@ export interface SearchResultItem {
@Injectable() @Injectable()
export class SecuritiesService { export class SecuritiesService {
constructor( constructor(
private readonly moexClient: MoexClientService, private readonly moexSecurities: MoexSecuritiesClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
@ -25,7 +25,7 @@ export class SecuritiesService {
'search', 'search',
[query.toLowerCase()], [query.toLowerCase()],
async () => { async () => {
const results = await this.moexClient.searchSecurities(query); const results = await this.moexSecurities.searchSecurities(query);
return results return results
.map((s): SearchResultItem | null => { .map((s): SearchResultItem | null => {
const type = const type =
@ -64,7 +64,7 @@ export class SecuritiesService {
async getShareBrief(secid: string): Promise<SearchResultItem | null> { async getShareBrief(secid: string): Promise<SearchResultItem | null> {
try { try {
const desc = await this.moexClient.getSecurityDescription(secid); const desc = await this.moexSecurities.getSecurityDescription(secid);
if (!desc) return null; if (!desc) return null;
return { return {
secid: desc.secid, secid: desc.secid,

View File

@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
export class DividendItemDto {
@ApiProperty({ example: '2026-05-15' })
registryCloseDate!: string;
@ApiProperty({ example: 33.47 })
value!: number;
@ApiProperty({ example: 'RUB' })
currency!: string;
}

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
export class HistoryItemDto {
@ApiProperty({ example: '2026-06-01' })
date!: string;
@ApiProperty({ example: 321.3 })
open!: number;
@ApiProperty({ example: 322.66 })
high!: number;
@ApiProperty({ example: 321.2 })
low!: number;
@ApiProperty({ example: 322.35 })
close!: number;
@ApiProperty({ example: 1925163 })
volume!: number;
@ApiProperty({ example: 620184479 })
value!: number;
}

View File

@ -0,0 +1,38 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { ShareResponseDto } from './share-response.dto';
import { ShareMarketDataResponseDto } from './share-marketdata-response.dto';
import { HistoryItemDto } from './history-item.dto';
import { DividendItemDto } from './dividend-item.dto';
export class ShareEnvelopeDto {
@ApiProperty({ type: ShareResponseDto })
data!: ShareResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class ShareMarketDataEnvelopeDto {
@ApiProperty({ type: ShareMarketDataResponseDto })
data!: ShareMarketDataResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class DividendsEnvelopeDto {
@ApiProperty({ type: [DividendItemDto] })
data!: DividendItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class ShareHistoryEnvelopeDto {
@ApiProperty({ type: [HistoryItemDto] })
data!: HistoryItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,33 +1,44 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
import {
ShareEnvelopeDto,
ShareMarketDataEnvelopeDto,
DividendsEnvelopeDto,
ShareHistoryEnvelopeDto,
} from './dto/shares-envelope.dto';
@ApiTags('Shares') @ApiTags('Shares')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities/shares') @Controller('securities/shares')
export class SharesController { export class SharesController {
constructor(private readonly sharesService: SharesService) {} constructor(private readonly sharesService: SharesService) {}
@Get(':secid') @Get(':secid')
@ApiOperation({ summary: 'Получить спецификацию акции' }) @ApiOperation({ summary: 'Получить спецификацию акции' })
@ApiOkResponse({ type: ShareEnvelopeDto })
async getShare(@Param('secid') secid: string) { async getShare(@Param('secid') secid: string) {
const share = await this.sharesService.getShare(secid); return this.sharesService.getShare(secid);
return { data: share, meta: { cachedAt: null, fromCache: false } };
} }
@Get(':secid/marketdata') @Get(':secid/marketdata')
@ApiOperation({ summary: 'Получить рыночные данные акции' }) @ApiOperation({ summary: 'Получить рыночные данные акции' })
@ApiOkResponse({ type: ShareMarketDataEnvelopeDto })
async getMarketData(@Param('secid') secid: string) { async getMarketData(@Param('secid') secid: string) {
return this.sharesService.getMarketData(secid); return this.sharesService.getMarketData(secid);
} }
@Get(':secid/dividends') @Get(':secid/dividends')
@ApiOperation({ summary: 'Получить дивиденды' }) @ApiOperation({ summary: 'Получить дивиденды' })
@ApiOkResponse({ type: DividendsEnvelopeDto })
async getDividends(@Param('secid') secid: string) { async getDividends(@Param('secid') secid: string) {
return this.sharesService.getDividends(secid); return this.sharesService.getDividends(secid);
} }
@Get(':secid/history') @Get(':secid/history')
@ApiOperation({ summary: 'Получить дневную историю торгов акции' }) @ApiOperation({ summary: 'Получить дневную историю торгов акции' })
@ApiOkResponse({ type: ShareHistoryEnvelopeDto })
async getHistory( async getHistory(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query('from') from: string, @Query('from') from: string,

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module';
import { SharesController } from './shares.controller'; import { SharesController } from './shares.controller';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
@Module({ @Module({
imports: [MoexClientModule],
controllers: [SharesController], controllers: [SharesController],
providers: [SharesService], providers: [SharesService],
exports: [SharesService], exports: [SharesService],

View File

@ -1,17 +1,23 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
describe('SharesService', () => { describe('SharesService', () => {
let service: SharesService; let service: SharesService;
let moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>; let moexSecurities: Pick<MoexSecuritiesClient, 'getSecurityDescription'>;
let moexMarketData: Pick<MoexMarketDataClient, 'getShareMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexClient = { moexSecurities = {
getSecurityDescription: vi.fn(), getSecurityDescription: vi.fn(),
};
moexMarketData = {
getShareMarketData: vi.fn(), getShareMarketData: vi.fn(),
}; };
cache = { cache = {
@ -25,7 +31,10 @@ describe('SharesService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
SharesService, SharesService,
{ provide: MoexClientService, useValue: moexClient }, { provide: MoexSecuritiesClient, useValue: moexSecurities },
{ provide: MoexMarketDataClient, useValue: moexMarketData },
{ provide: MoexDividendsClient, useValue: { getDividends: vi.fn() } },
{ provide: MoexHistoryClient, useValue: { getHistory: vi.fn() } },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -34,7 +43,7 @@ describe('SharesService', () => {
}); });
it('returns normalized SBER share spec and market data without live MOEX dependency', async () => { it('returns normalized SBER share spec and market data without live MOEX dependency', async () => {
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
secid: 'SBER', secid: 'SBER',
isin: 'RU0009029540', isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао', name: 'Сбербанк России ПАО ао',
@ -52,7 +61,7 @@ describe('SharesService', () => {
morningSession: true, morningSession: true,
eveningSession: true, eveningSession: true,
}); });
vi.mocked(moexClient.getShareMarketData).mockResolvedValue({ vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({
secid: 'SBER', secid: 'SBER',
boardid: 'TQBR', boardid: 'TQBR',
shortName: 'Сбербанк', shortName: 'Сбербанк',
@ -75,15 +84,15 @@ describe('SharesService', () => {
const result = await service.getShare('SBER'); const result = await service.getShare('SBER');
expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER'); expect(moexSecurities.getSecurityDescription).toHaveBeenCalledWith('SBER');
expect(cache.getOrFetch).toHaveBeenCalledWith( expect(cache.getOrFetch).toHaveBeenCalledWith(
'marketdata', 'marketdata',
['shares', 'SBER'], ['shares', 'SBER'],
expect.any(Function), expect.any(Function),
'marketDataTtl', 'marketDataTtl',
); );
expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER'); expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER');
expect(result).toMatchObject({ expect(result.data).toMatchObject({
secid: 'SBER', secid: 'SBER',
isin: 'RU0009029540', isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао', name: 'Сбербанк России ПАО ао',
@ -106,11 +115,11 @@ describe('SharesService', () => {
issueCapitalization: 6900000000000, issueCapitalization: 6900000000000,
}, },
}); });
expect(result.marketData.updatedAt).toMatch(/T18:45:00$/); expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
}); });
it('throws NotFoundException for non-share security', async () => { it('throws EntityNotFoundException for non-share security', async () => {
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
isin: 'RU000A1038V6', isin: 'RU000A1038V6',
name: 'ОФЗ 26238', name: 'ОФЗ 26238',
@ -129,7 +138,7 @@ describe('SharesService', () => {
eveningSession: false, eveningSession: false,
}); });
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException); await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException);
expect(cache.getOrFetch).not.toHaveBeenCalled(); expect(cache.getOrFetch).not.toHaveBeenCalled();
}); });
}); });

View File

@ -1,16 +1,24 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
@Injectable() @Injectable()
export class SharesService { export class SharesService {
constructor( constructor(
private readonly moexClient: MoexClientService, private readonly moexSecurities: MoexSecuritiesClient,
private readonly moexMarketData: MoexMarketDataClient,
private readonly moexDividends: MoexDividendsClient,
private readonly moexHistory: MoexHistoryClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
async getShare(secid: string) { async getShare(secid: string) {
const desc = await this.moexClient.getSecurityDescription(secid); const desc = await this.moexSecurities.getSecurityDescription(secid);
if ( if (
!desc || !desc ||
!( !(
@ -19,13 +27,17 @@ export class SharesService {
desc.type === 'preferred_share' desc.type === 'preferred_share'
) )
) { ) {
throw new NotFoundException(`Share ${secid} not found`); throw new EntityNotFoundException('Share', secid);
} }
const { data: marketData } = await this.cache.getOrFetch( const {
data: marketData,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['shares', secid], ['shares', secid],
() => this.moexClient.getShareMarketData(secid), () => this.moexMarketData.getShareMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
@ -33,7 +45,8 @@ export class SharesService {
const change = marketData?.lastChange ?? 0; const change = marketData?.lastChange ?? 0;
const changePercent = marketData?.lastChangePrcnt ?? 0; const changePercent = marketData?.lastChangePrcnt ?? 0;
return { return new ApiEnvelopePayload(
{
secid: desc.secid, secid: desc.secid,
isin: desc.isin, isin: desc.isin,
name: desc.name, name: desc.name,
@ -58,7 +71,10 @@ export class SharesService {
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(), : new Date().toISOString(),
}, },
}; },
fromCache,
cachedAt,
);
} }
async getMarketData(secid: string) { async getMarketData(secid: string) {
@ -69,16 +85,16 @@ export class SharesService {
} = await this.cache.getOrFetch( } = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['shares', secid], ['shares', secid],
() => this.moexClient.getShareMarketData(secid), () => this.moexMarketData.getShareMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
if (!marketData) { if (!marketData) {
throw new NotFoundException(`Market data for ${secid} not found`); throw new EntityNotFoundException('MarketData', secid);
} }
return { return new ApiEnvelopePayload(
data: { {
price: marketData.last ?? 0, price: marketData.last ?? 0,
change: marketData.lastChange ?? 0, change: marketData.lastChange ?? 0,
changePercent: marketData.lastChangePrcnt ?? 0, changePercent: marketData.lastChangePrcnt ?? 0,
@ -92,38 +108,40 @@ export class SharesService {
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(), : new Date().toISOString(),
}, },
meta: { fromCache, cachedAt }, fromCache,
}; cachedAt,
);
} }
async getDividends(secid: string) { async getDividends(secid: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'dividends', 'dividends',
[secid], [secid],
() => this.moexClient.getDividends(secid), () => this.moexDividends.getDividends(secid),
'dividendsTtl', 'dividendsTtl',
); );
return { return new ApiEnvelopePayload(
data: data.map((d) => ({ data.map((d) => ({
registryCloseDate: d.registryCloseDate, registryCloseDate: d.registryCloseDate,
value: d.value, value: d.value,
currency: d.currencyId, currency: d.currencyId,
})), })),
meta: { fromCache, cachedAt }, fromCache,
}; cachedAt,
);
} }
async getHistory(secid: string, from: string, till: string) { async getHistory(secid: string, from: string, till: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'history', 'history',
['shares', secid, from, till], ['shares', secid, from, till],
() => this.moexClient.getHistory(secid, from, till), () => this.moexHistory.getHistory(secid, from, till),
'historyTtl', 'historyTtl',
); );
return { return new ApiEnvelopePayload(
data: data.map((h) => ({ data.map((h) => ({
date: h.tradeDate, date: h.tradeDate,
open: h.open ?? 0, open: h.open ?? 0,
high: h.high ?? 0, high: h.high ?? 0,
@ -132,7 +150,8 @@ export class SharesService {
volume: h.volume, volume: h.volume,
value: h.value, value: h.value,
})), })),
meta: { fromCache, cachedAt }, fromCache,
}; cachedAt,
);
} }
} }

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrokerAnalyticsDto {
@ApiProperty()
totalDeposits!: number;
@ApiProperty()
totalWithdrawn!: number;
@ApiProperty()
netInvested!: number;
@ApiProperty()
totalDividends!: number;
@ApiProperty()
totalCoupons!: number;
@ApiProperty()
totalReceived!: number;
@ApiProperty()
totalFees!: number;
@ApiProperty()
totalTaxesPaid!: number;
@ApiProperty({ type: Number, nullable: true })
totalReturnPercent!: number | null;
@ApiProperty()
currency!: string;
}

View File

@ -1,54 +1,74 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerEventsDataDto } from './broker-events-response.dto';
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto'; import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto'; import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto'; import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
export class BrokerResponseMetaDto { import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto';
@ApiProperty({ nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class BrokerAccountsEnvelopeDto { export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] }) @ApiProperty({ type: [BrokerAccountResponseDto] })
data!: BrokerAccountResponseDto[]; data!: BrokerAccountResponseDto[];
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerPortfolioEnvelopeDto { export class BrokerPortfolioEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioResponseDto }) @ApiProperty({ type: BrokerPortfolioResponseDto })
data!: BrokerPortfolioResponseDto; data!: BrokerPortfolioResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerOperationsEnvelopeDto { export class BrokerOperationsEnvelopeDto {
@ApiProperty({ type: BrokerOperationsPageResponseDto }) @ApiProperty({ type: BrokerOperationsPageResponseDto })
data!: BrokerOperationsPageResponseDto; data!: BrokerOperationsPageResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerPositionsEnvelopeDto { export class BrokerPositionsEnvelopeDto {
@ApiProperty({ type: BrokerPositionsPageResponseDto }) @ApiProperty({ type: BrokerPositionsPageResponseDto })
data!: BrokerPositionsPageResponseDto; data!: BrokerPositionsPageResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerOperationSyncEnvelopeDto { export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerOperationSyncResponseDto }) @ApiProperty({ type: BrokerOperationSyncResponseDto })
data!: BrokerOperationSyncResponseDto; data!: BrokerOperationSyncResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
}
export class BrokerAnalyticsEnvelopeDto {
@ApiProperty({ type: BrokerAnalyticsDto })
data!: BrokerAnalyticsDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BrokerEventsEnvelopeDto {
@ApiProperty({ type: BrokerEventsDataDto })
data!: BrokerEventsDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BrokerPortfolioHistoryEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
data!: BrokerPortfolioHistoryDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
} }

View File

@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator';
export class BrokerEventsQueryDto {
@ApiProperty({ example: '2026-06-22', description: 'Start date inclusive (YYYY-MM-DD)' })
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'from must be YYYY-MM-DD' })
from!: string;
@ApiProperty({ example: '2026-07-29', description: 'End date inclusive (YYYY-MM-DD)' })
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'to must be YYYY-MM-DD' })
to!: string;
@ApiProperty({
required: false,
example: 'dividend,coupon,maturity,offer',
description: 'Comma-separated event types to include',
})
@IsOptional()
@Matches(/^(dividend|coupon|maturity|offer)(,(dividend|coupon|maturity|offer))*$/, {
message: 'types must be a comma-separated list of known event types',
})
types?: string;
}

View File

@ -0,0 +1,102 @@
import { ApiProperty } from '@nestjs/swagger';
const eventTypes = ['dividend', 'coupon', 'maturity', 'offer'] as const;
const eventSources = ['forecast', 'actual'] as const;
const eventCategories = ['cashflow', 'corporate'] as const;
const instrumentTypes = ['share', 'bond', 'other'] as const;
export class BrokerEventItemDto {
@ApiProperty()
id!: string;
@ApiProperty({ enum: eventTypes })
type!: string;
@ApiProperty({ enum: eventSources })
source!: string;
@ApiProperty({ enum: eventCategories })
category!: string;
@ApiProperty()
eventDate!: string;
@ApiProperty({ type: String, nullable: true })
paymentDate!: string | null;
@ApiProperty({ type: String, nullable: true })
ticker!: string | null;
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@ApiProperty({ type: String, nullable: true })
instrumentUid!: string | null;
@ApiProperty({ enum: instrumentTypes })
instrumentType!: string;
@ApiProperty({ type: Number, nullable: true })
quantitySnapshot!: number | null;
@ApiProperty({ type: Number, nullable: true })
payoutPerUnit!: number | null;
@ApiProperty({ type: Number, nullable: true })
estimatedAmount!: number | null;
@ApiProperty({ type: Number, nullable: true })
actualAmount!: number | null;
@ApiProperty({ type: String, nullable: true })
currency!: string | null;
@ApiProperty({ type: String, nullable: true, enum: ['current_position'] })
estimateMode!: 'current_position' | null;
}
export class BrokerEventsSummaryDto {
@ApiProperty({ minimum: 0 })
eventCount!: number;
@ApiProperty({ type: String, nullable: true })
nearestEventDate!: string | null;
@ApiProperty()
totalEstimatedCashflow!: number;
@ApiProperty()
actualCashflow!: number;
@ApiProperty()
forecastEstimatedCashflow!: number;
@ApiProperty()
dividendsTotal!: number;
@ApiProperty()
couponsTotal!: number;
@ApiProperty()
principalRepaymentTotal!: number;
@ApiProperty()
actualDividendsTotal!: number;
@ApiProperty()
actualCouponsTotal!: number;
@ApiProperty()
actualPrincipalRepaymentTotal!: number;
}
export class BrokerEventsDataDto {
@ApiProperty({ type: [BrokerEventItemDto] })
items!: BrokerEventItemDto[];
@ApiProperty({ type: BrokerEventsSummaryDto })
summary!: BrokerEventsSummaryDto;
@ApiProperty()
asOf!: string;
}

View File

@ -40,4 +40,9 @@ export class BrokerOperationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
state?: string; state?: string;
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
@IsOptional()
@IsString()
categories?: string;
} }

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPortfolioHistoryPointDto {
@ApiProperty()
month!: string;
@ApiProperty()
label!: string;
@ApiProperty({ type: BrokerMoneyDto })
value!: BrokerMoneyDto;
}
export class BrokerPortfolioHistoryDataDto {
@ApiProperty()
accountId!: string;
@ApiProperty({ type: [BrokerPortfolioHistoryPointDto] })
points!: BrokerPortfolioHistoryPointDto[];
@ApiProperty()
asOf!: string;
}

View File

@ -4,7 +4,7 @@ import { CacheService } from '../../cache/cache.service';
describe('BrokerAccountsService', () => { describe('BrokerAccountsService', () => {
const client = { const client = {
getServiceClient: vi.fn(), getUsersClient: vi.fn(),
callUnary: vi.fn(), callUnary: vi.fn(),
} as unknown as TBankClientService; } as unknown as TBankClientService;
const cache = { const cache = {
@ -23,7 +23,7 @@ describe('BrokerAccountsService', () => {
cachedAt: '2026-06-16T02:30:00.000Z', cachedAt: '2026-06-16T02:30:00.000Z',
}), }),
); );
vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any); vi.mocked(client.getUsersClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({ vi.mocked(client.callUnary).mockResolvedValue({
accounts: [ accounts: [
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' }, { id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
@ -38,7 +38,7 @@ describe('BrokerAccountsService', () => {
expect(result.data).toHaveLength(2); expect(result.data).toHaveLength(2);
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']); expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
expect(result.meta.fromCache).toBe(false); expect(result.fromCache).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith( expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:accounts', 'tbank:accounts',
['open-brokerage-iis'], ['open-brokerage-iis'],

View File

@ -5,6 +5,7 @@ import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types'; import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccountsResponse } from '../types/tbank-proto.types'; import type { TBankAccountsResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
@Injectable() @Injectable()
export class BrokerAccountsService { export class BrokerAccountsService {
@ -13,10 +14,7 @@ export class BrokerAccountsService {
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
async findAll(): Promise<{ async findAll(): Promise<ApiEnvelopePayload<BrokerAccount[]>> {
data: BrokerAccount[];
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.accounts, TBANK_CACHE_KEYS.accounts,
['open-brokerage-iis'], ['open-brokerage-iis'],
@ -24,10 +22,7 @@ export class BrokerAccountsService {
'tbankAccountsTtl', 'tbankAccountsTtl',
); );
return { return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
async findById(accountId: string): Promise<BrokerAccount | null> { async findById(accountId: string): Promise<BrokerAccount | null> {
@ -37,9 +32,9 @@ export class BrokerAccountsService {
} }
private async fetchAccounts(): Promise<BrokerAccount[]> { private async fetchAccounts(): Promise<BrokerAccount[]> {
const usersClient = this.tbankClient.getServiceClient('UsersService') as any; const usersClient = this.tbankClient.getUsersClient();
const response = await this.tbankClient.callUnary< const response = await this.tbankClient.callUnary<
Record<string, string>, { status: string },
TBankAccountsResponse TBankAccountsResponse
>( >(
'UsersService/GetAccounts', 'UsersService/GetAccounts',

View File

@ -0,0 +1,280 @@
import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerAnalyticsService } from './broker-analytics.service';
import { TBankClientService } from './tbank-client.service';
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse, TBankOperationItem } from '../types/tbank-proto.types';
describe('BrokerAnalyticsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const tbankClient = {
getOperationsClient: vi.fn(),
callUnary: vi.fn(),
} as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
const acc1 = {
id: 'acc-1',
type: 'brokerage' as const,
name: 'Test Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
};
const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() };
function mockCachePassthrough() {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
}
function mockPortfolio(expectedYield?: { units?: number | string; nano?: number }): TBankPortfolioResponse {
const response: TBankPortfolioResponse = { accountId: 'acc-1' };
if (expectedYield) response.expectedYield = expectedYield;
return response;
}
function makeItem(type: string, value: number, state = 'OPERATION_STATE_EXECUTED'): TBankOperationItem {
return {
type,
payment: { currency: 'RUB', units: Math.floor(Math.abs(value)), nano: Math.round((Math.abs(value) % 1) * 1e9) },
state,
id: `${type}-${value}`,
cursor: '',
brokerAccountId: 'acc-1',
};
}
function mockOpsResponse(items: TBankOperationItem[], hasNext = false, nextCursor = ''): TBankOperationsByCursorResponse {
return { items, hasNext, nextCursor };
}
function setupMocks(ops: TBankOperationItem[], portfolio?: TBankPortfolioResponse) {
vi.mocked(tbankClient.callUnary).mockImplementation(
async (label: string) => {
if (label.includes('GetPortfolio')) return (portfolio ?? mockPortfolio()) as any;
if (label.includes('GetOperationsByCursor')) return mockOpsResponse(ops) as any;
throw new Error(`Unexpected call: ${label}`);
},
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(tbankClient.getOperationsClient).mockReturnValue(mockClient as any);
});
it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException);
});
it('returns zeros for account with no operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data).toEqual({
totalDeposits: 0,
totalWithdrawn: 0,
netInvested: 0,
totalDividends: 0,
totalCoupons: 0,
totalReceived: 0,
totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
});
});
it('aggregates deposit types correctly', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_INPUT', 1000),
makeItem('OPERATION_TYPE_INPUT_SWIFT', 500),
makeItem('OPERATION_TYPE_INP_MULTI', 200),
makeItem('OPERATION_TYPE_OVER_PLACEMENT', 300),
makeItem('OPERATION_TYPE_TRANS_IIS_BS', 100),
makeItem('OPERATION_TYPE_TRANS_BS_BS', 50),
makeItem('OPERATION_TYPE_INPUT_ACQUIRING', 150),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(2300);
expect(result.data.totalWithdrawn).toBe(0);
expect(result.data.netInvested).toBe(2300);
});
it('aggregates withdrawal types with absolute value', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_OUTPUT', -500),
makeItem('OPERATION_TYPE_OUTPUT_SWIFT', -200),
makeItem('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
makeItem('OPERATION_TYPE_OUT_MULTI', -50),
makeItem('OPERATION_TYPE_INPUT', 1000),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalWithdrawn).toBe(850);
expect(result.data.netInvested).toBe(150);
});
it('aggregates dividend and coupon types', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_DIVIDEND', 300),
makeItem('OPERATION_TYPE_DIV_EXT', 150),
makeItem('OPERATION_TYPE_COUPON', 75),
makeItem('OPERATION_TYPE_COUPON', 25),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDividends).toBe(450);
expect(result.data.totalCoupons).toBe(100);
expect(result.data.totalReceived).toBe(550);
});
it('uses expectedYield from portfolio for totalReturnPercent', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks(
[
makeItem('OPERATION_TYPE_INPUT', 10000),
makeItem('OPERATION_TYPE_DIVIDEND', 500),
makeItem('OPERATION_TYPE_COUPON', 200),
],
mockPortfolio({ units: 7, nano: 0 }),
);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalReturnPercent).toBe(7);
});
it('returns null totalReturnPercent when portfolio has no expectedYield', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalReturnPercent).toBeNull();
});
it('aggregates fee and tax categories from operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_SERVICE_FEE', -100),
makeItem('OPERATION_TYPE_BROKER_FEE', -50),
makeItem('OPERATION_TYPE_TAX', -200),
makeItem('OPERATION_TYPE_DIVIDEND_TAX', -30),
makeItem('OPERATION_TYPE_INPUT', 1000),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalFees).toBe(150);
expect(result.data.totalTaxesPaid).toBe(230);
expect(result.data.netInvested).toBe(1000);
});
it('rounds all monetary values to 2 decimal places', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_INPUT', 100.336),
makeItem('OPERATION_TYPE_DIVIDEND', 50.789),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(100.34);
expect(result.data.totalDividends).toBe(50.79);
expect(result.data.totalReceived).toBe(50.79);
expect(result.data.netInvested).toBe(100.34);
});
it('wraps result in ApiResponse envelope through cache', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
vi.mocked(cache.getOrFetch).mockResolvedValue({
data: {
totalDeposits: 1000,
totalWithdrawn: 0,
netInvested: 1000,
totalDividends: 0,
totalCoupons: 0,
totalReceived: 0,
totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
},
fromCache: true,
cachedAt: '2026-06-24T10:00:00.000Z',
});
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(1000);
expect(result.fromCache).toBe(true);
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
});
it('paginates through multiple pages of operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
let callCount = 0;
vi.mocked(tbankClient.callUnary).mockImplementation(
async (label: string) => {
if (label.includes('GetPortfolio')) return mockPortfolio({ units: 5, nano: 0 }) as any;
if (label.includes('GetOperationsByCursor')) {
callCount++;
if (callCount === 1) {
return mockOpsResponse([makeItem('OPERATION_TYPE_INPUT', 1000)], true, 'cursor-1') as any;
}
return mockOpsResponse([makeItem('OPERATION_TYPE_DIVIDEND', 500)], false, '') as any;
}
throw new Error(`Unexpected call: ${label}`);
},
);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalDividends).toBe(500);
expect(result.data.totalReceived).toBe(500);
expect(callCount).toBe(2);
});
});

View File

@ -0,0 +1,168 @@
import { Injectable, Logger } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService, type TBankPortfolioRequest } from './tbank-client.service';
import type { BrokerOperation } from '../types/broker.types';
import { mapOperationsPage } from '../mappers/operation.mapper';
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse } from '../types/tbank-proto.types';
const DEPOSIT_TYPES = new Set([
'OPERATION_TYPE_INPUT',
'OPERATION_TYPE_INPUT_SWIFT',
'OPERATION_TYPE_INPUT_ACQUIRING',
'OPERATION_TYPE_INP_MULTI',
'OPERATION_TYPE_OVER_PLACEMENT',
'OPERATION_TYPE_TRANS_IIS_BS',
'OPERATION_TYPE_TRANS_BS_BS',
]);
const WITHDRAWAL_TYPES = new Set([
'OPERATION_TYPE_OUTPUT',
'OPERATION_TYPE_OUTPUT_SWIFT',
'OPERATION_TYPE_OUTPUT_ACQUIRING',
'OPERATION_TYPE_OUT_MULTI',
]);
const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT']);
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
const MAX_FETCH_PAGES = 50;
@Injectable()
export class BrokerAnalyticsService {
private readonly logger = new Logger(BrokerAnalyticsService.name);
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.analytics,
[accountId],
() => this.computeAnalytics(accountId),
'tbankAnalyticsTtl',
);
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
const [portfolio, allOperations] = await Promise.all([
this.fetchPortfolio(accountId),
this.fetchAllOperations(accountId),
]);
let totalDeposits = 0;
let totalWithdrawn = 0;
let totalDividends = 0;
let totalCoupons = 0;
let totalFees = 0;
let totalTaxesPaid = 0;
for (const op of allOperations) {
const value = op.payment?.value ?? 0;
if (op.category === 'fee') {
totalFees += Math.abs(value);
} else if (op.category === 'tax') {
totalTaxesPaid += Math.abs(value);
} else if (DEPOSIT_TYPES.has(op.type)) {
totalDeposits += value;
} else if (WITHDRAWAL_TYPES.has(op.type)) {
totalWithdrawn += Math.abs(value);
} else if (DIVIDEND_TYPES.has(op.type)) {
totalDividends += value;
} else if (COUPON_TYPES.has(op.type)) {
totalCoupons += value;
}
}
const netInvested = totalDeposits - totalWithdrawn;
const totalReceived = totalDividends + totalCoupons;
const portfolioYield = portfolio.expectedYield;
const expectedYieldPercent = portfolioYield
? Math.round((Number(portfolioYield.units ?? 0) + (portfolioYield.nano ?? 0) / 1e9) * 100) / 100
: null;
return {
totalDeposits: Math.round(totalDeposits * 100) / 100,
totalWithdrawn: Math.round(totalWithdrawn * 100) / 100,
netInvested: Math.round(netInvested * 100) / 100,
totalDividends: Math.round(totalDividends * 100) / 100,
totalCoupons: Math.round(totalCoupons * 100) / 100,
totalReceived: Math.round(totalReceived * 100) / 100,
totalFees: Math.round(totalFees * 100) / 100,
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
totalReturnPercent: expectedYieldPercent,
currency: 'RUB',
};
}
private async fetchPortfolio(accountId: string): Promise<TBankPortfolioResponse> {
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
TBankPortfolioRequest,
TBankPortfolioResponse
>(
'OperationsService/GetPortfolio',
operationsClient.getPortfolio.bind(operationsClient),
{ accountId, currency: 'RUB' },
);
return response;
}
private async fetchAllOperations(accountId: string): Promise<BrokerOperation[]> {
const allOps: BrokerOperation[] = [];
let cursor: string | undefined;
let pageCount = 0;
do {
if (pageCount >= MAX_FETCH_PAGES) {
this.logger.warn(`Reached max fetch pages (${MAX_FETCH_PAGES}) for account ${accountId}`);
break;
}
const request: Record<string, unknown> = {
accountId,
state: 'OPERATION_STATE_EXECUTED',
limit: 1000,
withoutCommissions: false,
withoutTrades: false,
withoutOvernights: false,
};
if (cursor) request.cursor = cursor;
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
Record<string, unknown>,
TBankOperationsByCursorResponse
>(
'OperationsService/GetOperationsByCursor',
operationsClient.getOperationsByCursor.bind(operationsClient),
request,
);
const page = mapOperationsPage(accountId, response);
allOps.push(...page.items);
pageCount++;
cursor = page.hasNext ? (page.nextCursor ?? undefined) : undefined;
} while (cursor);
return allOps;
}
}

View File

@ -0,0 +1,499 @@
import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../../moex-client/moex-dividends.client';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerEventsService } from './broker-events.service';
import { BrokerOperationsService } from './broker-operations.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
describe('BrokerEventsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService;
const moexMarketData = { getBondPositionDataBatch: vi.fn() } as unknown as MoexMarketDataClient;
const moexDividends = { getDividends: vi.fn() } as unknown as MoexDividendsClient;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
const acc1 = {
id: 'acc-1',
type: 'brokerage' as const,
name: 'Test Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
};
beforeEach(() => {
vi.clearAllMocks();
});
function mockCachePassthrough() {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
}
it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
await expect(
service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }),
).rejects.toThrow(EntityNotFoundException);
});
it('returns empty events for account with no positions', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [],
instruments: new Map(),
});
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' });
expect(result.data.items).toEqual([]);
expect(result.data.summary.eventCount).toBe(0);
expect(result.data.summary.nearestEventDate).toBeNull();
expect(result.data.summary.totalEstimatedCashflow).toBe(0);
expect(result.data.summary.actualCashflow).toBe(0);
expect(result.data.summary.forecastEstimatedCashflow).toBe(0);
});
it('builds dividend events from share positions in date range', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-sber',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
],
instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{
secid: 'SBER',
isin: 'RU000A0JS',
registryCloseDate: '2026-06-15',
value: 33.5,
currencyId: 'RUB',
},
{
secid: 'SBER',
isin: 'RU000A0JS',
registryCloseDate: '2026-06-25',
value: 33.5,
currencyId: 'RUB',
},
{
secid: 'SBER',
isin: 'RU000A0JS',
registryCloseDate: '2026-08-01',
value: 33.5,
currencyId: 'RUB',
},
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].type).toBe('dividend');
expect(result.data.items[0].eventDate).toBe('2026-06-25');
expect(result.data.items[0].estimatedAmount).toBe(335);
expect(result.data.items[0].actualAmount).toBeNull();
expect(result.data.items[0].source).toBe('forecast');
expect(result.data.items[0].currency).toBe('RUB');
expect(result.data.items[0].name).toBe('Sberbank');
});
it('builds coupon, maturity, and offer events for bonds', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SU26248RMFS4',
instrumentUid: 'uid-bond-1',
instrumentType: 'bond',
quantity: { units: '5', nano: 0 },
},
],
instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]),
});
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'SU26248RMFS4',
couponValue: 35.4,
nextCouponDate: '2026-06-25',
matDate: '2026-06-27',
offerDate: '2026-06-28',
faceValue: 1000,
boardid: 'TQCB',
shortName: '',
price: null,
yieldToMaturity: null,
duration: null,
couponPercent: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
},
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(3);
const coupon = result.data.items.find((e) => e.type === 'coupon')!;
expect(coupon.estimatedAmount).toBe(177);
const offer = result.data.items.find((e) => e.type === 'offer')!;
expect(offer.category).toBe('corporate');
const maturity = result.data.items.find((e) => e.type === 'maturity')!;
expect(maturity.estimatedAmount).toBe(5000);
});
it('handles partial failures gracefully', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'OK_SPLIT',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
{
ticker: 'GOOD',
instrumentUid: 'uid-2',
instrumentType: 'share',
quantity: { units: '5', nano: 0 },
},
],
instruments: new Map([
['uid-1', { name: 'Failing' }],
['uid-2', { name: 'Working' }],
]),
});
vi.mocked(moexDividends.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
vi.mocked(moexDividends.getDividends).mockResolvedValueOnce([
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].ticker).toBe('GOOD');
});
it('includes events without amount when payout is unknown', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'NO_AMT',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
],
instruments: new Map([['uid-1', { name: 'No Amount' }]]),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].estimatedAmount).toBe(0);
});
it('calculates summary totals correctly', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
{
ticker: 'BOND1',
instrumentUid: 'uid-2',
instrumentType: 'bond',
quantity: { units: '2', nano: 0 },
},
],
instruments: new Map([
['uid-1', { name: 'Sber' }],
['uid-2', { name: 'OFZ' }],
]),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'BOND1',
couponValue: 50,
nextCouponDate: '2026-06-26',
matDate: '2026-06-27',
offerDate: null,
faceValue: 1000,
boardid: 'TQCB',
shortName: '',
price: null,
yieldToMaturity: null,
duration: null,
couponPercent: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
},
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.summary.eventCount).toBe(3);
expect(result.data.summary.nearestEventDate).toBe('2026-06-25');
expect(result.data.summary.dividendsTotal).toBe(300);
expect(result.data.summary.couponsTotal).toBe(100);
expect(result.data.summary.principalRepaymentTotal).toBe(2000);
expect(result.data.summary.totalEstimatedCashflow).toBe(2400);
expect(result.data.summary.forecastEstimatedCashflow).toBe(2400);
expect(result.data.summary.actualCashflow).toBe(0);
});
it('filters events by inclusive date range', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '1', nano: 0 },
},
],
instruments: new Map([['uid-1', { name: 'Sber' }]]),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' },
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' });
expect(result.data.items).toHaveLength(2);
expect(result.data.items[0].eventDate).toBe('2026-06-20');
expect(result.data.items[1].eventDate).toBe('2026-07-29');
});
it('filters forecast events by selected event types', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-share',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
{
ticker: 'BOND1',
instrumentUid: 'uid-bond',
instrumentType: 'bond',
quantity: { units: '2', nano: 0 },
},
],
instruments: new Map(),
});
vi.mocked(moexDividends.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'BOND1',
couponValue: 50,
nextCouponDate: '2026-06-26',
matDate: '2026-06-27',
offerDate: '2026-06-28',
faceValue: 1000,
boardid: 'TQCB',
shortName: '',
price: null,
yieldToMaturity: null,
duration: null,
couponPercent: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
},
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', {
from: '2026-06-20',
to: '2026-07-10',
types: 'coupon,maturity',
});
expect(result.data.items.map((event) => event.type)).toEqual(['coupon', 'maturity']);
expect(cache.getOrFetch).toHaveBeenCalledWith(
expect.any(String),
['acc-1', '2026-06-20', '2026-07-10', 'coupon,maturity'],
expect.any(Function),
'tbankPortfolioTtl',
);
});
it('adds actual past income events from broker operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [],
instruments: new Map(),
});
vi.mocked(operations.getOperations).mockResolvedValue({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'cur-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-18T10:00:00.000Z',
type: 'OPERATION_TYPE_DIVIDEND',
category: 'income',
description: 'Dividend payment',
name: 'Sberbank',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-sber',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '123', nano: 450000000, value: 123.45 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
const result = await service.getEvents('acc-1', {
from: '2026-06-15',
to: '2026-06-20',
types: 'dividend',
});
expect(operations.getOperations).toHaveBeenCalledWith('acc-1', {
from: '2026-06-15T00:00:00.000Z',
to: '2026-06-20T23:59:59.999Z',
operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT',
limit: 100,
state: 'OPERATION_STATE_EXECUTED',
});
expect(result.data.items).toEqual([
expect.objectContaining({
id: 'actual-op-1',
type: 'dividend',
source: 'actual',
eventDate: '2026-06-18',
actualAmount: 123.45,
estimatedAmount: null,
estimateMode: null,
currency: 'RUB',
}),
]);
expect(result.data.summary.actualCashflow).toBe(123.45);
expect(result.data.summary.actualDividendsTotal).toBe(123.45);
expect(result.data.summary.forecastEstimatedCashflow).toBe(0);
});
});

Some files were not shown because too many files have changed in this diff Show More