Compare commits

..

No commits in common. "main" and "feat/portfolio-phase1" have entirely different histories.

780 changed files with 7425 additions and 76786 deletions

View File

@ -9,57 +9,46 @@ env:
NODE_VERSION: 20 NODE_VERSION: 20
jobs: jobs:
ci: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: 'Checkout repository'
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js - name: 'Setup dependencies'
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies - run: npm ci
run: npm ci - run: npm run lint
- run: npx prettier --check "**/*.{ts,tsx}"
- name: Build design system test:
run: npm run build:design-system runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
uses: actions/checkout@v4
- name: Lint - name: 'Setup dependencies'
run: npm run lint uses: actions/setup-node@v4
- name: Test backend
run: npm run test:backend
- name: Test frontend
run: npm run test:frontend
- name: Build backend
run: npm run build:backend
- name: Build frontend
run: npm run build:frontend
- name: Setup Playwright browsers
run: npx playwright install --with-deps chromium
- name: Test design system
run: npm run test:design-system
- name: Build Storybook
run: npm run build:storybook
- name: Test Storybook (browser)
run: npm run test:storybook
- name: Build docs
run: npm run build:docs
- name: Upload Storybook build (if failed)
if: failure()
uses: actions/upload-artifact@v4
with: with:
name: storybook-static node-version: ${{ env.NODE_VERSION }}
path: packages/design-system/storybook-static
retention-days: 3 - run: npm ci
- run: npm run test:backend
build:
runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
uses: actions/checkout@v4
- name: 'Setup dependencies'
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm run build:backend
- run: npm run build:frontend

7
.gitignore vendored
View File

@ -1,7 +1,5 @@
node_modules/ node_modules/
dist/ dist/
.superpowers/
.worktrees/
.env .env
*.log *.log
.DS_Store .DS_Store
@ -10,8 +8,3 @@ vite.config.d.ts
vite.config.js vite.config.js
apps/docs/.docusaurus/ apps/docs/.docusaurus/
apps/docs/build/ apps/docs/build/
.idea
.playwright-mcp
.opencode
dev.db
graphify-out/

View File

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

View File

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

6
.prettierrc Normal file
View File

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

2
.serena/.gitignore vendored
View File

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

View File

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

View File

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

539
AGENTS.md
View File

@ -1,448 +1,62 @@
# MoexVibe — Инструкция для агента # MoexVibe — Инструкция для агента
## Содержание ## Репозиторий
- [Обязательный подход к разработке](#обязательный-подход-к-разработке) npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite).
- [Процесс разработки](#процесс-разработки)
- [Структура документации](#структура-документации)
- [Назначение документов](#назначение-документов)
- [Правила разработки](#правила-разработки)
- [Определение бага](#определение-бага)
- [Процесс работы над фичей](#процесс-работы-над-фичей)
- [Работа с новыми идеями](#работа-с-новыми-идеями)
- [Работа с существующими фичами](#работа-с-существующими-фичами)
- [Поддержание документации](#поддержание-документации)
- [Поведение AI-агентов](#поведение-ai-агентов)
- [Anti-Loop: лимит на итерации](#anti-loop-лимит-на-итерации)
- [Приоритет источников информации](#приоритет-источников-информации)
- [Git workflow](#git-workflow)
- [Конвенция коммитов](#конвенция-коммитов)
- [Документация и SDD-артефакты](#документация-и-sdd-артефакты)
- [Definition of Done (DoD)](#definition-of-done-dod)
- [Технические регламенты](#технические-регламенты)
- [Правила тестирования](#правила-тестирования)
- [Работа с миграциями Prisma](#работа-с-миграциями-prisma)
- [Правила рефакторинга](#правила-рефакторинга)
- [Политики безопасности](#политики-безопасности)
- [ADR-процесс](#adr-процесс)
- [Правила обновления OpenAPI/типов](#правила-обновления-openapiтипов)
- [Цикл работы над API](#цикл-работы-над-api)
- [Инфраструктура проекта](#инфраструктура-проекта)
- [Команды](#команды)
- [Переменные окружения](#переменные-окружения)
- [Архитектура](#архитектура)
- [Бэкенд](#бэкенд)
- [Фронтенд](#фронтенд)
- [Стиль кода](#стиль-кода)
---
## Обязательный подход к разработке ## Обязательный подход к разработке
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria. - **SDD (Specification-Driven Development)**: перед написанием кода сначала сформировать спецификацию — PRD, доменную модель, ADR, OpenAPI-контракт, архитектуру фронтенда и бэкенда, план реализации по этапам.
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans для крупных многошаговых работ, subagent-driven-development как предпочтительный способ исполнения плана, executing-plans как fallback для явно связанных inline-задач, frontend-design для UI, requesting-code-review перед завершением крупных изменений. - **Superpowers**: обязательно использовать скиллы (Skills) при старте любой задачи — brainstorming, frontend-design, test-driven-development, writing-plans, executing-plans, requesting-code-review.
- **MCP-инструменты**: в проекте настроены `code-index-mcp` (файловый поиск/индексация), `serena` (LSP-символьный анализ кода) и `graphify` (knowledge graph). Использовать для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче. - **MCP-инструменты**: использовать MCP для анализа и генерации дизайна, работы с API, генерации кода.
- **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы.
## Команды
---
| Команда | Что делает |
## Процесс разработки |---|---|
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
Проект использует подход Specification-Driven Development (SDD). | `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
| `npm run build:backend` | `nest build` |
### Структура документации | `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
```text | `npm run lint` | ESLint только для бэкенда |
docs/ | `npm run format` | Prettier для всех `*.{ts,tsx}` |
| `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` |
├── inbox.md
├── roadmap.md Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend`
├── research/ ## Переменные окружения
├── epics/ | Переменная | По умолчанию | Описание |
│ └── {epic-name}.md |---|---|---|
| `PORT` | 3000 | Порт бэкенда |
└── features/ | `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
└── {feature-name}/ | `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
├── spec.md | `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
├── plan.md | `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
└── tasks.md | `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
``` | `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
Полный набор `spec.md`, `plan.md` и `tasks.md` обязателен для новых фич. Исторические feature-каталоги могут быть неполными: отсутствующие артефакты не требуется восстанавливать задним числом, если это не нужно для текущего изменения. | `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
### Назначение документов | `JWT_ACCESS_EXPIRES` | `15m` | TTL access token |
| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token |
#### inbox.md
Содержит идеи и мысли, которые появились во время работы над проектом.
Записи в inbox не являются требованиями и не должны реализовываться напрямую.
#### roadmap.md
Содержит список запланированных эпиков и фич.
Наличие задачи в roadmap не означает, что её нужно немедленно реализовать.
#### research/
Содержит результаты исследований и экспериментов.
Документы могут содержать гипотезы, предположения и открытые вопросы.
Результаты исследований необходимо проверять перед реализацией.
#### epics/
Эпик представляет собой крупную продуктовую возможность или модуль.
Эпик может состоять из нескольких фич.
#### features/{feature-name}/spec.md
Описывает **ЧТО** должно быть реализовано.
Спецификация должна содержать:
- цель
- требования
- ограничения
- критерии приемки (Acceptance Criteria)
Спецификация не должна содержать деталей реализации.
#### features/{feature-name}/plan.md
Описывает **КАК** будет реализована фича.
План может содержать:
- архитектурные решения
- API контракты
- потоки данных
- технический подход
#### features/{feature-name}/tasks.md
Содержит список задач для реализации.
Задачи должны быть:
- небольшими
- конкретными
- независимыми по возможности
### Правила разработки
#### Правило 1
Нельзя начинать реализацию новой фичи без спецификации.
Если спецификации нет:
- Провести исследование при необходимости.
- Создать spec.md.
- Уточнить требования.
- Только после этого переходить к реализации.
#### Правило 2
Реализация должна соответствовать spec.md.
Если в процессе разработки выясняется, что требования неполные или ошибочные:
- Не изменять поведение системы молча.
- Сначала обновить spec.md и plan.md.
- И только потом продолжать реализацию.
#### Правило 3
Спецификация является источником истины.
Если plan.md противоречит spec.md — приоритет имеет spec.md.
#### Правило 4
Не добавлять функциональность, которая отсутствует в спецификации.
Если появилась новая идея:
- обновить спецификацию;
- либо создать новую фичу.
#### Правило 5
Исправления ошибок можно выполнять напрямую. Новая функциональность должна проходить через спецификацию.
#### Определение бага
Баг — это поведение системы, противоречащее спецификации, acceptance criteria, API-контракту, зафиксированному тестами поведению или подтверждённому архитектурному инварианту.
Если желаемое поведение нигде не зафиксировано и не следует из существующего контракта или инварианта — это отсутствующая функциональность (new feature), а не баг.
Классификация:
- **Есть зафиксированный контракт, поведение не соответствует**баг (можно чинить напрямую, Правило 5)
- **Нет зафиксированного контракта, требуется новое поведение** → новая фича (нужна спецификация)
- **Spec есть, но в нём неопределённость** → сначала уточнить spec, потом решать, баг это или фича
### Процесс работы над фичей
При реализации новой фичи необходимо:
1. Ознакомиться с эпиком, если он существует.
2. Прочитать spec.md.
3. Прочитать plan.md.
4. Прочитать tasks.md.
5. Выполнять задачи последовательно.
6. Отмечать выполненные задачи.
7. Обновлять plan.md при изменении технических решений.
8. Обновлять spec.md при изменении требований.
Для исторической фичи сначала прочитать все имеющиеся артефакты. Отсутствие старого `plan.md` или `tasks.md` само по себе не блокирует maintenance или исправление бага и не требует создавать их задним числом. Для нового расширения такой фичи сначала подготовить недостающие артефакты в объёме текущего изменения.
Если есть согласованный `plan.md` для многошаговой реализации, агент по умолчанию должен
предпочитать `superpowers:subagent-driven-development`. `superpowers:executing-plans` использовать
только когда пользователь явно просит inline-исполнение или когда задачи настолько тесно связаны,
что разбиение по subagent-циклам ухудшит надёжность и скорость.
### Работа с новыми идеями
Если во время реализации появилась новая идея:
Не реализовывать её автоматически. Необходимо определить, является ли она:
- багом;
- улучшением существующей функциональности;
- новой фичей.
Если это улучшение или новая фича — добавить её в inbox.md или создать отдельную фичу.
### Работа с существующими фичами
Улучшения существующей функциональности обычно остаются внутри текущего эпика.
Пример:
```
Portfolio Dashboard
├── История операций
├── Пагинация истории операций
├── Фильтрация истории операций
└── Экспорт истории операций
```
Новый эпик создаётся только при появлении новой продуктовой возможности или нового домена.
### Поддержание документации
Документация должна соответствовать текущему состоянию проекта.
После значимых изменений необходимо обновлять:
- spec.md
- plan.md
- tasks.md
- ADR
- архитектурную документацию
Документация не должна отставать от реализации.
### Поведение AI-агентов
Перед написанием кода необходимо:
1. Изучить спецификацию фичи.
2. Проверить полноту требований.
3. Найти неоднозначности и противоречия.
4. При необходимости запросить уточнения.
5. Перед началом реализации агент должен кратко подтвердить понимание задачи и спецификации (одним сообщением).
**Запрещено:**
- придумывать требования;
- додумывать поведение системы;
- реализовывать неописанную функциональность.
Если информации недостаточно — остановиться и запросить уточнение вместо того, чтобы делать предположения.
### Pre-flight checklist (обязателен перед реализацией любой фичи)
Агент не имеет права начать реализацию, пока не выполнены все пункты:
- [ ] Feature branch создана: `codex/<feature-name>`
- [ ] spec.md написана и утверждена пользователем
- [ ] plan.md написан и утверждён пользователем
- [ ] tasks.md создан с чекбоксами до начала работы
- [ ] Все тесты проходят на текущем состоянии
Нарушение любого пункта = остановиться и вернуться к пропущенному шагу.
### Anti-Loop: лимит на итерации
Если после 3 последовательных неудачных попыток исправить одну и ту же проблему в рамках одной гипотезы симптом не изменился — остановиться и запросить помощь у пользователя.
Правила:
- Каждая попытка = один цикл «сформулировал гипотезу → внёс изменение → проверил → тот же симптом сохранился»
- Сбор новой диагностической информации без изменения кода попыткой не считается
- Не начинать 4-ю попытку без явного указания пользователя
- При запросе помощи приложить: что пытался сделать, что пошло не так, последнее состояние кода/логов
### Приоритет источников информации
При возникновении противоречий использовать следующий порядок приоритетов:
1. Текущая задача пользователя (она может изменить требования, но соответствующие SDD-артефакты обновляются до реализации).
2. spec.md фичи.
3. plan.md фичи.
4. ADR.
5. Архитектурная документация.
6. roadmap.md.
7. inbox.md.
roadmap.md и inbox.md никогда не являются основанием для реализации функциональности.
### Git workflow
- Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё.
- Имя ветки по умолчанию начинать с `codex/`, если пользователь не попросил другой префикс.
- Не смешивать независимые фичи в одной ветке. Небольшие связанные docs/chore/test-правки можно держать в той же ветке, если они относятся к текущей задаче.
### Конвенция коммитов
Использовать [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` — новая функциональность
- `fix:` — исправление бага
- `chore:` — обслуживание (зависимости, конфиги, CI)
- `docs:` — документация
- `refactor:` — рефакторинг без изменения поведения
- `test:` — добавление или исправление тестов
- `style:` — форматирование, кодстайл (prettier)
- `perf:` — улучшение производительности
- `build:` — изменения сборки и зависимостей
- `ci:` — изменения CI/CD
Формат: `<тип>(<необязательный scope>): <краткое описание в настоящем времени>`
Примеры:
- `feat: add portfolio rebalancing endpoint`
- `fix: handle empty dividend list from MOEX`
- `docs: update API authentication section`
### Документация и SDD-артефакты
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
- OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`.
### Definition of Done (DoD)
- Все acceptance criteria реализованы
- Тесты проходят
- Lint проходит
- Для новой фичи созданы и обновлены spec/plan/tasks; для исторической фичи обновлены существующие и необходимые для текущего изменения артефакты
- Документация обновлена
- Нет TODO без согласования
---
## Технические регламенты
### Правила тестирования
- Для новой бизнес-логики → обязательны unit-тесты
- Для API-контрактов → интеграционные тесты
- Не мокать собственный код без необходимости
- В unit-тестах мокать внешние API (MOEX, T-Bank) и Prisma
- При исправлении бага — сначала падающий тест (TDD)
- Тесты писать рядом с основным кодом
### Работа с миграциями Prisma
- Никогда не редактировать файлы в `prisma/migrations/` вручную
- После изменения `schema.prisma``npm exec -w apps/backend -- prisma migrate dev --name <name>`
- Изменять существующие миграции допустимо только до их публикации/мержа. После мержа создавать новую миграцию
- Всегда запускать `npm exec -w apps/backend -- prisma generate` после изменения схемы
### Правила рефакторинга
- Не выполнять крупный рефакторинг вне рамок задачи
- Допустимы: локальные улучшения, устранение техдолга рядом с изменяемым кодом, исправление архитектурных нарушений
- Запрещено: менять структуру проекта без ADR, переписывать модули без отдельной задачи
- Крупный рефакторинг требует отдельного эпика/фичи + ADR
### Политики безопасности
- Запрещено логировать токены, пароли, секреты
- Не отключать guard'ы
- Не хранить секреты в коде, не коммитить .env
- Использовать маскирование при выводе (например, `***`)
### ADR-процесс
- Создавать ADR при: выборе новой технологии, изменении архитектуры, изменении API-контрактов, изменении стратегии хранения данных
- ADR должен содержать: Контекст, Рассмотренные варианты, Решение, Последствия
### Правила обновления OpenAPI/типов
1. Обновить DTO/Controller на бэкенде
2. Обновить Swagger
3. Запустить `npm run codegen -w apps/frontend`
4. Использовать обновлённые типы из `src/api/types.ts`
5. Никогда не редактировать `types.ts` вручную
### Цикл работы над API
Стандартная процедура при любом изменении API-контракта:
1. **Бэкенд** — описать/обновить DTO и контроллер (NestJS)
2. **Swagger** — убедиться, что документация отдаётся корректно (`/api/docs-json`)
3. **Codegen**`npm run codegen -w apps/frontend` (генерирует `src/api/types.ts`)
4. **Фронтенд** — использовать обновлённые типы, адаптировать вызовы
5. **Проверка** — убедиться, что `npm run build` проходит в обоих пакетах
Обновление типов вручную (`types.ts`) запрещено — всегда через codegen.
---
## Инфраструктура проекта
### Команды
Основные команды проекта описаны в README.md.
Перед завершением задачи запускать тесты, lint и build затронутых пакетов.
### Переменные окружения
Основные настройки находятся в .env.
Полный список переменных описан в README.md.
---
## Архитектура ## Архитектура
### Бэкенд
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую. - **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
- Актуальная композиция backend-модулей определяется в `apps/backend/src/app.module.ts`; не дублировать динамический список модулей в инструкциях. Опубликованное описание архитектуры находится в `apps/docs/docs/backend/modules.md`. - Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`.
- `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 используется из `@prisma/client`; схема и миграции находятся в `apps/backend/prisma/`. - БД: SQLite через Prisma ORM. Prisma client генерируется в `src/generated/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 } }`.
- Алиасы: `@/*``src/*` в обоих пакетах. - Алиасы: `@/*``src/*` в обоих пакетах.
### Фронтенд ## Фронтенд
- React 18 + react-router-dom v6 + TanStack Query v5. - React 18 + react-router-dom v6 + TanStack Query v5.
- `lightweight-charts` v4 для графиков цен. - `lightweight-charts` v4 для графиков цен.
@ -451,75 +65,10 @@ roadmap.md и inbox.md никогда не являются основанием
- Конвенция ключей запросов: `['stock', secid]`, `['securities', 'search', query]`, и т.д. - Конвенция ключей запросов: `['stock', secid]`, `['securities', 'search', query]`, и т.д.
- CSS через `styles.css` (CSS custom properties, без CSS-in-JS или Tailwind). - CSS через `styles.css` (CSS custom properties, без CSS-in-JS или Tailwind).
### Стиль кода ## Стиль кода
- Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой. - Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля. - Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
- Бэкенд использует SWC через `unplugin-swc` (vitest config). - Бэкенд использует SWC через `unplugin-swc` (vitest config).
- Тесты фронтенда есть: Vitest + Testing Library + MSW. - Тесты фронтенда отсутствуют.
- CI находится в `.gitea/workflows/ci.yml`. - CI/CD в репозитории нет.
- Pre-commit checks настроены через Husky и lint-staged.
## code-index-mcp
В проекте настроен `code-index-mcp` — MCP-сервер для быстрого поиска файлов и кода.
**Инструменты:**
- `find_files(pattern)` — поиск файлов по glob-паттерну через in-memory индекс
- `search_code_advanced(pattern)` — поиск кода с поддержкой regex, контекста, фильтрации по типу файла
- `get_file_summary(path)` — сводка по файлу (строки, функции, классы, импорты)
- `get_symbol_body(path, symbol_name)` — получить тело символа (функции/класса)
- `find_implementations(name_path, relative_path)` — найти реализации символа
- `find_referencing_symbols(name_path, relative_path)` — найти ссылки на символ
**Когда использовать:**
- Поиск файлов по имени или паттерну (glob)
- Быстрый grep по коду с контекстом
- Получение только тела функции/класса без всего файла
---
## serena
В проекте настроена `serena` — MCP-сервер с LSP-символьным анализом кода. Предоставляет symbol-aware инструменты поверх TypeScript LSP.
**Инструменты:**
- `find_symbol(name_path_pattern)` — поиск символов (классы, функции, методы) по всему проекту
- `get_symbols_overview(relative_path)` — обзор символов в файле (группировка по типу)
- `find_referencing_symbols(name_path, relative_path)` — где используется символ
- `find_implementations(name_path, relative_path)` — реализации интерфейса/класса
- `find_declaration(relative_path, regex)` — найти объявление по вызову
- `replace_symbol_body(name_path, relative_path, body)` — заменить тело метода
- `rename_symbol(name_path, relative_path, new_name)` — рефакторинг-переименование
- `replace_content(relative_path, needle, repl, mode)` — regex-замена в файле
- `safe_delete_symbol(name_path, relative_path)` — удалить неиспользуемый символ
- `get_diagnostics_for_file(relative_path)` — ошибки/предупреждения в файле
- `write_memory/read_memory/list_memories` — сохранение контекста между сессиями
**Когда использовать:**
- Найти все использования функции/метода в коде
- Получить структуру файла (классы, методы)
- Безопасный рефакторинг (переименование, удаление)
- Получить LSP-диагностику (ошибки компиляции)
- Запомнить что-то между сессиями (memories)
---
## graphify
This project has a knowledge graph in `graphify-out/` with god nodes, community structure, and cross-file relationships. The graph is a local artifact, not a tracked repo asset.
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
Rules:
- Используй `graphify` в первую очередь, когда задача связана с архитектурой, границами модулей, кросс-файловым влиянием или трассировкой потока данных.
- Для таких вопросов сначала запускай `graphify query "<question>"`, если существует `graphify-out/graph.json`. Для связей используй `graphify path "<A>" "<B>"`, для точечных концептов — `graphify explain "<concept>"`. Обычно это даёт гораздо более узкий подграф, чем `GRAPH_REPORT.md` или raw grep.
- Предпочитай `graphify query` перед raw grep, когда нужен кратчайший путь между концептами, мост между комьюнити или трассировка того, как один подсистемный блок достигает другого.
- Для отладки багов начинай с симптома и спрашивай у graphify путь зависимости, bridge nodes или модули, которые могут объяснить неожиданное поведение.
- Если `graphify` возвращает только общую структуру, переходи к `serena` за символ-уровневыми фактами и затем повторяй `graphify` с более узким вопросом, где названы конкретные файлы, модули или сервисы.
- Dirty `graphify-out/` после хуков или инкрементальных обновлений считаются нормой; грязные файлы графа не повод пропускать `graphify`. Пропускать его можно только если задача именно про устаревший или некорректный граф, либо если пользователь прямо попросил не использовать его.
- В новом `worktree` сначала заново создай локальный граф командой `graphify extract .`.
- После первой сборки в этом `worktree` обновляй граф командой `graphify update .`.
- Если существует `graphify-out/wiki/index.md`, используй его для широкого обзора вместо ручного просмотра исходников.
- `graphify-out/GRAPH_REPORT.md` читай только для широкого архитектурного обзора или когда `query/path/explain` не дают достаточно контекста.
- После изменений в коде запускай `graphify update .`, чтобы держать граф актуальным (только AST, без затрат на LLM).

152
README.md
View File

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

View File

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

View File

@ -1,33 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
-----END CERTIFICATE-----

View File

@ -1,13 +1,4 @@
{ {
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src"
"compilerOptions": {
"assets": [
{
"include": "modules/tbank/proto/contracts/**/*",
"outDir": "dist"
}
],
"watchAssets": true
}
} }

View File

@ -8,13 +8,10 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\"", "lint": "eslint \"{src,test}/**/*.ts\"",
"test": "VITE_CJS_IGNORE_WARNING=1 vitest run --exclude \"src/**/*.integration.spec.ts\"", "test": "VITE_CJS_IGNORE_WARNING=1 vitest run",
"test:watch": "vitest --exclude \"src/**/*.integration.spec.ts\"", "test:watch": "vitest"
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "^1.14.4",
"@grpc/proto-loader": "^0.8.1",
"@libsql/client": "^0.17.3", "@libsql/client": "^0.17.3",
"@nestjs/axios": "^3.0.0", "@nestjs/axios": "^3.0.0",
"@nestjs/cache-manager": "^2.0.0", "@nestjs/cache-manager": "^2.0.0",
@ -32,9 +29,7 @@
"class-transformer": "^0.5.0", "class-transformer": "^0.5.0",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"long": "^5.3.2",
"p-queue": "^7.3.0", "p-queue": "^7.3.0",
"protobufjs": "^8.6.4",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"swagger-ui-express": "^5.0.0" "swagger-ui-express": "^5.0.0"

View File

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "Position" ADD COLUMN "buyDate" DATETIME;
ALTER TABLE "Position" ADD COLUMN "buyPrice" REAL;

View File

@ -1,50 +0,0 @@
-- CreateTable
CREATE TABLE "BrokerOperation" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"accountId" TEXT NOT NULL,
"cursor" TEXT,
"operationId" TEXT,
"parentOperationId" TEXT,
"date" DATETIME,
"type" TEXT NOT NULL,
"category" TEXT NOT NULL,
"state" TEXT,
"instrumentUid" TEXT,
"figi" TEXT,
"ticker" TEXT,
"classCode" TEXT,
"payment" TEXT,
"price" TEXT,
"commission" TEXT,
"yield" TEXT,
"accruedInt" TEXT,
"quantity" INTEGER,
"quantityDone" INTEGER,
"raw" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "BrokerOperationSyncState" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"accountId" TEXT NOT NULL,
"lastCursor" TEXT,
"lastSyncedFrom" DATETIME,
"lastSyncedTo" DATETIME,
"syncedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "BrokerOperation_accountId_date_idx" ON "BrokerOperation"("accountId", "date");
-- CreateIndex
CREATE INDEX "BrokerOperation_accountId_type_idx" ON "BrokerOperation"("accountId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "BrokerOperation_accountId_cursor_key" ON "BrokerOperation"("accountId", "cursor");
-- CreateIndex
CREATE UNIQUE INDEX "BrokerOperationSyncState_accountId_key" ON "BrokerOperationSyncState"("accountId");

View File

@ -7,35 +7,33 @@ datasource db {
} }
model Portfolio { model Portfolio {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
name String name String
description String? description String?
currency String @default("RUB") currency String @default("RUB")
targets String? targets String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
positions Position[] positions Position[]
@@unique([userId, name]) @@unique([userId, name])
} }
model Position { model Position {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
portfolioId Int portfolioId Int
secid String secid String
type String @default("share") type String @default("share")
quantity Int quantity Int
buyPrice Float?
buyDate DateTime?
notes String? notes String?
tags String? tags String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade) portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
@@unique([portfolioId, secid]) @@unique([portfolioId, secid])
} }
@ -51,44 +49,3 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
portfolios Portfolio[] portfolios Portfolio[]
} }
model BrokerOperation {
id Int @id @default(autoincrement())
accountId String
cursor String?
operationId String?
parentOperationId String?
date DateTime?
type String
category String
state String?
instrumentUid String?
figi String?
ticker String?
classCode String?
payment String?
price String?
commission String?
yield String?
accruedInt String?
quantity Int?
quantityDone Int?
raw String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([accountId, cursor])
@@index([accountId, date])
@@index([accountId, type])
}
model BrokerOperationSyncState {
id Int @id @default(autoincrement())
accountId String @unique
lastCursor String?
lastSyncedFrom DateTime?
lastSyncedTo DateTime?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,5 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export const DEV_JWT_SECRET = 'dev-jwt-secret-change-in-production';
export const DEV_JWT_REFRESH_SECRET = 'dev-refresh-secret-change-in-production';
const parseCsv = (value: string | undefined): string[] =>
(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
export default registerAs('app', () => ({ export default registerAs('app', () => ({
port: parseInt(process.env.PORT || '3000', 10), port: parseInt(process.env.PORT || '3000', 10),
database: { database: {
@ -23,32 +14,13 @@ export default registerAs('app', () => ({
10, 10,
), ),
}, },
tbank: {
token: process.env.T_BANK_TOKEN || '',
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
instrumentsRateLimitPerSecond: parseInt(process.env.T_BANK_INSTRUMENTS_RATE_LIMIT || '20', 10),
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
},
cache: { cache: {
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10), marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10), historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10), candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10), securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
screenerTtl: parseInt(process.env.CACHE_SCREENER_TTL || '900', 10),
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10),
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
},
cors: {
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
}, },
auth: { auth: {
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',

View File

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

View File

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

View File

@ -1,21 +1,10 @@
import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common'; import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common';
import { import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiCreatedResponse,
ApiOkResponse,
} from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
import {
AuthLogoutResponseDto,
AuthProfileResponseDto,
AuthTokenResponseDto,
} from './dto/auth-response.dto';
import { CurrentUser } from './decorators/current-user.decorator'; import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator'; import { Public } from './decorators/public.decorator';
import { JwtPayload } from './interfaces/jwt-payload.interface'; import { JwtPayload } from './interfaces/jwt-payload.interface';
@ -37,59 +26,82 @@ export class AuthController {
@Public() @Public()
@Post('register') @Post('register')
@ApiOperation({ summary: 'Register new user' }) @ApiOperation({ summary: 'Register new user' })
@ApiCreatedResponse({ type: AuthTokenResponseDto })
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.register(dto); const result = await this.authService.register(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { user: result.user, accessToken: result.accessToken }; return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
} }
@Public() @Public()
@Post('login') @Post('login')
@ApiOperation({ summary: 'Login with email and password' }) @ApiOperation({ summary: 'Login with email and password' })
@ApiCreatedResponse({ type: AuthTokenResponseDto })
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) { async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.login(dto); const result = await this.authService.login(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { user: result.user, accessToken: result.accessToken }; return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
} }
@Public() @Public()
@Post('refresh') @Post('refresh')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' }) @ApiOperation({ summary: 'Refresh access token' })
@ApiOkResponse({ type: AuthTokenResponseDto })
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) { async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const token = req.cookies?.[REFRESH_COOKIE]; const token = req.cookies?.[REFRESH_COOKIE];
const result = await this.authService.refresh(token); const result = await this.authService.refresh(token);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { user: result.user, accessToken: result.accessToken }; return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
} }
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Logout user' }) @ApiOperation({ summary: 'Logout user' })
@ApiOkResponse({ type: AuthLogoutResponseDto })
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) { async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
await this.authService.logout(user.sub); await this.authService.logout(user.sub);
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }); res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
return { message: 'Logged out successfully' }; return {
data: { message: 'Logged out successfully' },
meta: { fromCache: false, cachedAt: null },
};
} }
@Get('me') @Get('me')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async getProfile(@CurrentUser() user: JwtPayload) { async getProfile(@CurrentUser() user: JwtPayload) {
return this.authService.getProfile(user.sub); const profile = await this.authService.getProfile(user.sub);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
} }
@Patch('me') @Patch('me')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Update current user profile' }) @ApiOperation({ summary: 'Update current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) { async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
return this.authService.updateProfile(user.sub, dto); const profile = await this.authService.updateProfile(user.sub, dto);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
} }
} }

View File

@ -1,53 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
class AuthUserDto {
@ApiProperty()
id!: number;
@ApiProperty()
email!: string;
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@ApiProperty()
role!: string;
}
class AuthTokenDataDto {
@ApiProperty({ type: AuthUserDto })
user!: AuthUserDto;
@ApiProperty()
accessToken!: string;
}
class LogoutDataDto {
@ApiProperty()
message!: string;
}
export class AuthTokenResponseDto {
@ApiProperty({ type: AuthTokenDataDto })
data!: AuthTokenDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class AuthProfileResponseDto {
@ApiProperty({ type: AuthUserDto })
data!: AuthUserDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class AuthLogoutResponseDto {
@ApiProperty({ type: LogoutDataDto })
data!: LogoutDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

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

View File

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

View File

@ -1,147 +1,41 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { ConfigModule } from '@nestjs/config';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
describe('BondsService', () => { describe('BondsService', () => {
let service: BondsService; let service: BondsService;
let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexMarketData = {
getBondData: vi.fn(),
getBondMarketData: vi.fn(),
};
cache = {
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
})),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [ providers: [
BondsService, BondsService,
{ provide: MoexMarketDataClient, useValue: moexMarketData }, MoexClientService,
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } }, {
{ provide: CacheService, useValue: cache }, provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
], ],
}).compile(); }).compile();
service = module.get<BondsService>(BondsService); service = module.get<BondsService>(BondsService);
}); });
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => { it('should be defined', () => {
vi.mocked(moexMarketData.getBondData).mockResolvedValue({ expect(service).toBeDefined();
secid: 'SU26238RMFS5',
boardid: 'TQCB',
shortName: 'ОФЗ 26238',
prevWaprice: 73.2,
yieldAtPrevWaprice: 14.1,
couponValue: 35.4,
nextCoupon: '2026-06-24',
accruedInt: 34.1,
prevPrice: 73,
lotSize: 1,
faceValue: 1000,
matDate: '2041-05-15',
couponPeriod: 182,
issueSize: 150000000,
isin: 'RU000A1038V6',
couponPercent: 7.1,
offerDate: null,
buybackDate: null,
bondType: 'ofz',
bondSubType: 'fixed',
listLevel: 1,
});
vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({
secid: 'SU26238RMFS5',
bid: 72.9,
offer: 73.1,
open: 72.8,
low: 72.5,
high: 73.4,
last: 73.05,
yield: 14.2,
waprice: 73,
yieldAtWaprice: 14.15,
duration: 2250,
volume: 10000,
value: 7305000,
numtrades: 450,
tradingStatus: 'T',
updateTime: '18:45:00',
});
const result = await service.getBond('SU26238RMFS5');
expect(cache.getOrFetch).toHaveBeenNthCalledWith(
1,
'bond',
['SU26238RMFS5'],
expect.any(Function),
'securityTtl',
);
expect(cache.getOrFetch).toHaveBeenNthCalledWith(
2,
'marketdata',
['bonds', 'SU26238RMFS5'],
expect.any(Function),
'marketDataTtl',
);
expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
expect(result).toMatchObject({
data: {
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 150000000,
faceValue: 1000,
faceUnit: 'RUB',
matDate: '2041-05-15',
couponValue: 35.4,
couponPercent: 7.1,
couponPeriod: 182,
nextCoupon: '2026-06-24',
accruedInt: 34.1,
bondType: 'ofz',
bondSubType: 'fixed',
offerDate: null,
buybackDate: null,
marketData: {
price: 73.05,
yieldToMaturity: 14.2,
duration: 2250,
accruedInt: 34.1,
couponValue: 35.4,
couponPercent: 7.1,
nextCouponDate: '2026-06-24',
open: 72.8,
high: 73.4,
low: 72.5,
volume: 10000,
},
},
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
});
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
}); });
it('throws EntityNotFoundException when bond data is missing', async () => { it('should return OFZ bond data for SU26207RMFS9', async () => {
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null); const result = await service.getBond('SU26207RMFS9');
expect(result.data.secid).toBe('SU26207RMFS9');
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException); expect(result.data.marketData).toBeDefined();
expect(cache.getOrFetch).toHaveBeenCalledTimes(1); }, 15000);
expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled();
});
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,51 +1,40 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { CandlesService } from './candles.service'; import { CandlesService } from './candles.service';
import { MoexCandlesClient } from '../moex-client/moex-candles.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { CandleInterval } from './dto/candles-query.dto'; import { CandleInterval } from './dto/candles-query.dto';
describe('CandlesService', () => { describe('CandlesService', () => {
let service: CandlesService; let service: CandlesService;
let moexCandles: Pick<MoexCandlesClient, 'getCandles'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexCandles = {
getCandles: vi.fn(),
};
cache = {
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
})),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [ providers: [
CandlesService, CandlesService,
{ provide: MoexCandlesClient, useValue: moexCandles }, MoexClientService,
{ provide: CacheService, useValue: cache }, {
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
], ],
}).compile(); }).compile();
service = module.get<CandlesService>(CandlesService); service = module.get<CandlesService>(CandlesService);
}); });
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => { it('should be defined', () => {
vi.mocked(moexCandles.getCandles).mockResolvedValue([ expect(service).toBeDefined();
{ });
open: 320,
high: 325,
low: 318,
close: 323,
volume: 1500000,
value: 480000000,
begin: '2026-05-01 00:00:00',
end: '2026-05-01 23:59:59',
},
]);
it('should return daily candles for SBER', async () => {
const result = await service.getCandles( const result = await service.getCandles(
'shares', 'shares',
'SBER', 'SBER',
@ -53,63 +42,7 @@ describe('CandlesService', () => {
'2026-05-01', '2026-05-01',
'2026-06-01', '2026-06-01',
); );
expect(result.data.length).toBeGreaterThan(0);
expect(cache.getOrFetch).toHaveBeenCalledWith( expect(result.data[0].open).toBeDefined();
'candles', }, 15000);
['shares', 'SBER', '24', '2026-05-01', '2026-06-01'],
expect.any(Function),
'candlesTtl',
);
expect(moexCandles.getCandles).toHaveBeenCalledWith(
'stock',
'shares',
'SBER',
24,
'2026-05-01',
'2026-06-01',
);
expect(result).toEqual({
data: [
{
open: 320,
high: 325,
low: 318,
close: 323,
volume: 1500000,
value: 480000000,
begin: '2026-05-01 00:00:00',
end: '2026-05-01 23:59:59',
},
],
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
});
});
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
vi.mocked(moexCandles.getCandles).mockResolvedValue([]);
await service.getCandles(
'bonds',
'SU26238RMFS5',
CandleInterval.HOUR,
'2026-05-01',
'2026-06-01',
);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'candles',
['bonds', 'SU26238RMFS5', '60', '2026-05-01', '2026-06-01'],
expect.any(Function),
'candlesTtl',
);
expect(moexCandles.getCandles).toHaveBeenCalledWith(
'stock',
'bonds',
'SU26238RMFS5',
60,
'2026-05-01',
'2026-06-01',
);
});
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,38 @@
import 'reflect-metadata';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { MoexClientService } from './moex-client.service';
import configuration from '../../config/configuration';
describe('MoexClientService', () => {
let service: MoexClientService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [MoexClientService],
}).compile();
service = module.get<MoexClientService>(MoexClientService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('searchSecurities', () => {
it('should return results for SBER query', async () => {
const results = await service.searchSecurities('SBER');
expect(results.length).toBeGreaterThan(0);
expect(results[0].secid).toBeDefined();
}, 15000);
});
describe('getShareMarketData', () => {
it('should return market data for SBER', async () => {
const data = await service.getShareMarketData('SBER');
expect(data).toBeDefined();
expect(data!.secid).toBe('SBER');
}, 15000);
});
});

View File

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

View File

@ -20,7 +20,6 @@ export interface MoexSecurityDescription {
export interface MoexShareMarketData { export interface MoexShareMarketData {
secid: string; secid: string;
boardid: string; boardid: string;
shortName: string;
bid: number | null; bid: number | null;
offer: number | null; offer: number | null;
open: number | null; open: number | null;
@ -38,26 +37,6 @@ export interface MoexShareMarketData {
updateTime: string; updateTime: string;
} }
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;
}
export interface MoexBondData { export interface MoexBondData {
secid: string; secid: string;
boardid: string; boardid: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,13 +2,11 @@ import {
IsString, IsString,
IsOptional, IsOptional,
IsInt, IsInt,
IsNumber,
Min, Min,
IsArray, IsArray,
IsIn, IsIn,
MaxLength, MaxLength,
MinLength, MinLength,
IsDateString,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@ -32,19 +30,8 @@ export class AddPositionDto {
@ApiProperty({ example: 10 }) @ApiProperty({ example: 10 })
@IsInt() @IsInt()
@Min(1)
quantity!: number;
@ApiPropertyOptional({ example: 250.5 })
@IsNumber()
@Min(0) @Min(0)
@IsOptional() quantity!: number;
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-01' })
@IsDateString()
@IsOptional()
buyDate?: string;
@ApiPropertyOptional({ example: 'Покупка на дип' }) @ApiPropertyOptional({ example: 'Покупка на дип' })
@IsString() @IsString()
@ -52,7 +39,7 @@ export class AddPositionDto {
@MaxLength(500) @MaxLength(500)
notes?: string; notes?: string;
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS, isArray: true }) @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS })
@IsArray() @IsArray()
@IsIn(TAGS, { each: true }) @IsIn(TAGS, { each: true })
@IsOptional() @IsOptional()

View File

@ -1,37 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { PositionWithPriceDto } from './position-with-price.dto';
export class PortfolioSummaryDto {
@ApiProperty() totalInvested!: number;
@ApiProperty() totalValue!: number;
@ApiProperty() totalPnl!: number;
@ApiProperty({ type: Number, nullable: true }) totalPnlPercent!: number | null;
@ApiProperty() totalDividends!: number;
@ApiProperty() totalReturn!: number;
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
@ApiProperty() positionCount!: number;
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
@ApiProperty({ type: Number, nullable: true })
targetSharesPercent?: number | null;
@ApiProperty({ type: Number, nullable: true })
targetBondsPercent?: number | null;
@ApiProperty()
actualSharesPercent!: number;
@ApiProperty()
actualBondsPercent!: number;
@ApiProperty({ type: Number, nullable: true })
sharesDeviation?: number | null;
@ApiProperty({ type: Number, nullable: true })
bondsDeviation?: number | null;
}
export class AnalyticsResponseDto {
@ApiProperty({ type: [PositionWithPriceDto] }) positions!: PositionWithPriceDto[];
@ApiProperty() summary!: PortfolioSummaryDto;
}

View File

@ -1,46 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { AnalyticsResponseDto } from './analytics-response.dto';
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
import { PositionResponseDto } from './position-response.dto';
export class PortfolioListEnvelopeDto {
@ApiProperty({ type: [PortfolioListResponseDto] })
data!: PortfolioListResponseDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class PortfolioEnvelopeDto {
@ApiProperty({ type: PortfolioResponseDto })
data!: PortfolioResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class PortfolioDetailEnvelopeDto {
@ApiProperty({ type: PortfolioDetailResponseDto })
data!: PortfolioDetailResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class PositionEnvelopeDto {
@ApiProperty({ type: PositionResponseDto })
data!: PositionResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class AnalyticsEnvelopeDto {
@ApiProperty({ type: AnalyticsResponseDto })
data!: AnalyticsResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,16 +0,0 @@
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;
}

View File

@ -1,24 +1,38 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PortfolioSummaryDto } from './analytics-response.dto';
import { PositionWithPriceDto } from './position-with-price.dto'; class PositionWithPriceDto {
@ApiProperty() id!: number;
@ApiProperty({ example: 'SBER' }) secid!: string;
@ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string;
@ApiProperty({ example: 10 }) quantity!: number;
@ApiPropertyOptional() notes!: string | null;
@ApiPropertyOptional() tags!: string[] | null;
@ApiPropertyOptional() currentPrice!: number | null;
@ApiPropertyOptional() currentValue!: number | null;
@ApiProperty() weightPercent!: number;
@ApiPropertyOptional() change!: number | null;
@ApiPropertyOptional() changePercent!: number | null;
@ApiPropertyOptional() yieldToMaturity!: number | null;
@ApiPropertyOptional() duration!: number | null;
@ApiPropertyOptional() couponValue!: number | null;
@ApiPropertyOptional() couponPercent!: number | null;
@ApiPropertyOptional() nextCouponDate!: string | null;
@ApiPropertyOptional() matDate!: string | null;
@ApiPropertyOptional() accruedInt!: number | null;
@ApiPropertyOptional() bid!: number | null;
@ApiPropertyOptional() offer!: number | null;
@ApiPropertyOptional() couponPeriod!: number | null;
@ApiPropertyOptional() bondType!: string | null;
@ApiPropertyOptional() offerDate!: string | null;
}
export class PortfolioResponseDto { export class PortfolioResponseDto {
@ApiProperty() id!: number; @ApiProperty() id!: number;
@ApiProperty() name!: string; @ApiProperty() name!: string;
@ApiPropertyOptional({ type: String, nullable: true }) description!: string | null; @ApiPropertyOptional() description!: string | null;
@ApiProperty({ default: 'RUB' }) currency!: string; @ApiProperty({ default: 'RUB' }) currency!: string;
@ApiProperty() createdAt!: string; @ApiProperty() createdAt!: string;
@ApiProperty() updatedAt!: string; @ApiProperty() updatedAt!: string;
@ApiPropertyOptional({
type: 'object',
properties: {
sharesPercent: { type: 'number' },
bondsPercent: { type: 'number' },
},
nullable: true,
})
targets!: { sharesPercent: number; bondsPercent: number } | null;
} }
export class PortfolioDetailResponseDto extends PortfolioResponseDto { export class PortfolioDetailResponseDto extends PortfolioResponseDto {
@ -26,7 +40,4 @@ export class PortfolioDetailResponseDto extends PortfolioResponseDto {
positions!: PositionWithPriceDto[]; positions!: PositionWithPriceDto[];
@ApiProperty() totalValue!: number; @ApiProperty() totalValue!: number;
@ApiProperty({ type: PortfolioSummaryDto })
analytics!: PortfolioSummaryDto;
} }

View File

@ -4,8 +4,8 @@ export class PositionResponseDto {
@ApiProperty() id!: number; @ApiProperty() id!: number;
@ApiProperty({ example: 'SBER' }) secid!: string; @ApiProperty({ example: 'SBER' }) secid!: string;
@ApiProperty({ example: 10 }) quantity!: number; @ApiProperty({ example: 10 }) quantity!: number;
@ApiPropertyOptional({ type: String, nullable: true }) notes!: string | null; @ApiPropertyOptional() notes!: string | null;
@ApiPropertyOptional({ type: String, isArray: true, nullable: true }) tags!: string[] | null; @ApiPropertyOptional() tags!: string[] | null;
@ApiProperty() portfolioId!: number; @ApiProperty() portfolioId!: number;
@ApiProperty() createdAt!: string; @ApiProperty() createdAt!: string;
@ApiProperty() updatedAt!: string; @ApiProperty() updatedAt!: string;

View File

@ -1,99 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PositionWithPriceDto {
@ApiProperty()
id!: number;
@ApiProperty({ example: 'SBER' })
secid!: string;
@ApiPropertyOptional({ type: String, nullable: true })
shortName!: string | null;
@ApiProperty({ example: 'share', enum: ['share', 'bond'] })
type!: string;
@ApiProperty({ example: 10 })
quantity!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
buyPrice!: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
buyDate!: string | null;
@ApiPropertyOptional({ type: String, nullable: true })
notes!: string | null;
@ApiPropertyOptional({ type: String, isArray: true, nullable: true })
tags!: string[] | null;
@ApiPropertyOptional({ type: Number, nullable: true })
currentPrice!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
totalCost!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
currentValue!: number | null;
@ApiProperty()
weightPercent!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
pnl!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
pnlPercent!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
dividendIncome!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
totalReturn!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
totalReturnPercent!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
change?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
yieldToMaturity?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
duration?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
couponValue?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
couponPercent?: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
nextCouponDate?: string | null;
@ApiPropertyOptional({ type: String, nullable: true })
matDate?: string | null;
@ApiPropertyOptional({ type: Number, nullable: true })
accruedInt?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
bid?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
offer?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
couponPeriod?: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
bondType?: string | null;
@ApiPropertyOptional({ type: String, nullable: true })
offerDate?: string | null;
}

View File

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

View File

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

View File

@ -1,14 +1,4 @@
import { import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength } from 'class-validator';
IsString,
IsOptional,
IsInt,
IsNumber,
Min,
IsArray,
IsIn,
MaxLength,
IsDateString,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
const TAGS = [ const TAGS = [
@ -25,20 +15,9 @@ const TAGS = [
export class UpdatePositionDto { export class UpdatePositionDto {
@ApiPropertyOptional({ example: 15 }) @ApiPropertyOptional({ example: 15 })
@IsInt() @IsInt()
@Min(1)
@IsOptional()
quantity?: number;
@ApiPropertyOptional({ example: 260.0 })
@IsNumber()
@Min(0) @Min(0)
@IsOptional() @IsOptional()
buyPrice?: number; quantity?: number;
@ApiPropertyOptional({ example: '2026-06-15' })
@IsDateString()
@IsOptional()
buyDate?: string;
@ApiPropertyOptional({ example: 'Докупка' }) @ApiPropertyOptional({ example: 'Докупка' })
@IsString() @IsString()
@ -46,7 +25,7 @@ export class UpdatePositionDto {
@MaxLength(500) @MaxLength(500)
notes?: string; notes?: string;
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS, isArray: true }) @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS })
@IsArray() @IsArray()
@IsIn(TAGS, { each: true }) @IsIn(TAGS, { each: true })
@IsOptional() @IsOptional()

View File

@ -1,123 +1,88 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common';
import { import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
ApiCreatedResponse,
ApiExtraModels,
getSchemaPath,
} from '@nestjs/swagger';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto'; import { AddPositionDto } from './dto/add-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import {
AnalyticsEnvelopeDto,
PortfolioDetailEnvelopeDto,
PortfolioEnvelopeDto,
PortfolioListEnvelopeDto,
PositionEnvelopeDto,
} from './dto/portfolio-envelope.dto';
const nullDataEnvelopeSchema = {
type: 'object',
properties: {
data: { type: 'null' },
meta: { $ref: getSchemaPath(ApiResponseMeta) },
},
required: ['data', 'meta'],
};
@ApiTags('Portfolios') @ApiTags('Portfolios')
@ApiBearerAuth() @ApiBearerAuth()
@ApiExtraModels(ApiResponseMeta)
@Controller('portfolios') @Controller('portfolios')
export class PortfolioController { export class PortfolioController {
constructor(private readonly portfolioService: PortfolioService) {} constructor(private readonly portfolioService: PortfolioService) {}
@Get() @Get()
@ApiOperation({ summary: 'Get all portfolios for current user' }) @ApiOperation({ summary: 'Get all portfolios for current user' })
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
async findAll(@CurrentUser() user: { sub: number }) { async findAll(@CurrentUser() user: { sub: number }) {
return this.portfolioService.findAll(user.sub); const portfolios = await this.portfolioService.findAll(user.sub);
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
} }
@Post() @Post()
@ApiOperation({ summary: 'Create a new portfolio' }) @ApiOperation({ summary: 'Create a new portfolio' })
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) { async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
return this.portfolioService.create(user.sub, dto); const portfolio = await this.portfolioService.create(user.sub, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get portfolio details with positions and prices' }) @ApiOperation({ summary: 'Get portfolio details with positions and prices' })
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
return this.portfolioService.findOne(user.sub, id); const portfolio = await this.portfolioService.findOne(user.sub, id);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
} }
@Patch(':id') @Patch(':id')
@ApiOperation({ summary: 'Update portfolio' }) @ApiOperation({ summary: 'Update portfolio' })
@ApiOkResponse({ type: PortfolioEnvelopeDto })
async update( async update(
@CurrentUser() user: { sub: number }, @CurrentUser() user: { sub: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePortfolioDto, @Body() dto: UpdatePortfolioDto,
) { ) {
return this.portfolioService.update(user.sub, id, dto); const portfolio = await this.portfolioService.update(user.sub, id, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
} }
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: 'Delete portfolio' }) @ApiOperation({ summary: 'Delete portfolio' })
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
await this.portfolioService.remove(user.sub, id); await this.portfolioService.remove(user.sub, id);
return null; return { data: null, meta: { cachedAt: null, fromCache: false } };
} }
@Post(':id/positions') @Post(':id/positions')
@ApiOperation({ summary: 'Add position to portfolio' }) @ApiOperation({ summary: 'Add position to portfolio' })
@ApiCreatedResponse({ type: PositionEnvelopeDto })
async addPosition( async addPosition(
@CurrentUser() user: { sub: number }, @CurrentUser() user: { sub: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() dto: AddPositionDto, @Body() dto: AddPositionDto,
) { ) {
return this.portfolioService.addPosition(user.sub, id, dto); const position = await this.portfolioService.addPosition(user.sub, id, dto);
return { data: position, meta: { cachedAt: null, fromCache: false } };
} }
@Patch(':id/positions/:positionId') @Patch(':id/positions/:positionId')
@ApiOperation({ summary: 'Update position' }) @ApiOperation({ summary: 'Update position' })
@ApiOkResponse({ type: PositionEnvelopeDto })
async updatePosition( async updatePosition(
@CurrentUser() user: { sub: number }, @CurrentUser() user: { sub: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Param('positionId', ParseIntPipe) positionId: number, @Param('positionId', ParseIntPipe) positionId: number,
@Body() dto: UpdatePositionDto, @Body() dto: UpdatePositionDto,
) { ) {
return this.portfolioService.updatePosition(user.sub, id, positionId, dto); const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
} return { data: position, meta: { cachedAt: null, fromCache: false } };
@Get(':id/analytics')
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
return this.portfolioService.getAnalytics(user.sub, id);
} }
@Delete(':id/positions/:positionId') @Delete(':id/positions/:positionId')
@ApiOperation({ summary: 'Remove position from portfolio' }) @ApiOperation({ summary: 'Remove position from portfolio' })
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
async removePosition( async removePosition(
@CurrentUser() user: { sub: number }, @CurrentUser() user: { sub: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Param('positionId', ParseIntPipe) positionId: number, @Param('positionId', ParseIntPipe) positionId: number,
) { ) {
await this.portfolioService.removePosition(user.sub, id, positionId); await this.portfolioService.removePosition(user.sub, id, positionId);
return null; return { data: null, meta: { cachedAt: null, fromCache: false } };
} }
} }

View File

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

View File

@ -1,549 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { PortfolioService } from './portfolio.service';
import { PrismaService } from '../prisma/prisma.service';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
describe('PortfolioService', () => {
let service: PortfolioService;
let prisma: PrismaService;
let moexMarketData: MoexMarketDataClient;
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,
buyPrice: null,
buyDate: null,
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: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
},
{
provide: MoexSecuritiesClient,
useValue: { getSecurityDescription: vi.fn() },
},
{
provide: MoexMarketDataClient,
useValue: {
getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(),
},
},
{
provide: MoexDividendsClient,
useValue: { getDividends: vi.fn() },
},
{
provide: CacheService,
useValue: {
getOrFetch: vi.fn(),
},
},
],
}).compile();
service = module.get<PortfolioService>(PortfolioService);
prisma = module.get<PrismaService>(PrismaService);
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
});
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 and compute PnL', async () => {
const sharePosition = mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 230,
buyDate: new Date('2026-03-01'),
});
const bondPosition = mockPosition({
id: 2,
portfolioId: 1,
secid: 'SU26238RMFS5',
type: 'bond',
quantity: 5,
buyPrice: 950,
buyDate: new Date('2026-03-01'),
});
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
]);
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any);
vi.mocked(moexMarketData.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);
// Verify PnL for SBER share
// totalCost = 230 * 10 = 2300
// currentValue = 250 * 10 = 2500
// pnl = 2500 - 2300 = 200
// pnlPercent = 200 / 2300 * 100 ≈ 8.70
// Verify via findOne which gives enriched positions
});
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 EntityNotFoundException for non-existent portfolio', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.findOne(1, 999)).rejects.toThrow(EntityNotFoundException);
});
it('should throw PortfolioAccessDeniedException for wrong user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.findOne(1, 1)).rejects.toThrow(PortfolioAccessDeniedException);
});
it('should return portfolio with enriched positions and analytics summary', async () => {
const sharePosition = mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 200,
});
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(
mockPortfolio({ positions: [sharePosition] }) as any,
);
vi.mocked(prisma.position.findMany).mockResolvedValue([sharePosition] 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,
}),
);
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any);
const result = await service.findOne(1, 1);
expect(result.id).toBe(1);
expect(result.positions).toHaveLength(1);
expect(result.positions[0].secid).toBe('SBER');
expect(result.positions[0].weightPercent).toBe(100);
expect(result.totalValue).toBe(2500);
expect(result.analytics).toBeDefined();
expect(result.analytics.totalInvested).toBe(2000);
expect(result.analytics.totalValue).toBe(2500);
expect(result.analytics.totalPnl).toBe(500);
});
});
describe('getPositionsWithPrices', () => {
it('should return empty array when no positions exist', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([]);
const result = await service.getPositionsWithPrices(1);
expect(result).toEqual([]);
});
it('should return enriched positions with PnL for shares', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 230,
buyDate: new Date('2026-03-01'),
}),
] 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,
}),
);
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any);
const result = await service.getPositionsWithPrices(1);
expect(result).toHaveLength(1);
expect(result[0].secid).toBe('SBER');
expect(result[0].buyPrice).toBe(230);
expect(result[0].buyDate).toBe('2026-03-01T00:00:00.000Z');
expect(result[0].totalCost).toBe(2300);
expect(result[0].currentPrice).toBe(250);
expect(result[0].currentValue).toBe(2500);
expect(result[0].pnl).toBe(200);
expect(result[0].pnlPercent).toBeCloseTo(8.6957, 1);
expect(result[0].dividendIncome).toBe(0);
expect(result[0].totalReturn).toBe(200);
expect(result[0].totalReturnPercent).toBeCloseTo(8.6957, 1);
});
it('should return enriched positions with PnL for bonds', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 2,
portfolioId: 1,
secid: 'SU26238RMFS5',
type: 'bond',
quantity: 5,
buyPrice: 95, // 95% of face value
buyDate: new Date('2026-03-01'),
}),
] 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,
}),
);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'SU26238RMFS5',
shortName: 'OFZ 26238',
price: 98.5,
faceValue: 1000,
},
] as any);
const result = await service.getPositionsWithPrices(1);
expect(result).toHaveLength(1);
expect(result[0].secid).toBe('SU26238RMFS5');
// totalCost = 950 * 5 = 4750
expect(result[0].totalCost).toBe(4750);
// currentValue = (98.5 / 100) * 1000 * 5 = 4925
expect(result[0].currentValue).toBe(4925);
// pnl = 4925 - 4750 = 175
expect(result[0].pnl).toBe(175);
expect(result[0].pnlPercent).toBeCloseTo(3.6842, 1);
expect(result[0].totalReturn).toBe(175);
});
it('should set PnL to null when buyPrice is missing', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: null,
buyDate: null,
}),
] 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,
}),
);
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any);
const result = await service.getPositionsWithPrices(1);
expect(result[0].buyPrice).toBeNull();
expect(result[0].totalCost).toBeNull();
expect(result[0].currentValue).toBe(2500);
expect(result[0].pnl).toBeNull();
expect(result[0].pnlPercent).toBeNull();
expect(result[0].totalReturn).toBeNull();
expect(result[0].totalReturnPercent).toBeNull();
});
it('should set PnL to null when market data is missing', async () => {
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'UNKNOWN',
type: 'share',
quantity: 10,
buyPrice: 100,
buyDate: new Date('2026-03-01'),
}),
] 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,
}),
);
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([] as any);
const result = await service.getPositionsWithPrices(1);
expect(result[0].currentPrice).toBeNull();
expect(result[0].currentValue).toBeNull();
expect(result[0].pnl).toBeNull();
expect(result[0].totalReturn).toBeNull();
});
});
describe('getAnalytics', () => {
it('should return empty analytics when no positions', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any);
vi.mocked(prisma.position.findMany).mockResolvedValue([]);
const result = await service.getAnalytics(1, 1);
expect(result.summary.totalInvested).toBe(0);
expect(result.summary.totalValue).toBe(0);
expect(result.summary.totalPnl).toBe(0);
expect(result.summary.positionCount).toBe(0);
expect(result.summary.weightedYield).toBeNull();
});
it('should compute correct summary with share positions', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any);
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 230,
buyDate: new Date('2026-03-01'),
}),
mockPosition({
id: 2,
portfolioId: 1,
secid: 'GAZP',
type: 'share',
quantity: 5,
buyPrice: 150,
buyDate: new Date('2026-03-01'),
}),
] 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,
}),
);
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
] as any);
const result = await service.getAnalytics(1, 1);
// totalInvested: 230*10 + 150*5 = 2300 + 750 = 3050
expect(result.summary.totalInvested).toBe(3050);
// totalValue: 250*10 + 160*5 = 2500 + 800 = 3300
expect(result.summary.totalValue).toBe(3300);
// totalPnl: 200 + 50 = 250
expect(result.summary.totalPnl).toBe(250);
// totalPnlPercent: 250 / 3050 * 100 ≈ 8.20
expect(result.summary.totalPnlPercent).toBeCloseTo(8.1967, 1);
expect(result.summary.positionCount).toBe(2);
expect(result.summary.totalDividends).toBe(0);
expect(result.summary.totalReturn).toBe(250);
expect(result.summary.totalReturnPercent).toBeCloseTo(8.1967, 1);
});
it('should compute weightedYield correctly', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any);
vi.mocked(prisma.position.findMany).mockResolvedValue([
mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 100,
buyDate: new Date('2026-03-01'),
}),
mockPosition({
id: 2,
portfolioId: 1,
secid: 'GAZP',
type: 'share',
quantity: 10,
buyPrice: 200,
buyDate: new Date('2026-03-01'),
}),
] 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,
}),
);
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 120 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 180 },
] as any);
const result = await service.getAnalytics(1, 1);
// SBER: pnlPercent=20%, cost=1000, weight=1000/3000=1/3
// GAZP: pnlPercent=-10%, cost=2000, weight=2000/3000=2/3
// weightedYield = 20*(1/3) + (-10)*(2/3) = 20/3 - 20/3 = 0
expect(result.summary.weightedYield).toBeCloseTo(0, 1);
});
it('should throw PortfolioAccessDeniedException if portfolio belongs to another user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.getAnalytics(1, 1)).rejects.toThrow(PortfolioAccessDeniedException);
});
it('should throw EntityNotFoundException if portfolio does not exist', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException);
});
});
});

View File

@ -1,45 +1,28 @@
import { import {
Injectable, Injectable,
NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
import type {
MoexShareMarketData,
MoexBondPositionData,
MoexDividend,
} from '../moex-client/moex-client.types';
import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto'; import { AddPositionDto } from './dto/add-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto';
import { AnalyticsResponseDto } from './dto/analytics-response.dto';
export interface EnrichedPosition { export interface EnrichedPosition {
id: number; id: number;
portfolioId: number;
secid: string; secid: string;
shortName: string | null; shortName: string | null;
type: string; type: string;
quantity: number; quantity: number;
buyPrice: number | null;
buyDate: string | null;
notes: string | null; notes: string | null;
tags: string[] | null; tags: string[] | null;
currentPrice: number | null; currentPrice: number | null;
totalCost: number | null;
currentValue: number | null; currentValue: number | null;
weightPercent: number; weightPercent: number;
pnl: number | null;
pnlPercent: number | null;
dividendIncome: number | null;
totalReturn: number | null;
totalReturnPercent: number | null;
change?: number | null; change?: number | null;
changePercent?: number | null; changePercent?: number | null;
yieldToMaturity?: number | null; yieldToMaturity?: number | null;
@ -60,14 +43,12 @@ export interface EnrichedPosition {
export class PortfolioService { export class PortfolioService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly moexSecurities: MoexSecuritiesClient, private readonly moexClient: MoexClientService,
private readonly moexMarketData: MoexMarketDataClient,
private readonly moexDividends: MoexDividendsClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
async create(userId: number, dto: CreatePortfolioDto) { async create(userId: number, dto: CreatePortfolioDto) {
const portfolio = await this.prisma.portfolio.create({ return this.prisma.portfolio.create({
data: { data: {
userId, userId,
name: dto.name, name: dto.name,
@ -75,62 +56,13 @@ export class PortfolioService {
currency: dto.currency ?? 'RUB', currency: dto.currency ?? 'RUB',
}, },
}); });
return { ...portfolio, targets: null };
} }
async findAll(userId: number) { async findAll(userId: number) {
const portfolios = await this.prisma.portfolio.findMany({ return this.prisma.portfolio.findMany({
where: { userId }, where: { userId },
include: { positions: true },
orderBy: { updatedAt: 'desc' }, 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,
targets: p.targets ? JSON.parse(p.targets) : null,
}));
}
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,
targets: p.targets ? JSON.parse(p.targets) : null,
};
});
} }
async findOne(userId: number, id: number) { async findOne(userId: number, id: number) {
@ -139,10 +71,10 @@ export class PortfolioService {
include: { positions: true }, include: { positions: true },
}); });
if (!portfolio) throw new EntityNotFoundException('Portfolio', id); if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id); const positionsWithPrices = await this.enrichPositions(portfolio.positions);
const totalValue = positionsWithPrices.reduce((sum, p) => sum + (p.currentValue ?? 0), 0); const totalValue = positionsWithPrices.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
@ -154,8 +86,6 @@ export class PortfolioService {
}; };
}); });
const analytics = await this.getAnalytics(userId, id);
return { return {
id: portfolio.id, id: portfolio.id,
name: portfolio.name, name: portfolio.name,
@ -165,36 +95,28 @@ export class PortfolioService {
updatedAt: portfolio.updatedAt.toISOString(), updatedAt: portfolio.updatedAt.toISOString(),
positions: positionsWithWeights, positions: positionsWithWeights,
totalValue: Math.round(totalValue * 100) / 100, totalValue: Math.round(totalValue * 100) / 100,
analytics: analytics.summary,
targets: portfolio.targets ? JSON.parse(portfolio.targets) : null,
}; };
} }
async update(userId: number, id: number, dto: UpdatePortfolioDto) { async update(userId: number, id: number, dto: UpdatePortfolioDto) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', id); if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const updated = await this.prisma.portfolio.update({ return this.prisma.portfolio.update({
where: { id }, where: { id },
data: { data: {
...(dto.name !== undefined && { name: dto.name }), ...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }), ...(dto.description !== undefined && { description: dto.description }),
...(dto.currency !== undefined && { currency: dto.currency }), ...(dto.currency !== undefined && { currency: dto.currency }),
...(dto.targets !== undefined && { targets: JSON.stringify(dto.targets) }),
}, },
}); });
return {
...updated,
targets: updated.targets ? JSON.parse(updated.targets) : null,
};
} }
async remove(userId: number, id: number) { async remove(userId: number, id: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', id); if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
await this.prisma.portfolio.delete({ where: { id } }); await this.prisma.portfolio.delete({ where: { id } });
} }
@ -204,8 +126,8 @@ export class PortfolioService {
where: { id: portfolioId }, where: { id: portfolioId },
include: { positions: true }, include: { positions: true },
}); });
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const exists = portfolio.positions.find((p) => p.secid === dto.secid); const exists = portfolio.positions.find((p) => p.secid === dto.secid);
if (exists) if (exists)
@ -213,7 +135,7 @@ export class PortfolioService {
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0'); if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
const desc = await this.moexSecurities.getSecurityDescription(dto.secid); const desc = await this.moexClient.getSecurityDescription(dto.secid);
if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`); if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`);
const type = desc.group === 'stock_bonds' ? 'bond' : 'share'; const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
@ -224,8 +146,6 @@ export class PortfolioService {
secid: dto.secid, secid: dto.secid,
type, type,
quantity: dto.quantity, quantity: dto.quantity,
buyPrice: dto.buyPrice ?? null,
buyDate: dto.buyDate ? new Date(dto.buyDate) : null,
notes: dto.notes ?? null, notes: dto.notes ?? null,
tags: dto.tags ? JSON.stringify(dto.tags) : null, tags: dto.tags ? JSON.stringify(dto.tags) : null,
}, },
@ -239,20 +159,18 @@ export class PortfolioService {
dto: UpdatePositionDto, dto: UpdatePositionDto,
) { ) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const position = await this.prisma.position.findUnique({ where: { id: positionId } }); const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) { if (!position || position.portfolioId !== portfolioId) {
throw new EntityNotFoundException('Position', positionId); throw new NotFoundException(`Position ${positionId} not found`);
} }
return this.prisma.position.update({ return this.prisma.position.update({
where: { id: positionId }, where: { id: positionId },
data: { data: {
...(dto.quantity !== undefined && { quantity: dto.quantity }), ...(dto.quantity !== undefined && { quantity: dto.quantity }),
...(dto.buyPrice !== undefined && { buyPrice: dto.buyPrice }),
...(dto.buyDate !== undefined && { buyDate: new Date(dto.buyDate) }),
...(dto.notes !== undefined && { notes: dto.notes }), ...(dto.notes !== undefined && { notes: dto.notes }),
...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }), ...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }),
}, },
@ -261,12 +179,12 @@ export class PortfolioService {
async removePosition(userId: number, portfolioId: number, positionId: number) { async removePosition(userId: number, portfolioId: number, positionId: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const position = await this.prisma.position.findUnique({ where: { id: positionId } }); const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) { if (!position || position.portfolioId !== portfolioId) {
throw new EntityNotFoundException('Position', positionId); throw new NotFoundException(`Position ${positionId} not found`);
} }
await this.prisma.position.delete({ where: { id: positionId } }); await this.prisma.position.delete({ where: { id: positionId } });
@ -279,314 +197,149 @@ export class PortfolioService {
secid: string; secid: string;
type: string; type: string;
quantity: number; quantity: number;
buyPrice: number | null;
buyDate: Date | null;
notes: string | null; notes: string | null;
tags: string | null; tags: string | null;
}[], }[],
portfolioId?: number,
): Promise<EnrichedPosition[]> { ): Promise<EnrichedPosition[]> {
const sharePositions = positions.filter((p) => p.type === 'share'); return Promise.all(
const bondPositions = positions.filter((p) => p.type === 'bond'); positions.map(async (pos) => {
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort(); let shortName: string | null = null;
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort(); try {
const { data: desc } = await this.cache.getOrFetch(
'security',
['portfolio-name', pos.secid],
async () => {
const d = await this.moexClient.getSecurityDescription(pos.secid);
return { shortName: d?.shortName ?? null };
},
'securityTtl',
);
shortName = desc.shortName;
} catch {
shortName = null;
}
const [shareDataBySecid, bondDataBySecid, dividendsBySecid] = await Promise.all([ const base = {
this.fetchShareBatch(shareSecids, portfolioId), id: pos.id,
this.fetchBondBatch(bondSecids, portfolioId), secid: pos.secid,
this.fetchDividendsBatch(shareSecids, portfolioId), shortName,
]); 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,
};
const enriched: EnrichedPosition[] = []; if (pos.type === 'bond') {
return this.enrichBondPosition(pos, base);
for (const pos of positions) { }
const base = { return this.enrichSharePosition(pos, base);
id: pos.id,
portfolioId: pos.portfolioId,
secid: pos.secid,
shortName: null as string | null,
type: pos.type,
quantity: pos.quantity,
buyPrice: pos.buyPrice,
buyDate: pos.buyDate ? pos.buyDate.toISOString() : null,
notes: pos.notes,
tags: pos.tags ? JSON.parse(pos.tags) : null,
totalCost: null as number | null,
weightPercent: 0,
currentPrice: null as number | null,
currentValue: null as number | null,
pnl: null as number | null,
pnlPercent: null as number | null,
dividendIncome: null as number | null,
totalReturn: null as number | null,
totalReturnPercent: 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), dividendsBySecid.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 = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(',');
const { data } = await this.cache.getOrFetch(
'batchdata',
['shares', cacheKey],
() => this.moexMarketData.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 = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(',');
const { data } = await this.cache.getOrFetch(
'batchdata',
['bonds', cacheKey],
() => this.moexMarketData.getBondPositionDataBatch(secids),
'marketDataTtl',
);
return new Map(data.map((d) => [d.secid, d]));
}
private async fetchDividendsBatch(
secids: string[],
portfolioId?: number,
): Promise<Map<string, MoexDividend[]>> {
if (secids.length === 0) return new Map();
const results = await Promise.all(
secids.map(async (secid) => {
const cacheKey = portfolioId ? `pf:${portfolioId}:${secid}` : secid;
const { data } = await this.cache.getOrFetch(
'dividends',
[cacheKey],
() => this.moexDividends.getDividends(secid),
'marketDataTtl',
);
return { secid, dividends: data };
}), }),
); );
return new Map(results.map((r) => [r.secid, r.dividends]));
} }
private buildSharePosition( private async enrichSharePosition(
pos: { pos: { id: number; secid: string; quantity: number },
id: number;
secid: string;
quantity: number;
buyPrice: number | null;
buyDate: Date | null;
},
base: EnrichedPosition, base: EnrichedPosition,
data: MoexShareMarketData | undefined, ): Promise<EnrichedPosition> {
dividends?: MoexDividend[], try {
): EnrichedPosition { const { data: marketData } = await this.cache.getOrFetch(
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; 'marketdata',
let dividendIncome = 0; ['portfolio', pos.secid],
if (pos.buyDate && dividends && dividends.length > 0) { async () => {
const buyDateStr = pos.buyDate.toISOString().split('T')[0]; const data = await this.moexClient.getShareMarketData(pos.secid);
dividendIncome = dividends return {
.filter((d) => d.registryCloseDate >= buyDateStr) price: data?.last ?? null,
.reduce((sum, d) => sum + d.value * pos.quantity, 0); change: data?.lastChange ?? null,
} changePercent: data?.lastChangePrcnt ?? null,
};
},
'marketDataTtl',
);
if (!data) { return {
...base,
currentPrice: marketData.price,
change: marketData.change,
changePercent: marketData.changePercent,
currentValue: marketData.price !== null ? marketData.price * pos.quantity : null,
};
} catch {
return { ...base, currentPrice: null, change: null, changePercent: null, currentValue: null };
}
}
private async enrichBondPosition(
pos: { id: number; secid: string; quantity: number },
base: EnrichedPosition,
): Promise<EnrichedPosition> {
try {
const { data: bondData } = await this.cache.getOrFetch(
'bonddata',
['portfolio', pos.secid],
async () => {
const desc = await this.moexClient.getBondData(pos.secid);
const mkt = await this.moexClient.getBondMarketData(pos.secid);
return {
price: mkt?.last ?? null,
yieldToMaturity: mkt?.yield ?? null,
duration: mkt?.duration ?? null,
couponValue: desc?.couponValue ?? null,
couponPercent: desc?.couponPercent ?? null,
nextCouponDate: desc?.nextCoupon ?? null,
matDate: desc?.matDate ?? null,
accruedInt: desc?.accruedInt ?? null,
faceValue: desc?.faceValue ?? 1000,
bid: mkt?.bid ?? null,
offer: mkt?.offer ?? null,
couponPeriod: desc?.couponPeriod ?? null,
bondType: desc?.bondType ?? null,
offerDate: desc?.offerDate ?? null,
};
},
'marketDataTtl',
);
const currentValue =
bondData.price !== null ? (bondData.price / 100) * bondData.faceValue * pos.quantity : null;
return {
...base,
currentPrice: bondData.price,
yieldToMaturity: bondData.yieldToMaturity,
duration: bondData.duration,
couponValue: bondData.couponValue,
couponPercent: bondData.couponPercent,
nextCouponDate: bondData.nextCouponDate,
matDate: bondData.matDate,
accruedInt: bondData.accruedInt,
bid: bondData.bid,
offer: bondData.offer,
couponPeriod: bondData.couponPeriod,
bondType: bondData.bondType,
offerDate: bondData.offerDate,
currentValue,
};
} catch {
return { return {
...base, ...base,
currentPrice: null, currentPrice: null,
yieldToMaturity: null,
duration: null,
couponValue: null,
couponPercent: null,
nextCouponDate: null,
matDate: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
offerDate: null,
currentValue: null, currentValue: null,
totalCost,
pnl: null,
pnlPercent: null,
dividendIncome,
totalReturn: null,
totalReturnPercent: null,
}; };
} }
const currentPrice = data.last;
const currentValue = currentPrice !== null ? currentPrice * pos.quantity : null;
const pnl = currentValue !== null && totalCost !== null ? currentValue - totalCost : null;
const pnlPercent =
pnl !== null && totalCost !== null && totalCost !== 0 ? (pnl / totalCost) * 100 : null;
const totalReturn = pnl !== null ? pnl + dividendIncome : null;
const totalReturnPercent =
totalReturn !== null && totalCost !== null && totalCost !== 0
? (totalReturn / totalCost) * 100
: null;
return {
...base,
shortName: data.shortName,
currentPrice,
change: data.lastChange,
changePercent: data.lastChangePrcnt,
totalCost,
currentValue,
pnl,
pnlPercent,
dividendIncome,
totalReturn,
totalReturnPercent,
};
}
private buildBondPosition(
pos: {
id: number;
secid: string;
quantity: number;
buyPrice: number | null;
buyDate: Date | null;
},
base: EnrichedPosition,
data: MoexBondPositionData | undefined,
): EnrichedPosition {
if (!data) {
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; // Fallback for unknown faceValue
return {
...base,
currentPrice: null,
currentValue: null,
totalCost,
pnl: null,
pnlPercent: null,
dividendIncome: 0,
totalReturn: null,
totalReturnPercent: null,
};
}
const currentPrice = data.price;
const totalCost =
pos.buyPrice !== null ? (pos.buyPrice / 100) * data.faceValue * pos.quantity : null;
const currentValue =
data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null;
const pnl = currentValue !== null && totalCost !== null ? currentValue - totalCost : null;
const pnlPercent =
pnl !== null && totalCost !== null && totalCost !== 0 ? (pnl / totalCost) * 100 : null;
const dividendIncome = 0;
const totalReturn = pnl !== null ? pnl + dividendIncome : null;
const totalReturnPercent =
totalReturn !== null && totalCost !== null && totalCost !== 0
? (totalReturn / totalCost) * 100
: null;
return {
...base,
shortName: data.shortName,
currentPrice,
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,
totalCost,
currentValue,
pnl,
pnlPercent,
dividendIncome,
totalReturn,
totalReturnPercent,
};
}
async getPositionsWithPrices(portfolioId: number): Promise<EnrichedPosition[]> {
const positions = await this.prisma.position.findMany({ where: { portfolioId } });
if (positions.length === 0) return [];
return this.enrichPositions(positions);
}
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
const totalInvested = enrichedPositions.reduce((sum, p) => sum + (p.totalCost ?? 0), 0);
const totalValue = enrichedPositions.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
const totalPnl = enrichedPositions.reduce((sum, p) => sum + (p.pnl ?? 0), 0);
const totalDividends = enrichedPositions.reduce((sum, p) => sum + (p.dividendIncome ?? 0), 0);
const totalReturn = totalPnl + totalDividends;
const totalPnlPercent = totalInvested > 0 ? (totalPnl / totalInvested) * 100 : null;
const totalReturnPercent = totalInvested > 0 ? (totalReturn / totalInvested) * 100 : null;
const positionCount = enrichedPositions.length;
const weightedYield =
totalInvested > 0
? enrichedPositions.reduce(
(sum, p) => sum + ((p.pnlPercent ?? 0) * (p.totalCost ?? 0)) / totalInvested,
0,
)
: null;
let targetSharesPercent: number | null = null;
let targetBondsPercent: number | null = null;
if (portfolio.targets) {
const targets = JSON.parse(portfolio.targets);
targetSharesPercent = targets.sharesPercent;
targetBondsPercent = targets.bondsPercent;
}
let actualSharesPercent = 0;
let actualBondsPercent = 0;
if (totalValue > 0) {
const shareValue = enrichedPositions
.filter((p) => p.type === 'share')
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
const bondValue = enrichedPositions
.filter((p) => p.type === 'bond')
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
actualSharesPercent = Math.round((shareValue / totalValue) * 10000) / 100;
actualBondsPercent = Math.round((bondValue / totalValue) * 10000) / 100;
}
const sharesDeviation =
targetSharesPercent !== null ? Math.round((actualSharesPercent - targetSharesPercent) * 100) / 100 : null;
const bondsDeviation =
targetBondsPercent !== null ? Math.round((actualBondsPercent - targetBondsPercent) * 100) / 100 : null;
const summary = {
totalInvested,
totalValue,
totalPnl,
totalPnlPercent,
totalDividends,
totalReturn,
totalReturnPercent,
positionCount,
weightedYield,
targetSharesPercent,
targetBondsPercent,
actualSharesPercent,
actualBondsPercent,
sharesDeviation,
bondsDeviation,
};
return { positions: enrichedPositions, summary };
} }
} }

View File

@ -1,160 +0,0 @@
import { Type } from 'class-transformer';
import { IsString, IsOptional, IsNumber, IsInt, Min, Max, IsEnum, IsIn } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum ScreenerType {
SHARE = 'share',
BOND = 'bond',
}
export const SORTER_FIELDS = [
'price',
'changePercent',
'volume',
'listLevel',
'capitalization',
'yieldToMaturity',
'duration',
'couponValue',
'couponPercent',
] as const;
export class ScreenerQueryDto {
@ApiProperty({ enum: ScreenerType })
@IsEnum(ScreenerType)
type!: ScreenerType;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
priceMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
priceMax?: number;
@ApiPropertyOptional()
@IsInt()
@Min(0)
@IsOptional()
@Type(() => Number)
volumeMin?: number;
@ApiPropertyOptional()
@IsInt()
@Min(1)
@Max(3)
@IsOptional()
@Type(() => Number)
listLevel?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
changePercentMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
changePercentMax?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
capitalizationMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
yieldMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
yieldMax?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
durationMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
durationMax?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
couponMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
couponMax?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
couponPercentMin?: number;
@ApiPropertyOptional()
@IsNumber()
@IsOptional()
@Type(() => Number)
couponPercentMax?: number;
@ApiPropertyOptional()
@IsString()
@IsOptional()
maturityBefore?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
maturityAfter?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
bondType?: string;
@ApiPropertyOptional({ default: 'price' })
@IsString()
@IsOptional()
sortBy?: string;
@ApiPropertyOptional({ default: 'asc' })
@IsString()
@IsIn(['asc', 'desc'])
@IsOptional()
sortOrder?: 'asc' | 'desc';
@ApiPropertyOptional({ default: 1 })
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
pageSize?: number;
}

View File

@ -1,80 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
export class ScreenerItemDto {
@ApiProperty({ example: 'SBER' })
secid!: string;
@ApiProperty({ example: 'Сбербанк' })
shortName!: string;
@ApiProperty({ example: 'RU0009029540' })
isin!: string;
@ApiProperty({ enum: ['share', 'bond'] })
type!: 'share' | 'bond';
@ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 })
price!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 1.15 })
change!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 0.36 })
changePercent!: number | null;
@ApiProperty({ example: 1925163 })
volume!: number;
@ApiProperty({ example: 1 })
listLevel!: number;
@ApiPropertyOptional({ type: Number, nullable: true, example: 6958336818320 })
capitalization!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 })
yieldToMaturity!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 })
duration!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 40.64 })
couponValue!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 8.15 })
couponPercent!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 29.48 })
accruedInt!: number | null;
@ApiPropertyOptional({ type: String, nullable: true, example: '2027-02-03' })
matDate!: string | null;
@ApiPropertyOptional({ type: String, nullable: true, example: 'ОФЗ-ПД' })
bondType!: string | null;
}
export class ScreenerResultDto {
@ApiProperty({ type: [ScreenerItemDto] })
items!: ScreenerItemDto[];
@ApiProperty()
total!: number;
@ApiProperty()
page!: number;
@ApiProperty()
pageSize!: number;
@ApiProperty()
totalPages!: number;
}
export class ScreenerResponseDto {
@ApiProperty({ type: ScreenerResultDto })
data!: ScreenerResultDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

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

View File

@ -1,125 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ScreenerService } from './screener.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { CacheService } from '../cache/cache.service';
import { ScreenerType } from './dto/screener-query.dto';
describe('ScreenerService', () => {
let service: ScreenerService;
let cache: CacheService;
const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
ScreenerService,
{ provide: MoexMarketDataClient, useValue: moexMarketData },
{ provide: CacheService, useValue: { getOrFetch: vi.fn() } },
],
}).compile();
service = module.get<ScreenerService>(ScreenerService);
cache = module.get<CacheService>(CacheService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('screen', () => {
it('should cache full dataset with screenerTtl config', async () => {
const mockShares = [{
secid: 'SBER', shortName: 'Sberbank', last: 250, volume: 1000000,
lastChange: 5, lastChangePrcnt: 2, issueCapitalization: 1e9,
}];
moexMarketData.getShareMarketDataBatch.mockResolvedValue(mockShares);
vi.mocked(cache.getOrFetch).mockImplementation(async (_prefix, _keys, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}));
await service.screen({ type: ScreenerType.SHARE });
expect(cache.getOrFetch).toHaveBeenCalledWith(
'screener',
[ScreenerType.SHARE],
expect.any(Function),
'screenerTtl',
);
});
it('should filter and sort shares', async () => {
const mockShares = [
{
secid: 'SBER',
shortName: 'Sberbank',
price: 250,
volume: 1000000,
changePercent: 1,
type: 'share',
},
{
secid: 'GAZP',
shortName: 'Gazprom',
price: 150,
volume: 500000,
changePercent: -1,
type: 'share',
},
{
secid: 'LKOH',
shortName: 'Lukoil',
price: 5000,
volume: 100000,
changePercent: 0.5,
type: 'share',
},
];
vi.mocked(cache.getOrFetch).mockImplementation(async () => ({
data: mockShares,
fromCache: false,
cachedAt: null,
}));
const result = await service.screen({
type: ScreenerType.SHARE,
priceMax: 300,
sortOrder: 'desc',
sortBy: 'price',
});
expect(result.items).toHaveLength(2);
expect(result.items[0].secid).toBe('SBER'); // 250
expect(result.items[1].secid).toBe('GAZP'); // 150
expect(result.total).toBe(2);
});
it('should paginate results', async () => {
const mockShares = Array.from({ length: 50 }, (_, i) => ({
secid: `TICKER${i}`,
last: i,
}));
vi.mocked(cache.getOrFetch).mockImplementation(async () => ({
data: mockShares,
fromCache: false,
cachedAt: null,
}));
const result = await service.screen({
type: ScreenerType.SHARE,
page: 2,
pageSize: 10,
});
expect(result.items).toHaveLength(10);
expect(result.page).toBe(2);
expect(result.totalPages).toBe(5);
});
});
});

View File

@ -1,187 +0,0 @@
import { Injectable } from '@nestjs/common';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { CacheService } from '../cache/cache.service';
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';
@Injectable()
export class ScreenerService {
constructor(
private readonly moexMarketData: MoexMarketDataClient,
private readonly cache: CacheService,
) {}
async screen(q: ScreenerQueryDto): Promise<ScreenerResultDto> {
const board = await this.fetchBoard(q.type);
const filtered = board.filter((item) => this.matches(item, q));
const sorted = this.sort(filtered, q.sortBy || 'price', q.sortOrder || 'asc');
const total = sorted.length;
const page = q.page || 1;
const pageSize = q.pageSize || 20;
const totalPages = Math.ceil(total / pageSize);
const start = (page - 1) * pageSize;
const items = sorted.slice(start, start + pageSize);
return {
items,
total,
page,
pageSize,
totalPages,
};
}
private async fetchBoard(type: ScreenerType): Promise<ScreenerItemDto[]> {
const { data } = await this.cache.getOrFetch(
'screener',
[type],
async () => {
if (type === ScreenerType.SHARE) {
const shares = await this.moexMarketData.getShareMarketDataBatch([]);
return shares.map(
(s): ScreenerItemDto => ({
secid: s.secid,
shortName: s.shortName,
isin: '', // MOEX batch doesn't return ISIN in securities table sometimes, but we can live without it for screener
type: 'share',
price: s.last,
change: s.lastChange,
changePercent: s.lastChangePrcnt,
volume: s.volume,
listLevel: 0,
capitalization: s.issueCapitalization,
yieldToMaturity: null,
duration: null,
couponValue: null,
couponPercent: null,
accruedInt: null,
matDate: null,
bondType: null,
}),
);
} else {
const bonds = await this.moexMarketData.getBondPositionDataBatch([]);
return bonds.map(
(b): ScreenerItemDto => ({
secid: b.secid,
shortName: b.shortName,
isin: '',
type: 'bond',
price: b.price,
change: null,
changePercent: null,
volume: 0,
listLevel: 0,
capitalization: null,
yieldToMaturity: b.yieldToMaturity,
duration: b.duration,
couponValue: b.couponValue,
couponPercent: b.couponPercent,
accruedInt: b.accruedInt,
matDate: b.matDate,
bondType: b.bondType,
}),
);
}
},
'screenerTtl',
);
return data;
}
private matches(item: ScreenerItemDto, q: ScreenerQueryDto): boolean {
if (q.priceMin != null && (item.price == null || item.price < q.priceMin)) return false;
if (q.priceMax != null && (item.price == null || item.price > q.priceMax)) return false;
if (q.volumeMin != null && item.volume < q.volumeMin) return false;
if (q.listLevel != null && item.listLevel !== q.listLevel) return false;
if (item.type === 'share') {
if (
q.changePercentMin != null &&
(item.changePercent == null || item.changePercent < q.changePercentMin)
)
return false;
if (
q.changePercentMax != null &&
(item.changePercent == null || item.changePercent > q.changePercentMax)
)
return false;
if (
q.capitalizationMin != null &&
(item.capitalization == null || item.capitalization < q.capitalizationMin)
)
return false;
}
if (item.type === 'bond') {
if (q.yieldMin != null && (item.yieldToMaturity == null || item.yieldToMaturity < q.yieldMin))
return false;
if (q.yieldMax != null && (item.yieldToMaturity == null || item.yieldToMaturity > q.yieldMax))
return false;
if (q.durationMin != null && (item.duration == null || item.duration < q.durationMin))
return false;
if (q.durationMax != null && (item.duration == null || item.duration > q.durationMax))
return false;
if (q.couponMin != null && (item.couponValue == null || item.couponValue < q.couponMin))
return false;
if (q.couponMax != null && (item.couponValue == null || item.couponValue > q.couponMax))
return false;
if (
q.couponPercentMin != null &&
(item.couponPercent == null || item.couponPercent < q.couponPercentMin)
)
return false;
if (
q.couponPercentMax != null &&
(item.couponPercent == null || item.couponPercent > q.couponPercentMax)
)
return false;
if (q.maturityBefore != null && (item.matDate == null || item.matDate > q.maturityBefore))
return false;
if (q.maturityAfter != null && (item.matDate == null || item.matDate < q.maturityAfter))
return false;
if (q.bondType != null && item.bondType !== q.bondType) return false;
}
return true;
}
private sort(
items: ScreenerItemDto[],
sortBy: string,
sortOrder: 'asc' | 'desc',
): ScreenerItemDto[] {
const allowedFields = new Set([
'secid',
'shortName',
'price',
'change',
'changePercent',
'volume',
'listLevel',
'capitalization',
'yieldToMaturity',
'duration',
'couponValue',
'couponPercent',
'accruedInt',
'matDate',
]);
if (!allowedFields.has(sortBy)) {
sortBy = 'price';
}
return [...items].sort((a, b) => {
const aVal = (a as any)[sortBy];
const bVal = (b as any)[sortBy];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
if (typeof aVal === 'string') {
return sortOrder === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
}
return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
});
}
}

View File

@ -1,7 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { SecuritiesController } from './securities.controller'; import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service';
import { SecurityType } from './dto/search-query.dto'; import { SecurityType } from './dto/search-query.dto';
describe('SecuritiesController', () => { describe('SecuritiesController', () => {
@ -24,17 +23,10 @@ describe('SecuritiesController', () => {
search: vi.fn().mockResolvedValue(mockResults), search: vi.fn().mockResolvedValue(mockResults),
}; };
const mockScreenerService = {
screen: vi.fn(),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [SecuritiesController], controllers: [SecuritiesController],
providers: [ providers: [{ provide: SecuritiesService, useValue: mockService }],
{ provide: SecuritiesService, useValue: mockService },
{ provide: ScreenerService, useValue: mockScreenerService },
],
}).compile(); }).compile();
controller = module.get<SecuritiesController>(SecuritiesController); controller = module.get<SecuritiesController>(SecuritiesController);
@ -47,7 +39,7 @@ describe('SecuritiesController', () => {
it('should return search results', async () => { it('should return search results', async () => {
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 }); const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
expect(result).toEqual(mockResults); expect(result.data).toEqual(mockResults);
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5); expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
}); });
}); });

View File

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

View File

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

View File

@ -1,174 +1,38 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { SecurityType } from './dto/search-query.dto'; import { SecurityType } from './dto/search-query.dto';
describe('SecuritiesService', () => { describe('SecuritiesService', () => {
let service: SecuritiesService; let service: SecuritiesService;
let moexSecurities: Pick<MoexSecuritiesClient, 'searchSecurities'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexSecurities = {
searchSecurities: vi.fn(),
};
cache = {
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
})),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [ providers: [
SecuritiesService, SecuritiesService,
{ provide: MoexSecuritiesClient, useValue: moexSecurities }, MoexClientService,
{ provide: CacheService, useValue: cache }, {
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
], ],
}).compile(); }).compile();
service = module.get<SecuritiesService>(SecuritiesService); service = module.get<SecuritiesService>(SecuritiesService);
}); });
it('returns supported securities only and normalizes SUR currency to RUB', async () => { it('should return search results for SBER', async () => {
vi.mocked(moexSecurities.searchSecurities).mockResolvedValue([ const results = await service.search('SBER', SecurityType.ALL, 5);
{ expect(results.length).toBeGreaterThan(0);
secid: 'SBER', expect(results[0].secid).toBeDefined();
isin: 'RU0009029540', }, 15000);
name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586948000,
faceValue: 3,
faceUnit: 'SUR',
issueDate: '2007-07-20',
typeName: 'Акция обыкновенная',
group: 'stock_shares',
type: 'common_share',
isQualifiedInvestors: false,
morningSession: true,
eveningSession: true,
},
{
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 100000000,
faceValue: 1000,
faceUnit: 'RUB',
issueDate: '2021-06-23',
typeName: 'Государственная облигация',
group: 'stock_bonds',
type: 'ofz_bond',
isQualifiedInvestors: false,
morningSession: false,
eveningSession: false,
},
{
secid: 'SiM6',
isin: '',
name: 'USD/RUB Futures',
shortName: 'SiM6',
latName: null,
listLevel: 0,
issueSize: 0,
faceValue: 0,
faceUnit: '',
issueDate: '',
typeName: 'Фьючерс',
group: 'futures',
type: 'futures',
isQualifiedInvestors: false,
morningSession: false,
eveningSession: true,
},
]);
const results = await service.search('SbEr', SecurityType.ALL, 10);
expect(results).toEqual([
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбербанк',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
{
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
shortName: 'ОФЗ 26238',
type: 'bond',
listLevel: 1,
currency: 'RUB',
price: null,
},
]);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'search',
['sber'],
expect.any(Function),
'searchTtl',
);
expect(moexSecurities.searchSecurities).toHaveBeenCalledWith('SbEr');
});
it('filters by type and applies limit without live MOEX dependency', async () => {
vi.mocked(cache.getOrFetch).mockResolvedValue({
data: [
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбербанк',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
{
secid: 'GAZP',
isin: 'RU0007661625',
shortName: 'Газпром',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
{
secid: 'SU26238RMFS5',
isin: 'RU000A1038V6',
shortName: 'ОФЗ 26238',
type: 'bond',
listLevel: 1,
currency: 'RUB',
price: null,
},
],
fromCache: true,
cachedAt: null,
});
const results = await service.search('ru', SecurityType.SHARE, 1);
expect(results).toEqual([
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбербанк',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
]);
expect(moexSecurities.searchSecurities).not.toHaveBeenCalled();
});
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,144 +1,41 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { ConfigModule } from '@nestjs/config';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
describe('SharesService', () => { describe('SharesService', () => {
let service: SharesService; let service: SharesService;
let moexSecurities: Pick<MoexSecuritiesClient, 'getSecurityDescription'>;
let moexMarketData: Pick<MoexMarketDataClient, 'getShareMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexSecurities = {
getSecurityDescription: vi.fn(),
};
moexMarketData = {
getShareMarketData: vi.fn(),
};
cache = {
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
})),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [ providers: [
SharesService, SharesService,
{ provide: MoexSecuritiesClient, useValue: moexSecurities }, MoexClientService,
{ provide: MoexMarketDataClient, useValue: moexMarketData }, {
{ provide: MoexDividendsClient, useValue: { getDividends: vi.fn() } }, provide: 'CACHE_MANAGER',
{ provide: MoexHistoryClient, useValue: { getHistory: vi.fn() } }, useValue: {
{ provide: CacheService, useValue: cache }, get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
], ],
}).compile(); }).compile();
service = module.get<SharesService>(SharesService); service = module.get<SharesService>(SharesService);
}); });
it('returns normalized SBER share spec and market data without live MOEX dependency', async () => { it('should be defined', () => {
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({ expect(service).toBeDefined();
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586948000,
faceValue: 3,
faceUnit: 'SUR',
issueDate: '2007-07-20',
typeName: 'Акция обыкновенная',
group: 'stock_shares',
type: 'common_share',
isQualifiedInvestors: false,
morningSession: true,
eveningSession: true,
});
vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({
secid: 'SBER',
boardid: 'TQBR',
shortName: 'Сбербанк',
bid: 320,
offer: 321,
open: 318,
low: 317,
high: 325,
last: 323,
lastChange: 4,
lastChangePrcnt: 1.25,
volume: 1500000,
value: 480000000,
waprice: 321,
numtrades: 4200,
issueCapitalization: 6900000000000,
tradingStatus: 'T',
updateTime: '18:45:00',
});
const result = await service.getShare('SBER');
expect(moexSecurities.getSecurityDescription).toHaveBeenCalledWith('SBER');
expect(cache.getOrFetch).toHaveBeenCalledWith(
'marketdata',
['shares', 'SBER'],
expect.any(Function),
'marketDataTtl',
);
expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER');
expect(result.data).toMatchObject({
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586948000,
faceValue: 3,
faceUnit: 'RUB',
type: 'common_share',
marketData: {
price: 323,
change: 4,
changePercent: 1.25,
open: 318,
high: 325,
low: 317,
volume: 1500000,
value: 480000000,
issueCapitalization: 6900000000000,
},
});
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
}); });
it('throws EntityNotFoundException for non-share security', async () => { it('should return SBER share data', async () => {
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({ const share = await service.getShare('SBER');
secid: 'SU26238RMFS5', expect(share.secid).toBe('SBER');
isin: 'RU000A1038V6', expect(share.marketData).toBeDefined();
name: 'ОФЗ 26238', }, 15000);
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 100000000,
faceValue: 1000,
faceUnit: 'RUB',
issueDate: '2021-06-23',
typeName: 'Государственная облигация',
group: 'stock_bonds',
type: 'ofz_bond',
isQualifiedInvestors: false,
morningSession: false,
eveningSession: false,
});
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException);
expect(cache.getOrFetch).not.toHaveBeenCalled();
});
}); });

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