Compare commits

...

2 Commits

Author SHA1 Message Date
5b9d7f3a27 docs: fix historical files to new structure and update
All checks were successful
CI / ci (pull_request) Successful in 3m20s
CI / ci (push) Successful in 3m1s
2026-06-18 22:04:09 +03:00
dca8418843 feat: добавил инструкции к SDD подходу 2026-06-18 21:50:38 +03:00
45 changed files with 14569 additions and 23 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ vite.config.d.ts
vite.config.js
apps/docs/.docusaurus/
apps/docs/build/
.idea

228
AGENTS.md
View File

@ -11,6 +11,231 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
- **Visual Companion**: при обсуждении дизайна UI (mockups, макеты, варианты внешнего вида) использовать visual companion в браузере.
# Процесс разработки
Проект использует подход Specification-Driven Development (SDD).
## Структура документации
```text
docs/
├── inbox.md
├── roadmap.md
├── research/
├── epics/
│ └── {epic-name}.md
└── features/
└── {feature-name}/
├── spec.md
├── plan.md
└── tasks.md
```
## Назначение документов
### inbox.md
Содержит идеи и мысли, которые появились во время работы над проектом.
Записи в inbox не являются требованиями и не должны реализовываться напрямую.
### roadmap.md
Содержит список запланированных эпиков и фич.
Наличие задачи в roadmap не означает, что её нужно немедленно реализовать.
### research/
Содержит результаты исследований и экспериментов.
Документы могут содержать гипотезы, предположения и открытые вопросы.
Результаты исследований необходимо проверять перед реализацией.
### epics/
Эпик представляет собой крупную продуктовую возможность или модуль.
Эпик может состоять из нескольких фич.
### features/{feature-name}/spec.md
Описывает ЧТО должно быть реализовано.
Спецификация должна содержать:
- цель
- требования
- ограничения
- критерии приемки (Acceptance Criteria)
Спецификация не должна содержать деталей реализации.
### features/{feature-name}/plan.md
Описывает КАК будет реализована фича.
План может содержать:
- архитектурные решения
- API контракты
- потоки данных
- технический подход
### features/{feature-name}/tasks.md
Содержит список задач для реализации.
Задачи должны быть:
- небольшими
- конкретными
- независимыми по возможности
## Правила разработки
### Правило 1
Нельзя начинать реализацию без спецификации.
Если спецификации нет:
- Провести исследование при необходимости.
- Создать spec.md.
- Уточнить требования.
- Только после этого переходить к реализации.
### Правило 2
Реализация должна соответствовать spec.md.
Если в процессе разработки выясняется, что требования неполные или ошибочные:
Не изменять поведение системы молча.
Сначала обновить:
- spec.md
- plan.md
И только потом продолжать реализацию.
### Правило 3
Спецификация является источником истины.
Если plan.md противоречит spec.md:
Приоритет имеет spec.md.
### Правило 4
Не добавлять функциональность, которая отсутствует в спецификации.
Если появилась новая идея:
- обновить спецификацию;
- либо создать новую фичу.
### Правило 5
Исправления ошибок можно выполнять напрямую.
Новая функциональность должна проходить через спецификацию.
## Процесс работы над фичей
При реализации фичи необходимо:
1) Ознакомиться с эпиком, если он существует.
2) Прочитать spec.md.
3) Прочитать plan.md.
4) Прочитать tasks.md.
5) Выполнять задачи последовательно.
6) Отмечать выполненные задачи.
7) Обновлять plan.md при изменении технических решений.
8) Обновлять spec.md при изменении требований.
## Работа с новыми идеями
Если во время реализации появилась новая идея:
Не реализовывать её автоматически.
Необходимо определить, является ли она:
- багом;
- улучшением существующей функциональности;
- новой фичей.
Если это улучшение или новая фича:
Добавить её в:
- inbox.md
или создать отдельную фичу.
## Работа с существующими фичами
Улучшения существующей функциональности обычно остаются внутри текущего эпика.
Пример:
Portfolio Dashboard
- История операций
- Пагинация истории операций
- Фильтрация истории операций
- Экспорт истории операций
Все перечисленные возможности относятся к одному эпику.
Новый эпик создаётся только при появлении новой продуктовой возможности или нового домена.
## Поддержание документации
Документация должна соответствовать текущему состоянию проекта.
После значимых изменений необходимо обновлять:
- spec.md
- plan.md
- tasks.md
- ADR
- архитектурную документацию
Документация не должна отставать от реализации.
## Поведение AI-агентов
Перед написанием кода необходимо:
1) Изучить спецификацию фичи.
2) Проверить полноту требований.
3) Найти неоднозначности и противоречия.
4) При необходимости запросить уточнения.
Запрещено:
- придумывать требования;
- додумывать поведение системы;
- реализовывать неописанную функциональность.
Если информации недостаточно:
- Остановиться и запросить уточнение вместо того, чтобы делать предположения.
## Приоритет источников информации
При возникновении противоречий использовать следующий порядок приоритетов:
1. Текущая задача пользователя.
2. spec.md фичи.
3. plan.md фичи.
4. ADR.
5. Архитектурная документация.
6. roadmap.md.
7. inbox.md.
roadmap.md и inbox.md никогда не являются основанием для реализации функциональности.
## Git workflow
- Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё.
@ -20,11 +245,8 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
## Документация и SDD-артефакты
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`.
- Все SDD spec-файлы в `docs/superpowers/specs/` пишутся на русском языке; англоязычные термины допустимы для API, кода, протоколов и официальных названий.
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
- OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`.
- Superpowers plans и временные execution logs не коммитить по умолчанию. Если нужен план для ревью, держать его кратким и переносить устойчивые решения в spec/ADR/docs.
## Команды

View File

@ -54,5 +54,8 @@ apps/
frontend/ — React SPA with Vite
docs/ — Docusaurus documentation site
docs/
superpowers/specs/ — accepted SDD specifications
features/ — SDD feature specifications and implementation plans
epics/ — product epics
inbox.md — captured ideas and follow-ups
roadmap.md — planned epics and features
```

View File

@ -0,0 +1,4 @@
Авторизация
Features:
- auth-system

View File

@ -0,0 +1,8 @@
Портфель брокера
Features:
- broker-operations-ui-improvements
- broker-portfolio-display
- broker-positions-pagination-and-loading
- tbank-broker-portfolios
- tbank-deadline-queue-fix

5
docs/epics/DevOps.md Normal file
View File

@ -0,0 +1,5 @@
Developer Operations
Features:
- ci-cd
- pre-commit-checks

View File

@ -0,0 +1,5 @@
Документация
Features:
- docusaurus-docs
- russian-docs-and-architecture-diagrams

View File

@ -0,0 +1,9 @@
Страница портфеля
Features:
- pagination-loading-overlay
- portfolio
- portfolio-allocation-chart
- portfolio-analytics
- portfolio-enricher-optimization
- portfolio-list-enrichment

View File

@ -0,0 +1,5 @@
Контроль качества
Features:
- frontend-test-coverage
- quality-gate-contract-docs

File diff suppressed because it is too large Load Diff

110
docs/features/ci-cd/plan.md Normal file
View File

@ -0,0 +1,110 @@
# 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'а зелёные.

View File

@ -19,7 +19,7 @@
- `docker/Dockerfile.*`, `docker/nginx.conf` → инфраструктура
- `docker-compose.yml` → сервисы, порты
- `.gitea/workflows/ci.yml` → CI pipeline
- `docs/architecture/adr/*` → копия существующих ADR
- `apps/docs/docs/adr/*` → опубликованные ADR проекта
- `.prettierrc`, `tsconfig.base.json` → конфиги
## Pages

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -260,14 +260,14 @@ moex-vibe/
│ │ └── main.tsx
│ └── package.json
├── docs/
│ ├── superpowers/specs/
│ ├── architecture/
│ ├── adr/
│ ├── diagrams/
│ │ └── domain-model.md
├── openapi/
│ └── openapi.yaml
└── website/ (Docusaurus — post-MVP)
│ ├── epics/
│ ├── features/
├── inbox.md
└── roadmap.md
├── apps/
└── docs/
└── docs/
└── adr/
├── package.json
├── tsconfig.base.json
└── .gitignore

View File

@ -0,0 +1,514 @@
# Pagination Loading Overlay — 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 overlay + spinner to all broker paginated tables when switching pages
**Architecture:** Use TanStack Query's `isFetching` (any fetch) vs `isLoading` (initial fetch) to show overlay when data exists and a new page is loading. Keep `keepPreviousData` so old data stays visible under the overlay. Add CSS spinner animation.
**Tech Stack:** React, TanStack Query v5, CSS custom properties
---
### Task 1: CSS — spinner animation and overlay styles
**Files:**
- Modify: `apps/frontend/src/styles.css`
- [ ] **Step 1: Add spinner keyframes and loading-spinner class**
Add to `apps/frontend/src/styles.css` at the end:
```css
@keyframes loading-spin {
to { transform: rotate(360deg); }
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-bg);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
.table-container {
position: relative;
}
.table-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.65);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
transition: opacity 0.2s ease;
z-index: 1;
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend/src/styles.css
git commit -m "style: add loading-spinner and overlay CSS classes"
```
---
### Task 2: PositionGroupTable — overlay on pagination + spinner in buttons
**Files:**
- Modify: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx`
- [ ] **Step 1: Add `isFetching` to the query destructuring**
Line 103 changes from:
```tsx
const { data: page, isLoading } = useBrokerPositions(accountId, query);
```
to:
```tsx
const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query);
```
- [ ] **Step 2: Replace the loading rendering section**
Current (lines 179-213):
```tsx
{isLoading && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Тикер</th>
<th align="left" style={thStyle}>Название</th>
<th align="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<TableSkeleton rows={4} columns={[
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
]} />
</table>
</div>
)}
{!isLoading && positions.length > 0 && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
...
</table>
</div>
)}
```
Replace with new rendering logic:
```tsx
{isLoading && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Тикер</th>
<th align="left" style={thStyle}>Название</th>
<th align="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<TableSkeleton rows={4} columns={[
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
]} />
</table>
</div>
)}
{!isLoading && positions.length > 0 && (
<div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Тикер</th>
<th align="left" style={thStyle}>Название</th>
<th align="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<tbody>
{positions.map((position) => (
<tr
key={
position.positionUid ||
position.instrumentUid ||
position.ticker ||
position.figi
}
>
<td style={tdStyle}><PositionTicker position={position} /></td>
<td style={tdStyle}>
<span style={{ color: 'var(--color-text-secondary)' }}>
{position.name || '-'}
</span>
</td>
<td align="right" style={tdStyle}>{formatQuantity(position.quantity)}</td>
<td align="right" style={tdStyle}>{formatMoney(position.currentPrice)}</td>
<td align="right" style={tdStyle}>{formatMoney(position.currentValue)}</td>
</tr>
))}
</tbody>
</table>
</div>
{isFetching && (
<div className="table-loading-overlay">
<div className="loading-spinner" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber}…
</span>
</div>
)}
</div>
)}
```
- [ ] **Step 3: Update pagination buttons to show spinner during fetch**
Replace the button content in lines 167-174 (the "→" button):
```tsx
<button
type="button"
onClick={handleNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
</button>
```
Also update the "←" button (lines 148-155):
```tsx
<button
type="button"
onClick={handlePrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
</button>
```
- [ ] **Step 4: Commit**
```bash
git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
git commit -m "feat: add loading overlay and spinner to PositionGroupTable"
```
---
### Task 3: BrokerOperationsTable — new `isFetching` prop + overlay
**Files:**
- Modify: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx`
- [ ] **Step 1: Add `isFetching` to props interface**
Change the component props destructuring (line 96-113):
```tsx
export function BrokerOperationsTable({
isLoading,
isFetching,
page,
pageNumber,
canGoBack,
canGoForward,
onPrevious,
onNext,
}: {
isLoading: boolean;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
}) {
```
- [ ] **Step 2: Replace the loading/empty/data rendering**
Current (lines 158-229):
```tsx
{isLoading ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Дата</th>
<th align="left" style={thStyle}>Тип</th>
<th align="left" style={thStyle}>Инструмент</th>
<th align="right" style={thStyle}>Сумма</th>
</tr>
</thead>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
</table>
</div>
) : operations.length === 0 ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Дата</th>
<th align="left" style={thStyle}>Тип</th>
<th align="left" style={thStyle}>Инструмент</th>
<th align="right" style={thStyle}>Сумма</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => {
const impact = getBrokerOperationImpact(operation);
return (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}><span>{getBrokerOperationTypeLabel(operation)}</span></td>
<td style={tdStyle}><OperationInstrument operation={operation} /></td>
<td align="right" style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}>
{formatMoney(operation.payment)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
```
Replace with:
```tsx
{isLoading ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Дата</th>
<th align="left" style={thStyle}>Тип</th>
<th align="left" style={thStyle}>Инструмент</th>
<th align="right" style={thStyle}>Сумма</th>
</tr>
</thead>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
</table>
</div>
) : operations.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : (
<div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Дата</th>
<th align="left" style={thStyle}>Тип</th>
<th align="left" style={thStyle}>Инструмент</th>
<th align="right" style={thStyle}>Сумма</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => {
const impact = getBrokerOperationImpact(operation);
return (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}><span>{getBrokerOperationTypeLabel(operation)}</span></td>
<td style={tdStyle}><OperationInstrument operation={operation} /></td>
<td align="right" style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}>
{formatMoney(operation.payment)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{isFetching && (
<div className="table-loading-overlay">
<div className="loading-spinner" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber}…
</span>
</div>
)}
</div>
)}
```
Note: The empty state check changed from `operations.length === 0` to `operations.length === 0 && !isFetching` — this ensures the overlay shows on top of old data, not the empty message.
- [ ] **Step 3: Update pagination buttons**
Replace line 132-135 (← button):
```tsx
<button
type="button"
onClick={onPrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
</button>
```
Replace lines 147-154 (→ button):
```tsx
<button
type="button"
onClick={onNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
</button>
```
- [ ] **Step 4: Commit**
```bash
git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
git commit -m "feat: add loading overlay and spinner to BrokerOperationsTable"
```
---
### Task 4: BrokerAccountDetailPage — pass `isFetching` to operations table
**Files:**
- Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`
- [ ] **Step 1: Add `isFetching` to the BrokerOperationsTable props**
Change the `<BrokerOperationsTable>` call (line 123-131):
```tsx
<BrokerOperationsTable
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
pageNumber={operationCursorStack.length + 1}
canGoBack={operationCursorStack.length > 0}
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
onPrevious={handlePreviousOperationsPage}
onNext={handleNextOperationsPage}
/>
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
git commit -m "feat: pass isFetching to BrokerOperationsTable"
```
---
### Task 5: Update tests
**Files:**
- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx`
- [ ] **Step 1: Add `isFetching: false` to all existing position mocks**
In `mockUseBrokerPositions` (line 47-61), add `isFetching: false`:
```tsx
return {
data: {
accountId: 'acc-1',
items: filtered,
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any;
```
- [ ] **Step 2: Add `isFetching: false` to all operations mocks**
Add `isFetching: false` alongside each `isLoading: false` in the operations mocks (lines 58, 85, 112, 147, 190, 201, 266, 324, 363, 433).
For example, line 112 area becomes:
```tsx
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-17T00:00:00.000Z' },
isLoading: false,
isFetching: false,
```
- [ ] **Step 3: Verify tests pass**
```bash
npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend
```
Expected: All tests PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/frontend/src/pages/broker/BrokerPages.test.tsx
git commit -m "test: add isFetching to mock return values"
```
---
### Task 6: Lint and final verification
- [ ] **Step 1: Run lint**
```bash
npm run lint
```
Expected: No errors (or only pre-existing ones).
- [ ] **Step 2: Run full frontend test suite**
```bash
npm run test:frontend
```
Expected: All tests pass.
- [ ] **Step 3: Run typecheck**
```bash
npx tsc -b apps/frontend
```
Expected: No type errors.

View File

@ -0,0 +1,261 @@
# Диаграмма распределения портфеля — План реализации
> **Для агентов:** Требуется навык `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

View File

@ -368,7 +368,7 @@ model Position {
## 10. OpenAPI Specification
Дополнения к существующему `docs/openapi/openapi.yaml`:
Дополнения к текущему backend OpenAPI-контракту по `/api/docs-json`:
- Обновить схему `PositionResponse` — добавить `buyPrice`, `buyDate`
- Создать схему `PortfolioAnalytics` со всеми полями

View File

@ -0,0 +1,365 @@
# 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 `apps/docs/docs/adr/` as a new ADR 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)

View File

@ -0,0 +1,613 @@
# 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

View File

@ -392,7 +392,7 @@ Phase 1 не требует событийной шины. События док
## 10. OpenAPI Specification
Дополнение к существующему `docs/openapi/openapi.yaml` — новые эндпоинты и схемы для Portfolio и Position.
Дополнение к текущему backend OpenAPI-контракту по `/api/docs-json` — новые эндпоинты и схемы для Portfolio и Position.
---

View File

@ -0,0 +1,187 @@
# 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

View File

@ -17,7 +17,7 @@
настоящий `MoexClientService`.
- Ошибки live MOEX-запросов проявляются как Vitest `DataCloneError`, потому что `AxiosError`
содержит функции в конфигурации запроса, которые нельзя клонировать между worker'ами.
- `docs/openapi/openapi.yaml` и `apps/frontend/src/api/types.ts` не содержат актуальные эндпоинты
- backend Swagger JSON по `/api/docs-json` и `apps/frontend/src/api/types.ts` не отражают актуальные эндпоинты
`auth`, `portfolios` и `securities/screener`.
- README, AGENTS и страницы Docusaurus местами описывают старое состояние репозитория.
- `npm run build:docs` успешно генерирует статические файлы, но выводит предупреждения Docusaurus о
@ -78,7 +78,7 @@
- `apps/docs/docs/backend/api.md` не содержит portfolio и screener endpoints.
- `apps/docs/docs/backend/portfolio.md` документирует `PATCH /api/v1/portfolios/:id/patch`, хотя
controller реализует `PATCH /api/v1/portfolios/:id`.
- `docs/openapi/openapi.yaml` и `apps/frontend/src/api/types.ts` содержат только ранние paths для
- backend Swagger JSON по `/api/docs-json` и `apps/frontend/src/api/types.ts` содержат только ранние paths для
health, search, shares, bonds и candles.
## Доменная модель
@ -110,7 +110,7 @@ decorators по `/api/docs-json`.
Сгенерированные или синхронизированные артефакты:
- `docs/openapi/openapi.yaml`: checked-in человекочитаемый snapshot.
- `/api/docs-json`: live Swagger JSON, канонический machine-readable contract.
- `apps/frontend/src/api/types.ts`: сгенерированные TypeScript path и schema types.
- Docusaurus API pages: поясняющая документация, но не канонический machine contract.
@ -276,7 +276,7 @@ broken links на `/`. Отдельное update-check warning про permission
1. Добавить или завершить Swagger metadata для актуальных routes.
2. Перегенерировать `apps/frontend/src/api/types.ts`.
3. Синхронизировать `docs/openapi/openapi.yaml` с текущим contract.
3. Проверить `/api/docs-json` и синхронизировать `apps/frontend/src/api/types.ts` с текущим contract.
4. Проверить, что generated paths включают auth, screener и portfolio routes.
### Этап 3: обновить документацию
@ -311,7 +311,7 @@ npm run format:check
`/`.
- `npm run format:check` завершается с exit code 0.
- `apps/frontend/src/api/types.ts` содержит актуальные auth, screener и portfolio paths.
- `docs/openapi/openapi.yaml` содержит актуальные auth, screener и portfolio paths.
- `/api/docs-json` содержит актуальные auth, screener и portfolio paths.
- README, AGENTS и Docusaurus docs больше не утверждают, что frontend tests отсутствуют.
- Portfolio docs используют `PATCH /api/v1/portfolios/:id`, что соответствует controller.

View File

@ -7,8 +7,8 @@
## Контекст
Документация проекта публикуется только из `apps/docs` через Docusaurus. Текущая структура уже
зафиксирована в `docs/superpowers/specs/2026-06-13-docusaurus-docs-design.md`: страницы лежат в
`apps/docs/docs`, навигация описана в `apps/docs/sidebars.ts`, Mermaid включён через
зафиксирована в `docs/features/docusaurus-docs/spec.md`: страницы лежат в `apps/docs/docs`,
навигация описана в `apps/docs/sidebars.ts`, Mermaid включён через
`@docusaurus/theme-mermaid`.
Сейчас большая часть человекочитаемого текста в опубликованной документации написана на английском:

File diff suppressed because it is too large Load Diff

View File

@ -455,7 +455,7 @@ Phase 1 не требует событий. Для будущих фаз:
## 10. OpenAPI Specification
Дополнение к существующему `docs/openapi/openapi.yaml`:
Дополнение к текущему backend OpenAPI-контракту по `/api/docs-json`:
```yaml
paths:

15
docs/inbox.md Normal file
View File

@ -0,0 +1,15 @@
# Inbox
## Ideas
- Добавить календарь дивидендов
- Добавить экспорт портфеля в Excel
- Добавить темную тему
- Показывать прогноз дивидендов
## Improvements
- Перфоманс api
- Улучшить поиск по тикеру
## Questions
- Нужна ли поддержка вкладов?

0
docs/roadmap.md Normal file
View File