Compare commits

...

243 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
52ebe3256d docs: expand product vision and technical debt
All checks were successful
CI / ci (pull_request) Successful in 3m16s
CI / ci (push) Successful in 3m9s
2026-06-19 15:50:21 +03:00
1e007de5be feat: add broker accounts overview
All checks were successful
CI / ci (pull_request) Successful in 3m21s
CI / ci (push) Successful in 3m6s
2026-06-19 14:31:53 +03:00
706bb12b6c docs: plan broker accounts overview 2026-06-19 14:01:57 +03:00
b7b627a0f3 docs: define broker accounts overview 2026-06-19 13:55:35 +03:00
a95308762f docs: improve AGENTS.md and README.md
All checks were successful
CI / ci (push) Successful in 3m20s
2026-06-19 13:32:49 +03:00
1dc27a6e9b feat: добавил инструкции к SDD подходу
All checks were successful
CI / ci (push) Successful in 3m13s
2026-06-19 07:38:33 +03:00
d8b3886130 docs: complete broker account sections 2026-06-19 07:11:18 +03:00
2223463dd3 feat: filter broker operations by exact type 2026-06-19 07:06:49 +03:00
910494a4e5 feat: add broker asset pages 2026-06-19 07:03:09 +03:00
b82771107d fix: harden broker overview edge cases 2026-06-19 06:52:22 +03:00
a19ad67a89 feat: add broker account overview 2026-06-19 06:43:38 +03:00
1c26d2a3eb fix: improve broker account shell accessibility 2026-06-19 06:32:20 +03:00
633def5ebb fix: keep broker shell styling scoped 2026-06-19 06:27:04 +03:00
dc4b6ddf1b feat: add broker account section navigation 2026-06-19 06:23:35 +03:00
6d2df6a12b fix: stabilize broker display models 2026-06-19 06:13:23 +03:00
2462f2122c feat: add broker account display models 2026-06-19 06:08:17 +03:00
5cb51e44f2 chore: sync broker portfolio contract 2026-06-19 06:00:15 +03:00
8aea56cbbf feat: expose broker position counts 2026-06-18 23:15:32 +03:00
da6d05e425 chore: ignore local worktrees 2026-06-18 23:10:25 +03:00
be24184e4f docs: approve broker account sections plan 2026-06-18 23:07:42 +03:00
62cef9b1c4 docs: plan broker account sections 2026-06-18 23:04:47 +03:00
30922aaa28 docs: link broker account feature to roadmap 2026-06-18 22:51:08 +03:00
0c12e6d610 docs: specify broker account sections 2026-06-18 22:47:24 +03:00
5b9d7f3a27 docs: fix historical files to new structure and update
All checks were successful
CI / ci (pull_request) Successful in 3m20s
CI / ci (push) Successful in 3m1s
2026-06-18 22:04:09 +03:00
dca8418843 feat: добавил инструкции к SDD подходу 2026-06-18 21:50:38 +03:00
af135d5640 test: add isFetching to mock return values
All checks were successful
CI / ci (push) Successful in 2m59s
2026-06-18 07:29:35 +03:00
2d671669d4 feat: pass isFetching to BrokerOperationsTable 2026-06-18 07:29:35 +03:00
26371381e0 feat: add loading overlay and spinner to BrokerOperationsTable 2026-06-18 07:29:35 +03:00
90e9c468de feat: add loading overlay and spinner to PositionGroupTable 2026-06-18 07:29:35 +03:00
510ee91fab style: add loading-spinner and overlay CSS classes 2026-06-18 07:29:35 +03:00
ce538ff443 docs: add pagination loading overlay design spec (C3) 2026-06-18 07:29:35 +03:00
b9ace3ed5e ci: merge lint/test/build into single job, add frontend tests
Some checks are pending
CI / ci (pull_request) Successful in 3m6s
CI / ci (push) Has started running
2026-06-18 06:39:06 +03:00
feaff2103e perf(broker): parallel instrument name loading with per-service rate limit queues
- Split single p-queue (5 req/s) into 3 isolated queues:
  operations (5/s), instruments (20/s), users (5/s)
- Removed dead instruments param from mapBrokerPortfolio
- portfolio/positions endpoints share raw GetPortfolio cache
- Docs: T_BANK_INSTRUMENTS_RATE_LIMIT, CACHE_TBANK_POSITIONS_TTL,
  rate limiting section in tbank-invest.md
2026-06-18 06:34:55 +03:00
49ee364856 feat(broker): per-type positions pagination with independent tables
All checks were successful
CI / lint (pull_request) Successful in 2m9s
CI / test (pull_request) Successful in 1m56s
CI / build (pull_request) Successful in 2m6s
CI / lint (push) Successful in 1m56s
CI / test (push) Successful in 1m55s
CI / build (push) Successful in 2m19s
- Add type query param to GET /accounts/:accountId/positions endpoint
- Backend filters T-Bank portfolio positions by instrument type before pagination
- Each instrument type (share, bond, etf, fund) has its own frontend table with
  independent cursor-based pagination and skeleton loading
- Groups with no positions are automatically hidden
- Cache key includes type for correct per-type caching
- Remove centralized positions pagination state from BrokerAccountDetailPage
- 94 backend tests / 112 frontend tests pass
2026-06-18 06:02:54 +03:00
5ccd421259 test(frontend): update broker tests for positions hook and removal from portfolio 2026-06-17 14:50:35 +03:00
e25daa931a feat(frontend): add skeleton cards to broker accounts page 2026-06-17 14:47:10 +03:00
28579321a9 feat(frontend): add positions hook and skeleton loading to account detail page 2026-06-17 14:46:48 +03:00
2670097a5e feat(frontend): add shimmer loading and instrument name in operations table 2026-06-17 14:46:06 +03:00
e0c9d97ded feat(frontend): add pagination and skeleton to BrokerPositionsSection 2026-06-17 14:45:27 +03:00
d7b5a376a0 feat(frontend): add SkeletonBlock and TableSkeleton components 2026-06-17 14:44:15 +03:00
608e521674 feat(frontend): add BrokerPositionsPage types, API, and hook 2026-06-17 14:43:50 +03:00
33199a8749 feat(frontend): add shimmer animation and .skeleton CSS class 2026-06-17 14:42:49 +03:00
4b87eccba4 test(tbank): update portfolio tests, add getPositions tests, fix operation fixture name 2026-06-17 14:41:32 +03:00
8b202ef7d3 feat(tbank): add GET /positions endpoint with cursor pagination 2026-06-17 14:39:30 +03:00
6b3bdd2aec fix(tbank): include cursor/limit in positions cache key, add tbankPositionsTtl config 2026-06-17 14:38:51 +03:00
bb7fef8deb feat(tbank): add getPositions() method to BrokerPortfolioService 2026-06-17 14:36:32 +03:00
05f1e792c5 feat(tbank): extract mapBrokerPosition, add mapBrokerPositionsPage, add name to operation 2026-06-17 14:34:02 +03:00
953c7c29a6 feat(tbank): add positions page types/DTOs and operation name field 2026-06-17 14:30:48 +03:00
0517501415 chore: address review feedback - consistent style, spec accuracy, test regex 2026-06-17 13:50:36 +03:00
cc8ff0a088 feat: add keepPreviousData for smooth pagination 2026-06-17 13:48:00 +03:00
a8bce856d9 fix: add back BrokerOperationImpact type import for moneyColor 2026-06-17 13:47:34 +03:00
2e0c0e7df8 feat: remove impact badges, add + prefix, style pagination buttons 2026-06-17 13:45:07 +03:00
8e2151fc34 test: update broker page tests for new UI expectations 2026-06-17 13:43:43 +03:00
4682586002 refactor: remove unused getBrokerOperationImpactLabel helper 2026-06-17 13:41:17 +03:00
d16078fce9 docs: add broker operations UI improvements spec 2026-06-17 13:37:52 +03:00
732 changed files with 67909 additions and 9512 deletions

View File

@ -9,46 +9,57 @@ env:
NODE_VERSION: 20
jobs:
lint:
ci:
runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
- name: Checkout repository
uses: actions/checkout@v4
- name: 'Setup dependencies'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm run lint
- run: npx prettier --check "**/*.{ts,tsx}"
- name: Install dependencies
run: npm ci
test:
runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
uses: actions/checkout@v4
- name: Build design system
run: npm run build:design-system
- name: 'Setup dependencies'
uses: actions/setup-node@v4
- name: Lint
run: npm run lint
- name: Test backend
run: npm run test:backend
- name: Test frontend
run: npm run test:frontend
- name: Build backend
run: npm run build:backend
- name: 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:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm run test:backend
build:
runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
uses: actions/checkout@v4
- name: 'Setup dependencies'
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm run build:backend
- run: npm run build:frontend
name: storybook-static
path: packages/design-system/storybook-static
retention-days: 3

6
.gitignore vendored
View File

@ -1,6 +1,7 @@
node_modules/
dist/
.superpowers/
.worktrees/
.env
*.log
.DS_Store
@ -9,3 +10,8 @@ vite.config.d.ts
vite.config.js
apps/docs/.docusaurus/
apps/docs/build/
.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: []

527
AGENTS.md
View File

@ -1,81 +1,438 @@
# MoexVibe — Инструкция для агента
## Репозиторий
## Содержание
npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite), `apps/docs` (Docusaurus).
- [Обязательный подход к разработке](#обязательный-подход-к-разработке)
- [Процесс разработки](#процесс-разработки)
- [Структура документации](#структура-документации)
- [Назначение документов](#назначение-документов)
- [Правила разработки](#правила-разработки)
- [Определение бага](#определение-бага)
- [Процесс работы над фичей](#процесс-работы-над-фичей)
- [Работа с новыми идеями](#работа-с-новыми-идеями)
- [Работа с существующими фичами](#работа-с-существующими-фичами)
- [Поддержание документации](#поддержание-документации)
- [Поведение AI-агентов](#поведение-ai-агентов)
- [Anti-Loop: лимит на итерации](#anti-loop-лимит-на-итерации)
- [Приоритет источников информации](#приоритет-источников-информации)
- [Git workflow](#git-workflow)
- [Конвенция коммитов](#конвенция-коммитов)
- [Документация и SDD-артефакты](#документация-и-sdd-артефакты)
- [Definition of Done (DoD)](#definition-of-done-dod)
- [Технические регламенты](#технические-регламенты)
- [Правила тестирования](#правила-тестирования)
- [Работа с миграциями Prisma](#работа-с-миграциями-prisma)
- [Правила рефакторинга](#правила-рефакторинга)
- [Политики безопасности](#политики-безопасности)
- [ADR-процесс](#adr-процесс)
- [Правила обновления OpenAPI/типов](#правила-обновления-openapiтипов)
- [Цикл работы над API](#цикл-работы-над-api)
- [Инфраструктура проекта](#инфраструктура-проекта)
- [Команды](#команды)
- [Переменные окружения](#переменные-окружения)
- [Архитектура](#архитектура)
- [Бэкенд](#бэкенд)
- [Фронтенд](#фронтенд)
- [Стиль кода](#стиль-кода)
---
## Обязательный подход к разработке
- **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 перед завершением крупных изменений.
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
- **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-инструменты**: в проекте настроены `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 — инструмент, а не отдельный режим работы.
## Git workflow
---
## Процесс разработки
Проект использует подход Specification-Driven Development (SDD).
### Структура документации
```text
docs/
├── inbox.md
├── roadmap.md
├── research/
├── epics/
│ └── {epic-name}.md
└── features/
└── {feature-name}/
├── spec.md
├── plan.md
└── tasks.md
```
Полный набор `spec.md`, `plan.md` и `tasks.md` обязателен для новых фич. Исторические feature-каталоги могут быть неполными: отсутствующие артефакты не требуется восстанавливать задним числом, если это не нужно для текущего изменения.
### Назначение документов
#### inbox.md
Содержит идеи и мысли, которые появились во время работы над проектом.
Записи в inbox не являются требованиями и не должны реализовываться напрямую.
#### roadmap.md
Содержит список запланированных эпиков и фич.
Наличие задачи в roadmap не означает, что её нужно немедленно реализовать.
#### research/
Содержит результаты исследований и экспериментов.
Документы могут содержать гипотезы, предположения и открытые вопросы.
Результаты исследований необходимо проверять перед реализацией.
#### epics/
Эпик представляет собой крупную продуктовую возможность или модуль.
Эпик может состоять из нескольких фич.
#### features/{feature-name}/spec.md
Описывает **ЧТО** должно быть реализовано.
Спецификация должна содержать:
- цель
- требования
- ограничения
- критерии приемки (Acceptance Criteria)
Спецификация не должна содержать деталей реализации.
#### features/{feature-name}/plan.md
Описывает **КАК** будет реализована фича.
План может содержать:
- архитектурные решения
- API контракты
- потоки данных
- технический подход
#### features/{feature-name}/tasks.md
Содержит список задач для реализации.
Задачи должны быть:
- небольшими
- конкретными
- независимыми по возможности
### Правила разработки
#### Правило 1
Нельзя начинать реализацию новой фичи без спецификации.
Если спецификации нет:
- Провести исследование при необходимости.
- Создать spec.md.
- Уточнить требования.
- Только после этого переходить к реализации.
#### Правило 2
Реализация должна соответствовать spec.md.
Если в процессе разработки выясняется, что требования неполные или ошибочные:
- Не изменять поведение системы молча.
- Сначала обновить spec.md и plan.md.
- И только потом продолжать реализацию.
#### Правило 3
Спецификация является источником истины.
Если plan.md противоречит spec.md — приоритет имеет spec.md.
#### Правило 4
Не добавлять функциональность, которая отсутствует в спецификации.
Если появилась новая идея:
- обновить спецификацию;
- либо создать новую фичу.
#### Правило 5
Исправления ошибок можно выполнять напрямую. Новая функциональность должна проходить через спецификацию.
#### Определение бага
Баг — это поведение системы, противоречащее спецификации, acceptance criteria, API-контракту, зафиксированному тестами поведению или подтверждённому архитектурному инварианту.
Если желаемое поведение нигде не зафиксировано и не следует из существующего контракта или инварианта — это отсутствующая функциональность (new feature), а не баг.
Классификация:
- **Есть зафиксированный контракт, поведение не соответствует**баг (можно чинить напрямую, Правило 5)
- **Нет зафиксированного контракта, требуется новое поведение** → новая фича (нужна спецификация)
- **Spec есть, но в нём неопределённость** → сначала уточнить spec, потом решать, баг это или фича
### Процесс работы над фичей
При реализации новой фичи необходимо:
1. Ознакомиться с эпиком, если он существует.
2. Прочитать spec.md.
3. Прочитать plan.md.
4. Прочитать tasks.md.
5. Выполнять задачи последовательно.
6. Отмечать выполненные задачи.
7. Обновлять plan.md при изменении технических решений.
8. Обновлять spec.md при изменении требований.
Для исторической фичи сначала прочитать все имеющиеся артефакты. Отсутствие старого `plan.md` или `tasks.md` само по себе не блокирует maintenance или исправление бага и не требует создавать их задним числом. Для нового расширения такой фичи сначала подготовить недостающие артефакты в объёме текущего изменения.
Если есть согласованный `plan.md` для многошаговой реализации, агент по умолчанию должен
предпочитать `superpowers:subagent-driven-development`. `superpowers:executing-plans` использовать
только когда пользователь явно просит inline-исполнение или когда задачи настолько тесно связаны,
что разбиение по subagent-циклам ухудшит надёжность и скорость.
### Работа с новыми идеями
Если во время реализации появилась новая идея:
Не реализовывать её автоматически. Необходимо определить, является ли она:
- багом;
- улучшением существующей функциональности;
- новой фичей.
Если это улучшение или новая фича — добавить её в inbox.md или создать отдельную фичу.
### Работа с существующими фичами
Улучшения существующей функциональности обычно остаются внутри текущего эпика.
Пример:
```
Portfolio Dashboard
├── История операций
├── Пагинация истории операций
├── Фильтрация истории операций
└── Экспорт истории операций
```
Новый эпик создаётся только при появлении новой продуктовой возможности или нового домена.
### Поддержание документации
Документация должна соответствовать текущему состоянию проекта.
После значимых изменений необходимо обновлять:
- spec.md
- plan.md
- tasks.md
- ADR
- архитектурную документацию
Документация не должна отставать от реализации.
### Поведение AI-агентов
Перед написанием кода необходимо:
1. Изучить спецификацию фичи.
2. Проверить полноту требований.
3. Найти неоднозначности и противоречия.
4. При необходимости запросить уточнения.
5. Перед началом реализации агент должен кратко подтвердить понимание задачи и спецификации (одним сообщением).
**Запрещено:**
- придумывать требования;
- додумывать поведение системы;
- реализовывать неописанную функциональность.
Если информации недостаточно — остановиться и запросить уточнение вместо того, чтобы делать предположения.
### Pre-flight checklist (обязателен перед реализацией любой фичи)
Агент не имеет права начать реализацию, пока не выполнены все пункты:
- [ ] Feature branch создана: `codex/<feature-name>`
- [ ] spec.md написана и утверждена пользователем
- [ ] plan.md написан и утверждён пользователем
- [ ] tasks.md создан с чекбоксами до начала работы
- [ ] Все тесты проходят на текущем состоянии
Нарушение любого пункта = остановиться и вернуться к пропущенному шагу.
### Anti-Loop: лимит на итерации
Если после 3 последовательных неудачных попыток исправить одну и ту же проблему в рамках одной гипотезы симптом не изменился — остановиться и запросить помощь у пользователя.
Правила:
- Каждая попытка = один цикл «сформулировал гипотезу → внёс изменение → проверил → тот же симптом сохранился»
- Сбор новой диагностической информации без изменения кода попыткой не считается
- Не начинать 4-ю попытку без явного указания пользователя
- При запросе помощи приложить: что пытался сделать, что пошло не так, последнее состояние кода/логов
### Приоритет источников информации
При возникновении противоречий использовать следующий порядок приоритетов:
1. Текущая задача пользователя (она может изменить требования, но соответствующие SDD-артефакты обновляются до реализации).
2. spec.md фичи.
3. plan.md фичи.
4. ADR.
5. Архитектурная документация.
6. roadmap.md.
7. inbox.md.
roadmap.md и inbox.md никогда не являются основанием для реализации функциональности.
### Git workflow
- Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё.
- Имя ветки по умолчанию начинать с `codex/`, если пользователь не попросил другой префикс.
- Не смешивать независимые фичи в одной ветке. Небольшие связанные docs/chore/test-правки можно держать в той же ветке, если они относятся к текущей задаче.
## Документация и SDD-артефакты
### Конвенция коммитов
Использовать [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` — новая функциональность
- `fix:` — исправление бага
- `chore:` — обслуживание (зависимости, конфиги, CI)
- `docs:` — документация
- `refactor:` — рефакторинг без изменения поведения
- `test:` — добавление или исправление тестов
- `style:` — форматирование, кодстайл (prettier)
- `perf:` — улучшение производительности
- `build:` — изменения сборки и зависимостей
- `ci:` — изменения CI/CD
Формат: `<тип>(<необязательный scope>): <краткое описание в настоящем времени>`
Примеры:
- `feat: add portfolio rebalancing endpoint`
- `fix: handle empty dividend list from MOEX`
- `docs: update API authentication section`
### Документация и SDD-артефакты
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`.
- Все SDD spec-файлы в `docs/superpowers/specs/` пишутся на русском языке; англоязычные термины допустимы для API, кода, протоколов и официальных названий.
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
- OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`.
- Superpowers plans и временные execution logs не коммитить по умолчанию. Если нужен план для ревью, держать его кратким и переносить устойчивые решения в spec/ADR/docs.
## Команды
### Definition of Done (DoD)
| Команда | Что делает |
|---|---|
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
| `npm run dev:docs` | Docusaurus dev-сервер |
| `npm run build:backend` | `nest build` |
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
| `npm run build:docs` | `docusaurus build` |
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
| `npm run test:frontend` | Frontend Vitest suite |
| `npm run lint` | ESLint для backend и frontend |
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
| `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` |
- Все acceptance criteria реализованы
- Тесты проходят
- Lint проходит
- Для новой фичи созданы и обновлены spec/plan/tasks; для исторической фичи обновлены существующие и необходимые для текущего изменения артефакты
- Документация обновлена
- Нет TODO без согласования
Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
---
Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend`
## Технические регламенты
## Переменные окружения
### Правила тестирования
| Переменная | По умолчанию | Описание |
|---|---|---|
| `PORT` | 3000 | Порт бэкенда |
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker |
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть circuit breaker |
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Локальный rate limiter для T-Bank |
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Deadline gRPC-запроса (мс) |
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
| `CACHE_DIVIDENDS_TTL` | 86400 | TTL дивидендных данных (с) |
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
| `JWT_ACCESS_EXPIRES` | `15m` | TTL access token |
| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token |
- Для новой бизнес-логики → обязательны unit-тесты
- Для API-контрактов → интеграционные тесты
- Не мокать собственный код без необходимости
- В unit-тестах мокать внешние API (MOEX, T-Bank) и Prisma
- При исправлении бага — сначала падающий тест (TDD)
- Тесты писать рядом с основным кодом
### Работа с миграциями Prisma
- Никогда не редактировать файлы в `prisma/migrations/` вручную
- После изменения `schema.prisma``npm exec -w apps/backend -- prisma migrate dev --name <name>`
- Изменять существующие миграции допустимо только до их публикации/мержа. После мержа создавать новую миграцию
- Всегда запускать `npm exec -w apps/backend -- prisma generate` после изменения схемы
### Правила рефакторинга
- Не выполнять крупный рефакторинг вне рамок задачи
- Допустимы: локальные улучшения, устранение техдолга рядом с изменяемым кодом, исправление архитектурных нарушений
- Запрещено: менять структуру проекта без ADR, переписывать модули без отдельной задачи
- Крупный рефакторинг требует отдельного эпика/фичи + ADR
### Политики безопасности
- Запрещено логировать токены, пароли, секреты
- Не отключать guard'ы
- Не хранить секреты в коде, не коммитить .env
- Использовать маскирование при выводе (например, `***`)
### ADR-процесс
- Создавать ADR при: выборе новой технологии, изменении архитектуры, изменении API-контрактов, изменении стратегии хранения данных
- ADR должен содержать: Контекст, Рассмотренные варианты, Решение, Последствия
### Правила обновления OpenAPI/типов
1. Обновить DTO/Controller на бэкенде
2. Обновить Swagger
3. Запустить `npm run codegen -w apps/frontend`
4. Использовать обновлённые типы из `src/api/types.ts`
5. Никогда не редактировать `types.ts` вручную
### Цикл работы над API
Стандартная процедура при любом изменении API-контракта:
1. **Бэкенд** — описать/обновить DTO и контроллер (NestJS)
2. **Swagger** — убедиться, что документация отдаётся корректно (`/api/docs-json`)
3. **Codegen**`npm run codegen -w apps/frontend` (генерирует `src/api/types.ts`)
4. **Фронтенд** — использовать обновлённые типы, адаптировать вызовы
5. **Проверка** — убедиться, что `npm run build` проходит в обоих пакетах
Обновление типов вручную (`types.ts`) запрещено — всегда через codegen.
---
## Инфраструктура проекта
### Команды
Основные команды проекта описаны в README.md.
Перед завершением задачи запускать тесты, lint и build затронутых пакетов.
### Переменные окружения
Основные настройки находятся в .env.
Полный список переменных описан в README.md.
---
## Архитектура
### Бэкенд
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `PortfolioModule`, `HealthModule`.
- Актуальная композиция backend-модулей определяется в `apps/backend/src/app.module.ts`; не дублировать динамический список модулей в инструкциях. Опубликованное описание архитектуры находится в `apps/docs/docs/backend/modules.md`.
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов).
@ -85,7 +442,7 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
- Алиасы: `@/*``src/*` в обоих пакетах.
## Фронтенд
### Фронтенд
- React 18 + react-router-dom v6 + TanStack Query v5.
- `lightweight-charts` v4 для графиков цен.
@ -94,7 +451,7 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
- Конвенция ключей запросов: `['stock', secid]`, `['securities', 'search', query]`, и т.д.
- CSS через `styles.css` (CSS custom properties, без CSS-in-JS или Tailwind).
## Стиль кода
### Стиль кода
- Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
@ -102,3 +459,67 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
- CI находится в `.gitea/workflows/ci.yml`.
- 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).

149
README.md
View File

@ -2,57 +2,168 @@
Веб-приложение для анализа ценных бумаг Московской биржи (MOEX).
## Tech Stack
## Содержание
- **Backend:** NestJS, TypeScript, OpenAPI (Swagger)
- **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts
- **Docs:** Docusaurus
- **Infrastructure:** Docker, docker-compose
- [О проекте](#о-проекте)
- [Стек технологий](#стек-технологий)
- [Быстрый старт](#быстрый-старт)
- [Docker](#docker)
- [Тестирование](#тестирование)
- [Структура проекта](#структура-проекта)
- [Команды](#команды)
- [Переменные окружения](#переменные-окружения)
## Quick Start
---
## О проекте
npm workspaces монорепозиторий:
| Пакет | Назначение |
| --------------- | -------------------------------------------------- |
| `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) |
| `apps/frontend` | React SPA на Vite |
| `apps/docs` | Сайт документации Docusaurus |
| `packages/design-system` | Дизайн-система (Storybook, MUI-адаптер, UI-компоненты) |
---
## Стек технологий
- **Бэкенд:** NestJS, TypeScript, OpenAPI (Swagger)
- **Фронтенд:** React, TypeScript, Vite, TanStack Query, lightweight-charts
- **Дизайн-система:** MUI v7, Storybook 10, lightweight-charts
- **Документация:** Docusaurus
- **Инфраструктура:** Docker, docker-compose
---
## Быстрый старт
```bash
# Install dependencies
npm install
# Настройка локального окружения
cp apps/backend/.env.example apps/backend/.env
# Start backend (http://localhost:3000)
# Установка зависимостей и подготовка базы данных
npm install
npm exec -w apps/backend -- prisma migrate dev
# Запуск бэкенда (http://localhost:3000)
npm run dev:backend
# Start frontend (http://localhost:5173)
# Запуск фронтенда (http://localhost:5173)
npm run dev:frontend
```
Swagger UI: http://localhost:3000/api/docs
---
## Docker
```bash
docker compose up --build
```
- Frontend: http://localhost:80
- Backend: http://localhost:3000
- Фронтенд: http://localhost:80
- Бэкенд: http://localhost:3000
## Tests
---
## Тестирование
```bash
npm run test:backend
npm run test:frontend
```
Live MOEX integration checks are opt-in:
Интеграционные тесты с MOEX — опциональны:
```bash
npm run test:integration -w apps/backend
```
## Project Structure
---
## Структура проекта
```
apps/
backend/ — NestJS API (single point of access to MOEX ISS)
frontend/ — React SPA with Vite
docs/ — Docusaurus documentation site
backend/ — NestJS API, единая точка доступа к MOEX ISS
frontend/ — React SPA на Vite
docs/ — сайт документации Docusaurus
packages/
design-system/ — дизайн-система (MUI-адаптер, UI-компоненты, Storybook)
docs/
superpowers/specs/ — accepted SDD specifications
features/ — спецификации и планы реализации (SDD)
epics/ — продуктовые эпики
inbox.md — идеи и заметки
roadmap.md — запланированные эпики и фичи
```
---
## Команды
| Команда | Что делает |
| ---------------------------------- | --------------------------------------------------------------------------- |
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
| `npm run dev:docs` | Docusaurus dev-сервер (опубликованная документация) |
| `npm run build:backend` | `nest build` |
| `npm run build:frontend` | `tsc -b && vite 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:frontend` | Frontend Vitest suite |
| `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 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`.
Один backend-тест: `npm exec -w apps/backend -- vitest run src/path/to/test.spec.ts`
---
## Переменные окружения
| Переменная | По умолчанию | Описание |
| ------------------------------------ | -------------------------------- | -------------------------------------------------------------- |
| `PORT` | 3000 | Порт бэкенда |
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Адрес MOEX ISS |
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Ошибок до открытия circuit breaker |
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Секунд до попытки закрыть circuit breaker |
| `T_BANK_TOKEN` | `''` | Токен T-Bank Invest (серверный) |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Имя приложения для T-Bank |
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Rate limiter для OperationsService и UsersService (запросов/с) |
| `T_BANK_INSTRUMENTS_RATE_LIMIT` | 20 | Rate limiter для InstrumentsService (запросов/с) |
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Таймаут gRPC-запроса (мс) |
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
| `CACHE_DIVIDENDS_TTL` | 86400 | TTL дивидендных данных (с) |
| `CACHE_TBANK_ACCOUNTS_TTL` | 3600 | TTL брокерских счетов T-Bank (с) |
| `CACHE_TBANK_PORTFOLIO_TTL` | 60 | TTL брокерского портфеля T-Bank (с) |
| `CACHE_TBANK_OPERATIONS_TTL` | 300 | TTL брокерских операций T-Bank (с) |
| `CACHE_TBANK_POSITIONS_TTL` | 60 | TTL брокерских позиций T-Bank (с) |
| `CACHE_TBANK_INSTRUMENT_TTL` | 86400 | TTL инструментов T-Bank (с) |
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
| `JWT_ACCESS_EXPIRES` | `15m` | TTL access token |
| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token |

32
apps/backend/.env.example Normal file
View File

@ -0,0 +1,32 @@
PORT=3000
DATABASE_URL=file:./dev.db
MOEX_BASE_URL=https://iss.moex.com/iss
MOEX_RATE_LIMIT=10
MOEX_CIRCUIT_BREAKER_THRESHOLD=5
MOEX_CIRCUIT_BREAKER_RESET_SECONDS=30
T_BANK_TOKEN=
T_BANK_BASE_URL=invest-public-api.tbank.ru:443
T_BANK_CA_CERT_PATH=
T_BANK_APP_NAME=ksv741.moex-vibe
T_BANK_RATE_LIMIT_PER_SECOND=5
T_BANK_INSTRUMENTS_RATE_LIMIT=20
T_BANK_REQUEST_TIMEOUT_MS=10000
CACHE_MARKET_DATA_TTL=900
CACHE_HISTORY_TTL=3600
CACHE_CANDLES_TTL=3600
CACHE_SECURITY_TTL=86400
CACHE_SEARCH_TTL=3600
CACHE_DIVIDENDS_TTL=86400
CACHE_TBANK_ACCOUNTS_TTL=3600
CACHE_TBANK_PORTFOLIO_TTL=60
CACHE_TBANK_OPERATIONS_TTL=300
CACHE_TBANK_POSITIONS_TTL=60
CACHE_TBANK_INSTRUMENT_TTL=86400
JWT_SECRET=dev-jwt-secret-change-in-production
JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d

View File

@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CacheModule } from './modules/cache/cache.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 { AuthModule } from './modules/auth/auth.module';
import { TBankModule } from './modules/tbank/tbank.module';
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
import configuration from './config/configuration';
@Module({
@ -29,4 +30,8 @@ import configuration from './config/configuration';
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';
export class ApiResponseMeta {
@ApiProperty({ nullable: true })
@ApiProperty({ type: String, nullable: true })
cachedAt: string | null;
@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> {
data: T;
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';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
@ -24,7 +26,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
error = (r.error as string) || exception.name;
}
} 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({

View File

@ -1,7 +1,7 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiResponse } from '../dto/api-response.dto';
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
@Injectable()
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(
map((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);
}),
);

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';
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', () => ({
port: parseInt(process.env.PORT || '3000', 10),
database: {
@ -20,6 +29,7 @@ export default registerAs('app', () => ({
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
instrumentsRateLimitPerSecond: parseInt(process.env.T_BANK_INSTRUMENTS_RATE_LIMIT || '20', 10),
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
},
cache: {
@ -28,11 +38,17 @@ export default registerAs('app', () => ({
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 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),
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 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: {
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 { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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() {
const app = await NestFactory.create(AppModule);
@ -18,10 +46,20 @@ async function bootstrap() {
app.useGlobalInterceptors(new TransformInterceptor());
app.use(cookieParser());
const reqLogMiddleware = new RequestLoggingMiddleware();
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
const configService = app.get(ConfigService);
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()
.setTitle('MoexVibe API')
@ -36,4 +74,7 @@ async function bootstrap() {
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
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) {
const result = await this.authService.register(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
return { user: result.user, accessToken: result.accessToken };
}
@Public()
@ -57,13 +51,7 @@ export class AuthController {
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.login(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
return { user: result.user, accessToken: result.accessToken };
}
@Public()
@ -75,13 +63,7 @@ export class AuthController {
const token = req.cookies?.[REFRESH_COOKIE];
const result = await this.authService.refresh(token);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
return { user: result.user, accessToken: result.accessToken };
}
@Post('logout')
@ -92,10 +74,7 @@ export class AuthController {
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
await this.authService.logout(user.sub);
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
return {
data: { message: 'Logged out successfully' },
meta: { fromCache: false, cachedAt: null },
};
return { message: 'Logged out successfully' };
}
@Get('me')
@ -103,11 +82,7 @@ export class AuthController {
@ApiOperation({ summary: 'Get current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async getProfile(@CurrentUser() user: JwtPayload) {
const profile = await this.authService.getProfile(user.sub);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
return this.authService.getProfile(user.sub);
}
@Patch('me')
@ -115,10 +90,6 @@ export class AuthController {
@ApiOperation({ summary: 'Update current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
const profile = await this.authService.updateProfile(user.sub, dto);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
return this.authService.updateProfile(user.sub, dto);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,15 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { Injectable } from '@nestjs/common';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
@Injectable()
export class BondsService {
constructor(
private readonly moexClient: MoexClientService,
private readonly moexMarketData: MoexMarketDataClient,
private readonly moexHistory: MoexHistoryClient,
private readonly cache: CacheService,
) {}
@ -17,23 +21,23 @@ export class BondsService {
} = await this.cache.getOrFetch(
'bond',
[secid],
() => this.moexClient.getBondData(secid),
() => this.moexMarketData.getBondData(secid),
'securityTtl',
);
if (!bond) {
throw new NotFoundException(`Bond ${secid} not found`);
throw new EntityNotFoundException('Bond', secid);
}
const { data: mkt } = await this.cache.getOrFetch(
'marketdata',
['bonds', secid],
() => this.moexClient.getBondMarketData(secid),
() => this.moexMarketData.getBondMarketData(secid),
'marketDataTtl',
);
return {
data: {
return new ApiEnvelopePayload(
{
secid: bond.secid,
isin: bond.isin,
name: bond.shortName,
@ -70,8 +74,9 @@ export class BondsService {
: new Date().toISOString(),
},
},
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getMarketData(secid: string) {
@ -82,16 +87,16 @@ export class BondsService {
} = await this.cache.getOrFetch(
'marketdata',
['bonds', secid],
() => this.moexClient.getBondMarketData(secid),
() => this.moexMarketData.getBondMarketData(secid),
'marketDataTtl',
);
if (!mkt) {
throw new NotFoundException(`Market data for bond ${secid} not found`);
throw new EntityNotFoundException('MarketData', `bond ${secid}`);
}
return {
data: {
return new ApiEnvelopePayload(
{
price: mkt.last ?? 0,
yieldToMaturity: mkt.yield ?? null,
duration: mkt.duration ?? null,
@ -107,26 +112,28 @@ export class BondsService {
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
: new Date().toISOString(),
},
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getHistory(secid: string, from: string, till: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'history',
['bonds', secid, from, till],
() => this.moexClient.getBondHistory(secid, from, till),
() => this.moexHistory.getBondHistory(secid, from, till),
'historyTtl',
);
return {
data: data.map((h) => ({
return new ApiEnvelopePayload(
data.map((h) => ({
date: h.tradeDate,
closePrice: h.legalClosePrice ?? h.close ?? 0,
yieldClose: h.yieldClose ?? 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 { ConfigService } from '@nestjs/config';
type CacheEntry<T> = {
data: T;
cachedAt: string;
};
@Injectable()
export class CacheService {
constructor(
@ -18,6 +23,16 @@ export class CacheService {
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 {
return parts.join(':');
}
@ -31,14 +46,19 @@ export class CacheService {
const key = this.buildKey(keyPrefix, ...keyParts);
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) {
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();
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 { 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 { CandlesQueryDto } from './dto/candles-query.dto';
import { CandleEnvelopeDto } from './dto/candles-envelope.dto';
@ApiTags('Candles')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities')
export class CandlesController {
constructor(private readonly candlesService: CandlesService) {}
@Get('shares/:secid/candles')
@ApiOperation({ summary: 'Получить свечи акции' })
@ApiOkResponse({ type: CandleEnvelopeDto })
async getShareCandles(
@Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto,
@ -19,6 +23,7 @@ export class CandlesController {
@Get('bonds/:secid/candles')
@ApiOperation({ summary: 'Получить свечи облигации' })
@ApiOkResponse({ type: CandleEnvelopeDto })
async getBondCandles(
@Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto,

View File

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

View File

@ -1,16 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
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 { CandleInterval } from './dto/candles-query.dto';
describe('CandlesService', () => {
let service: CandlesService;
let moexClient: Pick<MoexClientService, 'getCandles'>;
let moexCandles: Pick<MoexCandlesClient, 'getCandles'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => {
moexClient = {
moexCandles = {
getCandles: vi.fn(),
};
cache = {
@ -24,7 +24,7 @@ describe('CandlesService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CandlesService,
{ provide: MoexClientService, useValue: moexClient },
{ provide: MoexCandlesClient, useValue: moexCandles },
{ provide: CacheService, useValue: cache },
],
}).compile();
@ -33,7 +33,7 @@ describe('CandlesService', () => {
});
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,
high: 325,
@ -60,7 +60,7 @@ describe('CandlesService', () => {
expect.any(Function),
'candlesTtl',
);
expect(moexClient.getCandles).toHaveBeenCalledWith(
expect(moexCandles.getCandles).toHaveBeenCalledWith(
'stock',
'shares',
'SBER',
@ -81,15 +81,13 @@ describe('CandlesService', () => {
end: '2026-05-01 23:59:59',
},
],
meta: {
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
},
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
});
});
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(
'bonds',
@ -105,7 +103,7 @@ describe('CandlesService', () => {
expect.any(Function),
'candlesTtl',
);
expect(moexClient.getCandles).toHaveBeenCalledWith(
expect(moexCandles.getCandles).toHaveBeenCalledWith(
'stock',
'bonds',
'SU26238RMFS5',

View File

@ -1,12 +1,13 @@
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 { CandleInterval } from './dto/candles-query.dto';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
@Injectable()
export class CandlesService {
constructor(
private readonly moexClient: MoexClientService,
private readonly moexCandles: MoexCandlesClient,
private readonly cache: CacheService,
) {}
@ -25,12 +26,12 @@ export class CandlesService {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'candles',
[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',
);
return {
data: data.map((c) => ({
return new ApiEnvelopePayload(
data.map((c) => ({
open: c.open,
high: c.high,
low: c.low,
@ -40,7 +41,8 @@ export class CandlesService {
begin: c.begin,
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 { 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 { HealthEnvelopeDto } from './dto/health-envelope.dto';
import { HealthService } from './health.service';
@ApiTags('Health')
@ApiExtraModels(ApiResponseMeta)
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
@Public()
@ApiOperation({ summary: 'Проверка состояния сервиса' })
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
};
@ApiOkResponse({ type: HealthEnvelopeDto })
async check() {
return this.healthService.check();
}
}

View File

@ -1,7 +1,11 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [HealthController],
providers: [HealthService],
})
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 { MoexClientService } from './moex-client.service';
import { Module } from '@nestjs/common';
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({
providers: [MoexClientService],
exports: [MoexClientService],
providers: [
MoexHttpClient,
MoexSecuritiesClient,
MoexMarketDataClient,
MoexCandlesClient,
MoexHistoryClient,
MoexDividendsClient,
],
exports: [
MoexSecuritiesClient,
MoexMarketDataClient,
MoexCandlesClient,
MoexHistoryClient,
MoexDividendsClient,
],
})
export class MoexClientModule {}

View File

@ -1,32 +1,35 @@
import 'reflect-metadata';
import { Test, TestingModule } from '@nestjs/testing';
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';
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 () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [MoexClientService],
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule],
}).compile();
service = module.get<MoexClientService>(MoexClientService);
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient);
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
});
it('возвращает результаты поиска для SBER из live MOEX', async () => {
const results = await service.searchSecurities('SBER');
const results = await moexSecurities.searchSecurities('SBER');
expect(results.length).toBeGreaterThan(0);
expect(results[0].secid).toBeDefined();
}, 15000);
it('возвращает рыночные данные SBER из live MOEX', async () => {
const data = await service.getShareMarketData('SBER');
const data = await moexMarketData.getShareMarketData('SBER');
expect(data).toBeDefined();
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,
MaxLength,
MinLength,
IsDateString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@ -31,7 +32,7 @@ export class AddPositionDto {
@ApiProperty({ example: 10 })
@IsInt()
@Min(0)
@Min(1)
quantity!: number;
@ApiPropertyOptional({ example: 250.5 })
@ -41,7 +42,7 @@ export class AddPositionDto {
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-01' })
@IsString()
@IsDateString()
@IsOptional()
buyDate?: string;

View File

@ -11,6 +11,24 @@ export class PortfolioSummaryDto {
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
@ApiProperty() positionCount!: number;
@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 {

View File

@ -1,53 +1,46 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { AnalyticsResponseDto } from './analytics-response.dto';
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-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 {
@ApiProperty({ type: [PortfolioListResponseDto] })
data!: PortfolioListResponseDto[];
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class PortfolioEnvelopeDto {
@ApiProperty({ type: PortfolioResponseDto })
data!: PortfolioResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class PortfolioDetailEnvelopeDto {
@ApiProperty({ type: PortfolioDetailResponseDto })
data!: PortfolioDetailResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class PositionEnvelopeDto {
@ApiProperty({ type: PositionResponseDto })
data!: PositionResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class AnalyticsEnvelopeDto {
@ApiProperty({ type: AnalyticsResponseDto })
data!: AnalyticsResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -9,6 +9,16 @@ export class PortfolioResponseDto {
@ApiProperty({ default: 'RUB' }) currency!: string;
@ApiProperty() createdAt!: 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 {

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 { ApiPropertyOptional } from '@nestjs/swagger';
import {
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;
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 {
@ApiPropertyOptional({ example: 'Мой портфель' })
@IsString()
@ -22,4 +48,11 @@ export class UpdatePortfolioDto {
@IsIn(CURRENCIES)
@IsOptional()
currency?: string;
@ApiPropertyOptional({ example: { sharesPercent: 70, bondsPercent: 30 } })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => PortfolioTargetsDto)
targets?: PortfolioTargetsDto;
}

View File

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

View File

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

View File

@ -2,15 +2,18 @@ import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { PortfolioService } from './portfolio.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 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', () => {
let service: PortfolioService;
let prisma: PrismaService;
let moexClient: MoexClientService;
let moexMarketData: MoexMarketDataClient;
let module: TestingModule;
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
@ -65,13 +68,20 @@ describe('PortfolioService', () => {
},
},
{
provide: MoexClientService,
provide: MoexSecuritiesClient,
useValue: { getSecurityDescription: vi.fn() },
},
{
provide: MoexMarketDataClient,
useValue: {
getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(),
getSecurityDescription: vi.fn(),
},
},
{
provide: MoexDividendsClient,
useValue: { getDividends: vi.fn() },
},
{
provide: CacheService,
useValue: {
@ -83,7 +93,7 @@ describe('PortfolioService', () => {
service = module.get<PortfolioService>(PortfolioService);
prisma = module.get<PrismaService>(PrismaService);
moexClient = module.get<MoexClientService>(MoexClientService);
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
});
beforeEach(() => {
@ -137,11 +147,11 @@ describe('PortfolioService', () => {
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 },
] as any);
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'SU26238RMFS5',
shortName: 'OFZ 26238',
@ -203,14 +213,14 @@ describe('PortfolioService', () => {
});
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);
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);
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 () => {
@ -235,7 +245,7 @@ describe('PortfolioService', () => {
}),
);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
] 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 },
] as any);
@ -323,7 +333,7 @@ describe('PortfolioService', () => {
}),
);
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'SU26238RMFS5',
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 },
] 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);
@ -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: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
] 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: 'GAZP', shortName: 'Gazprom', last: 180 },
] as any);
@ -524,16 +534,16 @@ describe('PortfolioService', () => {
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);
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);
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 {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
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 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 { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto';
@ -54,12 +60,14 @@ export interface EnrichedPosition {
export class PortfolioService {
constructor(
private readonly prisma: PrismaService,
private readonly moexClient: MoexClientService,
private readonly moexSecurities: MoexSecuritiesClient,
private readonly moexMarketData: MoexMarketDataClient,
private readonly moexDividends: MoexDividendsClient,
private readonly cache: CacheService,
) {}
async create(userId: number, dto: CreatePortfolioDto) {
return this.prisma.portfolio.create({
const portfolio = await this.prisma.portfolio.create({
data: {
userId,
name: dto.name,
@ -67,6 +75,8 @@ export class PortfolioService {
currency: dto.currency ?? 'RUB',
},
});
return { ...portfolio, targets: null };
}
async findAll(userId: number) {
@ -89,6 +99,7 @@ export class PortfolioService {
positionCount: 0,
shareCount: 0,
bondCount: 0,
targets: p.targets ? JSON.parse(p.targets) : null,
}));
}
@ -117,6 +128,7 @@ export class PortfolioService {
positionCount: positions.length,
shareCount: positions.filter((pos) => pos.type === 'share').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 },
});
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
@ -154,28 +166,35 @@ export class PortfolioService {
positions: positionsWithWeights,
totalValue: Math.round(totalValue * 100) / 100,
analytics: analytics.summary,
targets: portfolio.targets ? JSON.parse(portfolio.targets) : null,
};
}
async update(userId: number, id: number, dto: UpdatePortfolioDto) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
return this.prisma.portfolio.update({
const updated = await this.prisma.portfolio.update({
where: { id },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(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) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
await this.prisma.portfolio.delete({ where: { id } });
}
@ -185,8 +204,8 @@ export class PortfolioService {
where: { id: portfolioId },
include: { positions: true },
});
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const exists = portfolio.positions.find((p) => p.secid === dto.secid);
if (exists)
@ -194,7 +213,7 @@ export class PortfolioService {
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`);
const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
@ -220,12 +239,12 @@ export class PortfolioService {
dto: UpdatePositionDto,
) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) {
throw new NotFoundException(`Position ${positionId} not found`);
throw new EntityNotFoundException('Position', positionId);
}
return this.prisma.position.update({
@ -242,12 +261,12 @@ export class PortfolioService {
async removePosition(userId: number, portfolioId: number, positionId: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
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 } });
@ -272,9 +291,10 @@ export class PortfolioService {
const shareSecids = [...new Set(sharePositions.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.fetchBondBatch(bondSecids, portfolioId),
this.fetchDividendsBatch(shareSecids, portfolioId),
]);
const enriched: EnrichedPosition[] = [];
@ -305,7 +325,9 @@ export class PortfolioService {
if (pos.type === 'bond') {
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
} 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(
'batchdata',
['shares', cacheKey],
() => this.moexClient.getShareMarketDataBatch(secids),
() => this.moexMarketData.getShareMarketDataBatch(secids),
'marketDataTtl',
);
return new Map(data.map((d) => [d.secid, d]));
@ -336,12 +358,32 @@ export class PortfolioService {
const { data } = await this.cache.getOrFetch(
'batchdata',
['bonds', cacheKey],
() => this.moexClient.getBondPositionDataBatch(secids),
() => this.moexMarketData.getBondPositionDataBatch(secids),
'marketDataTtl',
);
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(
pos: {
id: number;
@ -352,9 +394,16 @@ export class PortfolioService {
},
base: EnrichedPosition,
data: MoexShareMarketData | undefined,
dividends?: MoexDividend[],
): EnrichedPosition {
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) {
return {
@ -472,8 +521,8 @@ export class PortfolioService {
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new ForbiddenException();
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
@ -494,6 +543,32 @@ export class PortfolioService {
)
: 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 = {
totalInvested,
totalValue,
@ -504,6 +579,12 @@ export class PortfolioService {
totalReturnPercent,
positionCount,
weightedYield,
targetSharesPercent,
targetBondsPercent,
actualSharesPercent,
actualBondsPercent,
sharesDeviation,
bondsDeviation,
};
return { positions: enrichedPositions, summary };

View File

@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
export class ScreenerItemDto {
@ApiProperty({ example: 'SBER' })
@ -70,18 +71,10 @@ export class ScreenerResultDto {
totalPages!: number;
}
class ScreenerResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class ScreenerResponseDto {
@ApiProperty({ type: ScreenerResultDto })
data!: ScreenerResultDto;
@ApiProperty({ type: ScreenerResponseMetaDto })
meta!: ScreenerResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
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 { 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 { ScreenerType } from './dto/screener-query.dto';
describe('ScreenerService', () => {
let service: ScreenerService;
let cache: CacheService;
const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
ScreenerService,
{
provide: MoexClientService,
useValue: {
getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(),
},
},
{
provide: CacheService,
useValue: {
getOrFetch: vi.fn(),
},
},
{ provide: MoexMarketDataClient, useValue: moexMarketData },
{ provide: CacheService, useValue: { getOrFetch: vi.fn() } },
],
}).compile();
@ -37,6 +28,30 @@ describe('ScreenerService', () => {
});
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 () => {
const mockShares = [
{

View File

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

View File

@ -47,7 +47,7 @@ describe('SecuritiesController', () => {
it('should return search results', async () => {
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);
});
});

View File

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

View File

@ -1,11 +1,11 @@
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 { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service';
@Module({
imports: [CacheModule],
imports: [MoexClientModule],
controllers: [SecuritiesController],
providers: [SecuritiesService, ScreenerService],
exports: [SecuritiesService],

View File

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

View File

@ -1,5 +1,5 @@
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 { SecurityType } from './dto/search-query.dto';
@ -16,7 +16,7 @@ export interface SearchResultItem {
@Injectable()
export class SecuritiesService {
constructor(
private readonly moexClient: MoexClientService,
private readonly moexSecurities: MoexSecuritiesClient,
private readonly cache: CacheService,
) {}
@ -25,7 +25,7 @@ export class SecuritiesService {
'search',
[query.toLowerCase()],
async () => {
const results = await this.moexClient.searchSecurities(query);
const results = await this.moexSecurities.searchSecurities(query);
return results
.map((s): SearchResultItem | null => {
const type =
@ -64,7 +64,7 @@ export class SecuritiesService {
async getShareBrief(secid: string): Promise<SearchResultItem | null> {
try {
const desc = await this.moexClient.getSecurityDescription(secid);
const desc = await this.moexSecurities.getSecurityDescription(secid);
if (!desc) return null;
return {
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 { 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 {
ShareEnvelopeDto,
ShareMarketDataEnvelopeDto,
DividendsEnvelopeDto,
ShareHistoryEnvelopeDto,
} from './dto/shares-envelope.dto';
@ApiTags('Shares')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities/shares')
export class SharesController {
constructor(private readonly sharesService: SharesService) {}
@Get(':secid')
@ApiOperation({ summary: 'Получить спецификацию акции' })
@ApiOkResponse({ type: ShareEnvelopeDto })
async getShare(@Param('secid') secid: string) {
const share = await this.sharesService.getShare(secid);
return { data: share, meta: { cachedAt: null, fromCache: false } };
return this.sharesService.getShare(secid);
}
@Get(':secid/marketdata')
@ApiOperation({ summary: 'Получить рыночные данные акции' })
@ApiOkResponse({ type: ShareMarketDataEnvelopeDto })
async getMarketData(@Param('secid') secid: string) {
return this.sharesService.getMarketData(secid);
}
@Get(':secid/dividends')
@ApiOperation({ summary: 'Получить дивиденды' })
@ApiOkResponse({ type: DividendsEnvelopeDto })
async getDividends(@Param('secid') secid: string) {
return this.sharesService.getDividends(secid);
}
@Get(':secid/history')
@ApiOperation({ summary: 'Получить дневную историю торгов акции' })
@ApiOkResponse({ type: ShareHistoryEnvelopeDto })
async getHistory(
@Param('secid') secid: string,
@Query('from') from: string,

View File

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

View File

@ -1,17 +1,23 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
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';
describe('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'>;
beforeEach(async () => {
moexClient = {
moexSecurities = {
getSecurityDescription: vi.fn(),
};
moexMarketData = {
getShareMarketData: vi.fn(),
};
cache = {
@ -25,7 +31,10 @@ describe('SharesService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
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 },
],
}).compile();
@ -34,7 +43,7 @@ describe('SharesService', () => {
});
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',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
@ -52,7 +61,7 @@ describe('SharesService', () => {
morningSession: true,
eveningSession: true,
});
vi.mocked(moexClient.getShareMarketData).mockResolvedValue({
vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({
secid: 'SBER',
boardid: 'TQBR',
shortName: 'Сбербанк',
@ -75,15 +84,15 @@ describe('SharesService', () => {
const result = await service.getShare('SBER');
expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER');
expect(moexSecurities.getSecurityDescription).toHaveBeenCalledWith('SBER');
expect(cache.getOrFetch).toHaveBeenCalledWith(
'marketdata',
['shares', 'SBER'],
expect.any(Function),
'marketDataTtl',
);
expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
expect(result).toMatchObject({
expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER');
expect(result.data).toMatchObject({
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
@ -106,11 +115,11 @@ describe('SharesService', () => {
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 () => {
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
it('throws EntityNotFoundException for non-share security', async () => {
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
name: 'ОФЗ 26238',
@ -129,7 +138,7 @@ describe('SharesService', () => {
eveningSession: false,
});
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException);
expect(cache.getOrFetch).not.toHaveBeenCalled();
});
});

View File

@ -1,16 +1,24 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { Injectable } from '@nestjs/common';
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 { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
@Injectable()
export class SharesService {
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,
) {}
async getShare(secid: string) {
const desc = await this.moexClient.getSecurityDescription(secid);
const desc = await this.moexSecurities.getSecurityDescription(secid);
if (
!desc ||
!(
@ -19,13 +27,17 @@ export class SharesService {
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',
['shares', secid],
() => this.moexClient.getShareMarketData(secid),
() => this.moexMarketData.getShareMarketData(secid),
'marketDataTtl',
);
@ -33,32 +45,36 @@ export class SharesService {
const change = marketData?.lastChange ?? 0;
const changePercent = marketData?.lastChangePrcnt ?? 0;
return {
secid: desc.secid,
isin: desc.isin,
name: desc.name,
shortName: desc.shortName,
latName: desc.latName,
listLevel: desc.listLevel,
issueSize: desc.issueSize,
faceValue: desc.faceValue,
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
type: desc.type,
marketData: {
price: price ?? 0,
change,
changePercent,
open: marketData?.open ?? 0,
high: marketData?.high ?? null,
low: marketData?.low ?? null,
volume: marketData?.volume ?? 0,
value: marketData?.value ?? 0,
issueCapitalization: marketData?.issueCapitalization ?? null,
updatedAt: marketData?.updateTime
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(),
return new ApiEnvelopePayload(
{
secid: desc.secid,
isin: desc.isin,
name: desc.name,
shortName: desc.shortName,
latName: desc.latName,
listLevel: desc.listLevel,
issueSize: desc.issueSize,
faceValue: desc.faceValue,
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
type: desc.type,
marketData: {
price: price ?? 0,
change,
changePercent,
open: marketData?.open ?? 0,
high: marketData?.high ?? null,
low: marketData?.low ?? null,
volume: marketData?.volume ?? 0,
value: marketData?.value ?? 0,
issueCapitalization: marketData?.issueCapitalization ?? null,
updatedAt: marketData?.updateTime
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(),
},
},
};
fromCache,
cachedAt,
);
}
async getMarketData(secid: string) {
@ -69,16 +85,16 @@ export class SharesService {
} = await this.cache.getOrFetch(
'marketdata',
['shares', secid],
() => this.moexClient.getShareMarketData(secid),
() => this.moexMarketData.getShareMarketData(secid),
'marketDataTtl',
);
if (!marketData) {
throw new NotFoundException(`Market data for ${secid} not found`);
throw new EntityNotFoundException('MarketData', secid);
}
return {
data: {
return new ApiEnvelopePayload(
{
price: marketData.last ?? 0,
change: marketData.lastChange ?? 0,
changePercent: marketData.lastChangePrcnt ?? 0,
@ -92,38 +108,40 @@ export class SharesService {
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(),
},
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getDividends(secid: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'dividends',
[secid],
() => this.moexClient.getDividends(secid),
() => this.moexDividends.getDividends(secid),
'dividendsTtl',
);
return {
data: data.map((d) => ({
return new ApiEnvelopePayload(
data.map((d) => ({
registryCloseDate: d.registryCloseDate,
value: d.value,
currency: d.currencyId,
})),
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getHistory(secid: string, from: string, till: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'history',
['shares', secid, from, till],
() => this.moexClient.getHistory(secid, from, till),
() => this.moexHistory.getHistory(secid, from, till),
'historyTtl',
);
return {
data: data.map((h) => ({
return new ApiEnvelopePayload(
data.map((h) => ({
date: h.tradeDate,
open: h.open ?? 0,
high: h.high ?? 0,
@ -132,7 +150,8 @@ export class SharesService {
volume: h.volume,
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,45 +1,74 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-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 { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
export class BrokerResponseMetaDto {
@ApiProperty({ nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto';
export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] })
data!: BrokerAccountResponseDto[];
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BrokerPortfolioEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioResponseDto })
data!: BrokerPortfolioResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BrokerOperationsEnvelopeDto {
@ApiProperty({ type: BrokerOperationsPageResponseDto })
data!: BrokerOperationsPageResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BrokerPositionsEnvelopeDto {
@ApiProperty({ type: BrokerPositionsPageResponseDto })
data!: BrokerPositionsPageResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerOperationSyncResponseDto })
data!: BrokerOperationSyncResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
@ApiProperty({ type: ApiResponseMeta })
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()
@IsString()
state?: string;
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
@IsOptional()
@IsString()
categories?: string;
}

View File

@ -28,6 +28,9 @@ export class BrokerOperationResponseDto {
@ApiProperty({ nullable: true })
description!: string | null;
@ApiProperty({ nullable: true })
name!: string | null;
@ApiProperty({ nullable: true })
state!: string | null;

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

@ -2,50 +2,6 @@ import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPositionResponseDto {
@ApiProperty({ nullable: true })
figi!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ nullable: true })
positionUid!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
classCode!: string | null;
@ApiProperty({ nullable: true })
instrumentType!: string | null;
@ApiProperty({ nullable: true })
name!: string | null;
@ApiProperty({ nullable: true })
quantity!: number | null;
@ApiProperty({ nullable: true })
blockedLots!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentPrice!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentValue!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
averagePositionPrice!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
expectedYieldPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dailyYield!: BrokerMoneyDto | null;
}
export class BrokerPortfolioTotalsDto {
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
shares!: BrokerMoneyDto | null;
@ -86,10 +42,27 @@ export class BrokerPortfolioYieldsDto {
dailyPercent!: number | null;
}
export class BrokerPortfolioPositionCountsDto {
@ApiProperty({ minimum: 0 })
shares!: number;
@ApiProperty({ minimum: 0 })
bonds!: number;
@ApiProperty({ minimum: 0 })
etf!: number;
@ApiProperty({ minimum: 0 })
other!: number;
}
export class BrokerPortfolioResponseDto {
@ApiProperty({ type: BrokerAccountResponseDto })
account!: BrokerAccountResponseDto;
@ApiProperty({ type: BrokerPortfolioPositionCountsDto })
positionCounts!: BrokerPortfolioPositionCountsDto;
@ApiProperty({ type: BrokerPortfolioTotalsDto })
totals!: BrokerPortfolioTotalsDto;
@ -102,9 +75,6 @@ export class BrokerPortfolioResponseDto {
@ApiProperty({ type: [BrokerMoneyDto] })
blockedCash!: BrokerMoneyDto[];
@ApiProperty({ type: [BrokerPositionResponseDto] })
positions!: BrokerPositionResponseDto[];
@ApiProperty()
asOf!: string;
}

View File

@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class BrokerPositionQueryDto {
@ApiPropertyOptional({ description: 'Cursor for pagination (positionUid)' })
@IsOptional()
@IsString()
cursor?: string;
@ApiPropertyOptional({ default: 10 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(100)
limit?: number = 10;
@ApiPropertyOptional({ description: 'Filter by instrument type (share, bond, etf, etc.)' })
@IsOptional()
@IsString()
type?: string;
}

View File

@ -0,0 +1,46 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPositionResponseDto {
@ApiProperty({ nullable: true })
figi!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ nullable: true })
positionUid!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
classCode!: string | null;
@ApiProperty({ nullable: true })
instrumentType!: string | null;
@ApiProperty({ nullable: true })
name!: string | null;
@ApiProperty({ nullable: true })
quantity!: number | null;
@ApiProperty({ nullable: true })
blockedLots!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentPrice!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentValue!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
averagePositionPrice!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
expectedYieldPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dailyYield!: BrokerMoneyDto | null;
}

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