Compare commits

..

No commits in common. "main" and "codex/frontend-infrastructure-tooling" have entirely different histories.

250 changed files with 2421 additions and 17422 deletions

View File

@ -23,9 +23,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build design system
run: npm run build:design-system
- name: Lint - name: Lint
run: npm run lint run: npm run lint
@ -47,6 +44,9 @@ jobs:
- name: Test design system - name: Test design system
run: npm run test:design-system run: npm run test:design-system
- name: Build design system
run: npm run build:design-system
- name: Build Storybook - name: Build Storybook
run: npm run build:storybook run: npm run build:storybook

4
.gitignore vendored
View File

@ -11,7 +11,3 @@ vite.config.js
apps/docs/.docusaurus/ apps/docs/.docusaurus/
apps/docs/build/ apps/docs/build/
.idea .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

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: []

View File

@ -41,7 +41,7 @@
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria. - **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans для крупных многошаговых работ, subagent-driven-development как предпочтительный способ исполнения плана, executing-plans как fallback для явно связанных inline-задач, frontend-design для UI, requesting-code-review перед завершением крупных изменений. - **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans для крупных многошаговых работ, subagent-driven-development как предпочтительный способ исполнения плана, executing-plans как fallback для явно связанных inline-задач, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
- **MCP-инструменты**: в проекте настроены `code-index-mcp` (файловый поиск/индексация), `serena` (LSP-символьный анализ кода) и `graphify` (knowledge graph). Использовать для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче. - **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
- **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы. - **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы.
--- ---
@ -459,67 +459,3 @@ roadmap.md и inbox.md никогда не являются основанием
- Тесты фронтенда есть: Vitest + Testing Library + MSW. - Тесты фронтенда есть: Vitest + Testing Library + MSW.
- CI находится в `.gitea/workflows/ci.yml`. - CI находится в `.gitea/workflows/ci.yml`.
- Pre-commit checks настроены через Husky и lint-staged. - 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).

View File

@ -125,8 +125,6 @@ docs/
| `npm run format` | Prettier для всех `*.{ts,tsx}` | | `npm run format` | Prettier для всех `*.{ts,tsx}` |
| `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` | | `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 для разработки компонентов. Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов.
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`. Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.

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';
@ -11,7 +11,6 @@ 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 { 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({
@ -30,8 +29,4 @@ import configuration from './config/configuration';
TBankModule, TBankModule,
], ],
}) })
export class AppModule implements NestModule { export class AppModule {}
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestLoggingMiddleware).forRoutes('*');
}
}

View File

@ -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: {
@ -38,17 +29,12 @@ export default registerAs('app', () => ({
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), tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10), tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10), tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10), tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10),
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 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

@ -41,7 +41,13 @@ export class AuthController {
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()
@ -51,7 +57,13 @@ export class AuthController {
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()
@ -63,7 +75,13 @@ export class AuthController {
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')
@ -74,7 +92,10 @@ export class AuthController {
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')
@ -82,7 +103,11 @@ export class AuthController {
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto }) @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')
@ -90,6 +115,10 @@ export class AuthController {
@ApiOperation({ summary: 'Update current user profile' }) @ApiOperation({ summary: 'Update current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto }) @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,5 +1,12 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
class AuthResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
class AuthUserDto { class AuthUserDto {
@ApiProperty() @ApiProperty()
@ -32,22 +39,22 @@ export class AuthTokenResponseDto {
@ApiProperty({ type: AuthTokenDataDto }) @ApiProperty({ type: AuthTokenDataDto })
data!: AuthTokenDataDto; data!: AuthTokenDataDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: AuthResponseMetaDto })
meta!: ApiResponseMeta; meta!: AuthResponseMetaDto;
} }
export class AuthProfileResponseDto { export class AuthProfileResponseDto {
@ApiProperty({ type: AuthUserDto }) @ApiProperty({ type: AuthUserDto })
data!: AuthUserDto; data!: AuthUserDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: AuthResponseMetaDto })
meta!: ApiResponseMeta; meta!: AuthResponseMetaDto;
} }
export class AuthLogoutResponseDto { export class AuthLogoutResponseDto {
@ApiProperty({ type: LogoutDataDto }) @ApiProperty({ type: LogoutDataDto })
data!: LogoutDataDto; data!: LogoutDataDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: AuthResponseMetaDto })
meta!: ApiResponseMeta; meta!: AuthResponseMetaDto;
} }

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,17 +1,16 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
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';
describe('BondsService', () => { describe('BondsService', () => {
let service: BondsService; let service: BondsService;
let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>; let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexMarketData = { moexClient = {
getBondData: vi.fn(), getBondData: vi.fn(),
getBondMarketData: vi.fn(), getBondMarketData: vi.fn(),
}; };
@ -26,8 +25,7 @@ describe('BondsService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
BondsService, BondsService,
{ provide: MoexMarketDataClient, useValue: moexMarketData }, { provide: MoexClientService, useValue: moexClient },
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -36,7 +34,7 @@ describe('BondsService', () => {
}); });
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => { it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
vi.mocked(moexMarketData.getBondData).mockResolvedValue({ vi.mocked(moexClient.getBondData).mockResolvedValue({
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
boardid: 'TQCB', boardid: 'TQCB',
shortName: 'ОФЗ 26238', shortName: 'ОФЗ 26238',
@ -59,7 +57,7 @@ describe('BondsService', () => {
bondSubType: 'fixed', bondSubType: 'fixed',
listLevel: 1, listLevel: 1,
}); });
vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({ vi.mocked(moexClient.getBondMarketData).mockResolvedValue({
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
bid: 72.9, bid: 72.9,
offer: 73.1, offer: 73.1,
@ -94,8 +92,8 @@ describe('BondsService', () => {
expect.any(Function), expect.any(Function),
'marketDataTtl', 'marketDataTtl',
); );
expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5'); expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5'); expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
expect(result).toMatchObject({ expect(result).toMatchObject({
data: { data: {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
@ -131,17 +129,19 @@ describe('BondsService', () => {
volume: 10000, volume: 10000,
}, },
}, },
fromCache: false, meta: {
cachedAt: '2026-06-15T00:00:00.000Z', fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
},
}); });
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
}); });
it('throws EntityNotFoundException when bond data is missing', async () => { it('throws NotFoundException when bond data is missing', async () => {
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null); vi.mocked(moexClient.getBondData).mockResolvedValue(null);
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException); await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException);
expect(cache.getOrFetch).toHaveBeenCalledTimes(1); expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled(); expect(moexClient.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,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,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,16 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
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 { 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 moexClient: Pick<MoexClientService, 'getCandles'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexCandles = { moexClient = {
getCandles: vi.fn(), getCandles: vi.fn(),
}; };
cache = { cache = {
@ -24,7 +24,7 @@ describe('CandlesService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
CandlesService, CandlesService,
{ provide: MoexCandlesClient, useValue: moexCandles }, { provide: MoexClientService, useValue: moexClient },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -33,7 +33,7 @@ describe('CandlesService', () => {
}); });
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => { it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
vi.mocked(moexCandles.getCandles).mockResolvedValue([ vi.mocked(moexClient.getCandles).mockResolvedValue([
{ {
open: 320, open: 320,
high: 325, high: 325,
@ -60,7 +60,7 @@ describe('CandlesService', () => {
expect.any(Function), expect.any(Function),
'candlesTtl', 'candlesTtl',
); );
expect(moexCandles.getCandles).toHaveBeenCalledWith( expect(moexClient.getCandles).toHaveBeenCalledWith(
'stock', 'stock',
'shares', 'shares',
'SBER', 'SBER',
@ -81,13 +81,15 @@ describe('CandlesService', () => {
end: '2026-05-01 23:59:59', end: '2026-05-01 23:59:59',
}, },
], ],
fromCache: false, meta: {
cachedAt: '2026-06-15T00:00:00.000Z', fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
},
}); });
}); });
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => { it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
vi.mocked(moexCandles.getCandles).mockResolvedValue([]); vi.mocked(moexClient.getCandles).mockResolvedValue([]);
await service.getCandles( await service.getCandles(
'bonds', 'bonds',
@ -103,7 +105,7 @@ describe('CandlesService', () => {
expect.any(Function), expect.any(Function),
'candlesTtl', 'candlesTtl',
); );
expect(moexCandles.getCandles).toHaveBeenCalledWith( expect(moexClient.getCandles).toHaveBeenCalledWith(
'stock', 'stock',
'bonds', 'bonds',
'SU26238RMFS5', 'SU26238RMFS5',

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,15 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty } 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 { export class HealthResponseDto {
@ApiProperty({ example: 'ok' }) @ApiProperty({ example: 'ok' })
@ -20,7 +9,4 @@ export class HealthResponseDto {
@ApiProperty({ example: 12345 }) @ApiProperty({ example: 12345 })
uptime!: number; uptime!: number;
@ApiProperty({ type: [HealthCheckResultDto] })
checks!: HealthCheckResultDto[];
} }

View File

@ -3,19 +3,20 @@ import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/sw
import { ApiResponseMeta } from '../../common/dto/api-response.dto'; 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 { HealthEnvelopeDto } from './dto/health-envelope.dto';
import { HealthService } from './health.service';
@ApiTags('Health') @ApiTags('Health')
@ApiExtraModels(ApiResponseMeta) @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 }) @ApiOkResponse({ type: HealthEnvelopeDto })
async check() { check() {
return this.healthService.check(); return {
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,35 +1,32 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { MoexClientModule } from './moex-client.module'; import { MoexClientService } from './moex-client.service';
import { MoexSecuritiesClient } from './moex-securities.client';
import { MoexMarketDataClient } from './moex-market-data.client';
import configuration from '../../config/configuration'; import configuration from '../../config/configuration';
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')( describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
'MoexClient live MOEX integration', 'MoexClientService live MOEX integration',
() => { () => {
let moexSecurities: MoexSecuritiesClient; let service: MoexClientService;
let moexMarketData: MoexMarketDataClient;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule], imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [MoexClientService],
}).compile(); }).compile();
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient); service = module.get<MoexClientService>(MoexClientService);
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
}); });
it('возвращает результаты поиска для SBER из live MOEX', async () => { it('возвращает результаты поиска для SBER из live MOEX', async () => {
const results = await moexSecurities.searchSecurities('SBER'); const results = await service.searchSecurities('SBER');
expect(results.length).toBeGreaterThan(0); expect(results.length).toBeGreaterThan(0);
expect(results[0].secid).toBeDefined(); expect(results[0].secid).toBeDefined();
}, 15000); }, 15000);
it('возвращает рыночные данные SBER из live MOEX', async () => { it('возвращает рыночные данные SBER из live MOEX', async () => {
const data = await moexMarketData.getShareMarketData('SBER'); const data = await service.getShareMarketData('SBER');
expect(data).toBeDefined(); expect(data).toBeDefined();
expect(data!.secid).toBe('SBER'); expect(data!.secid).toBe('SBER');

View File

@ -0,0 +1,186 @@
import 'reflect-metadata';
import axios from 'axios';
import { ConfigService } from '@nestjs/config';
import { MoexClientService } from './moex-client.service';
vi.mock('axios', () => ({
default: {
create: vi.fn(),
},
}));
describe('MoexClientService', () => {
let service: MoexClientService;
let getMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
getMock = vi.fn();
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
service = new MoexClientService({
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.moex.baseUrl': 'https://iss.moex.test/iss',
'app.moex.circuitBreakerThreshold': 5,
'app.moex.circuitBreakerResetSeconds': 30,
'app.moex.rateLimit': 10,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService);
});
it('создаётся с настроенным MOEX client', () => {
expect(service).toBeDefined();
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://iss.moex.test/iss',
timeout: 10000,
paramsSerializer: { indexes: null },
});
});
it('нормализует результаты поиска из ISS table format', async () => {
getMock.mockResolvedValueOnce({
data: {
securities: {
columns: [
'secid',
'isin',
'name',
'shortName',
'latName',
'listLevel',
'issuesize',
'facevalue',
'faceunit',
'issuedate',
'typename',
'group',
'type',
'isqualifiedinvestors',
'morningsession',
'eveningsession',
],
data: [
[
'SBER',
'RU0009029540',
'Сбербанк России ПАО ао',
'Сбербанк',
'Sberbank',
'1',
'21586948000',
'3',
'SUR',
'2007-07-20',
'Акция обыкновенная',
'stock_shares',
'common_share',
'0',
'1',
'1',
],
],
},
},
});
const results = await service.searchSecurities('SBER');
expect(getMock).toHaveBeenCalledWith('/securities.json', {
params: { q: 'SBER', 'iss.meta': 'off' },
});
expect(results).toEqual([
{
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586948000,
faceValue: 3,
faceUnit: 'SUR',
issueDate: '2007-07-20',
typeName: 'Акция обыкновенная',
group: 'stock_shares',
type: 'common_share',
isQualifiedInvestors: false,
morningSession: true,
eveningSession: true,
},
]);
});
it('нормализует market data акции без live MOEX запроса', async () => {
getMock.mockResolvedValueOnce({
data: {
securities: {
columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'],
data: [['SBER', 'TQBR', 'Сбербанк', '320.10']],
},
marketdata: {
columns: [
'SECID',
'BOARDID',
'BID',
'OFFER',
'OPEN',
'LOW',
'HIGH',
'LAST',
'LASTCHANGE',
'LASTCHANGEPRCNT',
'VOLTODAY',
'VALTODAY',
'WAPRICE',
'NUMTRADES',
'ISSUECAPITALIZATION',
'TRADINGSTATUS',
'UPDATETIME',
],
data: [
[
'SBER',
'TQBR',
'321',
'322',
'320',
'319',
'323',
'322.35',
'1.15',
'0.36',
'1925163',
'620184479',
'321.9',
'12345',
'6958336818320',
'T',
'10:30:00',
],
],
},
},
});
const data = await service.getShareMarketData('SBER');
expect(getMock).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER.json', {
params: { boards: 'TQBR', 'iss.meta': 'off' },
});
expect(data).toMatchObject({
secid: 'SBER',
boardid: 'TQBR',
shortName: 'Сбербанк',
last: 322.35,
lastChange: 1.15,
lastChangePrcnt: 0.36,
volume: 1925163,
value: 620184479,
issueCapitalization: 6958336818320,
tradingStatus: 'T',
updateTime: '10:30:00',
});
});
});

View File

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

View File

@ -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

@ -8,7 +8,6 @@ import {
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,7 +31,7 @@ export class AddPositionDto {
@ApiProperty({ example: 10 }) @ApiProperty({ example: 10 })
@IsInt() @IsInt()
@Min(1) @Min(0)
quantity!: number; quantity!: number;
@ApiPropertyOptional({ example: 250.5 }) @ApiPropertyOptional({ example: 250.5 })
@ -42,7 +41,7 @@ export class AddPositionDto {
buyPrice?: number; buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-01' }) @ApiPropertyOptional({ example: '2026-06-01' })
@IsDateString() @IsString()
@IsOptional() @IsOptional()
buyDate?: string; buyDate?: string;

View File

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

View File

@ -1,46 +1,53 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { AnalyticsResponseDto } from './analytics-response.dto'; import { AnalyticsResponseDto } from './analytics-response.dto';
import { PortfolioListResponseDto } from './portfolio-list-response.dto'; import { PortfolioListResponseDto } from './portfolio-list-response.dto';
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto'; import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
import { PositionResponseDto } from './position-response.dto'; import { PositionResponseDto } from './position-response.dto';
export class PortfolioResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class PortfolioListEnvelopeDto { export class PortfolioListEnvelopeDto {
@ApiProperty({ type: [PortfolioListResponseDto] }) @ApiProperty({ type: [PortfolioListResponseDto] })
data!: PortfolioListResponseDto[]; data!: PortfolioListResponseDto[];
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }
export class PortfolioEnvelopeDto { export class PortfolioEnvelopeDto {
@ApiProperty({ type: PortfolioResponseDto }) @ApiProperty({ type: PortfolioResponseDto })
data!: PortfolioResponseDto; data!: PortfolioResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }
export class PortfolioDetailEnvelopeDto { export class PortfolioDetailEnvelopeDto {
@ApiProperty({ type: PortfolioDetailResponseDto }) @ApiProperty({ type: PortfolioDetailResponseDto })
data!: PortfolioDetailResponseDto; data!: PortfolioDetailResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }
export class PositionEnvelopeDto { export class PositionEnvelopeDto {
@ApiProperty({ type: PositionResponseDto }) @ApiProperty({ type: PositionResponseDto })
data!: PositionResponseDto; data!: PositionResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }
export class AnalyticsEnvelopeDto { export class AnalyticsEnvelopeDto {
@ApiProperty({ type: AnalyticsResponseDto }) @ApiProperty({ type: AnalyticsResponseDto })
data!: AnalyticsResponseDto; data!: AnalyticsResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }

View File

@ -9,16 +9,6 @@ export class PortfolioResponseDto {
@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 {

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

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

View File

@ -13,28 +13,32 @@ 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 { import {
AnalyticsEnvelopeDto, AnalyticsEnvelopeDto,
PortfolioDetailEnvelopeDto, PortfolioDetailEnvelopeDto,
PortfolioEnvelopeDto, PortfolioEnvelopeDto,
PortfolioListEnvelopeDto, PortfolioListEnvelopeDto,
PortfolioResponseMetaDto,
PositionEnvelopeDto, PositionEnvelopeDto,
} from './dto/portfolio-envelope.dto'; } from './dto/portfolio-envelope.dto';
const nullDataEnvelopeSchema = { const nullDataEnvelopeSchema = {
type: 'object', type: 'object',
properties: { properties: {
data: { type: 'null' }, data: {
meta: { $ref: getSchemaPath(ApiResponseMeta) }, type: 'null',
},
meta: {
$ref: getSchemaPath(PortfolioResponseMetaDto),
},
}, },
required: ['data', 'meta'], required: ['data', 'meta'],
}; };
@ApiTags('Portfolios') @ApiTags('Portfolios')
@ApiBearerAuth() @ApiBearerAuth()
@ApiExtraModels(ApiResponseMeta) @ApiExtraModels(PortfolioResponseMetaDto)
@Controller('portfolios') @Controller('portfolios')
export class PortfolioController { export class PortfolioController {
constructor(private readonly portfolioService: PortfolioService) {} constructor(private readonly portfolioService: PortfolioService) {}
@ -43,21 +47,24 @@ export class PortfolioController {
@ApiOperation({ summary: 'Get all portfolios for current user' }) @ApiOperation({ summary: 'Get all portfolios for current user' })
@ApiOkResponse({ type: PortfolioListEnvelopeDto }) @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 }) @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 }) @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')
@ -68,7 +75,8 @@ export class PortfolioController {
@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')
@ -76,7 +84,7 @@ export class PortfolioController {
@ApiOkResponse({ schema: nullDataEnvelopeSchema }) @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')
@ -87,7 +95,8 @@ export class PortfolioController {
@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')
@ -99,14 +108,16 @@ export class PortfolioController {
@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') @Get(':id/analytics')
@ApiOperation({ summary: 'Get portfolio analytics with PnL' }) @ApiOperation({ summary: 'Get portfolio analytics with PnL' })
@ApiOkResponse({ type: AnalyticsEnvelopeDto }) @ApiOkResponse({ type: AnalyticsEnvelopeDto })
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
return this.portfolioService.getAnalytics(user.sub, id); const result = await this.portfolioService.getAnalytics(user.sub, id);
return { data: result, meta: { cachedAt: null, fromCache: false } };
} }
@Delete(':id/positions/:positionId') @Delete(':id/positions/:positionId')
@ -118,6 +129,6 @@ export class PortfolioController {
@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

@ -2,18 +2,15 @@ import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
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 configuration from '../../config/configuration'; import configuration from '../../config/configuration';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
describe('PortfolioService', () => { describe('PortfolioService', () => {
let service: PortfolioService; let service: PortfolioService;
let prisma: PrismaService; let prisma: PrismaService;
let moexMarketData: MoexMarketDataClient; let moexClient: MoexClientService;
let module: TestingModule; let module: TestingModule;
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({ const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
@ -68,20 +65,13 @@ describe('PortfolioService', () => {
}, },
}, },
{ {
provide: MoexSecuritiesClient, provide: MoexClientService,
useValue: { getSecurityDescription: vi.fn() },
},
{
provide: MoexMarketDataClient,
useValue: { useValue: {
getShareMarketDataBatch: vi.fn(), getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn(),
getSecurityDescription: vi.fn(),
}, },
}, },
{
provide: MoexDividendsClient,
useValue: { getDividends: vi.fn() },
},
{ {
provide: CacheService, provide: CacheService,
useValue: { useValue: {
@ -93,7 +83,7 @@ describe('PortfolioService', () => {
service = module.get<PortfolioService>(PortfolioService); service = module.get<PortfolioService>(PortfolioService);
prisma = module.get<PrismaService>(PrismaService); prisma = module.get<PrismaService>(PrismaService);
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient); moexClient = module.get<MoexClientService>(MoexClientService);
}); });
beforeEach(() => { beforeEach(() => {
@ -147,11 +137,11 @@ describe('PortfolioService', () => {
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any, mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
]); ]);
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any); ] as any);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
shortName: 'OFZ 26238', shortName: 'OFZ 26238',
@ -213,14 +203,14 @@ describe('PortfolioService', () => {
}); });
describe('findOne', () => { describe('findOne', () => {
it('should throw EntityNotFoundException for non-existent portfolio', async () => { it('should throw NotFoundException for non-existent portfolio', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.findOne(1, 999)).rejects.toThrow(EntityNotFoundException); await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException);
}); });
it('should throw PortfolioAccessDeniedException for wrong user', async () => { it('should throw ForbiddenException for wrong user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.findOne(1, 1)).rejects.toThrow(PortfolioAccessDeniedException); await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
}); });
it('should return portfolio with enriched positions and analytics summary', async () => { it('should return portfolio with enriched positions and analytics summary', async () => {
@ -245,7 +235,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 }, { secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any); ] as any);
@ -291,7 +281,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any); ] as any);
@ -333,7 +323,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
shortName: 'OFZ 26238', shortName: 'OFZ 26238',
@ -377,7 +367,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 }, { secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any); ] as any);
@ -413,7 +403,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([] as any); vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any);
const result = await service.getPositionsWithPrices(1); const result = await service.getPositionsWithPrices(1);
@ -469,7 +459,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 }, { secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
] as any); ] as any);
@ -521,7 +511,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 120 }, { secid: 'SBER', shortName: 'Sberbank', last: 120 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 180 }, { secid: 'GAZP', shortName: 'Gazprom', last: 180 },
] as any); ] as any);
@ -534,16 +524,16 @@ describe('PortfolioService', () => {
expect(result.summary.weightedYield).toBeCloseTo(0, 1); expect(result.summary.weightedYield).toBeCloseTo(0, 1);
}); });
it('should throw PortfolioAccessDeniedException if portfolio belongs to another user', async () => { it('should throw ForbiddenException if portfolio belongs to another user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.getAnalytics(1, 1)).rejects.toThrow(PortfolioAccessDeniedException); await expect(service.getAnalytics(1, 1)).rejects.toThrow(ForbiddenException);
}); });
it('should throw EntityNotFoundException if portfolio does not exist', async () => { it('should throw NotFoundException if portfolio does not exist', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException); await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException);
}); });
}); });
}); });

View File

@ -1,19 +1,13 @@
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 type { MoexShareMarketData, MoexBondPositionData } from '../moex-client/moex-client.types';
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';
@ -60,14 +54,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,8 +67,6 @@ export class PortfolioService {
currency: dto.currency ?? 'RUB', currency: dto.currency ?? 'RUB',
}, },
}); });
return { ...portfolio, targets: null };
} }
async findAll(userId: number) { async findAll(userId: number) {
@ -99,7 +89,6 @@ export class PortfolioService {
positionCount: 0, positionCount: 0,
shareCount: 0, shareCount: 0,
bondCount: 0, bondCount: 0,
targets: p.targets ? JSON.parse(p.targets) : null,
})); }));
} }
@ -128,7 +117,6 @@ export class PortfolioService {
positionCount: positions.length, positionCount: positions.length,
shareCount: positions.filter((pos) => pos.type === 'share').length, shareCount: positions.filter((pos) => pos.type === 'share').length,
bondCount: positions.filter((pos) => pos.type === 'bond').length, bondCount: positions.filter((pos) => pos.type === 'bond').length,
targets: p.targets ? JSON.parse(p.targets) : null,
}; };
}); });
} }
@ -139,8 +127,8 @@ 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, id);
@ -166,35 +154,28 @@ export class PortfolioService {
positions: positionsWithWeights, positions: positionsWithWeights,
totalValue: Math.round(totalValue * 100) / 100, totalValue: Math.round(totalValue * 100) / 100,
analytics: analytics.summary, 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 +185,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 +194,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';
@ -239,12 +220,12 @@ 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({
@ -261,12 +242,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 } });
@ -291,10 +272,9 @@ export class PortfolioService {
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort(); const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort(); const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
const [shareDataBySecid, bondDataBySecid, dividendsBySecid] = await Promise.all([ const [shareDataBySecid, bondDataBySecid] = await Promise.all([
this.fetchShareBatch(shareSecids, portfolioId), this.fetchShareBatch(shareSecids, portfolioId),
this.fetchBondBatch(bondSecids, portfolioId), this.fetchBondBatch(bondSecids, portfolioId),
this.fetchDividendsBatch(shareSecids, portfolioId),
]); ]);
const enriched: EnrichedPosition[] = []; const enriched: EnrichedPosition[] = [];
@ -325,9 +305,7 @@ export class PortfolioService {
if (pos.type === 'bond') { if (pos.type === 'bond') {
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid))); enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
} else { } else {
enriched.push( enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid), dividendsBySecid.get(pos.secid)),
);
} }
} }
@ -343,7 +321,7 @@ export class PortfolioService {
const { data } = await this.cache.getOrFetch( const { data } = await this.cache.getOrFetch(
'batchdata', 'batchdata',
['shares', cacheKey], ['shares', cacheKey],
() => this.moexMarketData.getShareMarketDataBatch(secids), () => this.moexClient.getShareMarketDataBatch(secids),
'marketDataTtl', 'marketDataTtl',
); );
return new Map(data.map((d) => [d.secid, d])); return new Map(data.map((d) => [d.secid, d]));
@ -358,32 +336,12 @@ export class PortfolioService {
const { data } = await this.cache.getOrFetch( const { data } = await this.cache.getOrFetch(
'batchdata', 'batchdata',
['bonds', cacheKey], ['bonds', cacheKey],
() => this.moexMarketData.getBondPositionDataBatch(secids), () => this.moexClient.getBondPositionDataBatch(secids),
'marketDataTtl', 'marketDataTtl',
); );
return new Map(data.map((d) => [d.secid, d])); 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 buildSharePosition(
pos: { pos: {
id: number; id: number;
@ -394,16 +352,9 @@ export class PortfolioService {
}, },
base: EnrichedPosition, base: EnrichedPosition,
data: MoexShareMarketData | undefined, data: MoexShareMarketData | undefined,
dividends?: MoexDividend[],
): EnrichedPosition { ): EnrichedPosition {
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null;
let dividendIncome = 0; const dividendIncome = 0;
if (pos.buyDate && dividends && dividends.length > 0) {
const buyDateStr = pos.buyDate.toISOString().split('T')[0];
dividendIncome = dividends
.filter((d) => d.registryCloseDate >= buyDateStr)
.reduce((sum, d) => sum + d.value * pos.quantity, 0);
}
if (!data) { if (!data) {
return { return {
@ -521,8 +472,8 @@ export class PortfolioService {
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> { async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
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();
const enrichedPositions = await this.getPositionsWithPrices(portfolioId); const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
@ -543,32 +494,6 @@ export class PortfolioService {
) )
: null; : 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 = { const summary = {
totalInvested, totalInvested,
totalValue, totalValue,
@ -579,12 +504,6 @@ export class PortfolioService {
totalReturnPercent, totalReturnPercent,
positionCount, positionCount,
weightedYield, weightedYield,
targetSharesPercent,
targetBondsPercent,
actualSharesPercent,
actualBondsPercent,
sharesDeviation,
bondsDeviation,
}; };
return { positions: enrichedPositions, summary }; return { positions: enrichedPositions, summary };

View File

@ -1,5 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
export class ScreenerItemDto { export class ScreenerItemDto {
@ApiProperty({ example: 'SBER' }) @ApiProperty({ example: 'SBER' })
@ -71,10 +70,18 @@ export class ScreenerResultDto {
totalPages!: number; totalPages!: number;
} }
class ScreenerResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class ScreenerResponseDto { export class ScreenerResponseDto {
@ApiProperty({ type: ScreenerResultDto }) @ApiProperty({ type: ScreenerResultDto })
data!: ScreenerResultDto; data!: ScreenerResultDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: ScreenerResponseMetaDto })
meta!: ApiResponseMeta; meta!: ScreenerResponseMetaDto;
} }

View File

@ -1,21 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ScreenerType } from './dto/screener-query.dto'; import { ScreenerType } from './dto/screener-query.dto';
describe('ScreenerService', () => { describe('ScreenerService', () => {
let service: ScreenerService; let service: ScreenerService;
let cache: CacheService; let cache: CacheService;
const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
ScreenerService, ScreenerService,
{ provide: MoexMarketDataClient, useValue: moexMarketData }, {
{ provide: CacheService, useValue: { getOrFetch: vi.fn() } }, provide: MoexClientService,
useValue: {
getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(),
},
},
{
provide: CacheService,
useValue: {
getOrFetch: vi.fn(),
},
},
], ],
}).compile(); }).compile();
@ -28,30 +37,6 @@ describe('ScreenerService', () => {
}); });
describe('screen', () => { 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 () => { it('should filter and sort shares', async () => {
const mockShares = [ const mockShares = [
{ {

View File

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

View File

@ -47,7 +47,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

@ -21,17 +21,19 @@ export class SecuritiesController {
@ApiOperation({ summary: 'Поиск по инструментам' }) @ApiOperation({ summary: 'Поиск по инструментам' })
@ApiOkResponse({ type: SearchEnvelopeDto }) @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') @Get('screener')
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' }) @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
@ApiOkResponse({ type: ScreenerResponseDto }) @ApiOkResponse({ type: ScreenerResponseDto })
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) { async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
return this.screenerService.screen(query); const result = await this.screenerService.screen(query);
return { data: result, meta: { cachedAt: null, fromCache: false } };
} }
} }

View File

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

View File

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

@ -19,7 +19,8 @@ export class SharesController {
@ApiOperation({ summary: 'Получить спецификацию акции' }) @ApiOperation({ summary: 'Получить спецификацию акции' })
@ApiOkResponse({ type: ShareEnvelopeDto }) @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')

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

View File

@ -1,24 +1,16 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
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 { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
@Injectable() @Injectable()
export class SharesService { export class SharesService {
constructor( constructor(
private readonly moexSecurities: MoexSecuritiesClient, private readonly moexClient: MoexClientService,
private readonly moexMarketData: MoexMarketDataClient,
private readonly moexDividends: MoexDividendsClient,
private readonly moexHistory: MoexHistoryClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
async getShare(secid: string) { async getShare(secid: string) {
const desc = await this.moexSecurities.getSecurityDescription(secid); const desc = await this.moexClient.getSecurityDescription(secid);
if ( if (
!desc || !desc ||
!( !(
@ -27,17 +19,13 @@ export class SharesService {
desc.type === 'preferred_share' desc.type === 'preferred_share'
) )
) { ) {
throw new EntityNotFoundException('Share', secid); throw new NotFoundException(`Share ${secid} not found`);
} }
const { const { data: marketData } = await this.cache.getOrFetch(
data: marketData,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['shares', secid], ['shares', secid],
() => this.moexMarketData.getShareMarketData(secid), () => this.moexClient.getShareMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
@ -45,36 +33,32 @@ export class SharesService {
const change = marketData?.lastChange ?? 0; const change = marketData?.lastChange ?? 0;
const changePercent = marketData?.lastChangePrcnt ?? 0; const changePercent = marketData?.lastChangePrcnt ?? 0;
return new ApiEnvelopePayload( return {
{ secid: desc.secid,
secid: desc.secid, isin: desc.isin,
isin: desc.isin, name: desc.name,
name: desc.name, shortName: desc.shortName,
shortName: desc.shortName, latName: desc.latName,
latName: desc.latName, listLevel: desc.listLevel,
listLevel: desc.listLevel, issueSize: desc.issueSize,
issueSize: desc.issueSize, faceValue: desc.faceValue,
faceValue: desc.faceValue, faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, type: desc.type,
type: desc.type, marketData: {
marketData: { price: price ?? 0,
price: price ?? 0, change,
change, changePercent,
changePercent, open: marketData?.open ?? 0,
open: marketData?.open ?? 0, high: marketData?.high ?? null,
high: marketData?.high ?? null, low: marketData?.low ?? null,
low: marketData?.low ?? null, volume: marketData?.volume ?? 0,
volume: marketData?.volume ?? 0, value: marketData?.value ?? 0,
value: marketData?.value ?? 0, issueCapitalization: marketData?.issueCapitalization ?? null,
issueCapitalization: marketData?.issueCapitalization ?? null, updatedAt: marketData?.updateTime
updatedAt: marketData?.updateTime ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime : new Date().toISOString(),
: new Date().toISOString(),
},
}, },
fromCache, };
cachedAt,
);
} }
async getMarketData(secid: string) { async getMarketData(secid: string) {
@ -85,16 +69,16 @@ export class SharesService {
} = await this.cache.getOrFetch( } = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['shares', secid], ['shares', secid],
() => this.moexMarketData.getShareMarketData(secid), () => this.moexClient.getShareMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
if (!marketData) { if (!marketData) {
throw new EntityNotFoundException('MarketData', secid); throw new NotFoundException(`Market data for ${secid} not found`);
} }
return new ApiEnvelopePayload( return {
{ data: {
price: marketData.last ?? 0, price: marketData.last ?? 0,
change: marketData.lastChange ?? 0, change: marketData.lastChange ?? 0,
changePercent: marketData.lastChangePrcnt ?? 0, changePercent: marketData.lastChangePrcnt ?? 0,
@ -108,40 +92,38 @@ export class SharesService {
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(), : new Date().toISOString(),
}, },
fromCache, meta: { fromCache, cachedAt },
cachedAt, };
);
} }
async getDividends(secid: string) { async getDividends(secid: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'dividends', 'dividends',
[secid], [secid],
() => this.moexDividends.getDividends(secid), () => this.moexClient.getDividends(secid),
'dividendsTtl', 'dividendsTtl',
); );
return new ApiEnvelopePayload( return {
data.map((d) => ({ data: data.map((d) => ({
registryCloseDate: d.registryCloseDate, registryCloseDate: d.registryCloseDate,
value: d.value, value: d.value,
currency: d.currencyId, currency: d.currencyId,
})), })),
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',
['shares', secid, from, till], ['shares', secid, from, till],
() => this.moexHistory.getHistory(secid, from, till), () => this.moexClient.getHistory(secid, from, till),
'historyTtl', 'historyTtl',
); );
return new ApiEnvelopePayload( return {
data.map((h) => ({ data: data.map((h) => ({
date: h.tradeDate, date: h.tradeDate,
open: h.open ?? 0, open: h.open ?? 0,
high: h.high ?? 0, high: h.high ?? 0,
@ -150,8 +132,7 @@ export class SharesService {
volume: h.volume, volume: h.volume,
value: h.value, value: h.value,
})), })),
fromCache, meta: { fromCache, cachedAt },
cachedAt, };
);
} }
} }

View File

@ -1,33 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrokerAnalyticsDto {
@ApiProperty()
totalDeposits!: number;
@ApiProperty()
totalWithdrawn!: number;
@ApiProperty()
netInvested!: number;
@ApiProperty()
totalDividends!: number;
@ApiProperty()
totalCoupons!: number;
@ApiProperty()
totalReceived!: number;
@ApiProperty()
totalFees!: number;
@ApiProperty()
totalTaxesPaid!: number;
@ApiProperty({ type: Number, nullable: true })
totalReturnPercent!: number | null;
@ApiProperty()
currency!: string;
}

View File

@ -1,74 +1,63 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerEventsDataDto } from './broker-events-response.dto'; import { BrokerEventsDataDto } from './broker-events-response.dto';
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto'; import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto'; import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto'; import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto'; export class BrokerResponseMetaDto {
@ApiProperty({ nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class BrokerAccountsEnvelopeDto { export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] }) @ApiProperty({ type: [BrokerAccountResponseDto] })
data!: BrokerAccountResponseDto[]; data!: BrokerAccountResponseDto[];
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerPortfolioEnvelopeDto { export class BrokerPortfolioEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioResponseDto }) @ApiProperty({ type: BrokerPortfolioResponseDto })
data!: BrokerPortfolioResponseDto; data!: BrokerPortfolioResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerOperationsEnvelopeDto { export class BrokerOperationsEnvelopeDto {
@ApiProperty({ type: BrokerOperationsPageResponseDto }) @ApiProperty({ type: BrokerOperationsPageResponseDto })
data!: BrokerOperationsPageResponseDto; data!: BrokerOperationsPageResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerPositionsEnvelopeDto { export class BrokerPositionsEnvelopeDto {
@ApiProperty({ type: BrokerPositionsPageResponseDto }) @ApiProperty({ type: BrokerPositionsPageResponseDto })
data!: BrokerPositionsPageResponseDto; data!: BrokerPositionsPageResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerOperationSyncEnvelopeDto { export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerOperationSyncResponseDto }) @ApiProperty({ type: BrokerOperationSyncResponseDto })
data!: BrokerOperationSyncResponseDto; data!: BrokerOperationSyncResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
}
export class BrokerAnalyticsEnvelopeDto {
@ApiProperty({ type: BrokerAnalyticsDto })
data!: BrokerAnalyticsDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
} }
export class BrokerEventsEnvelopeDto { export class BrokerEventsEnvelopeDto {
@ApiProperty({ type: BrokerEventsDataDto }) @ApiProperty({ type: BrokerEventsDataDto })
data!: BrokerEventsDataDto; data!: BrokerEventsDataDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
}
export class BrokerPortfolioHistoryEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
data!: BrokerPortfolioHistoryDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
} }

View File

@ -40,9 +40,4 @@ export class BrokerOperationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
state?: string; state?: string;
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
@IsOptional()
@IsString()
categories?: string;
} }

View File

@ -1,24 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPortfolioHistoryPointDto {
@ApiProperty()
month!: string;
@ApiProperty()
label!: string;
@ApiProperty({ type: BrokerMoneyDto })
value!: BrokerMoneyDto;
}
export class BrokerPortfolioHistoryDataDto {
@ApiProperty()
accountId!: string;
@ApiProperty({ type: [BrokerPortfolioHistoryPointDto] })
points!: BrokerPortfolioHistoryPointDto[];
@ApiProperty()
asOf!: string;
}

View File

@ -4,7 +4,7 @@ import { CacheService } from '../../cache/cache.service';
describe('BrokerAccountsService', () => { describe('BrokerAccountsService', () => {
const client = { const client = {
getUsersClient: vi.fn(), getServiceClient: vi.fn(),
callUnary: vi.fn(), callUnary: vi.fn(),
} as unknown as TBankClientService; } as unknown as TBankClientService;
const cache = { const cache = {
@ -23,7 +23,7 @@ describe('BrokerAccountsService', () => {
cachedAt: '2026-06-16T02:30:00.000Z', cachedAt: '2026-06-16T02:30:00.000Z',
}), }),
); );
vi.mocked(client.getUsersClient).mockReturnValue({ getAccounts: vi.fn() } as any); vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({ vi.mocked(client.callUnary).mockResolvedValue({
accounts: [ accounts: [
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' }, { id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
@ -38,7 +38,7 @@ describe('BrokerAccountsService', () => {
expect(result.data).toHaveLength(2); expect(result.data).toHaveLength(2);
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']); expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
expect(result.fromCache).toBe(false); expect(result.meta.fromCache).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith( expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:accounts', 'tbank:accounts',
['open-brokerage-iis'], ['open-brokerage-iis'],

View File

@ -5,7 +5,6 @@ import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types'; import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccountsResponse } from '../types/tbank-proto.types'; import type { TBankAccountsResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
@Injectable() @Injectable()
export class BrokerAccountsService { export class BrokerAccountsService {
@ -14,7 +13,10 @@ export class BrokerAccountsService {
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
async findAll(): Promise<ApiEnvelopePayload<BrokerAccount[]>> { async findAll(): Promise<{
data: BrokerAccount[];
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.accounts, TBANK_CACHE_KEYS.accounts,
['open-brokerage-iis'], ['open-brokerage-iis'],
@ -22,7 +24,10 @@ export class BrokerAccountsService {
'tbankAccountsTtl', 'tbankAccountsTtl',
); );
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
async findById(accountId: string): Promise<BrokerAccount | null> { async findById(accountId: string): Promise<BrokerAccount | null> {
@ -32,9 +37,9 @@ export class BrokerAccountsService {
} }
private async fetchAccounts(): Promise<BrokerAccount[]> { private async fetchAccounts(): Promise<BrokerAccount[]> {
const usersClient = this.tbankClient.getUsersClient(); const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
const response = await this.tbankClient.callUnary< const response = await this.tbankClient.callUnary<
{ status: string }, Record<string, string>,
TBankAccountsResponse TBankAccountsResponse
>( >(
'UsersService/GetAccounts', 'UsersService/GetAccounts',

View File

@ -1,280 +0,0 @@
import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerAnalyticsService } from './broker-analytics.service';
import { TBankClientService } from './tbank-client.service';
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse, TBankOperationItem } from '../types/tbank-proto.types';
describe('BrokerAnalyticsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const tbankClient = {
getOperationsClient: vi.fn(),
callUnary: vi.fn(),
} as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
const acc1 = {
id: 'acc-1',
type: 'brokerage' as const,
name: 'Test Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
};
const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() };
function mockCachePassthrough() {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
}
function mockPortfolio(expectedYield?: { units?: number | string; nano?: number }): TBankPortfolioResponse {
const response: TBankPortfolioResponse = { accountId: 'acc-1' };
if (expectedYield) response.expectedYield = expectedYield;
return response;
}
function makeItem(type: string, value: number, state = 'OPERATION_STATE_EXECUTED'): TBankOperationItem {
return {
type,
payment: { currency: 'RUB', units: Math.floor(Math.abs(value)), nano: Math.round((Math.abs(value) % 1) * 1e9) },
state,
id: `${type}-${value}`,
cursor: '',
brokerAccountId: 'acc-1',
};
}
function mockOpsResponse(items: TBankOperationItem[], hasNext = false, nextCursor = ''): TBankOperationsByCursorResponse {
return { items, hasNext, nextCursor };
}
function setupMocks(ops: TBankOperationItem[], portfolio?: TBankPortfolioResponse) {
vi.mocked(tbankClient.callUnary).mockImplementation(
async (label: string) => {
if (label.includes('GetPortfolio')) return (portfolio ?? mockPortfolio()) as any;
if (label.includes('GetOperationsByCursor')) return mockOpsResponse(ops) as any;
throw new Error(`Unexpected call: ${label}`);
},
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(tbankClient.getOperationsClient).mockReturnValue(mockClient as any);
});
it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException);
});
it('returns zeros for account with no operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data).toEqual({
totalDeposits: 0,
totalWithdrawn: 0,
netInvested: 0,
totalDividends: 0,
totalCoupons: 0,
totalReceived: 0,
totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
});
});
it('aggregates deposit types correctly', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_INPUT', 1000),
makeItem('OPERATION_TYPE_INPUT_SWIFT', 500),
makeItem('OPERATION_TYPE_INP_MULTI', 200),
makeItem('OPERATION_TYPE_OVER_PLACEMENT', 300),
makeItem('OPERATION_TYPE_TRANS_IIS_BS', 100),
makeItem('OPERATION_TYPE_TRANS_BS_BS', 50),
makeItem('OPERATION_TYPE_INPUT_ACQUIRING', 150),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(2300);
expect(result.data.totalWithdrawn).toBe(0);
expect(result.data.netInvested).toBe(2300);
});
it('aggregates withdrawal types with absolute value', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_OUTPUT', -500),
makeItem('OPERATION_TYPE_OUTPUT_SWIFT', -200),
makeItem('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
makeItem('OPERATION_TYPE_OUT_MULTI', -50),
makeItem('OPERATION_TYPE_INPUT', 1000),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalWithdrawn).toBe(850);
expect(result.data.netInvested).toBe(150);
});
it('aggregates dividend and coupon types', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_DIVIDEND', 300),
makeItem('OPERATION_TYPE_DIV_EXT', 150),
makeItem('OPERATION_TYPE_COUPON', 75),
makeItem('OPERATION_TYPE_COUPON', 25),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDividends).toBe(450);
expect(result.data.totalCoupons).toBe(100);
expect(result.data.totalReceived).toBe(550);
});
it('uses expectedYield from portfolio for totalReturnPercent', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks(
[
makeItem('OPERATION_TYPE_INPUT', 10000),
makeItem('OPERATION_TYPE_DIVIDEND', 500),
makeItem('OPERATION_TYPE_COUPON', 200),
],
mockPortfolio({ units: 7, nano: 0 }),
);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalReturnPercent).toBe(7);
});
it('returns null totalReturnPercent when portfolio has no expectedYield', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalReturnPercent).toBeNull();
});
it('aggregates fee and tax categories from operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_SERVICE_FEE', -100),
makeItem('OPERATION_TYPE_BROKER_FEE', -50),
makeItem('OPERATION_TYPE_TAX', -200),
makeItem('OPERATION_TYPE_DIVIDEND_TAX', -30),
makeItem('OPERATION_TYPE_INPUT', 1000),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalFees).toBe(150);
expect(result.data.totalTaxesPaid).toBe(230);
expect(result.data.netInvested).toBe(1000);
});
it('rounds all monetary values to 2 decimal places', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
setupMocks([
makeItem('OPERATION_TYPE_INPUT', 100.336),
makeItem('OPERATION_TYPE_DIVIDEND', 50.789),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(100.34);
expect(result.data.totalDividends).toBe(50.79);
expect(result.data.totalReceived).toBe(50.79);
expect(result.data.netInvested).toBe(100.34);
});
it('wraps result in ApiResponse envelope through cache', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
vi.mocked(cache.getOrFetch).mockResolvedValue({
data: {
totalDeposits: 1000,
totalWithdrawn: 0,
netInvested: 1000,
totalDividends: 0,
totalCoupons: 0,
totalReceived: 0,
totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
},
fromCache: true,
cachedAt: '2026-06-24T10:00:00.000Z',
});
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(1000);
expect(result.fromCache).toBe(true);
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
});
it('paginates through multiple pages of operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
let callCount = 0;
vi.mocked(tbankClient.callUnary).mockImplementation(
async (label: string) => {
if (label.includes('GetPortfolio')) return mockPortfolio({ units: 5, nano: 0 }) as any;
if (label.includes('GetOperationsByCursor')) {
callCount++;
if (callCount === 1) {
return mockOpsResponse([makeItem('OPERATION_TYPE_INPUT', 1000)], true, 'cursor-1') as any;
}
return mockOpsResponse([makeItem('OPERATION_TYPE_DIVIDEND', 500)], false, '') as any;
}
throw new Error(`Unexpected call: ${label}`);
},
);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalDividends).toBe(500);
expect(result.data.totalReceived).toBe(500);
expect(callCount).toBe(2);
});
});

View File

@ -1,168 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService, type TBankPortfolioRequest } from './tbank-client.service';
import type { BrokerOperation } from '../types/broker.types';
import { mapOperationsPage } from '../mappers/operation.mapper';
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse } from '../types/tbank-proto.types';
const DEPOSIT_TYPES = new Set([
'OPERATION_TYPE_INPUT',
'OPERATION_TYPE_INPUT_SWIFT',
'OPERATION_TYPE_INPUT_ACQUIRING',
'OPERATION_TYPE_INP_MULTI',
'OPERATION_TYPE_OVER_PLACEMENT',
'OPERATION_TYPE_TRANS_IIS_BS',
'OPERATION_TYPE_TRANS_BS_BS',
]);
const WITHDRAWAL_TYPES = new Set([
'OPERATION_TYPE_OUTPUT',
'OPERATION_TYPE_OUTPUT_SWIFT',
'OPERATION_TYPE_OUTPUT_ACQUIRING',
'OPERATION_TYPE_OUT_MULTI',
]);
const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT']);
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
const MAX_FETCH_PAGES = 50;
@Injectable()
export class BrokerAnalyticsService {
private readonly logger = new Logger(BrokerAnalyticsService.name);
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.analytics,
[accountId],
() => this.computeAnalytics(accountId),
'tbankAnalyticsTtl',
);
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
const [portfolio, allOperations] = await Promise.all([
this.fetchPortfolio(accountId),
this.fetchAllOperations(accountId),
]);
let totalDeposits = 0;
let totalWithdrawn = 0;
let totalDividends = 0;
let totalCoupons = 0;
let totalFees = 0;
let totalTaxesPaid = 0;
for (const op of allOperations) {
const value = op.payment?.value ?? 0;
if (op.category === 'fee') {
totalFees += Math.abs(value);
} else if (op.category === 'tax') {
totalTaxesPaid += Math.abs(value);
} else if (DEPOSIT_TYPES.has(op.type)) {
totalDeposits += value;
} else if (WITHDRAWAL_TYPES.has(op.type)) {
totalWithdrawn += Math.abs(value);
} else if (DIVIDEND_TYPES.has(op.type)) {
totalDividends += value;
} else if (COUPON_TYPES.has(op.type)) {
totalCoupons += value;
}
}
const netInvested = totalDeposits - totalWithdrawn;
const totalReceived = totalDividends + totalCoupons;
const portfolioYield = portfolio.expectedYield;
const expectedYieldPercent = portfolioYield
? Math.round((Number(portfolioYield.units ?? 0) + (portfolioYield.nano ?? 0) / 1e9) * 100) / 100
: null;
return {
totalDeposits: Math.round(totalDeposits * 100) / 100,
totalWithdrawn: Math.round(totalWithdrawn * 100) / 100,
netInvested: Math.round(netInvested * 100) / 100,
totalDividends: Math.round(totalDividends * 100) / 100,
totalCoupons: Math.round(totalCoupons * 100) / 100,
totalReceived: Math.round(totalReceived * 100) / 100,
totalFees: Math.round(totalFees * 100) / 100,
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
totalReturnPercent: expectedYieldPercent,
currency: 'RUB',
};
}
private async fetchPortfolio(accountId: string): Promise<TBankPortfolioResponse> {
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
TBankPortfolioRequest,
TBankPortfolioResponse
>(
'OperationsService/GetPortfolio',
operationsClient.getPortfolio.bind(operationsClient),
{ accountId, currency: 'RUB' },
);
return response;
}
private async fetchAllOperations(accountId: string): Promise<BrokerOperation[]> {
const allOps: BrokerOperation[] = [];
let cursor: string | undefined;
let pageCount = 0;
do {
if (pageCount >= MAX_FETCH_PAGES) {
this.logger.warn(`Reached max fetch pages (${MAX_FETCH_PAGES}) for account ${accountId}`);
break;
}
const request: Record<string, unknown> = {
accountId,
state: 'OPERATION_STATE_EXECUTED',
limit: 1000,
withoutCommissions: false,
withoutTrades: false,
withoutOvernights: false,
};
if (cursor) request.cursor = cursor;
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
Record<string, unknown>,
TBankOperationsByCursorResponse
>(
'OperationsService/GetOperationsByCursor',
operationsClient.getOperationsByCursor.bind(operationsClient),
request,
);
const page = mapOperationsPage(accountId, response);
allOps.push(...page.items);
pageCount++;
cursor = page.hasNext ? (page.nextCursor ?? undefined) : undefined;
} while (cursor);
return allOps;
}
}

View File

@ -1,7 +1,6 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; 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 { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerEventsService } from './broker-events.service'; import { BrokerEventsService } from './broker-events.service';
import { BrokerOperationsService } from './broker-operations.service'; import { BrokerOperationsService } from './broker-operations.service';
@ -10,8 +9,10 @@ import { BrokerPortfolioService } from './broker-portfolio.service';
describe('BrokerEventsService', () => { describe('BrokerEventsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService; const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService;
const moexMarketData = { getBondPositionDataBatch: vi.fn() } as unknown as MoexMarketDataClient; const moex = {
const moexDividends = { getDividends: vi.fn() } as unknown as MoexDividendsClient; getDividends: vi.fn(),
getBondPositionDataBatch: vi.fn(),
} as unknown as MoexClientService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
@ -41,10 +42,10 @@ describe('BrokerEventsService', () => {
it('throws 404 for missing account', async () => { it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
await expect( await expect(
service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }), service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }),
).rejects.toThrow(EntityNotFoundException); ).rejects.toThrow(NotFoundException);
}); });
it('returns empty events for account with no positions', async () => { it('returns empty events for account with no positions', async () => {
@ -57,11 +58,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' }); const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' });
expect(result.data.items).toEqual([]); expect(result.data.items).toEqual([]);
@ -86,7 +86,7 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]), instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ {
secid: 'SBER', secid: 'SBER',
isin: 'RU000A0JS', isin: 'RU000A0JS',
@ -112,11 +112,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1); expect(result.data.items).toHaveLength(1);
@ -143,7 +142,7 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]), instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]),
}); });
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'SU26248RMFS4', secid: 'SU26248RMFS4',
couponValue: 35.4, couponValue: 35.4,
@ -167,11 +166,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(3); expect(result.data.items).toHaveLength(3);
@ -206,18 +204,17 @@ describe('BrokerEventsService', () => {
['uid-2', { name: 'Working' }], ['uid-2', { name: 'Working' }],
]), ]),
}); });
vi.mocked(moexDividends.getDividends).mockRejectedValueOnce(new Error('MOEX error')); vi.mocked(moex.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
vi.mocked(moexDividends.getDividends).mockResolvedValueOnce([ vi.mocked(moex.getDividends).mockResolvedValueOnce([
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' }, { secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
]); ]);
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1); expect(result.data.items).toHaveLength(1);
@ -238,17 +235,16 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map([['uid-1', { name: 'No Amount' }]]), instruments: new Map([['uid-1', { name: 'No Amount' }]]),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' }, { secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
]); ]);
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1); expect(result.data.items).toHaveLength(1);
@ -278,10 +274,10 @@ describe('BrokerEventsService', () => {
['uid-2', { name: 'OFZ' }], ['uid-2', { name: 'OFZ' }],
]), ]),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]); ]);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'BOND1', secid: 'BOND1',
couponValue: 50, couponValue: 50,
@ -305,11 +301,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.summary.eventCount).toBe(3); expect(result.data.summary.eventCount).toBe(3);
@ -336,7 +331,7 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map([['uid-1', { name: 'Sber' }]]), instruments: new Map([['uid-1', { name: 'Sber' }]]),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' },
@ -344,11 +339,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' });
expect(result.data.items).toHaveLength(2); expect(result.data.items).toHaveLength(2);
@ -376,10 +370,10 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map(), instruments: new Map(),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]); ]);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'BOND1', secid: 'BOND1',
couponValue: 50, couponValue: 50,
@ -402,11 +396,10 @@ describe('BrokerEventsService', () => {
]); ]);
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { const result = await service.getEvents('acc-1', {
from: '2026-06-20', from: '2026-06-20',
to: '2026-07-10', to: '2026-07-10',
@ -462,11 +455,10 @@ describe('BrokerEventsService', () => {
hasNext: false, hasNext: false,
asOf: '2026-06-19T00:00:00.000Z', asOf: '2026-06-19T00:00:00.000Z',
}, },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { const result = await service.getEvents('acc-1', {
from: '2026-06-15', from: '2026-06-15',
to: '2026-06-20', to: '2026-06-20',

View File

@ -1,9 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client'; import { MoexClientService } from '../../moex-client/moex-client.service';
import { MoexDividendsClient } from '../../moex-client/moex-dividends.client';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { TBANK_CACHE_KEYS } from '../tbank.config'; import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapQuotationToNumber } from '../mappers/money.mapper'; import { mapQuotationToNumber } from '../mappers/money.mapper';
import type { import type {
@ -47,8 +44,7 @@ export class BrokerEventsService {
constructor( constructor(
private readonly accountsService: BrokerAccountsService, private readonly accountsService: BrokerAccountsService,
private readonly portfolioService: BrokerPortfolioService, private readonly portfolioService: BrokerPortfolioService,
private readonly moexMarketData: MoexMarketDataClient, private readonly moexClient: MoexClientService,
private readonly moexDividends: MoexDividendsClient,
private readonly operationsService: BrokerOperationsService, private readonly operationsService: BrokerOperationsService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
@ -56,9 +52,12 @@ export class BrokerEventsService {
async getEvents( async getEvents(
accountId: string, accountId: string,
query: BrokerEventsQuery, query: BrokerEventsQuery,
): Promise<ApiEnvelopePayload<BrokerEventsData>> { ): Promise<{
data: BrokerEventsData;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); if (!account) throw new NotFoundException('Broker account not found');
const eventTypes = this.parseEventTypes(query.types); const eventTypes = this.parseEventTypes(query.types);
const eventTypeKey = Array.from(eventTypes).join(','); const eventTypeKey = Array.from(eventTypes).join(',');
@ -69,7 +68,10 @@ export class BrokerEventsService {
'tbankPortfolioTtl', 'tbankPortfolioTtl',
); );
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
private async buildEvents( private async buildEvents(
@ -141,7 +143,7 @@ export class BrokerEventsService {
let dividends: { registryCloseDate: string; value: number; currencyId: string }[]; let dividends: { registryCloseDate: string; value: number; currencyId: string }[];
try { try {
dividends = await this.moexDividends.getDividends(ticker); dividends = await this.moexClient.getDividends(ticker);
} catch { } catch {
return []; return [];
} }
@ -201,7 +203,7 @@ export class BrokerEventsService {
faceValue: number; faceValue: number;
}[]; }[];
try { try {
bondData = await this.moexMarketData.getBondPositionDataBatch(secids); bondData = await this.moexClient.getBondPositionDataBatch(secids);
} catch { } catch {
return []; return [];
} }

View File

@ -23,7 +23,7 @@ export class BrokerInstrumentsService {
} }
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> { private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
const instrumentsClient = this.tbankClient.getInstrumentsClient(); const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
const response = await this.tbankClient.callUnary< const response = await this.tbankClient.callUnary<
{ idType: string; id: string }, { idType: string; id: string },
TBankInstrumentResponse TBankInstrumentResponse

View File

@ -48,13 +48,11 @@ describe('BrokerOperationSyncService', () => {
}, },
], ],
}, },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] }, data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerOperationSyncService(operations, prisma); const service = new BrokerOperationSyncService(operations, prisma);
@ -117,8 +115,7 @@ describe('BrokerOperationSyncService', () => {
}, },
], ],
}, },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerOperationSyncService(operations, prisma); const service = new BrokerOperationSyncService(operations, prisma);
@ -145,8 +142,7 @@ describe('BrokerOperationSyncService', () => {
asOf: '2026-06-16T00:00:00.000Z', asOf: '2026-06-16T00:00:00.000Z',
items: [], items: [],
}, },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerOperationSyncService(operations, prisma); const service = new BrokerOperationSyncService(operations, prisma);

View File

@ -1,12 +1,12 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerOperationsService } from './broker-operations.service'; import { BrokerOperationsService } from './broker-operations.service';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
describe('BrokerOperationsService', () => { describe('BrokerOperationsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService; const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => { beforeEach(() => {
@ -17,7 +17,7 @@ describe('BrokerOperationsService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerOperationsService(accounts, client, cache); const service = new BrokerOperationsService(accounts, client, cache);
await expect(service.getOperations('missing', {})).rejects.toThrow(EntityNotFoundException); await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException);
}); });
it('builds cursor request and maps operation page', async () => { it('builds cursor request and maps operation page', async () => {
@ -36,7 +36,7 @@ describe('BrokerOperationsService', () => {
cachedAt: null, cachedAt: null,
}), }),
); );
vi.mocked(client.getOperationsClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any); vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({ vi.mocked(client.callUnary).mockResolvedValue({
hasNext: false, hasNext: false,
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }], items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],

View File

@ -1,11 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'; import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
import { mapOperationsPage } from '../mappers/operation.mapper'; import { mapOperationsPage } from '../mappers/operation.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config'; import { TBANK_CACHE_KEYS } from '../tbank.config';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import type { BrokerOperationsPage } from '../types/broker.types';
import type { BrokerOperation, BrokerOperationsPage } from '../types/broker.types';
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types'; import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
@ -21,9 +19,12 @@ export class BrokerOperationsService {
async getOperations( async getOperations(
accountId: string, accountId: string,
query: BrokerOperationQueryDto, query: BrokerOperationQueryDto,
): Promise<ApiEnvelopePayload<BrokerOperationsPage>> { ): Promise<{
data: BrokerOperationsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); if (!account) throw new NotFoundException('Broker account not found');
const request = this.buildRequest(accountId, query); const request = this.buildRequest(accountId, query);
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
@ -33,20 +34,10 @@ export class BrokerOperationsService {
'tbankOperationsTtl', 'tbankOperationsTtl',
); );
if (query.categories) { return {
const allowedCategories = query.categories data: result.data,
.split(',') meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
.map((c) => c.trim() as BrokerOperation['category']) };
.filter((c) => ['trade', 'income', 'tax', 'fee', 'transfer', 'other'].includes(c));
if (allowedCategories.length > 0) {
result.data.items = result.data.items.filter((item) =>
allowedCategories.includes(item.category),
);
}
}
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
} }
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> { private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
@ -82,7 +73,7 @@ export class BrokerOperationsService {
accountId: string, accountId: string,
request: Record<string, unknown>, request: Record<string, unknown>,
): Promise<BrokerOperationsPage> { ): Promise<BrokerOperationsPage> {
const operationsClient = this.tbankClient.getOperationsClient(); const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const response = await this.tbankClient.callUnary< const response = await this.tbankClient.callUnary<
Record<string, unknown>, Record<string, unknown>,
TBankOperationsByCursorResponse TBankOperationsByCursorResponse

View File

@ -1,72 +0,0 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
import type { BrokerPortfolioHistoryDataDto, BrokerPortfolioHistoryPointDto } from '../dto/broker-portfolio-history-response.dto';
import type { BrokerMoney } from '../types/broker.types';
const RUSSIAN_MONTHS = [
'Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн',
'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек',
];
@Injectable()
export class BrokerPortfolioHistoryService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly portfolioService: BrokerPortfolioService,
private readonly cacheService: CacheService,
) {}
async getHistory(
accountId: string,
months: number,
): Promise<ApiEnvelopePayload<BrokerPortfolioHistoryDataDto>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.portfolio,
[accountId, 'history', String(months)],
() => this.computeHistory(accountId, months),
'tbankPortfolioTtl',
);
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async computeHistory(
accountId: string,
months: number,
): Promise<BrokerPortfolioHistoryDataDto> {
const portfolioEnvelope = await this.portfolioService.getPortfolio(accountId);
const currentValue = portfolioEnvelope.data.totals.portfolio;
const defaultMoney: BrokerMoney = currentValue ?? {
currency: 'RUB',
units: '0',
nano: 0,
value: 0,
};
const now = new Date();
const points: BrokerPortfolioHistoryPointDto[] = [];
for (let i = months - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const label = RUSSIAN_MONTHS[d.getMonth()];
points.push({ month, label, value: { ...defaultMoney } });
}
return {
accountId,
points,
asOf: new Date().toISOString(),
};
}
}

View File

@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service'; import { BrokerInstrumentsService } from './broker-instruments.service';
import { BrokerPortfolioService } from './broker-portfolio.service'; import { BrokerPortfolioService } from './broker-portfolio.service';
@ -8,7 +8,7 @@ import { TBankClientService } from './tbank-client.service';
describe('BrokerPortfolioService', () => { describe('BrokerPortfolioService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService; const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService; const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => { beforeEach(() => {
@ -19,7 +19,7 @@ describe('BrokerPortfolioService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache); const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPortfolio('missing')).rejects.toThrow(EntityNotFoundException); await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
}); });
it('fetches portfolio through cache without positions', async () => { it('fetches portfolio through cache without positions', async () => {
@ -38,7 +38,7 @@ describe('BrokerPortfolioService', () => {
cachedAt: null, cachedAt: null,
}), }),
); );
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
getPositions: vi.fn(), getPositions: vi.fn(),
} as any); } as any);
@ -98,13 +98,13 @@ describe('BrokerPortfolioService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache); const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPositions('missing')).rejects.toThrow(EntityNotFoundException); await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
}); });
it('returns first page of positions', async () => { it('returns first page of positions', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -139,7 +139,7 @@ describe('BrokerPortfolioService', () => {
it('paginates using cursor', async () => { it('paginates using cursor', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -179,7 +179,7 @@ describe('BrokerPortfolioService', () => {
it('returns last page with hasNext=false', async () => { it('returns last page with hasNext=false', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -206,7 +206,7 @@ describe('BrokerPortfolioService', () => {
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => { it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -229,7 +229,7 @@ describe('BrokerPortfolioService', () => {
it('filters by instrument type and caches with type in key', async () => { it('filters by instrument type and caches with type in key', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -279,7 +279,7 @@ describe('BrokerPortfolioService', () => {
it('returns empty items when type filter matches nothing', async () => { it('returns empty items when type filter matches nothing', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({

View File

@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper'; import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config'; import { TBANK_CACHE_KEYS } from '../tbank.config';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types'; import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types';
import type { import type {
TBankInstrument, TBankInstrument,
@ -13,7 +12,6 @@ import type {
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service'; import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
@Injectable() @Injectable()
export class BrokerPortfolioService { export class BrokerPortfolioService {
@ -24,9 +22,12 @@ export class BrokerPortfolioService {
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> { async getPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); if (!account) throw new NotFoundException('Broker account not found');
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.portfolio, TBANK_CACHE_KEYS.portfolio,
@ -42,7 +43,10 @@ export class BrokerPortfolioService {
'tbankPortfolioTtl', 'tbankPortfolioTtl',
); );
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
async getPositions( async getPositions(
@ -50,9 +54,12 @@ export class BrokerPortfolioService {
cursor?: string, cursor?: string,
limit = 10, limit = 10,
type?: string, type?: string,
): Promise<ApiEnvelopePayload<BrokerPositionsPage>> { ): Promise<{
data: BrokerPositionsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); if (!account) throw new NotFoundException('Broker account not found');
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.positions, TBANK_CACHE_KEYS.positions,
@ -80,7 +87,10 @@ export class BrokerPortfolioService {
'tbankPositionsTtl', 'tbankPositionsTtl',
); );
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
private async buildInstrumentMap( private async buildInstrumentMap(
@ -120,7 +130,7 @@ export class BrokerPortfolioService {
'tbank:raw-portfolio', 'tbank:raw-portfolio',
[accountId], [accountId],
async () => { async () => {
const operationsClient = this.tbankClient.getOperationsClient(); const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
return this.tbankClient.callUnary< return this.tbankClient.callUnary<
{ accountId: string; currency: string }, { accountId: string; currency: string },
TBankPortfolioResponse TBankPortfolioResponse
@ -139,7 +149,7 @@ export class BrokerPortfolioService {
} }
private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> { private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> {
const operationsClient = this.tbankClient.getOperationsClient(); const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>( return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
'OperationsService/GetPositions', 'OperationsService/GetPositions',
operationsClient.getPositions.bind(operationsClient), operationsClient.getPositions.bind(operationsClient),

View File

@ -1,5 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TBankNotConfiguredException } from '../../../common/exceptions/tbank-api.exception';
import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js'; import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
import { mkdtempSync, writeFileSync } from 'node:fs'; import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
@ -44,16 +44,6 @@ describe('TBankClientService', () => {
expect(() => service.getServiceClient('UsersService')).not.toThrow(); expect(() => service.getServiceClient('UsersService')).not.toThrow();
}); });
it('exposes typed service-client facades for broker services', () => {
const service = new TBankClientService(config);
expect(service.getUsersClient()).toHaveProperty('getAccounts');
expect(service.getOperationsClient()).toHaveProperty('getPortfolio');
expect(service.getOperationsClient()).toHaveProperty('getPositions');
expect(service.getOperationsClient()).toHaveProperty('getOperationsByCursor');
expect(service.getInstrumentsClient()).toHaveProperty('getInstrumentBy');
});
it('creates grpc SSL credentials with configured custom CA certificate', () => { it('creates grpc SSL credentials with configured custom CA certificate', () => {
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem'); const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n'); writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
@ -95,7 +85,7 @@ describe('TBankClientService', () => {
}, },
{}, {},
), ),
).rejects.toThrow(TBankNotConfiguredException); ).rejects.toThrow(ServiceUnavailableException);
}); });
it('wraps grpc errors with status code and tracking id', async () => { it('wraps grpc errors with status code and tracking id', async () => {
@ -117,7 +107,9 @@ describe('TBankClientService', () => {
{}, {},
), ),
).rejects.toMatchObject({ ).rejects.toMatchObject({
response: expect.stringContaining('T-Bank upstream error'), response: expect.objectContaining({
message: expect.stringContaining('T-Bank upstream error'),
}),
}); });
}); });

View File

@ -1,13 +1,10 @@
import { Injectable, Logger } from '@nestjs/common'; import {
BadGatewayException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception';
import type {
TBankAccountsResponse,
TBankInstrumentResponse,
TBankOperationsByCursorResponse,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { import {
CallOptions, CallOptions,
ChannelCredentials, ChannelCredentials,
@ -33,25 +30,6 @@ type GrpcUnary<TRequest, TResponse> = (
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client; type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
type TBankAccountsRequest = { status: string };
export type TBankPortfolioRequest = { accountId: string; currency: string };
type TBankPositionsRequest = { accountId: string };
type TBankInstrumentRequest = { idType: string; id: string };
export type TBankUsersClient = Client & {
getAccounts: GrpcUnary<TBankAccountsRequest, TBankAccountsResponse>;
};
export type TBankOperationsClient = Client & {
getPortfolio: GrpcUnary<TBankPortfolioRequest, TBankPortfolioResponse>;
getPositions: GrpcUnary<TBankPositionsRequest, TBankPositionsResponse>;
getOperationsByCursor: GrpcUnary<Record<string, unknown>, TBankOperationsByCursorResponse>;
};
export type TBankInstrumentsClient = Client & {
getInstrumentBy: GrpcUnary<TBankInstrumentRequest, TBankInstrumentResponse>;
};
type QueueName = 'operations' | 'instruments' | 'users'; type QueueName = 'operations' | 'instruments' | 'users';
@Injectable() @Injectable()
@ -101,7 +79,7 @@ export class TBankClientService {
createMetadata(): Metadata { createMetadata(): Metadata {
const token = this.configService.get<string>('app.tbank.token', ''); const token = this.configService.get<string>('app.tbank.token', '');
if (!token) { if (!token) {
throw new TBankNotConfiguredException(); throw new ServiceUnavailableException('T-Bank integration is not configured');
} }
const metadata = new Metadata(); const metadata = new Metadata();
@ -146,18 +124,6 @@ export class TBankClientService {
return client; return client;
} }
getUsersClient(): TBankUsersClient {
return this.getServiceClient('UsersService') as TBankUsersClient;
}
getOperationsClient(): TBankOperationsClient {
return this.getServiceClient('OperationsService') as TBankOperationsClient;
}
getInstrumentsClient(): TBankInstrumentsClient {
return this.getServiceClient('InstrumentsService') as TBankInstrumentsClient;
}
async callUnary<TRequest, TResponse>( async callUnary<TRequest, TResponse>(
label: string, label: string,
method: GrpcUnary<TRequest, TResponse>, method: GrpcUnary<TRequest, TResponse>,
@ -203,7 +169,7 @@ export class TBankClientService {
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}`, }`,
); );
throw new TBankApiException('T-Bank CA certificate is not readable'); throw new ServiceUnavailableException('T-Bank CA certificate is not readable');
} }
} }
@ -225,9 +191,10 @@ export class TBankClientService {
}), }),
); );
const detail = [publicMessage, trackingId && `trackingId:${trackingId}`, retryAfter && `retryAfter:${retryAfter}`] return new BadGatewayException({
.filter(Boolean) message: publicMessage,
.join('; '); trackingId: trackingId ? String(trackingId) : null,
return new TBankApiException(detail); retryAfter: retryAfter ? String(retryAfter) : null,
});
} }
} }

View File

@ -20,5 +20,4 @@ export const TBANK_CACHE_KEYS = {
operations: 'tbank:operations', operations: 'tbank:operations',
instrument: 'tbank:instrument', instrument: 'tbank:instrument',
events: 'tbank:events', events: 'tbank:events',
analytics: 'tbank:analytics',
} as const; } as const;

View File

@ -1,22 +1,18 @@
import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; import { ApiResponse } from '../../common/dto/api-response.dto';
import { TBankController } from './tbank.controller'; import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerEventsService } from './services/broker-events.service'; import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service';
describe('TBankController', () => { describe('TBankController', () => {
const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService; const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService;
const analytics = { getAnalytics: vi.fn() } as unknown as BrokerAnalyticsService;
const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService; const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService;
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService; const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService; const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
const portfolioHistory = { getHistory: vi.fn() } as unknown as BrokerPortfolioHistoryService;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -27,36 +23,32 @@ describe('TBankController', () => {
}); });
it('returns accounts in a single API envelope', async () => { it('returns accounts in a single API envelope', async () => {
vi.mocked(accounts.findAll).mockResolvedValueOnce( vi.mocked(accounts.findAll).mockResolvedValueOnce({
new ApiEnvelopePayload( data: [
[ {
{ id: 'acc-1',
id: 'acc-1', type: 'brokerage',
type: 'brokerage', name: 'Broker',
name: 'Broker', status: 'ACCOUNT_STATUS_OPEN',
status: 'ACCOUNT_STATUS_OPEN', openedAt: null,
openedAt: null, accessLevel: null,
accessLevel: null, },
}, ],
], meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
true, });
'2026-06-17T00:00:00.000Z',
),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const controller = new TBankController(accounts, portfolio, events, operations, sync);
const response = await controller.getAccounts(); const response = await controller.getAccounts();
expect(response).toBeInstanceOf(ApiEnvelopePayload); expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toHaveLength(1); expect(response.data).toHaveLength(1);
expect(response.fromCache).toBe(true); expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
expect(response.cachedAt).toBe('2026-06-17T00:00:00.000Z');
}); });
it('exposes a sync trigger for durable operation history', async () => { it('exposes a sync trigger for durable operation history', async () => {
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 }); vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const controller = new TBankController(accounts, portfolio, events, operations, sync);
const response = await controller.syncOperations('acc-1', { const response = await controller.syncOperations('acc-1', {
from: '2026-06-01T00:00:00.000Z', from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z', to: '2026-06-17T00:00:00.000Z',
@ -66,95 +58,7 @@ describe('TBankController', () => {
from: '2026-06-01T00:00:00.000Z', from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z', to: '2026-06-17T00:00:00.000Z',
}); });
expect(response).toEqual({ upserted: 2 }); expect(response.data).toEqual({ upserted: 2 });
});
it('exposes analytics endpoint through controller', async () => {
const analyticsData = {
totalDeposits: 1000,
totalWithdrawn: 200,
netInvested: 800,
totalDividends: 150,
totalCoupons: 50,
totalReceived: 200,
totalReturnPercent: 25,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
};
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
new ApiEnvelopePayload(analyticsData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getAnalytics('acc-1');
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
expect(response).toBeInstanceOf(ApiEnvelopePayload);
expect(response.data).toEqual(analyticsData);
expect(response.fromCache).toBe(false);
expect(response.cachedAt).toBeNull();
});
it('returns portfolio history with correct shape and points', async () => {
const historyData = {
accountId: 'acc-1',
points: [
{ month: '2026-01', label: 'Янв', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
{ month: '2026-02', label: 'Фев', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
],
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
new ApiEnvelopePayload(historyData, false, '2026-06-22T00:00:00.000Z'),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getPortfolioHistory('acc-1', 6);
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
expect(response).toBeInstanceOf(ApiEnvelopePayload);
expect(response.data).toEqual(historyData);
expect(response.data.points).toHaveLength(2);
expect(response.data.points[0].month).toBe('2026-01');
expect(response.data.points[0].label).toBe('Янв');
});
it('passes categories filter query to operations service', async () => {
const pageData = {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(operations.getOperations).mockResolvedValueOnce(
new ApiEnvelopePayload(pageData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const query = { from: '2026-06-01', to: '2026-06-30', categories: 'fee,tax' };
const response = await controller.getOperations('acc-1', query);
expect(operations.getOperations).toHaveBeenCalledWith('acc-1', query);
expect(response.data.accountId).toBe('acc-1');
});
it('uses default months parameter when not provided', async () => {
const historyData = {
accountId: 'acc-1',
points: [],
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
new ApiEnvelopePayload(historyData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getPortfolioHistory('acc-1');
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
expect(response.data.points).toHaveLength(0);
}); });
it('forwards events query and wraps response', async () => { it('forwards events query and wraps response', async () => {
@ -175,16 +79,17 @@ describe('TBankController', () => {
}, },
asOf: '2026-06-22T00:00:00.000Z', asOf: '2026-06-22T00:00:00.000Z',
}; };
vi.mocked(events.getEvents).mockResolvedValueOnce( vi.mocked(events.getEvents).mockResolvedValueOnce({
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'), data: eventsData,
); meta: { fromCache: false, cachedAt: '2026-06-22T00:00:00.000Z' },
});
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const controller = new TBankController(accounts, portfolio, events, operations, sync);
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' }; const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
const response = await controller.getEvents('acc-1', query); const response = await controller.getEvents('acc-1', query);
expect(events.getEvents).toHaveBeenCalledWith('acc-1', query); expect(events.getEvents).toHaveBeenCalledWith('acc-1', query);
expect(response).toBeInstanceOf(ApiEnvelopePayload); expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toEqual(eventsData); expect(response.data).toEqual(eventsData);
}); });
}); });

View File

@ -1,14 +1,13 @@
import { Controller, Get, Param, Post, Query } from '@nestjs/common'; import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { import {
BrokerAccountsEnvelopeDto, BrokerAccountsEnvelopeDto,
BrokerAnalyticsEnvelopeDto,
BrokerEventsEnvelopeDto, BrokerEventsEnvelopeDto,
BrokerOperationSyncEnvelopeDto, BrokerOperationSyncEnvelopeDto,
BrokerOperationsEnvelopeDto, BrokerOperationsEnvelopeDto,
BrokerPortfolioEnvelopeDto, BrokerPortfolioEnvelopeDto,
BrokerPortfolioHistoryEnvelopeDto,
BrokerPositionsEnvelopeDto, BrokerPositionsEnvelopeDto,
} from './dto/broker-envelope.dto'; } from './dto/broker-envelope.dto';
import { BrokerEventsQueryDto } from './dto/broker-events-query.dto'; import { BrokerEventsQueryDto } from './dto/broker-events-query.dto';
@ -16,11 +15,9 @@ import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto'; import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto'; import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerEventsService } from './services/broker-events.service'; import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ApiTags('Broker') @ApiTags('Broker')
@ -34,22 +31,22 @@ export class TBankController {
private readonly brokerEventsService: BrokerEventsService, private readonly brokerEventsService: BrokerEventsService,
private readonly brokerOperationsService: BrokerOperationsService, private readonly brokerOperationsService: BrokerOperationsService,
private readonly brokerOperationSyncService: BrokerOperationSyncService, private readonly brokerOperationSyncService: BrokerOperationSyncService,
private readonly brokerAnalyticsService: BrokerAnalyticsService,
private readonly brokerPortfolioHistoryService: BrokerPortfolioHistoryService,
) {} ) {}
@Get('accounts') @Get('accounts')
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' }) @ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto }) @ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
async getAccounts() { async getAccounts() {
return this.brokerAccountsService.findAll(); const result = await this.brokerAccountsService.findAll();
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/portfolio') @Get('accounts/:accountId/portfolio')
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' }) @ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto }) @ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
async getPortfolio(@Param('accountId') accountId: string) { async getPortfolio(@Param('accountId') accountId: string) {
return this.brokerPortfolioService.getPortfolio(accountId); const result = await this.brokerPortfolioService.getPortfolio(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/positions') @Get('accounts/:accountId/positions')
@ -59,12 +56,13 @@ export class TBankController {
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: BrokerPositionQueryDto, @Query() query: BrokerPositionQueryDto,
) { ) {
return this.brokerPortfolioService.getPositions( const result = await this.brokerPortfolioService.getPositions(
accountId, accountId,
query.cursor, query.cursor,
query.limit, query.limit,
query.type, query.type,
); );
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/operations') @Get('accounts/:accountId/operations')
@ -74,31 +72,16 @@ export class TBankController {
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: BrokerOperationQueryDto, @Query() query: BrokerOperationQueryDto,
) { ) {
return this.brokerOperationsService.getOperations(accountId, query); const result = await this.brokerOperationsService.getOperations(accountId, query);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/events') @Get('accounts/:accountId/events')
@ApiOperation({ summary: 'Get broker account calendar events and cashflow' }) @ApiOperation({ summary: 'Get broker account calendar events and cashflow' })
@ApiOkResponse({ type: BrokerEventsEnvelopeDto }) @ApiOkResponse({ type: BrokerEventsEnvelopeDto })
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) { async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
return this.brokerEventsService.getEvents(accountId, query); const result = await this.brokerEventsService.getEvents(accountId, query);
} return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
@Get('accounts/:accountId/portfolio/history')
@ApiOperation({ summary: 'Get portfolio value history for last N months' })
@ApiOkResponse({ type: BrokerPortfolioHistoryEnvelopeDto })
async getPortfolioHistory(
@Param('accountId') accountId: string,
@Query('months') months?: number,
) {
return this.brokerPortfolioHistoryService.getHistory(accountId, months ?? 6);
}
@Get('accounts/:accountId/analytics')
@ApiOperation({ summary: 'Get broker account profitability analytics' })
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
async getAnalytics(@Param('accountId') accountId: string) {
return this.brokerAnalyticsService.getAnalytics(accountId);
} }
@Post('accounts/:accountId/operations/sync') @Post('accounts/:accountId/operations/sync')
@ -108,6 +91,7 @@ export class TBankController {
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: BrokerOperationSyncQueryDto, @Query() query: BrokerOperationSyncQueryDto,
) { ) {
return this.brokerOperationSyncService.syncAccount(accountId, query); const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
return new ApiResponse(result);
} }
} }

View File

@ -2,12 +2,10 @@ import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module'; import { MoexClientModule } from '../moex-client/moex-client.module';
import { TBankController } from './tbank.controller'; import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerInstrumentsService } from './services/broker-instruments.service'; import { BrokerInstrumentsService } from './services/broker-instruments.service';
import { BrokerEventsService } from './services/broker-events.service'; import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { TBankClientService } from './services/tbank-client.service'; import { TBankClientService } from './services/tbank-client.service';
@ -22,13 +20,10 @@ import { TBankClientService } from './services/tbank-client.service';
BrokerEventsService, BrokerEventsService,
BrokerOperationsService, BrokerOperationsService,
BrokerOperationSyncService, BrokerOperationSyncService,
BrokerAnalyticsService,
BrokerPortfolioHistoryService,
], ],
exports: [ exports: [
TBankClientService, TBankClientService,
BrokerAccountsService, BrokerAccountsService,
BrokerAnalyticsService,
BrokerInstrumentsService, BrokerInstrumentsService,
BrokerPortfolioService, BrokerPortfolioService,
BrokerEventsService, BrokerEventsService,

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