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-контракт, архитектуру фронтенда и бэкенда, план реализации по этапам.
|
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
|
||||||
- **Superpowers**: обязательно использовать скиллы (Skills) при старте любой задачи — brainstorming, frontend-design, test-driven-development, writing-plans, executing-plans, requesting-code-review.
|
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
|
||||||
- **MCP-инструменты**: использовать MCP для анализа и генерации дизайна, работы с API, генерации кода.
|
- **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 | Порт бэкенда |
|
| `PORT` | 3000 | Порт бэкенда |
|
||||||
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
|
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
|
||||||
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
| `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_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
||||||
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
||||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||||
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
||||||
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
||||||
|
| `CACHE_DIVIDENDS_TTL` | 86400 | TTL дивидендных данных (с) |
|
||||||
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
|
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
|
||||||
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
|
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
|
||||||
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh 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 открыт).
|
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
|
||||||
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
|
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
|
||||||
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов).
|
- Аутентификация: 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`.
|
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
|
||||||
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
|
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
|
||||||
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
||||||
|
|||||||
@ -54,7 +54,5 @@ apps/
|
|||||||
frontend/ — React SPA with Vite
|
frontend/ — React SPA with Vite
|
||||||
docs/ — Docusaurus documentation site
|
docs/ — Docusaurus documentation site
|
||||||
docs/
|
docs/
|
||||||
architecture/ — ADR documents and diagrams
|
superpowers/specs/ — accepted SDD specifications
|
||||||
openapi/ — OpenAPI specification
|
|
||||||
superpowers/ — Design specs and implementation plans
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,14 +1,9 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
describe('checked-in OpenAPI frontend types', () => {
|
||||||
const { load } = require('js-yaml');
|
|
||||||
|
|
||||||
describe('checked-in OpenAPI artifacts', () => {
|
|
||||||
const rootDir = resolve(process.cwd(), '../..');
|
const rootDir = resolve(process.cwd(), '../..');
|
||||||
const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/api/types.ts'), 'utf8');
|
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 = [
|
const requiredPaths = [
|
||||||
'/api/v1/auth/register',
|
'/api/v1/auth/register',
|
||||||
@ -30,79 +25,33 @@ describe('checked-in OpenAPI artifacts', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('static OpenAPI YAML snapshot includes current protected domains', () => {
|
it('frontend generated types do not leak the local alternate codegen port', () => {
|
||||||
for (const path of requiredPaths) {
|
|
||||||
expect(openapiYaml).toContain(`${path}:`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('checked-in artifacts do not leak the local alternate codegen port', () => {
|
|
||||||
expect(frontendTypes).not.toContain('localhost:3001');
|
expect(frontendTypes).not.toContain('localhost:3001');
|
||||||
expect(frontendTypes).not.toContain('3001');
|
expect(frontendTypes).not.toContain('3001');
|
||||||
expect(openapiYaml).not.toContain('localhost:3001');
|
|
||||||
expect(openapiYaml).not.toContain('3001');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nullable primitive schemas are typed explicitly', () => {
|
it('frontend generated types include typed operations for current protected domains', () => {
|
||||||
const screenerItem = openapi.components.schemas.ScreenerItemDto;
|
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({
|
for (const operation of requiredOperations) {
|
||||||
type: 'number',
|
expect(frontendTypes).toContain(`operations['${operation}']`);
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,5 +9,8 @@
|
|||||||
| [ADR-005](ADR-005-openapi-codegen-frontend) | Accepted | OpenAPI Codegen for Frontend |
|
| [ADR-005](ADR-005-openapi-codegen-frontend) | Accepted | OpenAPI Codegen for Frontend |
|
||||||
| [ADR-006](ADR-006-no-cci) | Deprecated | No Custom Components Infrastructure |
|
| [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-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
|
## 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 |
|
| 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-006](adr/ADR-006-no-cci) | No CCI (Custom Components Infrastructure) |
|
||||||
| [ADR-007](adr/ADR-007-two-level-caching) | Two-level caching strategy |
|
| [ADR-007](adr/ADR-007-two-level-caching) | Two-level caching strategy |
|
||||||
| [ADR-008](adr/ADR-008-auth-system) | Authentication & Authorization |
|
| [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
|
## Response Format
|
||||||
|
|
||||||
|
|||||||
@ -7,17 +7,24 @@ graph TD
|
|||||||
AppModule --> ConfigModule
|
AppModule --> ConfigModule
|
||||||
AppModule --> CacheModule
|
AppModule --> CacheModule
|
||||||
AppModule --> MoexClientModule
|
AppModule --> MoexClientModule
|
||||||
|
AppModule --> PrismaModule
|
||||||
AppModule --> HealthModule
|
AppModule --> HealthModule
|
||||||
|
AppModule --> AuthModule
|
||||||
AppModule --> SecuritiesModule
|
AppModule --> SecuritiesModule
|
||||||
AppModule --> SharesModule
|
AppModule --> SharesModule
|
||||||
AppModule --> BondsModule
|
AppModule --> BondsModule
|
||||||
AppModule --> CandlesModule
|
AppModule --> CandlesModule
|
||||||
|
AppModule --> PortfolioModule
|
||||||
|
|
||||||
subgraph Global_Modules["Global Modules"]
|
subgraph Global_Modules["Global Modules"]
|
||||||
|
PrismaModule
|
||||||
CacheModule
|
CacheModule
|
||||||
MoexClientModule
|
MoexClientModule
|
||||||
end
|
end
|
||||||
|
|
||||||
|
AuthModule --> PrismaService["PrismaService"]
|
||||||
|
PortfolioModule --> PrismaService
|
||||||
|
PortfolioModule --> MoexClientService
|
||||||
SharesModule --> CacheService["CacheService"]
|
SharesModule --> CacheService["CacheService"]
|
||||||
BondsModule --> CacheService
|
BondsModule --> CacheService
|
||||||
SecuritiesModule --> CacheService
|
SecuritiesModule --> CacheService
|
||||||
@ -33,13 +40,24 @@ graph TD
|
|||||||
|
|
||||||
| Module | Global | Path | Description |
|
| Module | Global | Path | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| `PrismaModule` | Yes | `modules/prisma/` | Prisma client for SQLite |
|
||||||
| `CacheModule` | Yes | `modules/cache/` | In-memory cache (cache-manager) |
|
| `CacheModule` | Yes | `modules/cache/` | In-memory cache (cache-manager) |
|
||||||
| `MoexClientModule` | Yes | `modules/moex-client/` | HTTP-клиент MOEX ISS |
|
| `MoexClientModule` | Yes | `modules/moex-client/` | HTTP-клиент MOEX ISS |
|
||||||
| `HealthModule` | No | `modules/health/` | Health check endpoint |
|
| `HealthModule` | No | `modules/health/` | Health check endpoint |
|
||||||
|
| `AuthModule` | No | `modules/auth/` | JWT auth, refresh cookie, guards |
|
||||||
| `SecuritiesModule` | No | `modules/securities/` | Поиск инструментов |
|
| `SecuritiesModule` | No | `modules/securities/` | Поиск инструментов |
|
||||||
| `SharesModule` | No | `modules/shares/` | Акции |
|
| `SharesModule` | No | `modules/shares/` | Акции |
|
||||||
| `BondsModule` | No | `modules/bonds/` | Облигации |
|
| `BondsModule` | No | `modules/bonds/` | Облигации |
|
||||||
| `CandlesModule` | No | `modules/candles/` | Свечи OHLCV |
|
| `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
|
### CacheModule
|
||||||
|
|
||||||
@ -63,6 +81,15 @@ graph TD
|
|||||||
|
|
||||||
- `GET /api/v1/health` → `{ status: 'ok', timestamp, uptime }`
|
- `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
|
### SecuritiesModule
|
||||||
|
|
||||||
Поиск по инструментам (акции и облигации).
|
Поиск по инструментам (акции и облигации).
|
||||||
@ -92,3 +119,12 @@ graph TD
|
|||||||
- 2 эндпоинта: для акций и облигаций
|
- 2 эндпоинта: для акций и облигаций
|
||||||
- Интервалы: `1h` (60 min) и `24h` (daily)
|
- Интервалы: `1h` (60 min) и `24h` (daily)
|
||||||
- Маппинг: `1h` → MOEX interval 60, `24h` → MOEX interval 24
|
- Маппинг: `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
|
### Output
|
||||||
|
|
||||||
- `apps/frontend/src/api/types.ts` — сгенерированные типы `paths` и `operations`
|
- `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
|
### 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
|
npm run test -w apps/backend -- src/openapi-artifacts.spec.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Тест проверяет, что checked-in frontend types и YAML содержат актуальные auth, screener и portfolio
|
Тест проверяет, что checked-in frontend types содержат актуальные auth, screener и portfolio paths
|
||||||
paths, не содержат локальный alternate port и сохраняют важные schema metadata для nullable полей,
|
и не содержат локальный alternate port.
|
||||||
array enum tags и typed response envelopes.
|
|
||||||
|
|
||||||
### Manual Types
|
### Manual Types
|
||||||
|
|
||||||
|
|||||||
@ -22,9 +22,8 @@ moex-vibe/
|
|||||||
│ ├── frontend/ # React SPA
|
│ ├── frontend/ # React SPA
|
||||||
│ └── docs/ # Docusaurus documentation site
|
│ └── docs/ # Docusaurus documentation site
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── architecture/ # ADR и диаграммы
|
│ └── superpowers/
|
||||||
│ ├── openapi/ # OpenAPI-спецификация
|
│ └── specs/ # Согласованные SDD-спецификации
|
||||||
│ └── superpowers/ # Дизайн-спеки и планы
|
|
||||||
├── docker/
|
├── docker/
|
||||||
│ ├── Dockerfile.backend
|
│ ├── Dockerfile.backend
|
||||||
│ ├── Dockerfile.frontend
|
│ ├── Dockerfile.frontend
|
||||||
@ -39,3 +38,4 @@ moex-vibe/
|
|||||||
- npm workspaces монорепозиторий: `apps/backend`, `apps/frontend` и `apps/docs`.
|
- npm workspaces монорепозиторий: `apps/backend`, `apps/frontend` и `apps/docs`.
|
||||||
- Глобальный префикс API: `/api/v1`. Swagger: `/api/docs`.
|
- Глобальный префикс API: `/api/v1`. Swagger: `/api/docs`.
|
||||||
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
||||||
|
- `apps/docs` — опубликованная документация; root `docs` хранит только SDD specs.
|
||||||
|
|||||||
@ -18,6 +18,10 @@ const config: Config = {
|
|||||||
locales: ['ru'],
|
locales: ['ru'],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markdown: {
|
||||||
|
mermaid: true,
|
||||||
|
},
|
||||||
|
|
||||||
presets: [
|
presets: [
|
||||||
[
|
[
|
||||||
'classic',
|
'classic',
|
||||||
@ -34,6 +38,8 @@ const config: Config = {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
themes: ['@docusaurus/theme-mermaid'],
|
||||||
|
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
navbar: {
|
navbar: {
|
||||||
title: 'MoexVibe',
|
title: 'MoexVibe',
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "3.7.0",
|
"@docusaurus/core": "3.7.0",
|
||||||
"@docusaurus/preset-classic": "3.7.0",
|
"@docusaurus/preset-classic": "3.7.0",
|
||||||
|
"@docusaurus/theme-mermaid": "3.7.0",
|
||||||
"@mdx-js/react": "^3.0.0",
|
"@mdx-js/react": "^3.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^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