Compare commits
No commits in common. "main" and "codex/little-fixes" have entirely different histories.
main
...
codex/litt
@ -23,12 +23,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build design system
|
||||
run: npm run build:design-system
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Test backend
|
||||
run: npm run test:backend
|
||||
|
||||
@ -47,6 +47,9 @@ jobs:
|
||||
- name: Test design system
|
||||
run: npm run test:design-system
|
||||
|
||||
- name: Build design system
|
||||
run: npm run build:design-system
|
||||
|
||||
- name: Build Storybook
|
||||
run: npm run build:storybook
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -11,7 +11,3 @@ vite.config.js
|
||||
apps/docs/.docusaurus/
|
||||
apps/docs/build/
|
||||
.idea
|
||||
.playwright-mcp
|
||||
.opencode
|
||||
dev.db
|
||||
graphify-out/
|
||||
|
||||
@ -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
|
||||
@ -1,150 +0,0 @@
|
||||
#!/bin/sh
|
||||
# graphify-hook-start
|
||||
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
|
||||
# Installed by: graphify hook install
|
||||
|
||||
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
|
||||
# order is randomized per-process by PYTHONHASHSEED, so community assignments
|
||||
# churn run-to-run. Pinning it makes graphify-out reproducible.
|
||||
export PYTHONHASHSEED=0
|
||||
|
||||
# Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes
|
||||
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
|
||||
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
|
||||
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
|
||||
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
|
||||
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
|
||||
|
||||
[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
|
||||
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
|
||||
if [ -z "$CHANGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip when only graphify-out/ artifacts changed (avoids rebuild loop when graph outputs are tracked in git)
|
||||
_NON_GRAPH=$(echo "$CHANGED" | grep -v '^graphify-out/' || true)
|
||||
if [ -z "$_NON_GRAPH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
|
||||
# _PINNED was recorded at hook-install time; tried first so the hook works even
|
||||
# when the graphify launcher is not on PATH (common in GUI clients and CI).
|
||||
GRAPHIFY_PYTHON=""
|
||||
_PINNED='/Users/ksv741/.local/share/uv/tools/graphifyy/bin/python'
|
||||
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_PINNED"
|
||||
fi
|
||||
# Second probe: read graphify-out/.graphify_python (written by the skill and
|
||||
# CLI; survives uv-tool reinstalls and is the same source the README documents).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
|
||||
if [ -f "$_GFY_PYTHON_FILE" ]; then
|
||||
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
|
||||
case "$_FROM_FILE" in
|
||||
*[!a-zA-Z0-9/_.@:\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
|
||||
esac
|
||||
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_FROM_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Third probe: resolve via the graphify launcher on PATH (shebang probe).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
|
||||
if [ -n "$GRAPHIFY_BIN" ]; then
|
||||
case "$GRAPHIFY_BIN" in
|
||||
*.exe) _SHEBANG="" ;;
|
||||
*) _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | sed 's/^#![[:space:]]*//') ;;
|
||||
esac
|
||||
case "$_SHEBANG" in
|
||||
*/env\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
|
||||
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
|
||||
esac
|
||||
# Allowlist: only keep characters valid in a filesystem path to prevent
|
||||
# injection if the shebang contains shell metacharacters.
|
||||
case "$GRAPHIFY_PYTHON" in
|
||||
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
|
||||
esac
|
||||
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON=""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Last resort: try python3 / python (works for system/venv installs on PATH).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="python3"
|
||||
elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="python"
|
||||
else
|
||||
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
export GRAPHIFY_CHANGED="$CHANGED"
|
||||
|
||||
# Run the rebuild detached so git commit returns immediately. Full-repo rebuilds
|
||||
# can take hours; blocking the post-commit hook stalls the shell. The Python
|
||||
# launcher below detaches the child cross-platform, so it works on Git for
|
||||
# Windows' shell too (which lacks the coreutils backgrounding tools) (#1161).
|
||||
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
|
||||
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
|
||||
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
|
||||
echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
|
||||
"$GRAPHIFY_PYTHON" -c "import os, subprocess, sys
|
||||
_src = '''
|
||||
import os, signal, sys
|
||||
from pathlib import Path
|
||||
|
||||
changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
|
||||
changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
|
||||
|
||||
if not changed:
|
||||
sys.exit(0)
|
||||
|
||||
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
|
||||
|
||||
try:
|
||||
from graphify.watch import _rebuild_code, _apply_resource_limits
|
||||
_apply_resource_limits()
|
||||
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
|
||||
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
|
||||
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
|
||||
signal.alarm(_timeout)
|
||||
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
|
||||
_root = Path('.')
|
||||
_saved = Path('graphify-out/.graphify_root')
|
||||
if _saved.exists():
|
||||
_txt = _saved.read_text(encoding='utf-8').strip()
|
||||
if _txt:
|
||||
_root = Path(_txt)
|
||||
_rebuild_code(_root, changed_paths=changed, force=_force)
|
||||
except TimeoutError as exc:
|
||||
print(f'[graphify hook] {exc}')
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f'[graphify hook] Rebuild failed: {exc}')
|
||||
sys.exit(1)
|
||||
|
||||
'''
|
||||
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_log), exist_ok=True)
|
||||
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
|
||||
except OSError:
|
||||
_out = subprocess.DEVNULL
|
||||
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
|
||||
_cmd = [sys.executable, '-c', _src]
|
||||
if os.name == 'nt':
|
||||
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
||||
try:
|
||||
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
|
||||
except OSError:
|
||||
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
|
||||
else:
|
||||
subprocess.Popen(_cmd, start_new_session=True, **_kw)
|
||||
"
|
||||
# graphify-hook-end
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": true
|
||||
}
|
||||
2
.serena/.gitignore
vendored
2
.serena/.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
@ -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.
|
||||
@ -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 read‑only.
|
||||
# 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: []
|
||||
66
AGENTS.md
66
AGENTS.md
@ -41,7 +41,7 @@
|
||||
|
||||
- **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 перед завершением крупных изменений.
|
||||
- **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 — инструмент, а не отдельный режим работы.
|
||||
|
||||
---
|
||||
@ -459,67 +459,3 @@ roadmap.md и inbox.md никогда не являются основанием
|
||||
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
|
||||
- CI находится в `.gitea/workflows/ci.yml`.
|
||||
- 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).
|
||||
|
||||
@ -125,8 +125,6 @@ docs/
|
||||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||||
| `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` |
|
||||
|
||||
`graphify-out/` — локальный артефакт знания, он не хранится в git. В новом `worktree` сначала собери его заново: `graphify extract .`; дальше обновляй инкрементально: `graphify update .`. Для вопросов по коду используй `graphify query "..."`.
|
||||
|
||||
Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов.
|
||||
|
||||
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CacheModule } from './modules/cache/cache.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 { AuthModule } from './modules/auth/auth.module';
|
||||
import { TBankModule } from './modules/tbank/tbank.module';
|
||||
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
@ -30,8 +29,4 @@ import configuration from './config/configuration';
|
||||
TBankModule,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(RequestLoggingMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
export class AppModule {}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ApiResponseMeta {
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
cachedAt: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
@ -13,14 +13,6 @@ export class ApiResponseMeta {
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiEnvelopePayload<T> {
|
||||
constructor(
|
||||
public readonly data: T,
|
||||
public readonly fromCache: boolean,
|
||||
public readonly cachedAt: string | null,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ApiResponse<T> {
|
||||
data: T;
|
||||
meta: ApiResponseMeta;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
@ -26,9 +24,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
error = (r.error as string) || exception.name;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
||||
} else {
|
||||
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
|
||||
import { ApiResponse } from '../dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
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(
|
||||
map((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);
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -1,14 +1,5 @@
|
||||
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', () => ({
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
database: {
|
||||
@ -38,17 +29,12 @@ export default registerAs('app', () => ({
|
||||
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
|
||||
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 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),
|
||||
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
|
||||
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
|
||||
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
|
||||
tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10),
|
||||
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
|
||||
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
|
||||
},
|
||||
cors: {
|
||||
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
|
||||
},
|
||||
auth: {
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -4,37 +4,9 @@ import { AppModule } from './app.module';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
@ -46,20 +18,10 @@ async function bootstrap() {
|
||||
app.useGlobalInterceptors(new TransformInterceptor());
|
||||
app.use(cookieParser());
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const runtimeConfig: BackendRuntimeConfig = {
|
||||
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', []),
|
||||
};
|
||||
const reqLogMiddleware = new RequestLoggingMiddleware();
|
||||
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
||||
|
||||
assertSafeProductionConfig(runtimeConfig);
|
||||
|
||||
app.enableCors({
|
||||
origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins),
|
||||
credentials: true,
|
||||
});
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('MoexVibe API')
|
||||
@ -74,7 +36,4 @@ async function bootstrap() {
|
||||
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
void bootstrap();
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@ -41,7 +41,13 @@ export class AuthController {
|
||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.authService.register(dto);
|
||||
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()
|
||||
@ -51,7 +57,13 @@ export class AuthController {
|
||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.authService.login(dto);
|
||||
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()
|
||||
@ -63,7 +75,13 @@ export class AuthController {
|
||||
const token = req.cookies?.[REFRESH_COOKIE];
|
||||
const result = await this.authService.refresh(token);
|
||||
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')
|
||||
@ -74,7 +92,10 @@ export class AuthController {
|
||||
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
||||
await this.authService.logout(user.sub);
|
||||
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')
|
||||
@ -82,7 +103,11 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||
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')
|
||||
@ -90,6 +115,10 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Update current user profile' })
|
||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||
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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
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 {
|
||||
@ApiProperty()
|
||||
@ -32,22 +39,22 @@ export class AuthTokenResponseDto {
|
||||
@ApiProperty({ type: AuthTokenDataDto })
|
||||
data!: AuthTokenDataDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: AuthResponseMetaDto })
|
||||
meta!: AuthResponseMetaDto;
|
||||
}
|
||||
|
||||
export class AuthProfileResponseDto {
|
||||
@ApiProperty({ type: AuthUserDto })
|
||||
data!: AuthUserDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: AuthResponseMetaDto })
|
||||
meta!: AuthResponseMetaDto;
|
||||
}
|
||||
|
||||
export class AuthLogoutResponseDto {
|
||||
@ApiProperty({ type: LogoutDataDto })
|
||||
data!: LogoutDataDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: AuthResponseMetaDto })
|
||||
meta!: AuthResponseMetaDto;
|
||||
}
|
||||
|
||||
@ -1,32 +1,26 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { BondsService } from './bonds.service';
|
||||
import { BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto } from './dto/bonds-envelope.dto';
|
||||
|
||||
@ApiTags('Bonds')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('securities/bonds')
|
||||
export class BondsController {
|
||||
constructor(private readonly bondsService: BondsService) {}
|
||||
|
||||
@Get(':secid')
|
||||
@ApiOperation({ summary: 'Получить спецификацию облигации' })
|
||||
@ApiOkResponse({ type: BondEnvelopeDto })
|
||||
async getBond(@Param('secid') secid: string) {
|
||||
return this.bondsService.getBond(secid);
|
||||
}
|
||||
|
||||
@Get(':secid/marketdata')
|
||||
@ApiOperation({ summary: 'Получить рыночные данные облигации' })
|
||||
@ApiOkResponse({ type: BondMarketDataEnvelopeDto })
|
||||
async getMarketData(@Param('secid') secid: string) {
|
||||
return this.bondsService.getMarketData(secid);
|
||||
}
|
||||
|
||||
@Get(':secid/history')
|
||||
@ApiOperation({ summary: 'Получить дневную историю торгов облигации' })
|
||||
@ApiOkResponse({ type: BondHistoryEnvelopeDto })
|
||||
async getHistory(
|
||||
@Param('secid') secid: string,
|
||||
@Query('from') from: string,
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||
import { BondsController } from './bonds.controller';
|
||||
import { BondsService } from './bonds.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
controllers: [BondsController],
|
||||
providers: [BondsService],
|
||||
exports: [BondsService],
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
import { BondsService } from './bonds.service';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
|
||||
describe('BondsService', () => {
|
||||
let service: BondsService;
|
||||
let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>;
|
||||
let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexMarketData = {
|
||||
moexClient = {
|
||||
getBondData: vi.fn(),
|
||||
getBondMarketData: vi.fn(),
|
||||
};
|
||||
@ -26,8 +25,7 @@ describe('BondsService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BondsService,
|
||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
@ -36,7 +34,7 @@ describe('BondsService', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
boardid: 'TQCB',
|
||||
shortName: 'ОФЗ 26238',
|
||||
@ -59,7 +57,7 @@ describe('BondsService', () => {
|
||||
bondSubType: 'fixed',
|
||||
listLevel: 1,
|
||||
});
|
||||
vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({
|
||||
vi.mocked(moexClient.getBondMarketData).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
bid: 72.9,
|
||||
offer: 73.1,
|
||||
@ -94,8 +92,8 @@ describe('BondsService', () => {
|
||||
expect.any(Function),
|
||||
'marketDataTtl',
|
||||
);
|
||||
expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(result).toMatchObject({
|
||||
data: {
|
||||
secid: 'SU26238RMFS5',
|
||||
@ -131,17 +129,19 @@ describe('BondsService', () => {
|
||||
volume: 10000,
|
||||
},
|
||||
},
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
meta: {
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||
});
|
||||
|
||||
it('throws EntityNotFoundException when bond data is missing', async () => {
|
||||
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null);
|
||||
it('throws NotFoundException when bond data is missing', async () => {
|
||||
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(moexMarketData.getBondMarketData).not.toHaveBeenCalled();
|
||||
expect(moexClient.getBondMarketData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.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()
|
||||
export class BondsService {
|
||||
constructor(
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly moexHistory: MoexHistoryClient,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
@ -21,23 +17,23 @@ export class BondsService {
|
||||
} = await this.cache.getOrFetch(
|
||||
'bond',
|
||||
[secid],
|
||||
() => this.moexMarketData.getBondData(secid),
|
||||
() => this.moexClient.getBondData(secid),
|
||||
'securityTtl',
|
||||
);
|
||||
|
||||
if (!bond) {
|
||||
throw new EntityNotFoundException('Bond', secid);
|
||||
throw new NotFoundException(`Bond ${secid} not found`);
|
||||
}
|
||||
|
||||
const { data: mkt } = await this.cache.getOrFetch(
|
||||
'marketdata',
|
||||
['bonds', secid],
|
||||
() => this.moexMarketData.getBondMarketData(secid),
|
||||
() => this.moexClient.getBondMarketData(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
return new ApiEnvelopePayload(
|
||||
{
|
||||
return {
|
||||
data: {
|
||||
secid: bond.secid,
|
||||
isin: bond.isin,
|
||||
name: bond.shortName,
|
||||
@ -74,9 +70,8 @@ export class BondsService {
|
||||
: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
}
|
||||
|
||||
async getMarketData(secid: string) {
|
||||
@ -87,16 +82,16 @@ export class BondsService {
|
||||
} = await this.cache.getOrFetch(
|
||||
'marketdata',
|
||||
['bonds', secid],
|
||||
() => this.moexMarketData.getBondMarketData(secid),
|
||||
() => this.moexClient.getBondMarketData(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
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,
|
||||
yieldToMaturity: mkt.yield ?? null,
|
||||
duration: mkt.duration ?? null,
|
||||
@ -112,28 +107,26 @@ export class BondsService {
|
||||
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
||||
: new Date().toISOString(),
|
||||
},
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
}
|
||||
|
||||
async getHistory(secid: string, from: string, till: string) {
|
||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||
'history',
|
||||
['bonds', secid, from, till],
|
||||
() => this.moexHistory.getBondHistory(secid, from, till),
|
||||
() => this.moexClient.getBondHistory(secid, from, till),
|
||||
'historyTtl',
|
||||
);
|
||||
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((h) => ({
|
||||
return {
|
||||
data: data.map((h) => ({
|
||||
date: h.tradeDate,
|
||||
closePrice: h.legalClosePrice ?? h.close ?? 0,
|
||||
yieldClose: h.yieldClose ?? null,
|
||||
duration: h.duration ?? null,
|
||||
})),
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
import { BondMarketDataDto, BondResponseDto } from './bond-response.dto';
|
||||
import { BondHistoryItemDto } from './history-item.dto';
|
||||
|
||||
export class BondEnvelopeDto {
|
||||
@ApiProperty({ type: BondResponseDto })
|
||||
data!: BondResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class BondMarketDataEnvelopeDto {
|
||||
@ApiProperty({ type: BondMarketDataDto })
|
||||
data!: BondMarketDataDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class BondHistoryEnvelopeDto {
|
||||
@ApiProperty({ type: [BondHistoryItemDto] })
|
||||
data!: BondHistoryItemDto[];
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class BondHistoryItemDto {
|
||||
@ApiProperty({ example: '2026-06-01' })
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ example: 100.45 })
|
||||
closePrice!: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 })
|
||||
yieldClose!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 })
|
||||
duration!: number | null;
|
||||
}
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
28
apps/backend/src/modules/cache/cache.service.ts
vendored
28
apps/backend/src/modules/cache/cache.service.ts
vendored
@ -3,11 +3,6 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
type CacheEntry<T> = {
|
||||
data: T;
|
||||
cachedAt: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
constructor(
|
||||
@ -23,16 +18,6 @@ export class CacheService {
|
||||
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 {
|
||||
return parts.join(':');
|
||||
}
|
||||
@ -46,19 +31,14 @@ export class CacheService {
|
||||
const key = this.buildKey(keyPrefix, ...keyParts);
|
||||
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 (this.isCacheEntry<T>(cached)) {
|
||||
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
|
||||
}
|
||||
|
||||
return { data: cached as T, fromCache: true, cachedAt: null };
|
||||
return { data: cached, fromCache: true, cachedAt: null };
|
||||
}
|
||||
|
||||
const data = await fetchFn();
|
||||
const cachedAt = new Date().toISOString();
|
||||
await this.set(key, { data, cachedAt }, ttl);
|
||||
await this.set(key, data, ttl);
|
||||
|
||||
return { data, fromCache: false, cachedAt };
|
||||
return { data, fromCache: false, cachedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,15 @@
|
||||
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { CandlesService } from './candles.service';
|
||||
import { CandlesQueryDto } from './dto/candles-query.dto';
|
||||
import { CandleEnvelopeDto } from './dto/candles-envelope.dto';
|
||||
|
||||
@ApiTags('Candles')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('securities')
|
||||
export class CandlesController {
|
||||
constructor(private readonly candlesService: CandlesService) {}
|
||||
|
||||
@Get('shares/:secid/candles')
|
||||
@ApiOperation({ summary: 'Получить свечи акции' })
|
||||
@ApiOkResponse({ type: CandleEnvelopeDto })
|
||||
async getShareCandles(
|
||||
@Param('secid') secid: string,
|
||||
@Query(ValidationPipe) query: CandlesQueryDto,
|
||||
@ -23,7 +19,6 @@ export class CandlesController {
|
||||
|
||||
@Get('bonds/:secid/candles')
|
||||
@ApiOperation({ summary: 'Получить свечи облигации' })
|
||||
@ApiOkResponse({ type: CandleEnvelopeDto })
|
||||
async getBondCandles(
|
||||
@Param('secid') secid: string,
|
||||
@Query(ValidationPipe) query: CandlesQueryDto,
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||
import { CandlesController } from './candles.controller';
|
||||
import { CandlesService } from './candles.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
controllers: [CandlesController],
|
||||
providers: [CandlesService],
|
||||
exports: [CandlesService],
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
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 { CandleInterval } from './dto/candles-query.dto';
|
||||
|
||||
describe('CandlesService', () => {
|
||||
let service: CandlesService;
|
||||
let moexCandles: Pick<MoexCandlesClient, 'getCandles'>;
|
||||
let moexClient: Pick<MoexClientService, 'getCandles'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexCandles = {
|
||||
moexClient = {
|
||||
getCandles: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
@ -24,7 +24,7 @@ describe('CandlesService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CandlesService,
|
||||
{ provide: MoexCandlesClient, useValue: moexCandles },
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
@ -33,7 +33,7 @@ describe('CandlesService', () => {
|
||||
});
|
||||
|
||||
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,
|
||||
high: 325,
|
||||
@ -60,7 +60,7 @@ describe('CandlesService', () => {
|
||||
expect.any(Function),
|
||||
'candlesTtl',
|
||||
);
|
||||
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
||||
'stock',
|
||||
'shares',
|
||||
'SBER',
|
||||
@ -81,13 +81,15 @@ describe('CandlesService', () => {
|
||||
end: '2026-05-01 23:59:59',
|
||||
},
|
||||
],
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
meta: {
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
|
||||
vi.mocked(moexCandles.getCandles).mockResolvedValue([]);
|
||||
vi.mocked(moexClient.getCandles).mockResolvedValue([]);
|
||||
|
||||
await service.getCandles(
|
||||
'bonds',
|
||||
@ -103,7 +105,7 @@ describe('CandlesService', () => {
|
||||
expect.any(Function),
|
||||
'candlesTtl',
|
||||
);
|
||||
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
||||
'stock',
|
||||
'bonds',
|
||||
'SU26238RMFS5',
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
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 { CandleInterval } from './dto/candles-query.dto';
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CandlesService {
|
||||
constructor(
|
||||
private readonly moexCandles: MoexCandlesClient,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
@ -26,12 +25,12 @@ export class CandlesService {
|
||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||
'candles',
|
||||
[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',
|
||||
);
|
||||
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((c) => ({
|
||||
return {
|
||||
data: data.map((c) => ({
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
@ -41,8 +40,7 @@ export class CandlesService {
|
||||
begin: c.begin,
|
||||
end: c.end,
|
||||
})),
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CandleItemDto {
|
||||
@ApiProperty({ example: 321.3 })
|
||||
open!: number;
|
||||
|
||||
@ApiProperty({ example: 322.66 })
|
||||
high!: number;
|
||||
|
||||
@ApiProperty({ example: 321.2 })
|
||||
low!: number;
|
||||
|
||||
@ApiProperty({ example: 322.35 })
|
||||
close!: number;
|
||||
|
||||
@ApiProperty({ example: 1925163 })
|
||||
volume!: number;
|
||||
|
||||
@ApiProperty({ example: 620184479 })
|
||||
value!: number;
|
||||
|
||||
@ApiProperty({ example: '2026-06-01T10:00:00' })
|
||||
begin!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-01T10:59:00' })
|
||||
end!: string;
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
import { CandleItemDto } from './candle-item.dto';
|
||||
|
||||
export class CandleEnvelopeDto {
|
||||
@ApiProperty({ type: [CandleItemDto] })
|
||||
data!: CandleItemDto[];
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
import { HealthResponseDto } from './health-response.dto';
|
||||
|
||||
export class HealthEnvelopeDto {
|
||||
@ApiProperty({ type: HealthResponseDto })
|
||||
data!: HealthResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
class HealthCheckResultDto {
|
||||
@ApiProperty({ example: 'prisma' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ enum: ['ok', 'error'] })
|
||||
status!: 'ok' | 'error';
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class HealthResponseDto {
|
||||
@ApiProperty({ example: 'ok' })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-23T06:00:00.000Z' })
|
||||
timestamp!: string;
|
||||
|
||||
@ApiProperty({ example: 12345 })
|
||||
uptime!: number;
|
||||
|
||||
@ApiProperty({ type: [HealthCheckResultDto] })
|
||||
checks!: HealthCheckResultDto[];
|
||||
}
|
||||
@ -1,21 +1,18 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { HealthEnvelopeDto } from './dto/health-envelope.dto';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@ApiTags('Health')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Проверка состояния сервиса' })
|
||||
@ApiOkResponse({ type: HealthEnvelopeDto })
|
||||
async check() {
|
||||
return this.healthService.check();
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -1,26 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
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';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MoexClientService } from './moex-client.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
MoexHttpClient,
|
||||
MoexSecuritiesClient,
|
||||
MoexMarketDataClient,
|
||||
MoexCandlesClient,
|
||||
MoexHistoryClient,
|
||||
MoexDividendsClient,
|
||||
],
|
||||
exports: [
|
||||
MoexSecuritiesClient,
|
||||
MoexMarketDataClient,
|
||||
MoexCandlesClient,
|
||||
MoexHistoryClient,
|
||||
MoexDividendsClient,
|
||||
],
|
||||
providers: [MoexClientService],
|
||||
exports: [MoexClientService],
|
||||
})
|
||||
export class MoexClientModule {}
|
||||
|
||||
@ -1,35 +1,32 @@
|
||||
import 'reflect-metadata';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { MoexClientModule } from './moex-client.module';
|
||||
import { MoexSecuritiesClient } from './moex-securities.client';
|
||||
import { MoexMarketDataClient } from './moex-market-data.client';
|
||||
import { MoexClientService } from './moex-client.service';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
|
||||
'MoexClient live MOEX integration',
|
||||
'MoexClientService live MOEX integration',
|
||||
() => {
|
||||
let moexSecurities: MoexSecuritiesClient;
|
||||
let moexMarketData: MoexMarketDataClient;
|
||||
let service: MoexClientService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule],
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [MoexClientService],
|
||||
}).compile();
|
||||
|
||||
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient);
|
||||
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||
service = module.get<MoexClientService>(MoexClientService);
|
||||
});
|
||||
|
||||
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
||||
const results = await moexSecurities.searchSecurities('SBER');
|
||||
const results = await service.searchSecurities('SBER');
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it('возвращает рыночные данные SBER из live MOEX', async () => {
|
||||
const data = await moexMarketData.getShareMarketData('SBER');
|
||||
const data = await service.getShareMarketData('SBER');
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.secid).toBe('SBER');
|
||||
|
||||
186
apps/backend/src/modules/moex-client/moex-client.service.spec.ts
Normal file
186
apps/backend/src/modules/moex-client/moex-client.service.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
423
apps/backend/src/modules/moex-client/moex-client.service.ts
Normal file
423
apps/backend/src/modules/moex-client/moex-client.service.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,6 @@ import {
|
||||
IsIn,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@ -32,7 +31,7 @@ export class AddPositionDto {
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Min(0)
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 250.5 })
|
||||
@ -42,7 +41,7 @@ export class AddPositionDto {
|
||||
buyPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-01' })
|
||||
@IsDateString()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
buyDate?: string;
|
||||
|
||||
|
||||
@ -11,24 +11,6 @@ export class PortfolioSummaryDto {
|
||||
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
||||
@ApiProperty() positionCount!: number;
|
||||
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
targetSharesPercent?: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
targetBondsPercent?: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
actualSharesPercent!: number;
|
||||
|
||||
@ApiProperty()
|
||||
actualBondsPercent!: number;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
sharesDeviation?: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
bondsDeviation?: number | null;
|
||||
}
|
||||
|
||||
export class AnalyticsResponseDto {
|
||||
|
||||
@ -1,46 +1,53 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
import { AnalyticsResponseDto } from './analytics-response.dto';
|
||||
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
|
||||
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
|
||||
import { PositionResponseDto } from './position-response.dto';
|
||||
|
||||
export class PortfolioResponseMetaDto {
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
cachedAt!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
fromCache!: boolean;
|
||||
}
|
||||
|
||||
export class PortfolioListEnvelopeDto {
|
||||
@ApiProperty({ type: [PortfolioListResponseDto] })
|
||||
data!: PortfolioListResponseDto[];
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
||||
meta!: PortfolioResponseMetaDto;
|
||||
}
|
||||
|
||||
export class PortfolioEnvelopeDto {
|
||||
@ApiProperty({ type: PortfolioResponseDto })
|
||||
data!: PortfolioResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
||||
meta!: PortfolioResponseMetaDto;
|
||||
}
|
||||
|
||||
export class PortfolioDetailEnvelopeDto {
|
||||
@ApiProperty({ type: PortfolioDetailResponseDto })
|
||||
data!: PortfolioDetailResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
||||
meta!: PortfolioResponseMetaDto;
|
||||
}
|
||||
|
||||
export class PositionEnvelopeDto {
|
||||
@ApiProperty({ type: PositionResponseDto })
|
||||
data!: PositionResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
||||
meta!: PortfolioResponseMetaDto;
|
||||
}
|
||||
|
||||
export class AnalyticsEnvelopeDto {
|
||||
@ApiProperty({ type: AnalyticsResponseDto })
|
||||
data!: AnalyticsResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
||||
meta!: PortfolioResponseMetaDto;
|
||||
}
|
||||
|
||||
@ -9,16 +9,6 @@ export class PortfolioResponseDto {
|
||||
@ApiProperty({ default: 'RUB' }) currency!: string;
|
||||
@ApiProperty() createdAt!: 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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -1,34 +1,8 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsNumber,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
Min,
|
||||
Max,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
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 {
|
||||
@ApiPropertyOptional({ example: 'Мой портфель' })
|
||||
@IsString()
|
||||
@ -48,11 +22,4 @@ export class UpdatePortfolioDto {
|
||||
@IsIn(CURRENCIES)
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: { sharesPercent: 70, bondsPercent: 30 } })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => PortfolioTargetsDto)
|
||||
targets?: PortfolioTargetsDto;
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
MaxLength,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@ -25,7 +24,7 @@ const TAGS = [
|
||||
export class UpdatePositionDto {
|
||||
@ApiPropertyOptional({ example: 15 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
|
||||
@ -36,7 +35,7 @@ export class UpdatePositionDto {
|
||||
buyPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-15' })
|
||||
@IsDateString()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
buyDate?: string;
|
||||
|
||||
|
||||
@ -13,28 +13,32 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||
import { AddPositionDto } from './dto/add-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 {
|
||||
AnalyticsEnvelopeDto,
|
||||
PortfolioDetailEnvelopeDto,
|
||||
PortfolioEnvelopeDto,
|
||||
PortfolioListEnvelopeDto,
|
||||
PortfolioResponseMetaDto,
|
||||
PositionEnvelopeDto,
|
||||
} from './dto/portfolio-envelope.dto';
|
||||
|
||||
const nullDataEnvelopeSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: { type: 'null' },
|
||||
meta: { $ref: getSchemaPath(ApiResponseMeta) },
|
||||
data: {
|
||||
type: 'null',
|
||||
},
|
||||
meta: {
|
||||
$ref: getSchemaPath(PortfolioResponseMetaDto),
|
||||
},
|
||||
},
|
||||
required: ['data', 'meta'],
|
||||
};
|
||||
|
||||
@ApiTags('Portfolios')
|
||||
@ApiBearerAuth()
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@ApiExtraModels(PortfolioResponseMetaDto)
|
||||
@Controller('portfolios')
|
||||
export class PortfolioController {
|
||||
constructor(private readonly portfolioService: PortfolioService) {}
|
||||
@ -43,21 +47,24 @@ export class PortfolioController {
|
||||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
|
||||
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()
|
||||
@ApiOperation({ summary: 'Create a new portfolio' })
|
||||
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
|
||||
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')
|
||||
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
||||
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
|
||||
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')
|
||||
@ -68,7 +75,8 @@ export class PortfolioController {
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@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')
|
||||
@ -76,7 +84,7 @@ export class PortfolioController {
|
||||
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
|
||||
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
await this.portfolioService.remove(user.sub, id);
|
||||
return null;
|
||||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Post(':id/positions')
|
||||
@ -87,7 +95,8 @@ export class PortfolioController {
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@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')
|
||||
@ -99,14 +108,16 @@ export class PortfolioController {
|
||||
@Param('positionId', ParseIntPipe) positionId: number,
|
||||
@Body() dto: UpdatePositionDto,
|
||||
) {
|
||||
return this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||||
const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Get(':id/analytics')
|
||||
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
|
||||
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
|
||||
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.portfolioService.getAnalytics(user.sub, id);
|
||||
const result = await this.portfolioService.getAnalytics(user.sub, id);
|
||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Delete(':id/positions/:positionId')
|
||||
@ -118,6 +129,6 @@ export class PortfolioController {
|
||||
@Param('positionId', ParseIntPipe) positionId: number,
|
||||
) {
|
||||
await this.portfolioService.removePosition(user.sub, id, positionId);
|
||||
return null;
|
||||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
controllers: [PortfolioController],
|
||||
providers: [PortfolioService],
|
||||
exports: [PortfolioService],
|
||||
|
||||
@ -2,18 +2,15 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
let service: PortfolioService;
|
||||
let prisma: PrismaService;
|
||||
let moexMarketData: MoexMarketDataClient;
|
||||
let moexClient: MoexClientService;
|
||||
let module: TestingModule;
|
||||
|
||||
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
|
||||
@ -68,20 +65,13 @@ describe('PortfolioService', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MoexSecuritiesClient,
|
||||
useValue: { getSecurityDescription: vi.fn() },
|
||||
},
|
||||
{
|
||||
provide: MoexMarketDataClient,
|
||||
provide: MoexClientService,
|
||||
useValue: {
|
||||
getShareMarketDataBatch: vi.fn(),
|
||||
getBondPositionDataBatch: vi.fn(),
|
||||
getSecurityDescription: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MoexDividendsClient,
|
||||
useValue: { getDividends: vi.fn() },
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: {
|
||||
@ -93,7 +83,7 @@ describe('PortfolioService', () => {
|
||||
|
||||
service = module.get<PortfolioService>(PortfolioService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||
moexClient = module.get<MoexClientService>(MoexClientService);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@ -147,11 +137,11 @@ describe('PortfolioService', () => {
|
||||
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 },
|
||||
] as any);
|
||||
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
shortName: 'OFZ 26238',
|
||||
@ -213,14 +203,14 @@ describe('PortfolioService', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
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 () => {
|
||||
@ -245,7 +235,7 @@ describe('PortfolioService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
||||
] 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 },
|
||||
] as any);
|
||||
|
||||
@ -333,7 +323,7 @@ describe('PortfolioService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
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 },
|
||||
] 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);
|
||||
|
||||
@ -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: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
|
||||
] 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: 'GAZP', shortName: 'Gazprom', last: 180 },
|
||||
] as any);
|
||||
@ -534,16 +524,16 @@ describe('PortfolioService', () => {
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException);
|
||||
await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,19 +1,13 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
|
||||
import type {
|
||||
MoexShareMarketData,
|
||||
MoexBondPositionData,
|
||||
MoexDividend,
|
||||
} from '../moex-client/moex-client.types';
|
||||
import type { MoexShareMarketData, MoexBondPositionData } from '../moex-client/moex-client.types';
|
||||
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||
import { AddPositionDto } from './dto/add-position.dto';
|
||||
@ -60,14 +54,12 @@ export interface EnrichedPosition {
|
||||
export class PortfolioService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly moexSecurities: MoexSecuritiesClient,
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly moexDividends: MoexDividendsClient,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async create(userId: number, dto: CreatePortfolioDto) {
|
||||
const portfolio = await this.prisma.portfolio.create({
|
||||
return this.prisma.portfolio.create({
|
||||
data: {
|
||||
userId,
|
||||
name: dto.name,
|
||||
@ -75,8 +67,6 @@ export class PortfolioService {
|
||||
currency: dto.currency ?? 'RUB',
|
||||
},
|
||||
});
|
||||
|
||||
return { ...portfolio, targets: null };
|
||||
}
|
||||
|
||||
async findAll(userId: number) {
|
||||
@ -99,7 +89,6 @@ export class PortfolioService {
|
||||
positionCount: 0,
|
||||
shareCount: 0,
|
||||
bondCount: 0,
|
||||
targets: p.targets ? JSON.parse(p.targets) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -128,7 +117,6 @@ export class PortfolioService {
|
||||
positionCount: positions.length,
|
||||
shareCount: positions.filter((pos) => pos.type === 'share').length,
|
||||
bondCount: positions.filter((pos) => pos.type === 'bond').length,
|
||||
targets: p.targets ? JSON.parse(p.targets) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -139,8 +127,8 @@ export class PortfolioService {
|
||||
include: { positions: true },
|
||||
});
|
||||
|
||||
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
|
||||
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
|
||||
|
||||
@ -166,35 +154,28 @@ export class PortfolioService {
|
||||
positions: positionsWithWeights,
|
||||
totalValue: Math.round(totalValue * 100) / 100,
|
||||
analytics: analytics.summary,
|
||||
targets: portfolio.targets ? JSON.parse(portfolio.targets) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async update(userId: number, id: number, dto: UpdatePortfolioDto) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
||||
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
|
||||
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const updated = await this.prisma.portfolio.update({
|
||||
return this.prisma.portfolio.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(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) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
||||
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
|
||||
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
await this.prisma.portfolio.delete({ where: { id } });
|
||||
}
|
||||
@ -204,8 +185,8 @@ export class PortfolioService {
|
||||
where: { id: portfolioId },
|
||||
include: { positions: true },
|
||||
});
|
||||
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const exists = portfolio.positions.find((p) => p.secid === dto.secid);
|
||||
if (exists)
|
||||
@ -213,7 +194,7 @@ export class PortfolioService {
|
||||
|
||||
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`);
|
||||
|
||||
const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
|
||||
@ -239,12 +220,12 @@ export class PortfolioService {
|
||||
dto: UpdatePositionDto,
|
||||
) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||||
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
||||
if (!position || position.portfolioId !== portfolioId) {
|
||||
throw new EntityNotFoundException('Position', positionId);
|
||||
throw new NotFoundException(`Position ${positionId} not found`);
|
||||
}
|
||||
|
||||
return this.prisma.position.update({
|
||||
@ -261,12 +242,12 @@ export class PortfolioService {
|
||||
|
||||
async removePosition(userId: number, portfolioId: number, positionId: number) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||||
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
||||
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 } });
|
||||
@ -291,10 +272,9 @@ export class PortfolioService {
|
||||
const shareSecids = [...new Set(sharePositions.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.fetchBondBatch(bondSecids, portfolioId),
|
||||
this.fetchDividendsBatch(shareSecids, portfolioId),
|
||||
]);
|
||||
|
||||
const enriched: EnrichedPosition[] = [];
|
||||
@ -325,9 +305,7 @@ export class PortfolioService {
|
||||
if (pos.type === 'bond') {
|
||||
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
||||
} else {
|
||||
enriched.push(
|
||||
this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid), dividendsBySecid.get(pos.secid)),
|
||||
);
|
||||
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -343,7 +321,7 @@ export class PortfolioService {
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata',
|
||||
['shares', cacheKey],
|
||||
() => this.moexMarketData.getShareMarketDataBatch(secids),
|
||||
() => this.moexClient.getShareMarketDataBatch(secids),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
@ -358,32 +336,12 @@ export class PortfolioService {
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata',
|
||||
['bonds', cacheKey],
|
||||
() => this.moexMarketData.getBondPositionDataBatch(secids),
|
||||
() => this.moexClient.getBondPositionDataBatch(secids),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
}
|
||||
|
||||
private async fetchDividendsBatch(
|
||||
secids: string[],
|
||||
portfolioId?: number,
|
||||
): Promise<Map<string, MoexDividend[]>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const results = await Promise.all(
|
||||
secids.map(async (secid) => {
|
||||
const cacheKey = portfolioId ? `pf:${portfolioId}:${secid}` : secid;
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'dividends',
|
||||
[cacheKey],
|
||||
() => this.moexDividends.getDividends(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return { secid, dividends: data };
|
||||
}),
|
||||
);
|
||||
return new Map(results.map((r) => [r.secid, r.dividends]));
|
||||
}
|
||||
|
||||
private buildSharePosition(
|
||||
pos: {
|
||||
id: number;
|
||||
@ -394,16 +352,9 @@ export class PortfolioService {
|
||||
},
|
||||
base: EnrichedPosition,
|
||||
data: MoexShareMarketData | undefined,
|
||||
dividends?: MoexDividend[],
|
||||
): EnrichedPosition {
|
||||
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null;
|
||||
let 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);
|
||||
}
|
||||
const dividendIncome = 0;
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
@ -521,8 +472,8 @@ export class PortfolioService {
|
||||
|
||||
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||||
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException();
|
||||
|
||||
const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
|
||||
|
||||
@ -543,32 +494,6 @@ export class PortfolioService {
|
||||
)
|
||||
: null;
|
||||
|
||||
let targetSharesPercent: number | null = null;
|
||||
let targetBondsPercent: number | null = null;
|
||||
if (portfolio.targets) {
|
||||
const targets = JSON.parse(portfolio.targets);
|
||||
targetSharesPercent = targets.sharesPercent;
|
||||
targetBondsPercent = targets.bondsPercent;
|
||||
}
|
||||
|
||||
let actualSharesPercent = 0;
|
||||
let actualBondsPercent = 0;
|
||||
if (totalValue > 0) {
|
||||
const shareValue = enrichedPositions
|
||||
.filter((p) => p.type === 'share')
|
||||
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
const bondValue = enrichedPositions
|
||||
.filter((p) => p.type === 'bond')
|
||||
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
actualSharesPercent = Math.round((shareValue / totalValue) * 10000) / 100;
|
||||
actualBondsPercent = Math.round((bondValue / totalValue) * 10000) / 100;
|
||||
}
|
||||
|
||||
const sharesDeviation =
|
||||
targetSharesPercent !== null ? Math.round((actualSharesPercent - targetSharesPercent) * 100) / 100 : null;
|
||||
const bondsDeviation =
|
||||
targetBondsPercent !== null ? Math.round((actualBondsPercent - targetBondsPercent) * 100) / 100 : null;
|
||||
|
||||
const summary = {
|
||||
totalInvested,
|
||||
totalValue,
|
||||
@ -579,12 +504,6 @@ export class PortfolioService {
|
||||
totalReturnPercent,
|
||||
positionCount,
|
||||
weightedYield,
|
||||
targetSharesPercent,
|
||||
targetBondsPercent,
|
||||
actualSharesPercent,
|
||||
actualBondsPercent,
|
||||
sharesDeviation,
|
||||
bondsDeviation,
|
||||
};
|
||||
|
||||
return { positions: enrichedPositions, summary };
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
|
||||
export class ScreenerItemDto {
|
||||
@ApiProperty({ example: 'SBER' })
|
||||
@ -71,10 +70,18 @@ export class ScreenerResultDto {
|
||||
totalPages!: number;
|
||||
}
|
||||
|
||||
class ScreenerResponseMetaDto {
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
cachedAt!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
fromCache!: boolean;
|
||||
}
|
||||
|
||||
export class ScreenerResponseDto {
|
||||
@ApiProperty({ type: ScreenerResultDto })
|
||||
data!: ScreenerResultDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: ScreenerResponseMetaDto })
|
||||
meta!: ScreenerResponseMetaDto;
|
||||
}
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
|
||||
export class SearchResultItemDto {
|
||||
@ApiProperty({ example: 'SBER' })
|
||||
secid!: string;
|
||||
|
||||
@ApiProperty({ example: 'RU0009029540' })
|
||||
isin!: string;
|
||||
|
||||
@ApiProperty({ example: 'Сбербанк' })
|
||||
shortName!: string;
|
||||
|
||||
@ApiProperty({ enum: ['share', 'bond'] })
|
||||
type!: 'share' | 'bond';
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
listLevel!: number;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, example: 'RUB' })
|
||||
currency!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 })
|
||||
price!: number | null;
|
||||
}
|
||||
|
||||
export class SearchEnvelopeDto {
|
||||
@ApiProperty({ type: [SearchResultItemDto] })
|
||||
data!: SearchResultItemDto[];
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
@ -1,21 +1,30 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
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 { ScreenerType } from './dto/screener-query.dto';
|
||||
|
||||
describe('ScreenerService', () => {
|
||||
let service: ScreenerService;
|
||||
let cache: CacheService;
|
||||
const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ScreenerService,
|
||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||
{ provide: CacheService, useValue: { getOrFetch: vi.fn() } },
|
||||
{
|
||||
provide: MoexClientService,
|
||||
useValue: {
|
||||
getShareMarketDataBatch: vi.fn(),
|
||||
getBondPositionDataBatch: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: {
|
||||
getOrFetch: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@ -28,30 +37,6 @@ describe('ScreenerService', () => {
|
||||
});
|
||||
|
||||
describe('screen', () => {
|
||||
it('should cache full dataset with screenerTtl config', async () => {
|
||||
const mockShares = [{
|
||||
secid: 'SBER', shortName: 'Sberbank', last: 250, volume: 1000000,
|
||||
lastChange: 5, lastChangePrcnt: 2, issueCapitalization: 1e9,
|
||||
}];
|
||||
|
||||
moexMarketData.getShareMarketDataBatch.mockResolvedValue(mockShares);
|
||||
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(async (_prefix, _keys, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}));
|
||||
|
||||
await service.screen({ type: ScreenerType.SHARE });
|
||||
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'screener',
|
||||
[ScreenerType.SHARE],
|
||||
expect.any(Function),
|
||||
'screenerTtl',
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter and sort shares', async () => {
|
||||
const mockShares = [
|
||||
{
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
|
||||
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';
|
||||
@ -7,7 +7,7 @@ import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'
|
||||
@Injectable()
|
||||
export class ScreenerService {
|
||||
constructor(
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
@ -38,7 +38,7 @@ export class ScreenerService {
|
||||
[type],
|
||||
async () => {
|
||||
if (type === ScreenerType.SHARE) {
|
||||
const shares = await this.moexMarketData.getShareMarketDataBatch([]);
|
||||
const shares = await this.moexClient.getShareMarketDataBatch([]);
|
||||
return shares.map(
|
||||
(s): ScreenerItemDto => ({
|
||||
secid: s.secid,
|
||||
@ -61,7 +61,7 @@ export class ScreenerService {
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const bonds = await this.moexMarketData.getBondPositionDataBatch([]);
|
||||
const bonds = await this.moexClient.getBondPositionDataBatch([]);
|
||||
return bonds.map(
|
||||
(b): ScreenerItemDto => ({
|
||||
secid: b.secid,
|
||||
@ -85,7 +85,7 @@ export class ScreenerService {
|
||||
);
|
||||
}
|
||||
},
|
||||
'screenerTtl',
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
return data;
|
||||
|
||||
@ -47,7 +47,7 @@ describe('SecuritiesController', () => {
|
||||
|
||||
it('should return search results', async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { ScreenerService } from './screener.service';
|
||||
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
|
||||
import { ScreenerQueryDto } from './dto/screener-query.dto';
|
||||
import { ScreenerResponseDto } from './dto/screener-response.dto';
|
||||
import { SearchEnvelopeDto } from './dto/search-response.dto';
|
||||
|
||||
@ApiTags('Securities')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('securities')
|
||||
export class SecuritiesController {
|
||||
constructor(
|
||||
@ -19,19 +16,20 @@ export class SecuritiesController {
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Поиск по инструментам' })
|
||||
@ApiOkResponse({ type: SearchEnvelopeDto })
|
||||
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||||
return this.securitiesService.search(
|
||||
const results = await this.securitiesService.search(
|
||||
query.q,
|
||||
query.type || SecurityType.ALL,
|
||||
query.limit || 20,
|
||||
);
|
||||
return { data: results, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Get('screener')
|
||||
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
|
||||
@ApiOkResponse({ type: ScreenerResponseDto })
|
||||
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
||||
return this.screenerService.screen(query);
|
||||
const result = await this.screenerService.screen(query);
|
||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
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 { SecuritiesService } from './securities.service';
|
||||
import { ScreenerService } from './screener.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
imports: [CacheModule],
|
||||
controllers: [SecuritiesController],
|
||||
providers: [SecuritiesService, ScreenerService],
|
||||
exports: [SecuritiesService],
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
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 { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesService', () => {
|
||||
let service: SecuritiesService;
|
||||
let moexSecurities: Pick<MoexSecuritiesClient, 'searchSecurities'>;
|
||||
let moexClient: Pick<MoexClientService, 'searchSecurities'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexSecurities = {
|
||||
moexClient = {
|
||||
searchSecurities: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
@ -24,7 +24,7 @@ describe('SecuritiesService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SecuritiesService,
|
||||
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
@ -33,7 +33,7 @@ describe('SecuritiesService', () => {
|
||||
});
|
||||
|
||||
it('returns supported securities only and normalizes SUR currency to RUB', async () => {
|
||||
vi.mocked(moexSecurities.searchSecurities).mockResolvedValue([
|
||||
vi.mocked(moexClient.searchSecurities).mockResolvedValue([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
@ -118,7 +118,7 @@ describe('SecuritiesService', () => {
|
||||
expect.any(Function),
|
||||
'searchTtl',
|
||||
);
|
||||
expect(moexSecurities.searchSecurities).toHaveBeenCalledWith('SbEr');
|
||||
expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr');
|
||||
});
|
||||
|
||||
it('filters by type and applies limit without live MOEX dependency', async () => {
|
||||
@ -169,6 +169,6 @@ describe('SecuritiesService', () => {
|
||||
price: null,
|
||||
},
|
||||
]);
|
||||
expect(moexSecurities.searchSecurities).not.toHaveBeenCalled();
|
||||
expect(moexClient.searchSecurities).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
@ -16,7 +16,7 @@ export interface SearchResultItem {
|
||||
@Injectable()
|
||||
export class SecuritiesService {
|
||||
constructor(
|
||||
private readonly moexSecurities: MoexSecuritiesClient,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
@ -25,7 +25,7 @@ export class SecuritiesService {
|
||||
'search',
|
||||
[query.toLowerCase()],
|
||||
async () => {
|
||||
const results = await this.moexSecurities.searchSecurities(query);
|
||||
const results = await this.moexClient.searchSecurities(query);
|
||||
return results
|
||||
.map((s): SearchResultItem | null => {
|
||||
const type =
|
||||
@ -64,7 +64,7 @@ export class SecuritiesService {
|
||||
|
||||
async getShareBrief(secid: string): Promise<SearchResultItem | null> {
|
||||
try {
|
||||
const desc = await this.moexSecurities.getSecurityDescription(secid);
|
||||
const desc = await this.moexClient.getSecurityDescription(secid);
|
||||
if (!desc) return null;
|
||||
return {
|
||||
secid: desc.secid,
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DividendItemDto {
|
||||
@ApiProperty({ example: '2026-05-15' })
|
||||
registryCloseDate!: string;
|
||||
|
||||
@ApiProperty({ example: 33.47 })
|
||||
value!: number;
|
||||
|
||||
@ApiProperty({ example: 'RUB' })
|
||||
currency!: string;
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class HistoryItemDto {
|
||||
@ApiProperty({ example: '2026-06-01' })
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ example: 321.3 })
|
||||
open!: number;
|
||||
|
||||
@ApiProperty({ example: 322.66 })
|
||||
high!: number;
|
||||
|
||||
@ApiProperty({ example: 321.2 })
|
||||
low!: number;
|
||||
|
||||
@ApiProperty({ example: 322.35 })
|
||||
close!: number;
|
||||
|
||||
@ApiProperty({ example: 1925163 })
|
||||
volume!: number;
|
||||
|
||||
@ApiProperty({ example: 620184479 })
|
||||
value!: number;
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
import { ShareResponseDto } from './share-response.dto';
|
||||
import { ShareMarketDataResponseDto } from './share-marketdata-response.dto';
|
||||
import { HistoryItemDto } from './history-item.dto';
|
||||
import { DividendItemDto } from './dividend-item.dto';
|
||||
|
||||
export class ShareEnvelopeDto {
|
||||
@ApiProperty({ type: ShareResponseDto })
|
||||
data!: ShareResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class ShareMarketDataEnvelopeDto {
|
||||
@ApiProperty({ type: ShareMarketDataResponseDto })
|
||||
data!: ShareMarketDataResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class DividendsEnvelopeDto {
|
||||
@ApiProperty({ type: [DividendItemDto] })
|
||||
data!: DividendItemDto[];
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class ShareHistoryEnvelopeDto {
|
||||
@ApiProperty({ type: [HistoryItemDto] })
|
||||
data!: HistoryItemDto[];
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
@ -1,44 +1,33 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { SharesService } from './shares.service';
|
||||
import {
|
||||
ShareEnvelopeDto,
|
||||
ShareMarketDataEnvelopeDto,
|
||||
DividendsEnvelopeDto,
|
||||
ShareHistoryEnvelopeDto,
|
||||
} from './dto/shares-envelope.dto';
|
||||
|
||||
@ApiTags('Shares')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('securities/shares')
|
||||
export class SharesController {
|
||||
constructor(private readonly sharesService: SharesService) {}
|
||||
|
||||
@Get(':secid')
|
||||
@ApiOperation({ summary: 'Получить спецификацию акции' })
|
||||
@ApiOkResponse({ type: ShareEnvelopeDto })
|
||||
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')
|
||||
@ApiOperation({ summary: 'Получить рыночные данные акции' })
|
||||
@ApiOkResponse({ type: ShareMarketDataEnvelopeDto })
|
||||
async getMarketData(@Param('secid') secid: string) {
|
||||
return this.sharesService.getMarketData(secid);
|
||||
}
|
||||
|
||||
@Get(':secid/dividends')
|
||||
@ApiOperation({ summary: 'Получить дивиденды' })
|
||||
@ApiOkResponse({ type: DividendsEnvelopeDto })
|
||||
async getDividends(@Param('secid') secid: string) {
|
||||
return this.sharesService.getDividends(secid);
|
||||
}
|
||||
|
||||
@Get(':secid/history')
|
||||
@ApiOperation({ summary: 'Получить дневную историю торгов акции' })
|
||||
@ApiOkResponse({ type: ShareHistoryEnvelopeDto })
|
||||
async getHistory(
|
||||
@Param('secid') secid: string,
|
||||
@Query('from') from: string,
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||
import { SharesController } from './shares.controller';
|
||||
import { SharesService } from './shares.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
controllers: [SharesController],
|
||||
providers: [SharesService],
|
||||
exports: [SharesService],
|
||||
|
||||
@ -1,23 +1,17 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
import { SharesService } from './shares.service';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
|
||||
describe('SharesService', () => {
|
||||
let service: SharesService;
|
||||
let moexSecurities: Pick<MoexSecuritiesClient, 'getSecurityDescription'>;
|
||||
let moexMarketData: Pick<MoexMarketDataClient, 'getShareMarketData'>;
|
||||
let moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexSecurities = {
|
||||
moexClient = {
|
||||
getSecurityDescription: vi.fn(),
|
||||
};
|
||||
moexMarketData = {
|
||||
getShareMarketData: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
@ -31,10 +25,7 @@ describe('SharesService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SharesService,
|
||||
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||
{ provide: MoexDividendsClient, useValue: { getDividends: vi.fn() } },
|
||||
{ provide: MoexHistoryClient, useValue: { getHistory: vi.fn() } },
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
@ -43,7 +34,7 @@ describe('SharesService', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
isin: 'RU0009029540',
|
||||
name: 'Сбербанк России ПАО ао',
|
||||
@ -61,7 +52,7 @@ describe('SharesService', () => {
|
||||
morningSession: true,
|
||||
eveningSession: true,
|
||||
});
|
||||
vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({
|
||||
vi.mocked(moexClient.getShareMarketData).mockResolvedValue({
|
||||
secid: 'SBER',
|
||||
boardid: 'TQBR',
|
||||
shortName: 'Сбербанк',
|
||||
@ -84,15 +75,15 @@ describe('SharesService', () => {
|
||||
|
||||
const result = await service.getShare('SBER');
|
||||
|
||||
expect(moexSecurities.getSecurityDescription).toHaveBeenCalledWith('SBER');
|
||||
expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER');
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'marketdata',
|
||||
['shares', 'SBER'],
|
||||
expect.any(Function),
|
||||
'marketDataTtl',
|
||||
);
|
||||
expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER');
|
||||
expect(result.data).toMatchObject({
|
||||
expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
|
||||
expect(result).toMatchObject({
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
name: 'Сбербанк России ПАО ао',
|
||||
@ -115,11 +106,11 @@ describe('SharesService', () => {
|
||||
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 () => {
|
||||
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
|
||||
it('throws NotFoundException for non-share security', async () => {
|
||||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
name: 'ОФЗ 26238',
|
||||
@ -138,7 +129,7 @@ describe('SharesService', () => {
|
||||
eveningSession: false,
|
||||
});
|
||||
|
||||
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException);
|
||||
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(cache.getOrFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,24 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.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()
|
||||
export class SharesService {
|
||||
constructor(
|
||||
private readonly moexSecurities: MoexSecuritiesClient,
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly moexDividends: MoexDividendsClient,
|
||||
private readonly moexHistory: MoexHistoryClient,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async getShare(secid: string) {
|
||||
const desc = await this.moexSecurities.getSecurityDescription(secid);
|
||||
const desc = await this.moexClient.getSecurityDescription(secid);
|
||||
if (
|
||||
!desc ||
|
||||
!(
|
||||
@ -27,17 +19,13 @@ export class SharesService {
|
||||
desc.type === 'preferred_share'
|
||||
)
|
||||
) {
|
||||
throw new EntityNotFoundException('Share', secid);
|
||||
throw new NotFoundException(`Share ${secid} not found`);
|
||||
}
|
||||
|
||||
const {
|
||||
data: marketData,
|
||||
fromCache,
|
||||
cachedAt,
|
||||
} = await this.cache.getOrFetch(
|
||||
const { data: marketData } = await this.cache.getOrFetch(
|
||||
'marketdata',
|
||||
['shares', secid],
|
||||
() => this.moexMarketData.getShareMarketData(secid),
|
||||
() => this.moexClient.getShareMarketData(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
@ -45,36 +33,32 @@ export class SharesService {
|
||||
const change = marketData?.lastChange ?? 0;
|
||||
const changePercent = marketData?.lastChangePrcnt ?? 0;
|
||||
|
||||
return new ApiEnvelopePayload(
|
||||
{
|
||||
secid: desc.secid,
|
||||
isin: desc.isin,
|
||||
name: desc.name,
|
||||
shortName: desc.shortName,
|
||||
latName: desc.latName,
|
||||
listLevel: desc.listLevel,
|
||||
issueSize: desc.issueSize,
|
||||
faceValue: desc.faceValue,
|
||||
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
||||
type: desc.type,
|
||||
marketData: {
|
||||
price: price ?? 0,
|
||||
change,
|
||||
changePercent,
|
||||
open: marketData?.open ?? 0,
|
||||
high: marketData?.high ?? null,
|
||||
low: marketData?.low ?? null,
|
||||
volume: marketData?.volume ?? 0,
|
||||
value: marketData?.value ?? 0,
|
||||
issueCapitalization: marketData?.issueCapitalization ?? null,
|
||||
updatedAt: marketData?.updateTime
|
||||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||||
: new Date().toISOString(),
|
||||
},
|
||||
return {
|
||||
secid: desc.secid,
|
||||
isin: desc.isin,
|
||||
name: desc.name,
|
||||
shortName: desc.shortName,
|
||||
latName: desc.latName,
|
||||
listLevel: desc.listLevel,
|
||||
issueSize: desc.issueSize,
|
||||
faceValue: desc.faceValue,
|
||||
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
||||
type: desc.type,
|
||||
marketData: {
|
||||
price: price ?? 0,
|
||||
change,
|
||||
changePercent,
|
||||
open: marketData?.open ?? 0,
|
||||
high: marketData?.high ?? null,
|
||||
low: marketData?.low ?? null,
|
||||
volume: marketData?.volume ?? 0,
|
||||
value: marketData?.value ?? 0,
|
||||
issueCapitalization: marketData?.issueCapitalization ?? null,
|
||||
updatedAt: marketData?.updateTime
|
||||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||||
: new Date().toISOString(),
|
||||
},
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async getMarketData(secid: string) {
|
||||
@ -85,16 +69,16 @@ export class SharesService {
|
||||
} = await this.cache.getOrFetch(
|
||||
'marketdata',
|
||||
['shares', secid],
|
||||
() => this.moexMarketData.getShareMarketData(secid),
|
||||
() => this.moexClient.getShareMarketData(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
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,
|
||||
change: marketData.lastChange ?? 0,
|
||||
changePercent: marketData.lastChangePrcnt ?? 0,
|
||||
@ -108,40 +92,38 @@ export class SharesService {
|
||||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||||
: new Date().toISOString(),
|
||||
},
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
}
|
||||
|
||||
async getDividends(secid: string) {
|
||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||
'dividends',
|
||||
[secid],
|
||||
() => this.moexDividends.getDividends(secid),
|
||||
() => this.moexClient.getDividends(secid),
|
||||
'dividendsTtl',
|
||||
);
|
||||
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((d) => ({
|
||||
return {
|
||||
data: data.map((d) => ({
|
||||
registryCloseDate: d.registryCloseDate,
|
||||
value: d.value,
|
||||
currency: d.currencyId,
|
||||
})),
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
}
|
||||
|
||||
async getHistory(secid: string, from: string, till: string) {
|
||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||
'history',
|
||||
['shares', secid, from, till],
|
||||
() => this.moexHistory.getHistory(secid, from, till),
|
||||
() => this.moexClient.getHistory(secid, from, till),
|
||||
'historyTtl',
|
||||
);
|
||||
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((h) => ({
|
||||
return {
|
||||
data: data.map((h) => ({
|
||||
date: h.tradeDate,
|
||||
open: h.open ?? 0,
|
||||
high: h.high ?? 0,
|
||||
@ -150,8 +132,7 @@ export class SharesService {
|
||||
volume: h.volume,
|
||||
value: h.value,
|
||||
})),
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1,74 +1,63 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||
import { BrokerEventsDataDto } from './broker-events-response.dto';
|
||||
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
|
||||
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
|
||||
import { BrokerPositionsPageResponseDto } from './broker-positions-page-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 {
|
||||
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
||||
data!: BrokerAccountResponseDto[];
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerPortfolioEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerPortfolioResponseDto })
|
||||
data!: BrokerPortfolioResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerOperationsEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerOperationsPageResponseDto })
|
||||
data!: BrokerOperationsPageResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerPositionsEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerPositionsPageResponseDto })
|
||||
data!: BrokerPositionsPageResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerOperationSyncEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
||||
data!: BrokerOperationSyncResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class BrokerAnalyticsEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerAnalyticsDto })
|
||||
data!: BrokerAnalyticsDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
export class BrokerEventsEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerEventsDataDto })
|
||||
data!: BrokerEventsDataDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class BrokerPortfolioHistoryEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
|
||||
data!: BrokerPortfolioHistoryDataDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
|
||||
@ -21,37 +21,37 @@ export class BrokerEventItemDto {
|
||||
@ApiProperty()
|
||||
eventDate!: string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
paymentDate!: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
ticker!: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
instrumentUid!: string | null;
|
||||
|
||||
@ApiProperty({ enum: instrumentTypes })
|
||||
instrumentType!: string;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
quantitySnapshot!: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
payoutPerUnit!: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
estimatedAmount!: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
actualAmount!: number | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
currency!: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, enum: ['current_position'] })
|
||||
@ApiProperty({ nullable: true })
|
||||
estimateMode!: 'current_position' | null;
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ export class BrokerEventsSummaryDto {
|
||||
@ApiProperty({ minimum: 0 })
|
||||
eventCount!: number;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
@ApiProperty({ nullable: true })
|
||||
nearestEventDate!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
|
||||
@ -40,9 +40,4 @@ export class BrokerOperationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categories?: string;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -4,7 +4,7 @@ import { CacheService } from '../../cache/cache.service';
|
||||
|
||||
describe('BrokerAccountsService', () => {
|
||||
const client = {
|
||||
getUsersClient: vi.fn(),
|
||||
getServiceClient: vi.fn(),
|
||||
callUnary: vi.fn(),
|
||||
} as unknown as TBankClientService;
|
||||
const cache = {
|
||||
@ -23,7 +23,7 @@ describe('BrokerAccountsService', () => {
|
||||
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({
|
||||
accounts: [
|
||||
{ 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.map((account) => account.type)).toEqual(['brokerage', 'iis']);
|
||||
expect(result.fromCache).toBe(false);
|
||||
expect(result.meta.fromCache).toBe(false);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'tbank:accounts',
|
||||
['open-brokerage-iis'],
|
||||
|
||||
@ -5,7 +5,6 @@ import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import type { BrokerAccount } from '../types/broker.types';
|
||||
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerAccountsService {
|
||||
@ -14,7 +13,10 @@ export class BrokerAccountsService {
|
||||
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(
|
||||
TBANK_CACHE_KEYS.accounts,
|
||||
['open-brokerage-iis'],
|
||||
@ -22,7 +24,10 @@ export class BrokerAccountsService {
|
||||
'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> {
|
||||
@ -32,9 +37,9 @@ export class BrokerAccountsService {
|
||||
}
|
||||
|
||||
private async fetchAccounts(): Promise<BrokerAccount[]> {
|
||||
const usersClient = this.tbankClient.getUsersClient();
|
||||
const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
|
||||
const response = await this.tbankClient.callUnary<
|
||||
{ status: string },
|
||||
Record<string, string>,
|
||||
TBankAccountsResponse
|
||||
>(
|
||||
'UsersService/GetAccounts',
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../../moex-client/moex-dividends.client';
|
||||
import { MoexClientService } from '../../moex-client/moex-client.service';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { BrokerEventsService } from './broker-events.service';
|
||||
import { BrokerOperationsService } from './broker-operations.service';
|
||||
@ -10,8 +9,10 @@ import { BrokerPortfolioService } from './broker-portfolio.service';
|
||||
describe('BrokerEventsService', () => {
|
||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||||
const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService;
|
||||
const moexMarketData = { getBondPositionDataBatch: vi.fn() } as unknown as MoexMarketDataClient;
|
||||
const moexDividends = { getDividends: vi.fn() } as unknown as MoexDividendsClient;
|
||||
const moex = {
|
||||
getDividends: vi.fn(),
|
||||
getBondPositionDataBatch: vi.fn(),
|
||||
} as unknown as MoexClientService;
|
||||
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
||||
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||
|
||||
@ -41,10 +42,10 @@ describe('BrokerEventsService', () => {
|
||||
it('throws 404 for missing account', async () => {
|
||||
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(
|
||||
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 () => {
|
||||
@ -57,11 +58,10 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
meta: { fromCache: false, 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' });
|
||||
|
||||
expect(result.data.items).toEqual([]);
|
||||
@ -86,7 +86,7 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]),
|
||||
});
|
||||
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU000A0JS',
|
||||
@ -112,11 +112,10 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
meta: { fromCache: false, 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' });
|
||||
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
@ -143,7 +142,7 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]),
|
||||
});
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'SU26248RMFS4',
|
||||
couponValue: 35.4,
|
||||
@ -167,11 +166,10 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
meta: { fromCache: false, 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' });
|
||||
|
||||
expect(result.data.items).toHaveLength(3);
|
||||
@ -206,18 +204,17 @@ describe('BrokerEventsService', () => {
|
||||
['uid-2', { name: 'Working' }],
|
||||
]),
|
||||
});
|
||||
vi.mocked(moexDividends.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
|
||||
vi.mocked(moexDividends.getDividends).mockResolvedValueOnce([
|
||||
vi.mocked(moex.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
|
||||
vi.mocked(moex.getDividends).mockResolvedValueOnce([
|
||||
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
|
||||
]);
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
meta: { fromCache: false, 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' });
|
||||
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
@ -238,17 +235,16 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
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' },
|
||||
]);
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
meta: { fromCache: false, 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' });
|
||||
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
@ -278,10 +274,10 @@ describe('BrokerEventsService', () => {
|
||||
['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' },
|
||||
]);
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'BOND1',
|
||||
couponValue: 50,
|
||||
@ -305,11 +301,10 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
meta: { fromCache: false, 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' });
|
||||
|
||||
expect(result.data.summary.eventCount).toBe(3);
|
||||
@ -336,7 +331,7 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
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-07-29', 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({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
meta: { fromCache: false, 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' });
|
||||
|
||||
expect(result.data.items).toHaveLength(2);
|
||||
@ -376,10 +370,10 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
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' },
|
||||
]);
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'BOND1',
|
||||
couponValue: 50,
|
||||
@ -402,11 +396,10 @@ describe('BrokerEventsService', () => {
|
||||
]);
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
meta: { fromCache: false, 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',
|
||||
@ -462,11 +455,10 @@ describe('BrokerEventsService', () => {
|
||||
hasNext: false,
|
||||
asOf: '2026-06-19T00:00:00.000Z',
|
||||
},
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
meta: { fromCache: false, 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-15',
|
||||
to: '2026-06-20',
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client';
|
||||
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 { MoexClientService } from '../../moex-client/moex-client.service';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import { mapQuotationToNumber } from '../mappers/money.mapper';
|
||||
import type {
|
||||
@ -47,8 +44,7 @@ export class BrokerEventsService {
|
||||
constructor(
|
||||
private readonly accountsService: BrokerAccountsService,
|
||||
private readonly portfolioService: BrokerPortfolioService,
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly moexDividends: MoexDividendsClient,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly operationsService: BrokerOperationsService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
@ -56,9 +52,12 @@ export class BrokerEventsService {
|
||||
async getEvents(
|
||||
accountId: string,
|
||||
query: BrokerEventsQuery,
|
||||
): Promise<ApiEnvelopePayload<BrokerEventsData>> {
|
||||
): Promise<{
|
||||
data: BrokerEventsData;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
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 eventTypeKey = Array.from(eventTypes).join(',');
|
||||
@ -69,7 +68,10 @@ export class BrokerEventsService {
|
||||
'tbankPortfolioTtl',
|
||||
);
|
||||
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
}
|
||||
|
||||
private async buildEvents(
|
||||
@ -141,7 +143,7 @@ export class BrokerEventsService {
|
||||
|
||||
let dividends: { registryCloseDate: string; value: number; currencyId: string }[];
|
||||
try {
|
||||
dividends = await this.moexDividends.getDividends(ticker);
|
||||
dividends = await this.moexClient.getDividends(ticker);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@ -201,7 +203,7 @@ export class BrokerEventsService {
|
||||
faceValue: number;
|
||||
}[];
|
||||
try {
|
||||
bondData = await this.moexMarketData.getBondPositionDataBatch(secids);
|
||||
bondData = await this.moexClient.getBondPositionDataBatch(secids);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user