docs: consolidate SDD documentation #13
25
AGENTS.md
25
AGENTS.md
@ -6,9 +6,23 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
||||
|
||||
## Обязательный подход к разработке
|
||||
|
||||
- **SDD (Specification-Driven Development)**: перед написанием кода сначала сформировать спецификацию — PRD, доменную модель, ADR, OpenAPI-контракт, архитектуру фронтенда и бэкенда, план реализации по этапам.
|
||||
- **Superpowers**: обязательно использовать скиллы (Skills) при старте любой задачи — brainstorming, frontend-design, test-driven-development, writing-plans, executing-plans, requesting-code-review.
|
||||
- **MCP-инструменты**: использовать MCP для анализа и генерации дизайна, работы с 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, когда это полезно задаче.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё.
|
||||
- Имя ветки по умолчанию начинать с `codex/`, если пользователь не попросил другой префикс.
|
||||
- Не смешивать независимые фичи в одной ветке. Небольшие связанные docs/chore/test-правки можно держать в той же ветке, если они относятся к текущей задаче.
|
||||
|
||||
## Документация и SDD-артефакты
|
||||
|
||||
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
|
||||
- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`.
|
||||
- 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.
|
||||
|
||||
## Команды
|
||||
|
||||
@ -37,11 +51,14 @@ Live MOEX integration tests opt-in: `npm run test:integration -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 |
|
||||
| `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 |
|
||||
@ -55,7 +72,7 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
|
||||
- `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()` для открытых эндпоинтов).
|
||||
- БД: SQLite через Prisma ORM. Prisma client генерируется в `src/generated/prisma/`.
|
||||
- БД: SQLite через Prisma ORM. Prisma client используется из `@prisma/client`; схема и миграции находятся в `apps/backend/prisma/`.
|
||||
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
|
||||
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
|
||||
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
||||
|
||||
@ -54,7 +54,5 @@ apps/
|
||||
frontend/ — React SPA with Vite
|
||||
docs/ — Docusaurus documentation site
|
||||
docs/
|
||||
architecture/ — ADR documents and diagrams
|
||||
openapi/ — OpenAPI specification
|
||||
superpowers/ — Design specs and implementation plans
|
||||
superpowers/specs/ — accepted SDD specifications
|
||||
```
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { load } = require('js-yaml');
|
||||
|
||||
describe('checked-in OpenAPI artifacts', () => {
|
||||
describe('checked-in OpenAPI frontend types', () => {
|
||||
const rootDir = resolve(process.cwd(), '../..');
|
||||
const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/api/types.ts'), 'utf8');
|
||||
const openapiYaml = readFileSync(join(rootDir, 'docs/openapi/openapi.yaml'), 'utf8');
|
||||
const openapi = load(openapiYaml) as any;
|
||||
|
||||
const requiredPaths = [
|
||||
'/api/v1/auth/register',
|
||||
@ -30,79 +25,33 @@ describe('checked-in OpenAPI artifacts', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('static OpenAPI YAML snapshot includes current protected domains', () => {
|
||||
for (const path of requiredPaths) {
|
||||
expect(openapiYaml).toContain(`${path}:`);
|
||||
}
|
||||
});
|
||||
|
||||
it('checked-in artifacts do not leak the local alternate codegen port', () => {
|
||||
it('frontend generated types do not leak the local alternate codegen port', () => {
|
||||
expect(frontendTypes).not.toContain('localhost:3001');
|
||||
expect(frontendTypes).not.toContain('3001');
|
||||
expect(openapiYaml).not.toContain('localhost:3001');
|
||||
expect(openapiYaml).not.toContain('3001');
|
||||
});
|
||||
|
||||
it('nullable primitive schemas are typed explicitly', () => {
|
||||
const screenerItem = openapi.components.schemas.ScreenerItemDto;
|
||||
it('frontend generated types include typed operations for current protected domains', () => {
|
||||
const requiredOperations = [
|
||||
'AuthController_register',
|
||||
'AuthController_login',
|
||||
'AuthController_refresh',
|
||||
'AuthController_logout',
|
||||
'AuthController_getProfile',
|
||||
'AuthController_updateProfile',
|
||||
'SecuritiesController_screener',
|
||||
'PortfolioController_findAll',
|
||||
'PortfolioController_create',
|
||||
'PortfolioController_findOne',
|
||||
'PortfolioController_update',
|
||||
'PortfolioController_remove',
|
||||
'PortfolioController_addPosition',
|
||||
'PortfolioController_updatePosition',
|
||||
'PortfolioController_removePosition',
|
||||
'PortfolioController_getAnalytics',
|
||||
];
|
||||
|
||||
expect(screenerItem.properties.price).toMatchObject({
|
||||
type: 'number',
|
||||
nullable: true,
|
||||
});
|
||||
expect(screenerItem.properties.matDate).toMatchObject({
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('position tags request schemas are arrays of known tags', () => {
|
||||
for (const schemaName of ['AddPositionDto', 'UpdatePositionDto']) {
|
||||
const tags = openapi.components.schemas[schemaName].properties.tags;
|
||||
|
||||
expect(tags).toMatchObject({
|
||||
type: 'array',
|
||||
});
|
||||
expect(tags.items.enum).toContain('DIVIDEND');
|
||||
}
|
||||
});
|
||||
|
||||
it('current auth, screener and portfolio operations have typed JSON responses', () => {
|
||||
const requiredJsonResponses = [
|
||||
['post', '/api/v1/auth/register', 201],
|
||||
['post', '/api/v1/auth/login', 201],
|
||||
['post', '/api/v1/auth/refresh', 200],
|
||||
['post', '/api/v1/auth/logout', 200],
|
||||
['get', '/api/v1/auth/me', 200],
|
||||
['patch', '/api/v1/auth/me', 200],
|
||||
['get', '/api/v1/securities/screener', 200],
|
||||
['get', '/api/v1/portfolios', 200],
|
||||
['post', '/api/v1/portfolios', 201],
|
||||
['get', '/api/v1/portfolios/{id}', 200],
|
||||
['patch', '/api/v1/portfolios/{id}', 200],
|
||||
['delete', '/api/v1/portfolios/{id}', 200],
|
||||
['post', '/api/v1/portfolios/{id}/positions', 201],
|
||||
['patch', '/api/v1/portfolios/{id}/positions/{positionId}', 200],
|
||||
['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200],
|
||||
['get', '/api/v1/portfolios/{id}/analytics', 200],
|
||||
] as const;
|
||||
|
||||
for (const [method, path, status] of requiredJsonResponses) {
|
||||
expect(
|
||||
openapi.paths[path][method].responses[status].content?.['application/json'],
|
||||
).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('portfolio delete operations document a null data envelope', () => {
|
||||
for (const [method, path, status] of [
|
||||
['delete', '/api/v1/portfolios/{id}', 200],
|
||||
['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200],
|
||||
] as const) {
|
||||
expect(
|
||||
openapi.paths[path][method].responses[status].content['application/json'].schema.properties
|
||||
.data,
|
||||
).toMatchObject({ type: 'null' });
|
||||
for (const operation of requiredOperations) {
|
||||
expect(frontendTypes).toContain(`operations['${operation}']`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,5 +9,8 @@
|
||||
| [ADR-005](ADR-005-openapi-codegen-frontend) | Accepted | OpenAPI Codegen for Frontend |
|
||||
| [ADR-006](ADR-006-no-cci) | Deprecated | No Custom Components Infrastructure |
|
||||
| [ADR-007](ADR-007-two-level-caching) | Draft | Two-Level Caching (In-Memory + Redis) |
|
||||
| [ADR-008](ADR-008-auth-system) | Accepted | Authentication & Authorization |
|
||||
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Portfolio Domain Model |
|
||||
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Backend Price Computation |
|
||||
|
||||
Все ADR находятся в `docs/architecture/adr/`.
|
||||
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.
|
||||
|
||||
@ -73,7 +73,7 @@ sequenceDiagram
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
All architectural decisions are documented as ADR in `docs/architecture/adr/`:
|
||||
All architectural decisions are documented as ADR pages in this documentation app:
|
||||
|
||||
| ADR | Summary |
|
||||
|---|---|
|
||||
@ -85,6 +85,8 @@ All architectural decisions are documented as ADR in `docs/architecture/adr/`:
|
||||
| [ADR-006](adr/ADR-006-no-cci) | No CCI (Custom Components Infrastructure) |
|
||||
| [ADR-007](adr/ADR-007-two-level-caching) | Two-level caching strategy |
|
||||
| [ADR-008](adr/ADR-008-auth-system) | Authentication & Authorization |
|
||||
| [ADR-009](adr/ADR-009-portfolio-domain) | Portfolio domain model |
|
||||
| [ADR-010](adr/ADR-010-backend-price-computation) | Backend price computation |
|
||||
|
||||
## Response Format
|
||||
|
||||
|
||||
@ -7,17 +7,24 @@ graph TD
|
||||
AppModule --> ConfigModule
|
||||
AppModule --> CacheModule
|
||||
AppModule --> MoexClientModule
|
||||
AppModule --> PrismaModule
|
||||
AppModule --> HealthModule
|
||||
AppModule --> AuthModule
|
||||
AppModule --> SecuritiesModule
|
||||
AppModule --> SharesModule
|
||||
AppModule --> BondsModule
|
||||
AppModule --> CandlesModule
|
||||
AppModule --> PortfolioModule
|
||||
|
||||
subgraph Global_Modules["Global Modules"]
|
||||
PrismaModule
|
||||
CacheModule
|
||||
MoexClientModule
|
||||
end
|
||||
|
||||
AuthModule --> PrismaService["PrismaService"]
|
||||
PortfolioModule --> PrismaService
|
||||
PortfolioModule --> MoexClientService
|
||||
SharesModule --> CacheService["CacheService"]
|
||||
BondsModule --> CacheService
|
||||
SecuritiesModule --> CacheService
|
||||
@ -33,13 +40,24 @@ graph TD
|
||||
|
||||
| Module | Global | Path | Description |
|
||||
|---|---|---|---|
|
||||
| `PrismaModule` | Yes | `modules/prisma/` | Prisma client for SQLite |
|
||||
| `CacheModule` | Yes | `modules/cache/` | In-memory cache (cache-manager) |
|
||||
| `MoexClientModule` | Yes | `modules/moex-client/` | HTTP-клиент MOEX ISS |
|
||||
| `HealthModule` | No | `modules/health/` | Health check endpoint |
|
||||
| `AuthModule` | No | `modules/auth/` | JWT auth, refresh cookie, guards |
|
||||
| `SecuritiesModule` | No | `modules/securities/` | Поиск инструментов |
|
||||
| `SharesModule` | No | `modules/shares/` | Акции |
|
||||
| `BondsModule` | No | `modules/bonds/` | Облигации |
|
||||
| `CandlesModule` | No | `modules/candles/` | Свечи OHLCV |
|
||||
| `PortfolioModule` | No | `modules/portfolio/` | Пользовательские портфели и аналитика |
|
||||
|
||||
### PrismaModule
|
||||
|
||||
Глобальный модуль доступа к SQLite через Prisma.
|
||||
|
||||
- `PrismaService` подключается к базе из `DATABASE_URL`
|
||||
- Схема и миграции находятся в `apps/backend/prisma/`
|
||||
- Используется `@prisma/client`; отдельный generated client в `src/generated/prisma/` не создаётся
|
||||
|
||||
### CacheModule
|
||||
|
||||
@ -63,6 +81,15 @@ graph TD
|
||||
|
||||
- `GET /api/v1/health` → `{ status: 'ok', timestamp, uptime }`
|
||||
|
||||
### AuthModule
|
||||
|
||||
Аутентификация и авторизация.
|
||||
|
||||
- JWT access token передаётся в `Authorization: Bearer <token>`
|
||||
- Refresh token хранится в httpOnly cookie и в БД как bcrypt hash
|
||||
- `JwtAuthGuard` и `RolesGuard` зарегистрированы глобально через `APP_GUARD`
|
||||
- Открытые endpoints помечаются `@Public()`
|
||||
|
||||
### SecuritiesModule
|
||||
|
||||
Поиск по инструментам (акции и облигации).
|
||||
@ -92,3 +119,12 @@ graph TD
|
||||
- 2 эндпоинта: для акций и облигаций
|
||||
- Интервалы: `1h` (60 min) и `24h` (daily)
|
||||
- Маппинг: `1h` → MOEX interval 60, `24h` → MOEX interval 24
|
||||
|
||||
### PortfolioModule
|
||||
|
||||
Портфели пользователя и аналитика позиций.
|
||||
|
||||
- CRUD портфелей и позиций
|
||||
- Обогащение позиций текущими ценами из MOEX
|
||||
- Расчёт summary, PnL и долей портфеля
|
||||
- Все endpoints защищены JWT
|
||||
|
||||
@ -24,7 +24,7 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts
|
||||
### Output
|
||||
|
||||
- `apps/frontend/src/api/types.ts` — сгенерированные типы `paths` и `operations`
|
||||
- `docs/openapi/openapi.yaml` — статический snapshot Swagger JSON для ревью и документации
|
||||
- Live Swagger JSON на `http://localhost:3000/api/docs-json` остаётся источником OpenAPI-контракта
|
||||
|
||||
### Verify Artifacts
|
||||
|
||||
@ -34,9 +34,8 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts
|
||||
npm run test -w apps/backend -- src/openapi-artifacts.spec.ts
|
||||
```
|
||||
|
||||
Тест проверяет, что checked-in frontend types и YAML содержат актуальные auth, screener и portfolio
|
||||
paths, не содержат локальный alternate port и сохраняют важные schema metadata для nullable полей,
|
||||
array enum tags и typed response envelopes.
|
||||
Тест проверяет, что checked-in frontend types содержат актуальные auth, screener и portfolio paths
|
||||
и не содержат локальный alternate port.
|
||||
|
||||
### Manual Types
|
||||
|
||||
|
||||
@ -22,9 +22,8 @@ moex-vibe/
|
||||
│ ├── frontend/ # React SPA
|
||||
│ └── docs/ # Docusaurus documentation site
|
||||
├── docs/
|
||||
│ ├── architecture/ # ADR и диаграммы
|
||||
│ ├── openapi/ # OpenAPI-спецификация
|
||||
│ └── superpowers/ # Дизайн-спеки и планы
|
||||
│ └── superpowers/
|
||||
│ └── specs/ # Согласованные SDD-спецификации
|
||||
├── docker/
|
||||
│ ├── Dockerfile.backend
|
||||
│ ├── Dockerfile.frontend
|
||||
@ -39,3 +38,4 @@ moex-vibe/
|
||||
- npm workspaces монорепозиторий: `apps/backend`, `apps/frontend` и `apps/docs`.
|
||||
- Глобальный префикс API: `/api/v1`. Swagger: `/api/docs`.
|
||||
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
||||
- `apps/docs` — опубликованная документация; root `docs` хранит только SDD specs.
|
||||
|
||||
@ -18,6 +18,10 @@ const config: Config = {
|
||||
locales: ['ru'],
|
||||
},
|
||||
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
@ -34,6 +38,8 @@ const config: Config = {
|
||||
],
|
||||
],
|
||||
|
||||
themes: ['@docusaurus/theme-mermaid'],
|
||||
|
||||
themeConfig: {
|
||||
navbar: {
|
||||
title: 'MoexVibe',
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.7.0",
|
||||
"@docusaurus/preset-classic": "3.7.0",
|
||||
"@docusaurus/theme-mermaid": "3.7.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
# ADR-001: Backend — Single Point of Access to MOEX
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Deciders:** Architect, Tech Lead
|
||||
|
||||
## Context
|
||||
Frontend должен отображать данные Московской биржи. MOEX ISS API отдаёт сырые данные со сложной структурой (вложенные таблицы, различные форматы). Прямые запросы с фронта приведут к дублированию логики нормализации, усложнят обработку ошибок и сделают систему зависимой от внешнего API.
|
||||
|
||||
## Decision
|
||||
Backend (NestJS) является единственной точкой доступа к MOEX. Frontend никогда не обращается к MOEX напрямую.
|
||||
|
||||
Backend:
|
||||
- Проксирует запросы к MOEX ISS
|
||||
- Нормализует данные в доменные модели
|
||||
- Кеширует ответы
|
||||
- Обрабатывает ошибки MOEX (пустые данные, rate limit, таймауты)
|
||||
- Предоставляет собственный OpenAPI-контракт для фронта
|
||||
|
||||
## Consequences
|
||||
- Единый источник правды для трансформации данных
|
||||
- Изоляция изменений MOEX API — меняется только MoexClient
|
||||
- Централизованное кеширование сокращает количество запросов к MOEX
|
||||
- Фронтенд остаётся тонким клиентом
|
||||
- Дополнительная задержка (один hop), но нивелируется кешированием
|
||||
@ -1,33 +0,0 @@
|
||||
# ADR-002: In-Memory Cache with Migration Path to Redis
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Deciders:** Architect, Tech Lead
|
||||
|
||||
## Context
|
||||
Для MVP требуется кеширование MOEX-данных, чтобы снизить нагрузку на внешнее API и обеспечить приемлемое время ответа. На начальном этапе нет требований к горизонтальному масштабированию, и хочется избежать внешних зависимостей.
|
||||
|
||||
## Decision
|
||||
Использовать `@nestjs/cache-manager` с MemoryStore. TTL настраивается per-endpoint через конфигурацию.
|
||||
|
||||
Архитектура позволяет переключиться на Redis заменой импорта провайдера:
|
||||
|
||||
```typescript
|
||||
// Текущая реализация
|
||||
CacheModule.register({ store: 'memory', ttl: 900 })
|
||||
|
||||
// Миграция на Redis (меняется только registration)
|
||||
CacheModule.registerAsync({
|
||||
useFactory: () => ({
|
||||
store: redisStore,
|
||||
host: process.env.REDIS_HOST,
|
||||
port: process.env.REDIS_PORT,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## Consequences
|
||||
- Нет внешних зависимостей для MVP
|
||||
- Кеш сбрасывается при рестарте сервера (приемлемо для read-only приложения)
|
||||
- Чистый путь миграции на Redis
|
||||
- Единый API для cache (cache-manager abstraction)
|
||||
@ -1,21 +0,0 @@
|
||||
# ADR-003: Rate Limiting Strategy for MOEX Client
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Deciders:** Architect, Tech Lead
|
||||
|
||||
## Context
|
||||
MOEX ISS не документирует жёсткие лимиты на количество запросов, но массовые запросы могут привести к блокировке или ухудшению качества обслуживания. Backend является единственным клиентом MOEX и должен контролировать исходящий трафик.
|
||||
|
||||
## Decision
|
||||
Внедрить два механизма в MoexClient:
|
||||
|
||||
1. **Request Queue (p-queue)**: конфигурируемый лимит запросов в секунду (default: 10 req/s). Запросы сверх лимита ставятся в очередь и выполняются по расписанию.
|
||||
|
||||
2. **Circuit Breaker (`@nestjs/axios` + interceptor)**: при 5+ последовательных ошибках (5xx, timeout, network error) клиент перестаёт отправлять запросы к MOEX на 30 секунд. После таймаута — пробный запрос для восстановления.
|
||||
|
||||
## Consequences
|
||||
- Плавная нагрузка на MOEX, без пиков
|
||||
- Автоматическое восстановление после сбоев MOEX
|
||||
- Graceful degradation: при отключённом circuit breaker возвращаются кешированные данные
|
||||
- Параметр конфигурации `MOEX_RATE_LIMIT` (int, req/s)
|
||||
@ -1,29 +0,0 @@
|
||||
# ADR-004: Feature Modules by Domain
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Deciders:** Architect, Tech Lead
|
||||
|
||||
## Context
|
||||
NestJS рекомендует модульную архитектуру. Требования указывают на архитектуру по feature modules. Модули должны иметь чёткие границы и быть тестируемыми изолированно.
|
||||
|
||||
## Decision
|
||||
Каждый бизнес-домен — отдельный NestJS feature module:
|
||||
|
||||
| Module | Responsibility |
|
||||
|--------|---------------|
|
||||
| `MoexClientModule` | HTTP-клиент к MOEX ISS, rate limiting, circuit breaker |
|
||||
| `CacheModule` | Абстракция кеширования |
|
||||
| `SecuritiesModule` | Поиск по инструментам |
|
||||
| `SharesModule` | Спецификация, marketdata, дивиденды |
|
||||
| `BondsModule` | Спецификация, marketdata |
|
||||
| `CandlesModule` | OHLCV свечи (общий для shares+bonds) |
|
||||
| `HealthModule` | Healthcheck endpoint |
|
||||
|
||||
Каждый module exports свой сервис, control imports через `@Module({ imports: [...] })`.
|
||||
|
||||
## Consequences
|
||||
- Чёткие границы, изолированное тестирование
|
||||
- Возможность вынести модуль в отдельный микросервис
|
||||
- Понятная навигация по коду
|
||||
- Нет циклических зависимостей (MoexClient — единственный downstream)
|
||||
@ -1,40 +0,0 @@
|
||||
# ADR-005: OpenAPI Codegen with openapi-typescript
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Deciders:** Architect, Tech Lead
|
||||
|
||||
## Context
|
||||
Frontend должен потреблять API бэкенда. Ручное написание клиентов и DTO приводит к рассинхронизации с бэкендом и ошибкам типизации.
|
||||
|
||||
## Decision
|
||||
Использовать `openapi-typescript` + `openapi-fetch` для генерации:
|
||||
|
||||
- TypeScript типов (DTO, request/response schemas)
|
||||
- Fetcher клиента (типобезопасные вызовы)
|
||||
|
||||
Процесс:
|
||||
1. Backend генерирует OpenAPI spec через `@nestjs/swagger`
|
||||
2. `openapi-typescript` на фронте генерирует типы
|
||||
3. `openapi-fetch` создаёт типобезопасный HTTP-клиент
|
||||
4. Разработчик пишет TanStack Query hooks вручную поверх сгенерированного клиента
|
||||
|
||||
```typescript
|
||||
// Пример: типобезопасный хук
|
||||
import { getSharesSecid } from '@/api/client';
|
||||
import type { components } from '@/api/types';
|
||||
|
||||
export function useStock(secid: string) {
|
||||
return useQuery({
|
||||
queryKey: ['stock', secid],
|
||||
queryFn: () => getSharesSecid(secid),
|
||||
staleTime: 900_000, // 15 min
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
- Полная типобезопасность на стыке frontend/backend
|
||||
- Автоматическая синхронизация с API-контрактом
|
||||
- TanStack Query hooks пишутся вручную — полный контроль staleTime/caching
|
||||
- Добавляется шаг в CI: codegen при изменении OpenAPI spec
|
||||
@ -1,20 +0,0 @@
|
||||
# ADR-006: CCI (Financial Reporting) Moved Out of MVP
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Deciders:** Architect, Product
|
||||
|
||||
## Context
|
||||
MOEX предоставляет корпоративную информацию (CCI) — финансовую отчётность по МСФО/РСБУ. Данные включают отчёты о прибылях/убытках, балансовые отчёты, мультипликаторы. Однако:
|
||||
|
||||
- CCI API имеет собственную сложную структуру (виды отчётности, периоды, индикаторы)
|
||||
- Данные требуют дополнительной нормализации и расчёта метрик
|
||||
- Для MVP пользователи хотят базовую информацию (цена, купон, график)
|
||||
|
||||
## Decision
|
||||
Не включать CCI в MVP. Roadmap на post-MVP.
|
||||
|
||||
## Consequences
|
||||
- Меньший объём работы в MVP
|
||||
- API не привязывается к CCI-схемам (будет отдельный модуль)
|
||||
- Пользователи не увидят мультипликаторы (P/E, EV/EBITDA) в первой версии
|
||||
@ -1,27 +0,0 @@
|
||||
# ADR-007: Two-Level Caching (Backend + Frontend)
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Deciders:** Architect
|
||||
|
||||
## Context
|
||||
Данные MOEX имеют задержку 15 минут. Кеширование на одном уровне (только бэкенд или только фронтенд) неоптимально:
|
||||
- Только бэкенд: каждый пользователь создаёт запрос к серверу
|
||||
- Только фронтенд: нет централизованного кеша, не защищает MOEX от повторных запросов
|
||||
|
||||
## Decision
|
||||
Внедрить два уровня кеширования:
|
||||
|
||||
1. **Backend (in-memory cache-manager)**: централизованное кеширование ответов от MOEX. Предотвращает повторные запросы к MOEX от разных пользователей.
|
||||
|
||||
2. **Frontend (TanStack Query staleTime)**: предотвращает повторные запросы к бэкенду при навигации или монтировании компонентов.
|
||||
|
||||
TTL согласованы (см. Caching Strategy).
|
||||
|
||||
Cache-Control заголовки в HTTP-ответах для промежуточных proxy/CDN (опционально).
|
||||
|
||||
## Consequences
|
||||
- Избыточность intentional: resilience при отказе одного уровня
|
||||
- TanStack Query staleTime = backend TTL (нет лишних запросов)
|
||||
- При рестарте бэкенда фронт всё ещё имеет данные в memory cache
|
||||
- Небольшое увеличение memory на фронте (приемлемо для SPA)
|
||||
@ -1,24 +0,0 @@
|
||||
# Architecture Overview
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Frontend as React SPA
|
||||
participant Backend as NestJS API
|
||||
participant Cache as In-Memory Cache
|
||||
participant MOEX as MOEX ISS
|
||||
|
||||
User->>Frontend: Search / View instrument
|
||||
Frontend->>Backend: GET /api/v1/securities/search?q=SBER
|
||||
Backend->>Cache: getOrFetch('search:sber')
|
||||
alt Cache miss
|
||||
Cache->>Backend: null
|
||||
Backend->>MOEX: GET /iss/securities?q=SBER
|
||||
MOEX-->>Backend: raw data
|
||||
Backend->>Cache: set('search:sber', normalized, TTL=3600)
|
||||
else Cache hit
|
||||
Cache-->>Backend: cached data
|
||||
end
|
||||
Backend-->>Frontend: normalized response
|
||||
Frontend-->>User: rendered UI
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,193 +0,0 @@
|
||||
Ты выступаешь как Senior Solution Architect, Tech Lead и Product Analyst.
|
||||
|
||||
Нужно спроектировать MVP приложения для анализа инвестиций на Московской бирже (MOEX).
|
||||
|
||||
Перед составлением спецификации и плана разработки ты ОБЯЗАН выявить все недостающие требования и задать уточняющие вопросы. Не переходи к проектированию, пока все критические вопросы не будут закрыты.
|
||||
|
||||
## Источники данных
|
||||
|
||||
Использовать только официальные API и документацию MOEX:
|
||||
|
||||
* https://www.moex.com/a2193
|
||||
* https://www.moex.com/a7939
|
||||
* https://iss.moex.com/iss/reference/
|
||||
|
||||
Перед проектированием изучи доступные методы API и предложи оптимальную модель интеграции.
|
||||
|
||||
---
|
||||
|
||||
# Цель MVP
|
||||
|
||||
Разработать веб-приложение для анализа ценных бумаг Московской биржи.
|
||||
|
||||
## MVP должен включать
|
||||
|
||||
### Главная страница
|
||||
|
||||
* глобальный поиск по инструментам
|
||||
* поиск акций
|
||||
* поиск облигаций
|
||||
* отображение результатов поиска
|
||||
* переход на карточку инструмента
|
||||
|
||||
### Страница акции
|
||||
|
||||
Отображение:
|
||||
|
||||
* тикера
|
||||
* названия компании
|
||||
* текущей цены
|
||||
* капитализации
|
||||
* дивидендной информации
|
||||
* доходности
|
||||
* основных финансовых показателей (если доступны через MOEX)
|
||||
* исторических данных
|
||||
* графика цены
|
||||
|
||||
### Страница облигации
|
||||
|
||||
Отображение:
|
||||
|
||||
* ISIN
|
||||
* тикера
|
||||
* эмитента
|
||||
* номинала
|
||||
* купона
|
||||
* даты погашения
|
||||
* текущей цены
|
||||
* доходности к погашению
|
||||
* накопленного купонного дохода
|
||||
* графика цены
|
||||
* прочих доступных параметров
|
||||
|
||||
---
|
||||
|
||||
# Технологический стек
|
||||
|
||||
## Frontend
|
||||
|
||||
* React
|
||||
* TypeScript
|
||||
* Vite
|
||||
* TanStack Query
|
||||
* React Router
|
||||
* OpenAPI Code Generation
|
||||
* максимальная типизация
|
||||
* SSR не требуется
|
||||
|
||||
## Backend
|
||||
|
||||
* NestJS
|
||||
* TypeScript
|
||||
* OpenAPI (Swagger)
|
||||
* архитектура по feature modules
|
||||
* DTO validation
|
||||
* централизованная обработка ошибок
|
||||
* structured logging
|
||||
* request/response logging middleware
|
||||
* healthcheck endpoint
|
||||
* configuration module
|
||||
|
||||
## Документация
|
||||
|
||||
Использовать Docusaurus.
|
||||
|
||||
Документация должна включать:
|
||||
|
||||
* архитектурные решения (ADR)
|
||||
* sequence diagrams
|
||||
* component diagrams
|
||||
* deployment diagrams
|
||||
* API documentation
|
||||
* OpenAPI схемы
|
||||
* описание бизнес-процессов
|
||||
* onboarding разработчиков
|
||||
|
||||
---
|
||||
|
||||
# Подход к разработке
|
||||
|
||||
Использовать:
|
||||
|
||||
* Superpowers
|
||||
* OpenSpec
|
||||
|
||||
Разработка должна начинаться со спецификации.
|
||||
|
||||
Сначала сформировать:
|
||||
|
||||
1. Product Requirements Document (PRD)
|
||||
2. Domain Model
|
||||
3. Architecture Decision Records (ADR)
|
||||
4. OpenAPI Contract
|
||||
5. Frontend Architecture
|
||||
6. Backend Architecture
|
||||
7. План реализации по этапам
|
||||
|
||||
---
|
||||
|
||||
# Требования к API
|
||||
|
||||
Backend является единственной точкой доступа к MOEX.
|
||||
|
||||
Frontend не должен обращаться к MOEX напрямую.
|
||||
|
||||
Backend должен:
|
||||
|
||||
* агрегировать данные MOEX
|
||||
* кешировать ответы
|
||||
* нормализовать модели данных
|
||||
* предоставлять собственный OpenAPI контракт
|
||||
|
||||
Необходимо предложить стратегию:
|
||||
|
||||
* кеширования
|
||||
* rate limiting
|
||||
* обработки ошибок MOEX
|
||||
* обновления данных
|
||||
|
||||
---
|
||||
|
||||
# Требования к Frontend
|
||||
|
||||
Использовать OpenAPI codegen для генерации:
|
||||
|
||||
* API clients
|
||||
* DTO
|
||||
* React Query hooks (если возможно)
|
||||
|
||||
Не писать API-клиенты вручную без необходимости.
|
||||
|
||||
Предложить оптимальную структуру проекта.
|
||||
|
||||
---
|
||||
|
||||
# UX/UI
|
||||
|
||||
Использовать современные практики frontend разработки.
|
||||
|
||||
При проектировании интерфейсов:
|
||||
|
||||
* использовать MCP инструменты для анализа и генерации дизайна
|
||||
* использовать frontend design skills
|
||||
* подготовить описание экранов
|
||||
* подготовить user flow
|
||||
* подготовить wireframes в текстовом виде
|
||||
|
||||
---
|
||||
|
||||
# Ожидаемый результат
|
||||
|
||||
После уточнения требований сформируй:
|
||||
|
||||
1. список вопросов
|
||||
2. PRD
|
||||
3. OpenSpec спецификацию
|
||||
4. архитектуру системы
|
||||
5. структуру репозитория
|
||||
6. OpenAPI проект
|
||||
7. план реализации по спринтам
|
||||
8. список рисков
|
||||
9. roadmap развития после MVP
|
||||
|
||||
Не сокращай ответы. Действуй как архитектор уровня Staff+/Principal Engineer.
|
||||
@ -1,65 +0,0 @@
|
||||
# ADR: Portfolio Enricher Optimization
|
||||
|
||||
**Date:** 2026-06-14
|
||||
**Status:** Implemented
|
||||
**Deciders:** AI Agent + Human
|
||||
|
||||
## Context
|
||||
|
||||
`GET /api/v1/portfolios/1` выполнялся ~29 секунд для портфеля с 104 позициями.
|
||||
Причина: per-position enrichment генерировал 298 последовательных HTTP-запросов к MOEX ISS через rate limiter (10 req/s).
|
||||
|
||||
## Decision
|
||||
|
||||
Три оптимизации, реализованные одновременно:
|
||||
|
||||
### 1. Merge bond data calls
|
||||
|
||||
`getBondData` и `getBondMarketData` вызывали **один и тот же** MOEX endpoint
|
||||
(`/engines/stock/markets/bonds/securities/{secid}`), но парсили разные таблицы ответа.
|
||||
|
||||
Новый метод `getBondPositionDataBatch` делает один запрос на все облигации и парсит обе таблицы.
|
||||
|
||||
**Profit:** 180 → 90 запросов для bonds
|
||||
|
||||
### 2. Remove redundant `getSecurityDescription`
|
||||
|
||||
Каждая позиция делала отдельный запрос для shortName. Но shortName уже доступен:
|
||||
- в `securities` таблице ответа `getShareMarketData`
|
||||
- в `getBondData` / `getBondPositionDataBatch`
|
||||
|
||||
Удалили вызов `getSecurityDescription` из `enrichPositions`.
|
||||
|
||||
**Profit:** 104 → 0 запросов
|
||||
|
||||
### 3. Batch requests by market
|
||||
|
||||
Вместо N индивидуальных запросов — группируем secid по типу и делаем 2 batch-запроса:
|
||||
- `GET /engines/stock/markets/shares/securities.json?securities=SBER,VTBR,...`
|
||||
- `GET /engines/stock/markets/bonds/securities.json?securities=RU000...,SU262...`
|
||||
|
||||
Новые методы: `getShareMarketDataBatch`, `getBondPositionDataBatch`.
|
||||
|
||||
**Profit:** 104 → 2 запроса
|
||||
|
||||
## Results
|
||||
|
||||
| Metric | Before | After | Reduction |
|
||||
|---|---|---|---|
|
||||
| API calls to MOEX | 298 | 2 | **99.3%** |
|
||||
| Estimated latency (cache cold) | ~29.8s | ~0.3s | **99%** |
|
||||
| Code in PortfolioService | ~150 lines | ~90 lines | **40%** |
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Cache key format changed**: from `marketdata:portfolio:{secid}` / `bonddata:portfolio:{secid}` / `security:portfolio-name:{secid}` to `batchdata:shares:{sortedSecids}` / `batchdata:bonds:{sortedSecids}`. Old cache entries will naturally expire via TTL.
|
||||
- **Cache granularity**: batch results are cached as a unit. If portfolio positions change, the cache key changes (because sorted secids change), triggering a fresh fetch.
|
||||
- **Backward compatibility**: `getShareMarketData(secid)` and `getBondData(secid)` + `getBondMarketData(secid)` are preserved for other consumers.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `moex-client.types.ts` | Added `shortName` to `MoexShareMarketData`, added `MoexBondPositionData` |
|
||||
| `moex-client.service.ts` | Added `getShareMarketDataBatch`, `getBondPositionDataBatch`, added `shortName` to `getShareMarketData` |
|
||||
| `portfolio.service.ts` | Rewrote `enrichPositions` to batch, removed redundant `getSecurityDescription` calls, removed old per-position enrichment methods |
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,110 +0,0 @@
|
||||
# CI/CD Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add Gitea Actions CI pipeline with lint, test, and build for the MoexVibe monorepo.
|
||||
|
||||
**Architecture:** Single `.gitea/workflows/ci.yml` file with three parallel jobs (lint, test, build) triggered on push/PR to main. One script addition to root `package.json` for format checking.
|
||||
|
||||
**Tech Stack:** Gitea Actions (GitHub Actions-compatible YAML), Node.js 20, npm workspaces
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `format:check` script to root package.json
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (root)
|
||||
|
||||
- [ ] **Step 1: Read root package.json**
|
||||
|
||||
- [ ] **Step 2: Add format:check script**
|
||||
|
||||
Edit `package.json`: add `"format:check": "prettier --check \"**/*.{ts,tsx}\""` to the `scripts` section, after `format`.
|
||||
|
||||
- [ ] **Step 3: Verify the script runs**
|
||||
|
||||
Run: `npm run format:check`
|
||||
Expected: exits 0 (all files already formatted) or lists formatting errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "ci: add format:check script for CI pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create Gitea Actions workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitea/workflows/ci.yml`
|
||||
|
||||
- [ ] **Step 1: Create workflow directory**
|
||||
|
||||
Run: `mkdir -p .gitea/workflows`
|
||||
|
||||
- [ ] **Step 2: Create ci.yml with full pipeline**
|
||||
|
||||
Create `.gitea/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npx prettier --check "**/*.{ts,tsx}"
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:backend
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build:backend
|
||||
- run: npm run build:frontend
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitea/workflows/ci.yml
|
||||
git commit -m "ci: add Gitea Actions pipeline with lint, test, build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Verification
|
||||
|
||||
После пуша в `main` (или создания PR) проверить на https://git.ksv741.keenetic.pro/moex/moex-vibe/actions что pipeline запустился и все 3 job'а зелёные.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,261 +0,0 @@
|
||||
# Диаграмма распределения портфеля — План реализации
|
||||
|
||||
> **Для агентов:** Требуется навык `superpowers:subagent-driven-development` или `superpowers:executing-plans`. Шаги используют `- [ ]`.
|
||||
|
||||
**Цель:** Добавить SVG-диаграмму donut в PortfolioSummary, показывающую распределение стоимости между акциями и облигациями.
|
||||
|
||||
**Архитектура:** Всё на клиенте. Бэкенд уже возвращает `positions` с `currentValue` и `type`. Новый компонент `AllocationChart` агрегирует данные и рисует SVG. `PortfolioSummary` включает его.
|
||||
|
||||
**Технологии:** React 18, SVG (без библиотек).
|
||||
|
||||
---
|
||||
|
||||
### Задача 1: Создать AllocationChart
|
||||
|
||||
**Файлы:**
|
||||
- Создать: `apps/frontend/src/components/portfolios/AllocationChart.tsx`
|
||||
|
||||
- [ ] **Шаг 1: Создать AllocationChart.tsx**
|
||||
|
||||
```tsx
|
||||
import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface AllocationChartProps {
|
||||
positions: PositionWithPrice[];
|
||||
totalValue: number;
|
||||
}
|
||||
|
||||
interface SectorData {
|
||||
type: 'share' | 'bond';
|
||||
label: string;
|
||||
value: number;
|
||||
count: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const SECTOR_COLORS = {
|
||||
share: 'var(--color-primary, #1976d2)',
|
||||
bond: '#f57c00',
|
||||
} as const;
|
||||
|
||||
const SECTOR_LABELS = {
|
||||
share: 'Акции',
|
||||
bond: 'Облигации',
|
||||
} as const;
|
||||
|
||||
function computeSectors(positions: PositionWithPrice[]): SectorData[] {
|
||||
const sectors: SectorData[] = [
|
||||
{ type: 'share', label: SECTOR_LABELS.share, value: 0, count: 0, color: SECTOR_COLORS.share },
|
||||
{ type: 'bond', label: SECTOR_LABELS.bond, value: 0, count: 0, color: SECTOR_COLORS.bond },
|
||||
];
|
||||
|
||||
for (const p of positions) {
|
||||
const sector = sectors.find((s) => s.type === p.type);
|
||||
if (sector) {
|
||||
sector.value += p.currentValue ?? 0;
|
||||
sector.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return sectors;
|
||||
}
|
||||
|
||||
export function AllocationChart({ positions, totalValue }: AllocationChartProps) {
|
||||
const sectors = computeSectors(positions);
|
||||
const nonZero = sectors.filter((s) => s.value > 0);
|
||||
const hasData = nonZero.length > 0;
|
||||
|
||||
const cx = 60;
|
||||
const cy = 60;
|
||||
const r = 44;
|
||||
const strokeWidth = 10;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const viewBoxSize = 120;
|
||||
|
||||
function renderArcs() {
|
||||
if (!hasData) {
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="#e0e0e0"
|
||||
strokeWidth={strokeWidth}
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (nonZero.length === 1) {
|
||||
const sector = nonZero[0];
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={sector.color}
|
||||
strokeWidth={strokeWidth}
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return sectors.map((sector, i) => {
|
||||
const ratio = totalValue > 0 ? sector.value / totalValue : 0;
|
||||
const dashLen = ratio * circumference;
|
||||
const gapLen = circumference - dashLen;
|
||||
let rotation = -90;
|
||||
|
||||
for (let j = 0; j < i; j++) {
|
||||
const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0;
|
||||
rotation += prevRatio * 360;
|
||||
}
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={sector.type}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={sector.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${dashLen} ${gapLen}`}
|
||||
transform={`rotate(${rotation} ${cx} ${cy})`}
|
||||
style={{ transition: 'stroke-dasharray 0.3s ease' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<svg
|
||||
width={90}
|
||||
height={90}
|
||||
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{renderArcs()}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
style={{
|
||||
fontSize: hasData && positions.length > 0 ? 14 : 10,
|
||||
fontWeight: 700,
|
||||
fill: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{positions.length === 0
|
||||
? 'Нет позиций'
|
||||
: totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })}
|
||||
</text>
|
||||
</svg>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6 }}>
|
||||
{sectors.map((s) => {
|
||||
const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0;
|
||||
return (
|
||||
<div key={s.type} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: s.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{s.label}: {s.count} / {ratio.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Шаг 2: Проверить сборку**
|
||||
|
||||
Run: `npm run build:frontend`
|
||||
Expected: без ошибок
|
||||
|
||||
---
|
||||
|
||||
### Задача 2: Интегрировать в PortfolioSummary
|
||||
|
||||
**Файлы:**
|
||||
- Изменить: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx`
|
||||
|
||||
- [ ] **Шаг 1: Обновить PortfolioSummary**
|
||||
|
||||
```tsx
|
||||
import { AllocationChart } from './AllocationChart';
|
||||
import type { PortfolioDetail } from '../../api/responses';
|
||||
|
||||
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 32,
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
}}
|
||||
>
|
||||
<AllocationChart positions={portfolio.positions} totalValue={portfolio.totalValue} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||
Общая стоимость
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{portfolio.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||
Позиций
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>{portfolio.positions.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Шаг 2: Проверить сборку**
|
||||
|
||||
Run: `npm run build:frontend`
|
||||
Expected: без ошибок
|
||||
|
||||
- [ ] **Шаг 3: Проверить линтер**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: без ошибок
|
||||
|
||||
- [ ] **Шаг 4: Проверить форматирование**
|
||||
|
||||
Run: `npm run format`
|
||||
Expected: без изменений
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,365 +0,0 @@
|
||||
# Portfolio Enricher Optimization — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) for tracking.
|
||||
|
||||
**Goal:** Reduce portfolio enrichment from 298 MOEX API calls (~30s) to 2 batch calls (~0.3s) by merging redundant bond data calls, eliminating extra security descriptions, and batching by market.
|
||||
|
||||
**Architecture:** 3-phase: (1) type changes, (2) new batch methods on MoexClientService, (3) rewrite PortfolioService.enrichPositions to use batch + remove redundant calls.
|
||||
|
||||
**Tech Stack:** NestJS, TypeScript, MOEX ISS API, PQueue
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add types — `shortName` on share market data + `MoexBondPositionData` combined type
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/moex-client/moex-client.types.ts`
|
||||
|
||||
- [ ] **Step 1: Extend `MoexShareMarketData` with `shortName`**
|
||||
|
||||
Add `shortName: string;` field — it's already returned by MOEX in the `securities` table of the share endpoint, but was never extracted.
|
||||
|
||||
- [ ] **Step 2: Add `MoexBondPositionData` combined type**
|
||||
|
||||
```typescript
|
||||
export interface MoexBondPositionData {
|
||||
secid: string;
|
||||
boardid: string;
|
||||
shortName: string;
|
||||
price: number | null;
|
||||
yieldToMaturity: number | null;
|
||||
duration: number | null;
|
||||
couponValue: number | null;
|
||||
couponPercent: number | null;
|
||||
nextCouponDate: string | null;
|
||||
matDate: string | null;
|
||||
accruedInt: number | null;
|
||||
faceValue: number;
|
||||
bid: number | null;
|
||||
offer: number | null;
|
||||
couponPeriod: number | null;
|
||||
bondType: string | null;
|
||||
offerDate: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
This replaces the need for both `MoexBondData` + `MoexBondMarketData` — combined from a single endpoint response.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add batch methods to MoexClientService
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/moex-client/moex-client.service.ts`
|
||||
|
||||
- [ ] **Step 1: Add `getShareMarketDataBatch` method**
|
||||
|
||||
```typescript
|
||||
async getShareMarketDataBatch(
|
||||
secids: string[],
|
||||
boardId = 'TQBR',
|
||||
): Promise<MoexShareMarketData[]> {
|
||||
if (secids.length === 0) return [];
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities`,
|
||||
{ securities: secids.join(','), boards: boardId },
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
return secids.map((secid) => {
|
||||
const sec = securities.find((r) => r.SECID === secid && r.BOARDID === boardId)
|
||||
?? securities.find((r) => r.SECID === secid);
|
||||
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) || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Key: uses existing `request()` method (rate-limited via PQueue). The `securities` param accepts comma-separated secids.
|
||||
|
||||
- [ ] **Step 2: Add `getBondPositionDataBatch` method**
|
||||
|
||||
```typescript
|
||||
async getBondPositionDataBatch(
|
||||
secids: string[],
|
||||
boardId = 'TQCB',
|
||||
): Promise<MoexBondPositionData[]> {
|
||||
if (secids.length === 0) return [];
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities`,
|
||||
{ securities: secids.join(','), boards: boardId },
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
return secids.map((secid) => {
|
||||
const bond =
|
||||
securities.find((r) => r.SECID === secid && r.BOARDID === boardId && r.PREVWAPRICE != null) ||
|
||||
securities.find((r) => r.SECID === secid && r.PREVWAPRICE != null) ||
|
||||
securities.find((r) => r.SECID === secid);
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
|
||||
marketdata.find((r) => r.LAST != null) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This replaces `getBondData` + `getBondMarketData` with a single batch call that parses both tables.
|
||||
|
||||
- [ ] **Step 3: Update `getShareMarketData` to also extract `shortName`**
|
||||
|
||||
In the single-security `getShareMarketData`, find the securities row and extract shortName:
|
||||
|
||||
```typescript
|
||||
const share = rows.find((r) => r.BOARDID === boardId);
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (share?.SHORTNAME as string) || '', // NEW
|
||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||
// ... rest unchanged
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run existing tests**
|
||||
|
||||
```bash
|
||||
npx vitest run -w apps/backend
|
||||
```
|
||||
|
||||
Expected: existing tests pass (no regressions).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewrite `enrichPositions` in PortfolioService
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts`
|
||||
|
||||
- [ ] **Step 1: Rewrite `enrichPositions` to use batch + eliminate redundant calls**
|
||||
|
||||
Strategy:
|
||||
1. Group positions by type (share/bond)
|
||||
2. For shares: 1 `getShareMarketDataBatch` call → map by secid
|
||||
3. For bonds: 1 `getBondPositionDataBatch` call → map by secid
|
||||
4. Build enriched positions from maps (no more individual API calls)
|
||||
5. shortName comes from market data response (no more `getSecurityDescription`)
|
||||
|
||||
```typescript
|
||||
private async enrichPositions(
|
||||
positions: {
|
||||
id: number; portfolioId: number; secid: string;
|
||||
type: string; quantity: number; notes: string | null; tags: string | null;
|
||||
}[],
|
||||
portfolioId: number,
|
||||
): Promise<EnrichedPosition[]> {
|
||||
const sharePositions = positions.filter((p) => p.type === 'share');
|
||||
const bondPositions = positions.filter((p) => p.type === 'bond');
|
||||
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([
|
||||
this.fetchShareBatch(shareSecids, portfolioId),
|
||||
this.fetchBondBatch(bondSecids, portfolioId),
|
||||
]);
|
||||
|
||||
const enriched: EnrichedPosition[] = [];
|
||||
|
||||
for (const pos of positions) {
|
||||
const base = {
|
||||
id: pos.id, secid: pos.secid,
|
||||
shortName: null as string | null,
|
||||
type: pos.type, quantity: pos.quantity,
|
||||
notes: pos.notes, tags: pos.tags ? JSON.parse(pos.tags) : null,
|
||||
weightPercent: 0, currentPrice: null as number | null,
|
||||
currentValue: null as number | null,
|
||||
};
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
private async fetchShareBatch(
|
||||
secids: string[], portfolioId: number,
|
||||
): Promise<Map<string, MoexShareMarketData>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const cacheKey = secids.join(',');
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata', ['shares', cacheKey],
|
||||
() => this.moexClient.getShareMarketDataBatch(secids),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
}
|
||||
|
||||
private async fetchBondBatch(
|
||||
secids: string[], portfolioId: number,
|
||||
): Promise<Map<string, MoexBondPositionData>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const cacheKey = secids.join(',');
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata', ['bonds', cacheKey],
|
||||
() => this.moexClient.getBondPositionDataBatch(secids),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `buildSharePosition` method**
|
||||
|
||||
```typescript
|
||||
private buildSharePosition(
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
base: EnrichedPosition,
|
||||
data: MoexShareMarketData | undefined,
|
||||
): EnrichedPosition {
|
||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
||||
return {
|
||||
...base,
|
||||
shortName: data.shortName,
|
||||
currentPrice: data.last,
|
||||
change: data.lastChange,
|
||||
changePercent: data.lastChangePrcnt,
|
||||
currentValue: data.last !== null ? data.last * pos.quantity : null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `buildBondPosition` method**
|
||||
|
||||
```typescript
|
||||
private buildBondPosition(
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
base: EnrichedPosition,
|
||||
data: MoexBondPositionData | undefined,
|
||||
): EnrichedPosition {
|
||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
||||
const currentValue =
|
||||
data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null;
|
||||
return {
|
||||
...base,
|
||||
shortName: data.shortName,
|
||||
currentPrice: data.price,
|
||||
yieldToMaturity: data.yieldToMaturity,
|
||||
duration: data.duration,
|
||||
couponValue: data.couponValue,
|
||||
couponPercent: data.couponPercent,
|
||||
nextCouponDate: data.nextCouponDate,
|
||||
matDate: data.matDate,
|
||||
accruedInt: data.accruedInt,
|
||||
bid: data.bid,
|
||||
offer: data.offer,
|
||||
couponPeriod: data.couponPeriod,
|
||||
bondType: data.bondType,
|
||||
offerDate: data.offerDate,
|
||||
currentValue,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `findOne` to pass `portfolio.id` to `enrichPositions`**
|
||||
|
||||
```typescript
|
||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions, portfolio.id);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Clean up removed methods**
|
||||
|
||||
Remove old private methods: `enrichSharePosition`, `enrichBondPosition` (replaced by `buildSharePosition`, `buildBondPosition`).
|
||||
|
||||
- [ ] **Step 6: Remove unused import `CacheService` if it becomes unused**
|
||||
|
||||
Actually `CacheService` is still used via `fetchShareBatch`/`fetchBondBatch`. Keep it.
|
||||
|
||||
- [ ] **Step 7: Run tests**
|
||||
|
||||
```bash
|
||||
npx vitest run -w apps/backend
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify and lint
|
||||
|
||||
- [ ] **Step 1: TypeScript check**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit -w apps/backend
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lint**
|
||||
|
||||
```bash
|
||||
npm run lint 2>/dev/null || echo "Lint check complete"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Format**
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Document performance gain
|
||||
|
||||
- [ ] **Step 1: Write ADR or performance note in docs**
|
||||
|
||||
Add to `docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md` documenting:
|
||||
- Problem: 298 API calls → 29s
|
||||
- Changes made: merged bond calls, removed redundant securityDescription, batch by market
|
||||
- Result: 2 API calls → ~0.3s (97% reduction)
|
||||
@ -1,613 +0,0 @@
|
||||
# Portfolio List Enrichment — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Enrich `GET /api/v1/portfolios` with totalValue, positionCount, shareCount, bondCount from MOEX batch data and display on PortfolioCard.
|
||||
|
||||
**Architecture:** Backend collects all positions across user's portfolios, does ONE batch MOEX call (cached), computes aggregates per portfolio. Frontend displays new fields on the existing card component.
|
||||
|
||||
**Tech Stack:** NestJS, Prisma, MoexClientService (batch), React, TanStack Query
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create PortfolioListResponseDto
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts`
|
||||
|
||||
- [ ] **Step 1: Create DTO file**
|
||||
|
||||
```typescript
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PortfolioResponseDto } from './portfolio-response.dto';
|
||||
|
||||
export class PortfolioListResponseDto extends PortfolioResponseDto {
|
||||
@ApiProperty({ description: 'Total market value of all positions' })
|
||||
totalValue!: number;
|
||||
|
||||
@ApiProperty({ description: 'Total number of positions' })
|
||||
positionCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of share positions' })
|
||||
shareCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of bond positions' })
|
||||
bondCount!: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit -w apps/backend`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts
|
||||
git commit -m "feat(backend): add PortfolioListResponseDto"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Write failing tests for PortfolioService.findAll enrichment
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/modules/portfolio/portfolio.service.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Create test file with failing tests**
|
||||
|
||||
```typescript
|
||||
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 { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
let service: PortfolioService;
|
||||
let prisma: PrismaService;
|
||||
let moexClient: MoexClientService;
|
||||
let module: TestingModule;
|
||||
|
||||
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'Test Portfolio',
|
||||
description: 'A test portfolio',
|
||||
currency: 'RUB',
|
||||
targets: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-06-14'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockPosition = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
portfolioId: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
notes: null,
|
||||
tags: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-06-14'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
PortfolioService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: {
|
||||
portfolio: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
position: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MoexClientService,
|
||||
useValue: {
|
||||
getShareMarketDataBatch: vi.fn(),
|
||||
getBondPositionDataBatch: vi.fn(),
|
||||
getSecurityDescription: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: {
|
||||
getOrFetch: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PortfolioService>(PortfolioService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
moexClient = module.get<MoexClientService>(MoexClientService);
|
||||
// CacheService is a useValue mock object
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return empty array when user has no portfolios', async () => {
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);
|
||||
const result = await service.findAll(1);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return portfolios with zero aggregates when no positions exist', async () => {
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||
mockPortfolio({ positions: [] }) as any,
|
||||
]);
|
||||
|
||||
const result = await service.findAll(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'Test Portfolio',
|
||||
totalValue: 0,
|
||||
positionCount: 0,
|
||||
shareCount: 0,
|
||||
bondCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should enrich portfolios with market data from batch MOEX call', async () => {
|
||||
const sharePosition = mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
});
|
||||
const bondPosition = mockPosition({
|
||||
id: 2,
|
||||
portfolioId: 1,
|
||||
secid: 'SU26238RMFS5',
|
||||
type: 'bond',
|
||||
quantity: 5,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
|
||||
]);
|
||||
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||
] as any);
|
||||
|
||||
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
shortName: 'OFZ 26238',
|
||||
price: 98.5,
|
||||
faceValue: 1000,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.findAll(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Test Portfolio');
|
||||
expect(result[0].positionCount).toBe(2);
|
||||
expect(result[0].shareCount).toBe(1);
|
||||
expect(result[0].bondCount).toBe(1);
|
||||
// SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925
|
||||
expect(result[0].totalValue).toBe(7425);
|
||||
});
|
||||
|
||||
it('should propagate MOEX errors to the caller', async () => {
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||
mockPortfolio({ positions: [mockPosition()] }) as any,
|
||||
]);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockRejectedValue(new Error('MOEX down'));
|
||||
|
||||
await expect(service.findAll(1)).rejects.toThrow('MOEX down');
|
||||
});
|
||||
|
||||
it('should only return portfolios belonging to the requesting user', async () => {
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);
|
||||
|
||||
await service.findAll(2);
|
||||
|
||||
expect(prisma.portfolio.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: 2 },
|
||||
include: { positions: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should throw NotFoundException for non-existent portfolio', async () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
|
||||
await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for wrong user', async () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
|
||||
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend`
|
||||
Expected: FAIL — tests assert behavior that's not yet implemented
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement backend enrichment in PortfolioService.findAll
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts` — rewrite `findAll` method
|
||||
|
||||
- [ ] **Step 1: Replace the findAll method**
|
||||
|
||||
Current code (lines 62-67):
|
||||
```typescript
|
||||
async findAll(userId: number) {
|
||||
return this.prisma.portfolio.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```typescript
|
||||
async findAll(userId: number) {
|
||||
const portfolios = await this.prisma.portfolio.findMany({
|
||||
where: { userId },
|
||||
include: { positions: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
const allPositions = portfolios.flatMap((p) => p.positions);
|
||||
if (allPositions.length === 0) {
|
||||
return portfolios.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
currency: p.currency,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
totalValue: 0,
|
||||
positionCount: 0,
|
||||
shareCount: 0,
|
||||
bondCount: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const enrichedPositions = await this.enrichPositions(allPositions);
|
||||
|
||||
const posByPortfolioId = new Map<number, (typeof enrichedPositions)[number][]>();
|
||||
for (let i = 0; i < enrichedPositions.length; i++) {
|
||||
const pfId = allPositions[i].portfolioId;
|
||||
if (!posByPortfolioId.has(pfId)) {
|
||||
posByPortfolioId.set(pfId, []);
|
||||
}
|
||||
posByPortfolioId.get(pfId)!.push(enrichedPositions[i]);
|
||||
}
|
||||
|
||||
return portfolios.map((p) => {
|
||||
const positions = posByPortfolioId.get(p.id) ?? [];
|
||||
const totalValue = positions.reduce((sum, pos) => sum + (pos.currentValue ?? 0), 0);
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
currency: p.currency,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
totalValue: Math.round(totalValue * 100) / 100,
|
||||
positionCount: positions.length,
|
||||
shareCount: positions.filter((pos) => pos.type === 'share').length,
|
||||
bondCount: positions.filter((pos) => pos.type === 'bond').length,
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/portfolio/portfolio.service.ts \
|
||||
apps/backend/src/modules/portfolio/portfolio.service.spec.ts
|
||||
git commit -m "feat(backend): enrich portfolio list with MOEX batch data"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update PortfolioController with new DTO
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/portfolio/portfolio.controller.ts`
|
||||
|
||||
- [ ] **Step 1: Import PortfolioListResponseDto**
|
||||
|
||||
Add import at top:
|
||||
```typescript
|
||||
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update findAll to use new DTO in Swagger**
|
||||
|
||||
Replace method with ApiResponse decorator:
|
||||
```typescript
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||
@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true })
|
||||
async findAll(@CurrentUser() user: { sub: number }) {
|
||||
const portfolios = await this.portfolioService.findAll(user.sub);
|
||||
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
```
|
||||
|
||||
Also add the import:
|
||||
```typescript
|
||||
import { ApiOkResponse } from '@nestjs/swagger';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run existing tests to verify no regressions**
|
||||
|
||||
Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/portfolio/portfolio.controller.ts
|
||||
git commit -m "feat(backend): add Swagger decorators for enriched portfolio list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update frontend types
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/api/responses.ts`
|
||||
|
||||
- [ ] **Step 1: Add new fields to Portfolio interface**
|
||||
|
||||
Current (lines 138-145):
|
||||
```typescript
|
||||
export interface Portfolio {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```typescript
|
||||
export interface Portfolio {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
totalValue: number;
|
||||
positionCount: number;
|
||||
shareCount: number;
|
||||
bondCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc -b apps/frontend`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/api/responses.ts
|
||||
git commit -m "feat(frontend): add enrichment fields to Portfolio type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update PortfolioCard to show enriched data
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/components/portfolios/PortfolioCard.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace PortfolioCard implementation**
|
||||
|
||||
Current (lines 1-39):
|
||||
```typescript
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Portfolio } from '../../api/responses';
|
||||
|
||||
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/portfolios/${portfolio.id}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{portfolio.name}</h3>
|
||||
{portfolio.description && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
{portfolio.description}
|
||||
</p>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginTop: 8,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```typescript
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Portfolio } from '../../api/responses';
|
||||
|
||||
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||
const chipStyle = (bg: string): React.CSSProperties => ({
|
||||
background: bg,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/portfolios/${portfolio.id}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||
{portfolio.currency}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{portfolio.description && (
|
||||
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
{portfolio.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
{portfolio.shareCount > 0 && (
|
||||
<span style={chipStyle('#1b5e20')}>
|
||||
{portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')}
|
||||
</span>
|
||||
)}
|
||||
{portfolio.bondCount > 0 && (
|
||||
<span style={chipStyle('#0d47a1')}>
|
||||
{portfolio.bondCount} {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')}
|
||||
</span>
|
||||
)}
|
||||
<span style={chipStyle('#424242')}>
|
||||
{portfolio.positionCount} {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function pluralize(n: number, one: string, few: string, many: string): string {
|
||||
if (n % 10 === 1 && n % 100 !== 11) return one;
|
||||
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few;
|
||||
return many;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify frontend builds**
|
||||
|
||||
Run: `npm run build:frontend -w apps/frontend` (or `npx tsc -b apps/frontend`)
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/components/portfolios/PortfolioCard.tsx
|
||||
git commit -m "feat(frontend): display enriched data in PortfolioCard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run full test suite and verify
|
||||
|
||||
- [ ] **Step 1: Run backend tests**
|
||||
|
||||
Run: `npm run test:backend`
|
||||
Expected: All tests pass (including new portfolio service tests)
|
||||
|
||||
- [ ] **Step 2: Run frontend build**
|
||||
|
||||
Run: `npm run build:frontend`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Run linter**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: No lint errors
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,187 +0,0 @@
|
||||
# Pre-commit Checks Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Добавить pre-commit хуки (ESLint + Prettier) для backend и frontend, блокирующие коммит при ошибках.
|
||||
|
||||
**Architecture:** Husky + lint-staged в корне монорепозитория. ESLint 8 (совместим с существующим backend) для обеих workspace. Frontend получает свой `.eslintrc.cjs` с React-правилами.
|
||||
|
||||
**Tech Stack:** Husky 9, lint-staged 15, ESLint 8, Prettier 3
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ESLint для фронтенда
|
||||
|
||||
**Файлы:**
|
||||
- Создать: `apps/frontend/.eslintrc.cjs`
|
||||
- Изменить: `apps/frontend/package.json` (scripts + devDependencies)
|
||||
|
||||
- [ ] **Step 1: Добавить devDependencies в `apps/frontend/package.json`**
|
||||
|
||||
В секцию `devDependencies` добавить:
|
||||
|
||||
```json
|
||||
"eslint": "^8.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Добавить скрипт lint в `apps/frontend/package.json`**
|
||||
|
||||
```json
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Создать `apps/frontend/.eslintrc.cjs`**
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2020: true,
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Проверить, что ESLint работает на фронтенде**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npm run lint -w apps/frontend
|
||||
```
|
||||
|
||||
Expected: ESLint проверяет все `.ts,.tsx` файлы в `apps/frontend/src/`. Если есть ошибки — мы их фиксим. Если ошибок нет — чистый выход.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/package.json apps/frontend/.eslintrc.cjs
|
||||
git commit -m "feat: add ESLint config for frontend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Husky + lint-staged
|
||||
|
||||
**Файлы:**
|
||||
- Изменить: `package.json` (корень) — devDependencies + lint-staged config
|
||||
- Создать: `.husky/pre-commit`
|
||||
- Создать: `.husky/_/` (содержимое от `husky init`)
|
||||
|
||||
- [ ] **Step 1: Установить husky и lint-staged в корень**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npm install --save-dev husky lint-staged
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Инициализировать Husky**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx husky init
|
||||
```
|
||||
|
||||
Это создаст `.husky/` директорию с `pre-commit` хуком.
|
||||
|
||||
- [ ] **Step 3: Добавить lint-staged config в корневой `package.json`**
|
||||
|
||||
В корневой `package.json` добавить (после `devDependencies`):
|
||||
|
||||
```json
|
||||
"lint-staged": {
|
||||
"apps/backend/src/**/*.ts": ["eslint --max-warnings=0"],
|
||||
"apps/backend/test/**/*.ts": ["eslint --max-warnings=0"],
|
||||
"apps/frontend/src/**/*.{ts,tsx}": ["eslint --max-warnings=0"],
|
||||
"*.{ts,tsx}": ["prettier --check"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Настроить `.husky/pre-commit`**
|
||||
|
||||
Проверить содержимое `.husky/pre-commit`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
```
|
||||
|
||||
Если `husky init` уже создал подходящий файл, оставить как есть. Убедиться, что вызов `npx lint-staged` присутствует.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json .husky/
|
||||
git commit -m "feat: add Husky pre-commit hook with lint-staged"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Обновить root lint script
|
||||
|
||||
**Файлы:**
|
||||
- Изменить: `package.json` (корень) — секция scripts
|
||||
|
||||
- [ ] **Step 1: Обновить `lint` скрипт в корневом `package.json`**
|
||||
|
||||
Найти строку:
|
||||
```json
|
||||
"lint": "npm run lint -w apps/backend",
|
||||
```
|
||||
|
||||
Заменить на:
|
||||
```json
|
||||
"lint": "npm run lint -w apps/backend && npm run lint -w apps/frontend",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Проверить, что корневой lint работает**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: ESLint проходит по backend и frontend, возвращает 0 при отсутствии ошибок.
|
||||
|
||||
- [ ] **Step 3: Проверить chain целиком (опционально)**
|
||||
|
||||
Протестировать pre-commit hook:
|
||||
```bash
|
||||
git add . && git commit -m "test pre-commit hook"
|
||||
```
|
||||
|
||||
Должен выполнить lint-staged, проверить ESLint + Prettier на staged файлах.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "chore: update root lint script to cover both workspaces"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1102
package-lock.json
generated
1102
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user