Compare commits
No commits in common. "main" and "perf/portfolio-enricher-optimization" have entirely different histories.
main
...
perf/portf
@ -9,57 +9,46 @@ env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: 'Setup dependencies'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npx prettier --check "**/*.{ts,tsx}"
|
||||
|
||||
- name: Build design system
|
||||
run: npm run build:design-system
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test backend
|
||||
run: npm run test:backend
|
||||
|
||||
- name: Test frontend
|
||||
run: npm run test:frontend
|
||||
|
||||
- name: Build backend
|
||||
run: npm run build:backend
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build:frontend
|
||||
|
||||
- name: Setup Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Test design system
|
||||
run: npm run test:design-system
|
||||
|
||||
- name: Build Storybook
|
||||
run: npm run build:storybook
|
||||
|
||||
- name: Test Storybook (browser)
|
||||
run: npm run test:storybook
|
||||
|
||||
- name: Build docs
|
||||
run: npm run build:docs
|
||||
|
||||
- name: Upload Storybook build (if failed)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: 'Setup dependencies'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: packages/design-system/storybook-static
|
||||
retention-days: 3
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run test:backend
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 'Setup dependencies'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build:backend
|
||||
- run: npm run build:frontend
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,7 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
@ -10,8 +9,3 @@ vite.config.d.ts
|
||||
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: []
|
||||
539
AGENTS.md
539
AGENTS.md
@ -1,448 +1,62 @@
|
||||
# MoexVibe — Инструкция для агента
|
||||
|
||||
## Содержание
|
||||
## Репозиторий
|
||||
|
||||
- [Обязательный подход к разработке](#обязательный-подход-к-разработке)
|
||||
- [Процесс разработки](#процесс-разработки)
|
||||
- [Структура документации](#структура-документации)
|
||||
- [Назначение документов](#назначение-документов)
|
||||
- [Правила разработки](#правила-разработки)
|
||||
- [Определение бага](#определение-бага)
|
||||
- [Процесс работы над фичей](#процесс-работы-над-фичей)
|
||||
- [Работа с новыми идеями](#работа-с-новыми-идеями)
|
||||
- [Работа с существующими фичами](#работа-с-существующими-фичами)
|
||||
- [Поддержание документации](#поддержание-документации)
|
||||
- [Поведение AI-агентов](#поведение-ai-агентов)
|
||||
- [Anti-Loop: лимит на итерации](#anti-loop-лимит-на-итерации)
|
||||
- [Приоритет источников информации](#приоритет-источников-информации)
|
||||
- [Git workflow](#git-workflow)
|
||||
- [Конвенция коммитов](#конвенция-коммитов)
|
||||
- [Документация и SDD-артефакты](#документация-и-sdd-артефакты)
|
||||
- [Definition of Done (DoD)](#definition-of-done-dod)
|
||||
- [Технические регламенты](#технические-регламенты)
|
||||
- [Правила тестирования](#правила-тестирования)
|
||||
- [Работа с миграциями Prisma](#работа-с-миграциями-prisma)
|
||||
- [Правила рефакторинга](#правила-рефакторинга)
|
||||
- [Политики безопасности](#политики-безопасности)
|
||||
- [ADR-процесс](#adr-процесс)
|
||||
- [Правила обновления OpenAPI/типов](#правила-обновления-openapiтипов)
|
||||
- [Цикл работы над API](#цикл-работы-над-api)
|
||||
- [Инфраструктура проекта](#инфраструктура-проекта)
|
||||
- [Команды](#команды)
|
||||
- [Переменные окружения](#переменные-окружения)
|
||||
- [Архитектура](#архитектура)
|
||||
- [Бэкенд](#бэкенд)
|
||||
- [Фронтенд](#фронтенд)
|
||||
- [Стиль кода](#стиль-кода)
|
||||
|
||||
---
|
||||
npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite).
|
||||
|
||||
## Обязательный подход к разработке
|
||||
|
||||
- **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, когда это полезно задаче.
|
||||
- **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы.
|
||||
|
||||
---
|
||||
|
||||
## Процесс разработки
|
||||
|
||||
Проект использует подход Specification-Driven Development (SDD).
|
||||
|
||||
### Структура документации
|
||||
|
||||
```text
|
||||
docs/
|
||||
|
||||
├── inbox.md
|
||||
├── roadmap.md
|
||||
│
|
||||
├── research/
|
||||
│
|
||||
├── epics/
|
||||
│ └── {epic-name}.md
|
||||
│
|
||||
└── features/
|
||||
└── {feature-name}/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
└── tasks.md
|
||||
|
||||
```
|
||||
|
||||
Полный набор `spec.md`, `plan.md` и `tasks.md` обязателен для новых фич. Исторические feature-каталоги могут быть неполными: отсутствующие артефакты не требуется восстанавливать задним числом, если это не нужно для текущего изменения.
|
||||
|
||||
### Назначение документов
|
||||
|
||||
#### inbox.md
|
||||
|
||||
Содержит идеи и мысли, которые появились во время работы над проектом.
|
||||
|
||||
Записи в inbox не являются требованиями и не должны реализовываться напрямую.
|
||||
|
||||
#### roadmap.md
|
||||
|
||||
Содержит список запланированных эпиков и фич.
|
||||
|
||||
Наличие задачи в roadmap не означает, что её нужно немедленно реализовать.
|
||||
|
||||
#### research/
|
||||
|
||||
Содержит результаты исследований и экспериментов.
|
||||
|
||||
Документы могут содержать гипотезы, предположения и открытые вопросы.
|
||||
|
||||
Результаты исследований необходимо проверять перед реализацией.
|
||||
|
||||
#### epics/
|
||||
|
||||
Эпик представляет собой крупную продуктовую возможность или модуль.
|
||||
|
||||
Эпик может состоять из нескольких фич.
|
||||
|
||||
#### features/{feature-name}/spec.md
|
||||
|
||||
Описывает **ЧТО** должно быть реализовано.
|
||||
|
||||
Спецификация должна содержать:
|
||||
|
||||
- цель
|
||||
- требования
|
||||
- ограничения
|
||||
- критерии приемки (Acceptance Criteria)
|
||||
|
||||
Спецификация не должна содержать деталей реализации.
|
||||
|
||||
#### features/{feature-name}/plan.md
|
||||
|
||||
Описывает **КАК** будет реализована фича.
|
||||
|
||||
План может содержать:
|
||||
|
||||
- архитектурные решения
|
||||
- API контракты
|
||||
- потоки данных
|
||||
- технический подход
|
||||
|
||||
#### features/{feature-name}/tasks.md
|
||||
|
||||
Содержит список задач для реализации.
|
||||
|
||||
Задачи должны быть:
|
||||
|
||||
- небольшими
|
||||
- конкретными
|
||||
- независимыми по возможности
|
||||
|
||||
### Правила разработки
|
||||
|
||||
#### Правило 1
|
||||
|
||||
Нельзя начинать реализацию новой фичи без спецификации.
|
||||
|
||||
Если спецификации нет:
|
||||
|
||||
- Провести исследование при необходимости.
|
||||
- Создать spec.md.
|
||||
- Уточнить требования.
|
||||
- Только после этого переходить к реализации.
|
||||
|
||||
#### Правило 2
|
||||
|
||||
Реализация должна соответствовать spec.md.
|
||||
|
||||
Если в процессе разработки выясняется, что требования неполные или ошибочные:
|
||||
|
||||
- Не изменять поведение системы молча.
|
||||
- Сначала обновить spec.md и plan.md.
|
||||
- И только потом продолжать реализацию.
|
||||
|
||||
#### Правило 3
|
||||
|
||||
Спецификация является источником истины.
|
||||
|
||||
Если plan.md противоречит spec.md — приоритет имеет spec.md.
|
||||
|
||||
#### Правило 4
|
||||
|
||||
Не добавлять функциональность, которая отсутствует в спецификации.
|
||||
|
||||
Если появилась новая идея:
|
||||
|
||||
- обновить спецификацию;
|
||||
- либо создать новую фичу.
|
||||
|
||||
#### Правило 5
|
||||
|
||||
Исправления ошибок можно выполнять напрямую. Новая функциональность должна проходить через спецификацию.
|
||||
|
||||
#### Определение бага
|
||||
|
||||
Баг — это поведение системы, противоречащее спецификации, acceptance criteria, API-контракту, зафиксированному тестами поведению или подтверждённому архитектурному инварианту.
|
||||
|
||||
Если желаемое поведение нигде не зафиксировано и не следует из существующего контракта или инварианта — это отсутствующая функциональность (new feature), а не баг.
|
||||
|
||||
Классификация:
|
||||
|
||||
- **Есть зафиксированный контракт, поведение не соответствует** → баг (можно чинить напрямую, Правило 5)
|
||||
- **Нет зафиксированного контракта, требуется новое поведение** → новая фича (нужна спецификация)
|
||||
- **Spec есть, но в нём неопределённость** → сначала уточнить spec, потом решать, баг это или фича
|
||||
|
||||
### Процесс работы над фичей
|
||||
|
||||
При реализации новой фичи необходимо:
|
||||
|
||||
1. Ознакомиться с эпиком, если он существует.
|
||||
2. Прочитать spec.md.
|
||||
3. Прочитать plan.md.
|
||||
4. Прочитать tasks.md.
|
||||
5. Выполнять задачи последовательно.
|
||||
6. Отмечать выполненные задачи.
|
||||
7. Обновлять plan.md при изменении технических решений.
|
||||
8. Обновлять spec.md при изменении требований.
|
||||
|
||||
Для исторической фичи сначала прочитать все имеющиеся артефакты. Отсутствие старого `plan.md` или `tasks.md` само по себе не блокирует maintenance или исправление бага и не требует создавать их задним числом. Для нового расширения такой фичи сначала подготовить недостающие артефакты в объёме текущего изменения.
|
||||
|
||||
Если есть согласованный `plan.md` для многошаговой реализации, агент по умолчанию должен
|
||||
предпочитать `superpowers:subagent-driven-development`. `superpowers:executing-plans` использовать
|
||||
только когда пользователь явно просит inline-исполнение или когда задачи настолько тесно связаны,
|
||||
что разбиение по subagent-циклам ухудшит надёжность и скорость.
|
||||
|
||||
### Работа с новыми идеями
|
||||
|
||||
Если во время реализации появилась новая идея:
|
||||
|
||||
Не реализовывать её автоматически. Необходимо определить, является ли она:
|
||||
|
||||
- багом;
|
||||
- улучшением существующей функциональности;
|
||||
- новой фичей.
|
||||
|
||||
Если это улучшение или новая фича — добавить её в inbox.md или создать отдельную фичу.
|
||||
|
||||
### Работа с существующими фичами
|
||||
|
||||
Улучшения существующей функциональности обычно остаются внутри текущего эпика.
|
||||
|
||||
Пример:
|
||||
|
||||
```
|
||||
Portfolio Dashboard
|
||||
├── История операций
|
||||
├── Пагинация истории операций
|
||||
├── Фильтрация истории операций
|
||||
└── Экспорт истории операций
|
||||
```
|
||||
|
||||
Новый эпик создаётся только при появлении новой продуктовой возможности или нового домена.
|
||||
|
||||
### Поддержание документации
|
||||
|
||||
Документация должна соответствовать текущему состоянию проекта.
|
||||
|
||||
После значимых изменений необходимо обновлять:
|
||||
|
||||
- spec.md
|
||||
- plan.md
|
||||
- tasks.md
|
||||
- ADR
|
||||
- архитектурную документацию
|
||||
|
||||
Документация не должна отставать от реализации.
|
||||
|
||||
### Поведение AI-агентов
|
||||
|
||||
Перед написанием кода необходимо:
|
||||
|
||||
1. Изучить спецификацию фичи.
|
||||
2. Проверить полноту требований.
|
||||
3. Найти неоднозначности и противоречия.
|
||||
4. При необходимости запросить уточнения.
|
||||
5. Перед началом реализации агент должен кратко подтвердить понимание задачи и спецификации (одним сообщением).
|
||||
|
||||
**Запрещено:**
|
||||
|
||||
- придумывать требования;
|
||||
- додумывать поведение системы;
|
||||
- реализовывать неописанную функциональность.
|
||||
|
||||
Если информации недостаточно — остановиться и запросить уточнение вместо того, чтобы делать предположения.
|
||||
|
||||
### Pre-flight checklist (обязателен перед реализацией любой фичи)
|
||||
|
||||
Агент не имеет права начать реализацию, пока не выполнены все пункты:
|
||||
|
||||
- [ ] Feature branch создана: `codex/<feature-name>`
|
||||
- [ ] spec.md написана и утверждена пользователем
|
||||
- [ ] plan.md написан и утверждён пользователем
|
||||
- [ ] tasks.md создан с чекбоксами до начала работы
|
||||
- [ ] Все тесты проходят на текущем состоянии
|
||||
|
||||
Нарушение любого пункта = остановиться и вернуться к пропущенному шагу.
|
||||
|
||||
### Anti-Loop: лимит на итерации
|
||||
|
||||
Если после 3 последовательных неудачных попыток исправить одну и ту же проблему в рамках одной гипотезы симптом не изменился — остановиться и запросить помощь у пользователя.
|
||||
|
||||
Правила:
|
||||
|
||||
- Каждая попытка = один цикл «сформулировал гипотезу → внёс изменение → проверил → тот же симптом сохранился»
|
||||
- Сбор новой диагностической информации без изменения кода попыткой не считается
|
||||
- Не начинать 4-ю попытку без явного указания пользователя
|
||||
- При запросе помощи приложить: что пытался сделать, что пошло не так, последнее состояние кода/логов
|
||||
|
||||
### Приоритет источников информации
|
||||
|
||||
При возникновении противоречий использовать следующий порядок приоритетов:
|
||||
|
||||
1. Текущая задача пользователя (она может изменить требования, но соответствующие SDD-артефакты обновляются до реализации).
|
||||
2. spec.md фичи.
|
||||
3. plan.md фичи.
|
||||
4. ADR.
|
||||
5. Архитектурная документация.
|
||||
6. roadmap.md.
|
||||
7. inbox.md.
|
||||
|
||||
roadmap.md и inbox.md никогда не являются основанием для реализации функциональности.
|
||||
|
||||
### Git workflow
|
||||
|
||||
- Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё.
|
||||
- Имя ветки по умолчанию начинать с `codex/`, если пользователь не попросил другой префикс.
|
||||
- Не смешивать независимые фичи в одной ветке. Небольшие связанные docs/chore/test-правки можно держать в той же ветке, если они относятся к текущей задаче.
|
||||
|
||||
### Конвенция коммитов
|
||||
|
||||
Использовать [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
- `feat:` — новая функциональность
|
||||
- `fix:` — исправление бага
|
||||
- `chore:` — обслуживание (зависимости, конфиги, CI)
|
||||
- `docs:` — документация
|
||||
- `refactor:` — рефакторинг без изменения поведения
|
||||
- `test:` — добавление или исправление тестов
|
||||
- `style:` — форматирование, кодстайл (prettier)
|
||||
- `perf:` — улучшение производительности
|
||||
- `build:` — изменения сборки и зависимостей
|
||||
- `ci:` — изменения CI/CD
|
||||
|
||||
Формат: `<тип>(<необязательный scope>): <краткое описание в настоящем времени>`
|
||||
|
||||
Примеры:
|
||||
|
||||
- `feat: add portfolio rebalancing endpoint`
|
||||
- `fix: handle empty dividend list from MOEX`
|
||||
- `docs: update API authentication section`
|
||||
|
||||
### Документация и SDD-артефакты
|
||||
|
||||
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
|
||||
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
|
||||
- OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`.
|
||||
|
||||
### Definition of Done (DoD)
|
||||
|
||||
- Все acceptance criteria реализованы
|
||||
- Тесты проходят
|
||||
- Lint проходит
|
||||
- Для новой фичи созданы и обновлены spec/plan/tasks; для исторической фичи обновлены существующие и необходимые для текущего изменения артефакты
|
||||
- Документация обновлена
|
||||
- Нет TODO без согласования
|
||||
|
||||
---
|
||||
|
||||
## Технические регламенты
|
||||
|
||||
### Правила тестирования
|
||||
|
||||
- Для новой бизнес-логики → обязательны unit-тесты
|
||||
- Для API-контрактов → интеграционные тесты
|
||||
- Не мокать собственный код без необходимости
|
||||
- В unit-тестах мокать внешние API (MOEX, T-Bank) и Prisma
|
||||
- При исправлении бага — сначала падающий тест (TDD)
|
||||
- Тесты писать рядом с основным кодом
|
||||
|
||||
### Работа с миграциями Prisma
|
||||
|
||||
- Никогда не редактировать файлы в `prisma/migrations/` вручную
|
||||
- После изменения `schema.prisma` → `npm exec -w apps/backend -- prisma migrate dev --name <name>`
|
||||
- Изменять существующие миграции допустимо только до их публикации/мержа. После мержа создавать новую миграцию
|
||||
- Всегда запускать `npm exec -w apps/backend -- prisma generate` после изменения схемы
|
||||
|
||||
### Правила рефакторинга
|
||||
|
||||
- Не выполнять крупный рефакторинг вне рамок задачи
|
||||
- Допустимы: локальные улучшения, устранение техдолга рядом с изменяемым кодом, исправление архитектурных нарушений
|
||||
- Запрещено: менять структуру проекта без ADR, переписывать модули без отдельной задачи
|
||||
- Крупный рефакторинг требует отдельного эпика/фичи + ADR
|
||||
|
||||
### Политики безопасности
|
||||
|
||||
- Запрещено логировать токены, пароли, секреты
|
||||
- Не отключать guard'ы
|
||||
- Не хранить секреты в коде, не коммитить .env
|
||||
- Использовать маскирование при выводе (например, `***`)
|
||||
|
||||
### ADR-процесс
|
||||
|
||||
- Создавать ADR при: выборе новой технологии, изменении архитектуры, изменении API-контрактов, изменении стратегии хранения данных
|
||||
- ADR должен содержать: Контекст, Рассмотренные варианты, Решение, Последствия
|
||||
|
||||
### Правила обновления OpenAPI/типов
|
||||
|
||||
1. Обновить DTO/Controller на бэкенде
|
||||
2. Обновить Swagger
|
||||
3. Запустить `npm run codegen -w apps/frontend`
|
||||
4. Использовать обновлённые типы из `src/api/types.ts`
|
||||
5. Никогда не редактировать `types.ts` вручную
|
||||
|
||||
### Цикл работы над API
|
||||
|
||||
Стандартная процедура при любом изменении API-контракта:
|
||||
|
||||
1. **Бэкенд** — описать/обновить DTO и контроллер (NestJS)
|
||||
2. **Swagger** — убедиться, что документация отдаётся корректно (`/api/docs-json`)
|
||||
3. **Codegen** — `npm run codegen -w apps/frontend` (генерирует `src/api/types.ts`)
|
||||
4. **Фронтенд** — использовать обновлённые типы, адаптировать вызовы
|
||||
5. **Проверка** — убедиться, что `npm run build` проходит в обоих пакетах
|
||||
|
||||
Обновление типов вручную (`types.ts`) запрещено — всегда через codegen.
|
||||
|
||||
---
|
||||
|
||||
## Инфраструктура проекта
|
||||
|
||||
### Команды
|
||||
|
||||
Основные команды проекта описаны в README.md.
|
||||
|
||||
Перед завершением задачи запускать тесты, lint и build затронутых пакетов.
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
Основные настройки находятся в .env.
|
||||
|
||||
Полный список переменных описан в README.md.
|
||||
|
||||
---
|
||||
- **SDD (Specification-Driven Development)**: перед написанием кода сначала сформировать спецификацию — PRD, доменную модель, ADR, OpenAPI-контракт, архитектуру фронтенда и бэкенда, план реализации по этапам.
|
||||
- **Superpowers**: обязательно использовать скиллы (Skills) при старте любой задачи — brainstorming, frontend-design, test-driven-development, writing-plans, executing-plans, requesting-code-review.
|
||||
- **MCP-инструменты**: использовать MCP для анализа и генерации дизайна, работы с API, генерации кода.
|
||||
|
||||
## Команды
|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
|
||||
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
|
||||
| `npm run build:backend` | `nest build` |
|
||||
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
|
||||
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
|
||||
| `npm run lint` | ESLint только для бэкенда |
|
||||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||||
| `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` |
|
||||
|
||||
Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend`
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
|---|---|---|
|
||||
| `PORT` | 3000 | Порт бэкенда |
|
||||
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
|
||||
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
||||
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
||||
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
||||
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
||||
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
|
||||
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
|
||||
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
|
||||
| `JWT_ACCESS_EXPIRES` | `15m` | TTL access token |
|
||||
| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token |
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Бэкенд
|
||||
|
||||
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
|
||||
- Актуальная композиция backend-модулей определяется в `apps/backend/src/app.module.ts`; не дублировать динамический список модулей в инструкциях. Опубликованное описание архитектуры находится в `apps/docs/docs/backend/modules.md`.
|
||||
- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`.
|
||||
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
|
||||
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
|
||||
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов).
|
||||
- БД: SQLite через Prisma ORM. Prisma client используется из `@prisma/client`; схема и миграции находятся в `apps/backend/prisma/`.
|
||||
- БД: SQLite через Prisma ORM. Prisma client генерируется в `src/generated/prisma/`.
|
||||
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
|
||||
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
|
||||
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
||||
- Алиасы: `@/*` → `src/*` в обоих пакетах.
|
||||
|
||||
### Фронтенд
|
||||
## Фронтенд
|
||||
|
||||
- React 18 + react-router-dom v6 + TanStack Query v5.
|
||||
- `lightweight-charts` v4 для графиков цен.
|
||||
@ -451,75 +65,10 @@ roadmap.md и inbox.md никогда не являются основанием
|
||||
- Конвенция ключей запросов: `['stock', secid]`, `['securities', 'search', query]`, и т.д.
|
||||
- CSS через `styles.css` (CSS custom properties, без CSS-in-JS или Tailwind).
|
||||
|
||||
### Стиль кода
|
||||
## Стиль кода
|
||||
|
||||
- Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
|
||||
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
|
||||
- Бэкенд использует SWC через `unplugin-swc` (vitest config).
|
||||
- Тесты фронтенда есть: 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).
|
||||
- Тесты фронтенда отсутствуют.
|
||||
- CI/CD в репозитории нет.
|
||||
|
||||
152
README.md
152
README.md
@ -2,168 +2,50 @@
|
||||
|
||||
Веб-приложение для анализа ценных бумаг Московской биржи (MOEX).
|
||||
|
||||
## Содержание
|
||||
## Tech Stack
|
||||
|
||||
- [О проекте](#о-проекте)
|
||||
- [Стек технологий](#стек-технологий)
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [Docker](#docker)
|
||||
- [Тестирование](#тестирование)
|
||||
- [Структура проекта](#структура-проекта)
|
||||
- [Команды](#команды)
|
||||
- [Переменные окружения](#переменные-окружения)
|
||||
- **Backend:** NestJS, TypeScript, OpenAPI (Swagger)
|
||||
- **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts
|
||||
- **Infrastructure:** Docker, docker-compose
|
||||
|
||||
---
|
||||
|
||||
## О проекте
|
||||
|
||||
npm workspaces монорепозиторий:
|
||||
|
||||
| Пакет | Назначение |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) |
|
||||
| `apps/frontend` | React SPA на Vite |
|
||||
| `apps/docs` | Сайт документации Docusaurus |
|
||||
| `packages/design-system` | Дизайн-система (Storybook, MUI-адаптер, UI-компоненты) |
|
||||
|
||||
---
|
||||
|
||||
## Стек технологий
|
||||
|
||||
- **Бэкенд:** NestJS, TypeScript, OpenAPI (Swagger)
|
||||
- **Фронтенд:** React, TypeScript, Vite, TanStack Query, lightweight-charts
|
||||
- **Дизайн-система:** MUI v7, Storybook 10, lightweight-charts
|
||||
- **Документация:** Docusaurus
|
||||
- **Инфраструктура:** Docker, docker-compose
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Настройка локального окружения
|
||||
cp apps/backend/.env.example apps/backend/.env
|
||||
|
||||
# Установка зависимостей и подготовка базы данных
|
||||
# Install dependencies
|
||||
npm install
|
||||
npm exec -w apps/backend -- prisma migrate dev
|
||||
|
||||
# Запуск бэкенда (http://localhost:3000)
|
||||
# Start backend (http://localhost:3000)
|
||||
npm run dev:backend
|
||||
|
||||
# Запуск фронтенда (http://localhost:5173)
|
||||
# Start frontend (http://localhost:5173)
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
Swagger UI: http://localhost:3000/api/docs
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- Фронтенд: http://localhost:80
|
||||
- Бэкенд: http://localhost:3000
|
||||
- Frontend: http://localhost:80
|
||||
- Backend: http://localhost:3000
|
||||
|
||||
---
|
||||
|
||||
## Тестирование
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm run test:backend
|
||||
npm run test:frontend
|
||||
```
|
||||
|
||||
Интеграционные тесты с MOEX — опциональны:
|
||||
|
||||
```bash
|
||||
npm run test:integration -w apps/backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/
|
||||
backend/ — NestJS API, единая точка доступа к MOEX ISS
|
||||
frontend/ — React SPA на Vite
|
||||
docs/ — сайт документации Docusaurus
|
||||
packages/
|
||||
design-system/ — дизайн-система (MUI-адаптер, UI-компоненты, Storybook)
|
||||
backend/ — NestJS API (single point of access to MOEX ISS)
|
||||
frontend/ — React SPA with Vite
|
||||
docs/
|
||||
features/ — спецификации и планы реализации (SDD)
|
||||
epics/ — продуктовые эпики
|
||||
inbox.md — идеи и заметки
|
||||
roadmap.md — запланированные эпики и фичи
|
||||
architecture/ — ADR documents and diagrams
|
||||
openapi/ — OpenAPI specification
|
||||
superpowers/ — Design specs and implementation plans
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Команды
|
||||
|
||||
| Команда | Что делает |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
|
||||
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
|
||||
| `npm run dev:docs` | Docusaurus dev-сервер (опубликованная документация) |
|
||||
| `npm run build:backend` | `nest build` |
|
||||
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
|
||||
| `npm run build:docs` | `docusaurus build` |
|
||||
| `npm run build:design-system` | Сборка дизайн-системы (`tsc`) |
|
||||
| `npm run build:storybook` | Статическая сборка Storybook |
|
||||
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
|
||||
| `npm run test:frontend` | Frontend Vitest suite |
|
||||
| `npm run test:design-system` | Unit-тесты дизайн-системы (Vitest) |
|
||||
| `npm run test:storybook` | Браузерные тесты Storybook (Vitest browser mode + Playwright) |
|
||||
| `npm run storybook` | Storybook dev-сервер на :6006 (инженерный workbench, не docs) |
|
||||
| `npm run lint` | ESLint для backend, frontend и design-system |
|
||||
| `npm run lint:design-system` | ESLint для дизайн-системы |
|
||||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||||
| `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` |
|
||||
|
||||
`graphify-out/` — локальный артефакт знания, он не хранится в git. В новом `worktree` сначала собери его заново: `graphify extract .`; дальше обновляй инкрементально: `graphify update .`. Для вопросов по коду используй `graphify query "..."`.
|
||||
|
||||
Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов.
|
||||
|
||||
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.
|
||||
|
||||
Один backend-тест: `npm exec -w apps/backend -- vitest run src/path/to/test.spec.ts`
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
| ------------------------------------ | -------------------------------- | -------------------------------------------------------------- |
|
||||
| `PORT` | 3000 | Порт бэкенда |
|
||||
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Адрес MOEX ISS |
|
||||
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
||||
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Ошибок до открытия circuit breaker |
|
||||
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Секунд до попытки закрыть circuit breaker |
|
||||
| `T_BANK_TOKEN` | `''` | Токен T-Bank Invest (серверный) |
|
||||
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
||||
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS |
|
||||
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Имя приложения для T-Bank |
|
||||
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Rate limiter для OperationsService и UsersService (запросов/с) |
|
||||
| `T_BANK_INSTRUMENTS_RATE_LIMIT` | 20 | Rate limiter для InstrumentsService (запросов/с) |
|
||||
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Таймаут gRPC-запроса (мс) |
|
||||
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
||||
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
||||
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
||||
| `CACHE_DIVIDENDS_TTL` | 86400 | TTL дивидендных данных (с) |
|
||||
| `CACHE_TBANK_ACCOUNTS_TTL` | 3600 | TTL брокерских счетов T-Bank (с) |
|
||||
| `CACHE_TBANK_PORTFOLIO_TTL` | 60 | TTL брокерского портфеля T-Bank (с) |
|
||||
| `CACHE_TBANK_OPERATIONS_TTL` | 300 | TTL брокерских операций T-Bank (с) |
|
||||
| `CACHE_TBANK_POSITIONS_TTL` | 60 | TTL брокерских позиций T-Bank (с) |
|
||||
| `CACHE_TBANK_INSTRUMENT_TTL` | 86400 | TTL инструментов T-Bank (с) |
|
||||
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
|
||||
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
|
||||
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
|
||||
| `JWT_ACCESS_EXPIRES` | `15m` | TTL access token |
|
||||
| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token |
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
PORT=3000
|
||||
DATABASE_URL=file:./dev.db
|
||||
|
||||
MOEX_BASE_URL=https://iss.moex.com/iss
|
||||
MOEX_RATE_LIMIT=10
|
||||
MOEX_CIRCUIT_BREAKER_THRESHOLD=5
|
||||
MOEX_CIRCUIT_BREAKER_RESET_SECONDS=30
|
||||
|
||||
T_BANK_TOKEN=
|
||||
T_BANK_BASE_URL=invest-public-api.tbank.ru:443
|
||||
T_BANK_CA_CERT_PATH=
|
||||
T_BANK_APP_NAME=ksv741.moex-vibe
|
||||
T_BANK_RATE_LIMIT_PER_SECOND=5
|
||||
T_BANK_INSTRUMENTS_RATE_LIMIT=20
|
||||
T_BANK_REQUEST_TIMEOUT_MS=10000
|
||||
|
||||
CACHE_MARKET_DATA_TTL=900
|
||||
CACHE_HISTORY_TTL=3600
|
||||
CACHE_CANDLES_TTL=3600
|
||||
CACHE_SECURITY_TTL=86400
|
||||
CACHE_SEARCH_TTL=3600
|
||||
CACHE_DIVIDENDS_TTL=86400
|
||||
CACHE_TBANK_ACCOUNTS_TTL=3600
|
||||
CACHE_TBANK_PORTFOLIO_TTL=60
|
||||
CACHE_TBANK_OPERATIONS_TTL=300
|
||||
CACHE_TBANK_POSITIONS_TTL=60
|
||||
CACHE_TBANK_INSTRUMENT_TTL=86400
|
||||
|
||||
JWT_SECRET=dev-jwt-secret-change-in-production
|
||||
JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production
|
||||
JWT_ACCESS_EXPIRES=15m
|
||||
JWT_REFRESH_EXPIRES=7d
|
||||
@ -1,33 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
||||
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
||||
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
||||
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
|
||||
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
||||
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
|
||||
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
|
||||
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
|
||||
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
|
||||
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
|
||||
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
|
||||
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
|
||||
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
|
||||
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
|
||||
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
|
||||
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
|
||||
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
|
||||
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
|
||||
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
|
||||
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
|
||||
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
|
||||
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
|
||||
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
|
||||
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
|
||||
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
|
||||
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
|
||||
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
|
||||
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
|
||||
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
|
||||
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
|
||||
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
|
||||
-----END CERTIFICATE-----
|
||||
@ -1,13 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": [
|
||||
{
|
||||
"include": "modules/tbank/proto/contracts/**/*",
|
||||
"outDir": "dist"
|
||||
}
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
|
||||
@ -8,13 +8,10 @@
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\"",
|
||||
"test": "VITE_CJS_IGNORE_WARNING=1 vitest run --exclude \"src/**/*.integration.spec.ts\"",
|
||||
"test:watch": "vitest --exclude \"src/**/*.integration.spec.ts\"",
|
||||
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
|
||||
"test": "VITE_CJS_IGNORE_WARNING=1 vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.4",
|
||||
"@grpc/proto-loader": "^0.8.1",
|
||||
"@libsql/client": "^0.17.3",
|
||||
"@nestjs/axios": "^3.0.0",
|
||||
"@nestjs/cache-manager": "^2.0.0",
|
||||
@ -32,9 +29,7 @@
|
||||
"class-transformer": "^0.5.0",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"long": "^5.3.2",
|
||||
"p-queue": "^7.3.0",
|
||||
"protobufjs": "^8.6.4",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.0",
|
||||
"swagger-ui-express": "^5.0.0"
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Position" ADD COLUMN "buyDate" DATETIME;
|
||||
ALTER TABLE "Position" ADD COLUMN "buyPrice" REAL;
|
||||
@ -1,50 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "BrokerOperation" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"cursor" TEXT,
|
||||
"operationId" TEXT,
|
||||
"parentOperationId" TEXT,
|
||||
"date" DATETIME,
|
||||
"type" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"state" TEXT,
|
||||
"instrumentUid" TEXT,
|
||||
"figi" TEXT,
|
||||
"ticker" TEXT,
|
||||
"classCode" TEXT,
|
||||
"payment" TEXT,
|
||||
"price" TEXT,
|
||||
"commission" TEXT,
|
||||
"yield" TEXT,
|
||||
"accruedInt" TEXT,
|
||||
"quantity" INTEGER,
|
||||
"quantityDone" INTEGER,
|
||||
"raw" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BrokerOperationSyncState" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"lastCursor" TEXT,
|
||||
"lastSyncedFrom" DATETIME,
|
||||
"lastSyncedTo" DATETIME,
|
||||
"syncedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BrokerOperation_accountId_date_idx" ON "BrokerOperation"("accountId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BrokerOperation_accountId_type_idx" ON "BrokerOperation"("accountId", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BrokerOperation_accountId_cursor_key" ON "BrokerOperation"("accountId", "cursor");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BrokerOperationSyncState_accountId_key" ON "BrokerOperationSyncState"("accountId");
|
||||
@ -7,35 +7,33 @@ datasource db {
|
||||
}
|
||||
|
||||
model Portfolio {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
name String
|
||||
description String?
|
||||
currency String @default("RUB")
|
||||
currency String @default("RUB")
|
||||
targets String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
positions Position[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
positions Position[]
|
||||
|
||||
@@unique([userId, name])
|
||||
}
|
||||
|
||||
model Position {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
portfolioId Int
|
||||
secid String
|
||||
type String @default("share")
|
||||
type String @default("share")
|
||||
quantity Int
|
||||
buyPrice Float?
|
||||
buyDate DateTime?
|
||||
notes String?
|
||||
tags String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
||||
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([portfolioId, secid])
|
||||
}
|
||||
@ -51,44 +49,3 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
portfolios Portfolio[]
|
||||
}
|
||||
|
||||
model BrokerOperation {
|
||||
id Int @id @default(autoincrement())
|
||||
accountId String
|
||||
cursor String?
|
||||
operationId String?
|
||||
parentOperationId String?
|
||||
date DateTime?
|
||||
type String
|
||||
category String
|
||||
state String?
|
||||
instrumentUid String?
|
||||
figi String?
|
||||
ticker String?
|
||||
classCode String?
|
||||
payment String?
|
||||
price String?
|
||||
commission String?
|
||||
yield String?
|
||||
accruedInt String?
|
||||
quantity Int?
|
||||
quantityDone Int?
|
||||
raw String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([accountId, cursor])
|
||||
@@index([accountId, date])
|
||||
@@index([accountId, type])
|
||||
}
|
||||
|
||||
model BrokerOperationSyncState {
|
||||
id Int @id @default(autoincrement())
|
||||
accountId String @unique
|
||||
lastCursor String?
|
||||
lastSyncedFrom DateTime?
|
||||
lastSyncedTo DateTime?
|
||||
syncedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@ -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';
|
||||
@ -10,8 +10,6 @@ import { CandlesModule } from './modules/candles/candles.module';
|
||||
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({
|
||||
@ -27,11 +25,6 @@ import configuration from './config/configuration';
|
||||
BondsModule,
|
||||
CandlesModule,
|
||||
PortfolioModule,
|
||||
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: {
|
||||
@ -23,32 +14,13 @@ export default registerAs('app', () => ({
|
||||
10,
|
||||
),
|
||||
},
|
||||
tbank: {
|
||||
token: process.env.T_BANK_TOKEN || '',
|
||||
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
|
||||
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
|
||||
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
||||
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
||||
instrumentsRateLimitPerSecond: parseInt(process.env.T_BANK_INSTRUMENTS_RATE_LIMIT || '20', 10),
|
||||
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
||||
},
|
||||
cache: {
|
||||
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
||||
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
||||
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();
|
||||
|
||||
@ -1,21 +1,10 @@
|
||||
import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import {
|
||||
AuthLogoutResponseDto,
|
||||
AuthProfileResponseDto,
|
||||
AuthTokenResponseDto,
|
||||
} from './dto/auth-response.dto';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
@ -37,59 +26,82 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register new user' })
|
||||
@ApiCreatedResponse({ type: AuthTokenResponseDto })
|
||||
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()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@ApiCreatedResponse({ type: AuthTokenResponseDto })
|
||||
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()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiOkResponse({ type: AuthTokenResponseDto })
|
||||
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
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')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Logout user' })
|
||||
@ApiOkResponse({ type: AuthLogoutResponseDto })
|
||||
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')
|
||||
@ApiBearerAuth()
|
||||
@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')
|
||||
@ApiBearerAuth()
|
||||
@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,53 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
|
||||
class AuthUserDto {
|
||||
@ApiProperty()
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
role!: string;
|
||||
}
|
||||
|
||||
class AuthTokenDataDto {
|
||||
@ApiProperty({ type: AuthUserDto })
|
||||
user!: AuthUserDto;
|
||||
|
||||
@ApiProperty()
|
||||
accessToken!: string;
|
||||
}
|
||||
|
||||
class LogoutDataDto {
|
||||
@ApiProperty()
|
||||
message!: string;
|
||||
}
|
||||
|
||||
export class AuthTokenResponseDto {
|
||||
@ApiProperty({ type: AuthTokenDataDto })
|
||||
data!: AuthTokenDataDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class AuthProfileResponseDto {
|
||||
@ApiProperty({ type: AuthUserDto })
|
||||
data!: AuthUserDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class AuthLogoutResponseDto {
|
||||
@ApiProperty({ type: LogoutDataDto })
|
||||
data!: LogoutDataDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
@ -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,147 +1,41 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BondsService } from './bonds.service';
|
||||
import { 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';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe('BondsService', () => {
|
||||
let service: BondsService;
|
||||
let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexMarketData = {
|
||||
getBondData: vi.fn(),
|
||||
getBondMarketData: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
BondsService,
|
||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: {
|
||||
get: () => undefined,
|
||||
set: () => Promise.resolve(),
|
||||
del: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
CacheService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BondsService>(BondsService);
|
||||
});
|
||||
|
||||
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
|
||||
vi.mocked(moexMarketData.getBondData).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
boardid: 'TQCB',
|
||||
shortName: 'ОФЗ 26238',
|
||||
prevWaprice: 73.2,
|
||||
yieldAtPrevWaprice: 14.1,
|
||||
couponValue: 35.4,
|
||||
nextCoupon: '2026-06-24',
|
||||
accruedInt: 34.1,
|
||||
prevPrice: 73,
|
||||
lotSize: 1,
|
||||
faceValue: 1000,
|
||||
matDate: '2041-05-15',
|
||||
couponPeriod: 182,
|
||||
issueSize: 150000000,
|
||||
isin: 'RU000A1038V6',
|
||||
couponPercent: 7.1,
|
||||
offerDate: null,
|
||||
buybackDate: null,
|
||||
bondType: 'ofz',
|
||||
bondSubType: 'fixed',
|
||||
listLevel: 1,
|
||||
});
|
||||
vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
bid: 72.9,
|
||||
offer: 73.1,
|
||||
open: 72.8,
|
||||
low: 72.5,
|
||||
high: 73.4,
|
||||
last: 73.05,
|
||||
yield: 14.2,
|
||||
waprice: 73,
|
||||
yieldAtWaprice: 14.15,
|
||||
duration: 2250,
|
||||
volume: 10000,
|
||||
value: 7305000,
|
||||
numtrades: 450,
|
||||
tradingStatus: 'T',
|
||||
updateTime: '18:45:00',
|
||||
});
|
||||
|
||||
const result = await service.getBond('SU26238RMFS5');
|
||||
|
||||
expect(cache.getOrFetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'bond',
|
||||
['SU26238RMFS5'],
|
||||
expect.any(Function),
|
||||
'securityTtl',
|
||||
);
|
||||
expect(cache.getOrFetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'marketdata',
|
||||
['bonds', 'SU26238RMFS5'],
|
||||
expect.any(Function),
|
||||
'marketDataTtl',
|
||||
);
|
||||
expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(result).toMatchObject({
|
||||
data: {
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
name: 'ОФЗ 26238',
|
||||
shortName: 'ОФЗ 26238',
|
||||
latName: null,
|
||||
listLevel: 1,
|
||||
issueSize: 150000000,
|
||||
faceValue: 1000,
|
||||
faceUnit: 'RUB',
|
||||
matDate: '2041-05-15',
|
||||
couponValue: 35.4,
|
||||
couponPercent: 7.1,
|
||||
couponPeriod: 182,
|
||||
nextCoupon: '2026-06-24',
|
||||
accruedInt: 34.1,
|
||||
bondType: 'ofz',
|
||||
bondSubType: 'fixed',
|
||||
offerDate: null,
|
||||
buybackDate: null,
|
||||
marketData: {
|
||||
price: 73.05,
|
||||
yieldToMaturity: 14.2,
|
||||
duration: 2250,
|
||||
accruedInt: 34.1,
|
||||
couponValue: 35.4,
|
||||
couponPercent: 7.1,
|
||||
nextCouponDate: '2026-06-24',
|
||||
open: 72.8,
|
||||
high: 73.4,
|
||||
low: 72.5,
|
||||
volume: 10000,
|
||||
},
|
||||
},
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
});
|
||||
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws EntityNotFoundException when bond data is missing', async () => {
|
||||
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
|
||||
expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should return OFZ bond data for SU26207RMFS9', async () => {
|
||||
const result = await service.getBond('SU26207RMFS9');
|
||||
expect(result.data.secid).toBe('SU26207RMFS9');
|
||||
expect(result.data.marketData).toBeDefined();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
@ -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,51 +1,40 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
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 configuration from '../../config/configuration';
|
||||
import { CandleInterval } from './dto/candles-query.dto';
|
||||
|
||||
describe('CandlesService', () => {
|
||||
let service: CandlesService;
|
||||
let moexCandles: Pick<MoexCandlesClient, 'getCandles'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexCandles = {
|
||||
getCandles: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
CandlesService,
|
||||
{ provide: MoexCandlesClient, useValue: moexCandles },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: {
|
||||
get: () => undefined,
|
||||
set: () => Promise.resolve(),
|
||||
del: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
CacheService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CandlesService>(CandlesService);
|
||||
});
|
||||
|
||||
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
|
||||
vi.mocked(moexCandles.getCandles).mockResolvedValue([
|
||||
{
|
||||
open: 320,
|
||||
high: 325,
|
||||
low: 318,
|
||||
close: 323,
|
||||
volume: 1500000,
|
||||
value: 480000000,
|
||||
begin: '2026-05-01 00:00:00',
|
||||
end: '2026-05-01 23:59:59',
|
||||
},
|
||||
]);
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return daily candles for SBER', async () => {
|
||||
const result = await service.getCandles(
|
||||
'shares',
|
||||
'SBER',
|
||||
@ -53,63 +42,7 @@ describe('CandlesService', () => {
|
||||
'2026-05-01',
|
||||
'2026-06-01',
|
||||
);
|
||||
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'candles',
|
||||
['shares', 'SBER', '24', '2026-05-01', '2026-06-01'],
|
||||
expect.any(Function),
|
||||
'candlesTtl',
|
||||
);
|
||||
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||
'stock',
|
||||
'shares',
|
||||
'SBER',
|
||||
24,
|
||||
'2026-05-01',
|
||||
'2026-06-01',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{
|
||||
open: 320,
|
||||
high: 325,
|
||||
low: 318,
|
||||
close: 323,
|
||||
volume: 1500000,
|
||||
value: 480000000,
|
||||
begin: '2026-05-01 00:00:00',
|
||||
end: '2026-05-01 23:59:59',
|
||||
},
|
||||
],
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
|
||||
vi.mocked(moexCandles.getCandles).mockResolvedValue([]);
|
||||
|
||||
await service.getCandles(
|
||||
'bonds',
|
||||
'SU26238RMFS5',
|
||||
CandleInterval.HOUR,
|
||||
'2026-05-01',
|
||||
'2026-06-01',
|
||||
);
|
||||
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'candles',
|
||||
['bonds', 'SU26238RMFS5', '60', '2026-05-01', '2026-06-01'],
|
||||
expect.any(Function),
|
||||
'candlesTtl',
|
||||
);
|
||||
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||
'stock',
|
||||
'bonds',
|
||||
'SU26238RMFS5',
|
||||
60,
|
||||
'2026-05-01',
|
||||
'2026-06-01',
|
||||
);
|
||||
});
|
||||
expect(result.data.length).toBeGreaterThan(0);
|
||||
expect(result.data[0].open).toBeDefined();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
@ -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,38 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { MoexClientModule } from './moex-client.module';
|
||||
import { MoexSecuritiesClient } from './moex-securities.client';
|
||||
import { MoexMarketDataClient } from './moex-market-data.client';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
|
||||
'MoexClient live MOEX integration',
|
||||
() => {
|
||||
let moexSecurities: MoexSecuritiesClient;
|
||||
let moexMarketData: MoexMarketDataClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule],
|
||||
}).compile();
|
||||
|
||||
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient);
|
||||
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||
});
|
||||
|
||||
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
||||
const results = await moexSecurities.searchSecurities('SBER');
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it('возвращает рыночные данные SBER из live MOEX', async () => {
|
||||
const data = await moexMarketData.getShareMarketData('SBER');
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.secid).toBe('SBER');
|
||||
}, 15000);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,38 @@
|
||||
import 'reflect-metadata';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { MoexClientService } from './moex-client.service';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe('MoexClientService', () => {
|
||||
let service: MoexClientService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [MoexClientService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MoexClientService>(MoexClientService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('searchSecurities', () => {
|
||||
it('should return results for SBER query', async () => {
|
||||
const results = await service.searchSecurities('SBER');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('getShareMarketData', () => {
|
||||
it('should return market data for SBER', async () => {
|
||||
const data = await service.getShareMarketData('SBER');
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.secid).toBe('SBER');
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
414
apps/backend/src/modules/moex-client/moex-client.service.ts
Normal file
414
apps/backend/src/modules/moex-client/moex-client.service.ts
Normal file
@ -0,0 +1,414 @@
|
||||
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[]> {
|
||||
if (secids.length === 0) return [];
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities`,
|
||||
{ securities: secids.join(','), boards: boardId },
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
return secids.map((secid) => {
|
||||
const sec =
|
||||
securities.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
||||
securities.find((r) => r.SECID === secid);
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (sec?.SHORTNAME as string) || '',
|
||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
||||
last: mkt
|
||||
? parseFloat((mkt.LAST as string) || '')
|
||||
: parseFloat((sec?.PREVPRICE as string) || ''),
|
||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getBondPositionDataBatch(
|
||||
secids: string[],
|
||||
boardId = 'TQCB',
|
||||
): Promise<MoexBondPositionData[]> {
|
||||
if (secids.length === 0) return [];
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities`,
|
||||
{ securities: secids.join(','), boards: boardId },
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
return secids.map((secid) => {
|
||||
const bond =
|
||||
securities.find(
|
||||
(r) => r.SECID === secid && r.BOARDID === boardId && r.PREVWAPRICE != null,
|
||||
) ||
|
||||
securities.find((r) => r.SECID === secid && r.PREVWAPRICE != null) ||
|
||||
securities.find((r) => r.SECID === secid);
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
|
||||
marketdata.find((r) => r.LAST != null) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (bond?.SHORTNAME as string) || '',
|
||||
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
||||
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
||||
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
||||
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
||||
couponPercent:
|
||||
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
||||
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
|
||||
matDate: (bond?.MATDATE as string) || null,
|
||||
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
||||
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
|
||||
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
|
||||
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
||||
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
|
||||
bondType: (bond?.BONDTYPE as string) || null,
|
||||
offerDate: (bond?.OFFERDATE as string) || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,11 @@ import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsArray,
|
||||
IsIn,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@ -32,19 +30,8 @@ export class AddPositionDto {
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 250.5 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
buyPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-01' })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
buyDate?: string;
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Покупка на дип' })
|
||||
@IsString()
|
||||
@ -52,7 +39,7 @@ export class AddPositionDto {
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS, isArray: true })
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS })
|
||||
@IsArray()
|
||||
@IsIn(TAGS, { each: true })
|
||||
@IsOptional()
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PositionWithPriceDto } from './position-with-price.dto';
|
||||
|
||||
export class PortfolioSummaryDto {
|
||||
@ApiProperty() totalInvested!: number;
|
||||
@ApiProperty() totalValue!: number;
|
||||
@ApiProperty() totalPnl!: number;
|
||||
@ApiProperty({ type: Number, nullable: true }) totalPnlPercent!: number | null;
|
||||
@ApiProperty() totalDividends!: number;
|
||||
@ApiProperty() totalReturn!: number;
|
||||
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
||||
@ApiProperty() positionCount!: number;
|
||||
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
targetSharesPercent?: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
targetBondsPercent?: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
actualSharesPercent!: number;
|
||||
|
||||
@ApiProperty()
|
||||
actualBondsPercent!: number;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
sharesDeviation?: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
bondsDeviation?: number | null;
|
||||
}
|
||||
|
||||
export class AnalyticsResponseDto {
|
||||
@ApiProperty({ type: [PositionWithPriceDto] }) positions!: PositionWithPriceDto[];
|
||||
@ApiProperty() summary!: PortfolioSummaryDto;
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
import { AnalyticsResponseDto } from './analytics-response.dto';
|
||||
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
|
||||
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
|
||||
import { PositionResponseDto } from './position-response.dto';
|
||||
|
||||
export class PortfolioListEnvelopeDto {
|
||||
@ApiProperty({ type: [PortfolioListResponseDto] })
|
||||
data!: PortfolioListResponseDto[];
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class PortfolioEnvelopeDto {
|
||||
@ApiProperty({ type: PortfolioResponseDto })
|
||||
data!: PortfolioResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class PortfolioDetailEnvelopeDto {
|
||||
@ApiProperty({ type: PortfolioDetailResponseDto })
|
||||
data!: PortfolioDetailResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class PositionEnvelopeDto {
|
||||
@ApiProperty({ type: PositionResponseDto })
|
||||
data!: PositionResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class AnalyticsEnvelopeDto {
|
||||
@ApiProperty({ type: AnalyticsResponseDto })
|
||||
data!: AnalyticsResponseDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
@ -1,24 +1,38 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PortfolioSummaryDto } from './analytics-response.dto';
|
||||
import { PositionWithPriceDto } from './position-with-price.dto';
|
||||
|
||||
class PositionWithPriceDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||||
@ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string;
|
||||
@ApiProperty({ example: 10 }) quantity!: number;
|
||||
@ApiPropertyOptional() notes!: string | null;
|
||||
@ApiPropertyOptional() tags!: string[] | null;
|
||||
@ApiPropertyOptional() currentPrice!: number | null;
|
||||
@ApiPropertyOptional() currentValue!: number | null;
|
||||
@ApiProperty() weightPercent!: number;
|
||||
@ApiPropertyOptional() change!: number | null;
|
||||
@ApiPropertyOptional() changePercent!: number | null;
|
||||
@ApiPropertyOptional() yieldToMaturity!: number | null;
|
||||
@ApiPropertyOptional() duration!: number | null;
|
||||
@ApiPropertyOptional() couponValue!: number | null;
|
||||
@ApiPropertyOptional() couponPercent!: number | null;
|
||||
@ApiPropertyOptional() nextCouponDate!: string | null;
|
||||
@ApiPropertyOptional() matDate!: string | null;
|
||||
@ApiPropertyOptional() accruedInt!: number | null;
|
||||
@ApiPropertyOptional() bid!: number | null;
|
||||
@ApiPropertyOptional() offer!: number | null;
|
||||
@ApiPropertyOptional() couponPeriod!: number | null;
|
||||
@ApiPropertyOptional() bondType!: string | null;
|
||||
@ApiPropertyOptional() offerDate!: string | null;
|
||||
}
|
||||
|
||||
export class PortfolioResponseDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiPropertyOptional({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiPropertyOptional() description!: string | null;
|
||||
@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 {
|
||||
@ -26,7 +40,4 @@ export class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
||||
positions!: PositionWithPriceDto[];
|
||||
|
||||
@ApiProperty() totalValue!: number;
|
||||
|
||||
@ApiProperty({ type: PortfolioSummaryDto })
|
||||
analytics!: PortfolioSummaryDto;
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@ export class PositionResponseDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||||
@ApiProperty({ example: 10 }) quantity!: number;
|
||||
@ApiPropertyOptional({ type: String, nullable: true }) notes!: string | null;
|
||||
@ApiPropertyOptional({ type: String, isArray: true, nullable: true }) tags!: string[] | null;
|
||||
@ApiPropertyOptional() notes!: string | null;
|
||||
@ApiPropertyOptional() tags!: string[] | null;
|
||||
@ApiProperty() portfolioId!: number;
|
||||
@ApiProperty() createdAt!: string;
|
||||
@ApiProperty() updatedAt!: string;
|
||||
|
||||
@ -1,99 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PositionWithPriceDto {
|
||||
@ApiProperty()
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ example: 'SBER' })
|
||||
secid!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
shortName!: string | null;
|
||||
|
||||
@ApiProperty({ example: 'share', enum: ['share', 'bond'] })
|
||||
type!: string;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
buyPrice!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
buyDate!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
notes!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, isArray: true, nullable: true })
|
||||
tags!: string[] | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
currentPrice!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
totalCost!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
currentValue!: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
weightPercent!: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
pnl!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
pnlPercent!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
dividendIncome!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
totalReturn!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
totalReturnPercent!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
change?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
changePercent?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
yieldToMaturity?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
duration?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
couponValue?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
couponPercent?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
nextCouponDate?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
matDate?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
accruedInt?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
bid?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
offer?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
couponPeriod?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
bondType?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
offerDate?: string | null;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,14 +1,4 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsArray,
|
||||
IsIn,
|
||||
MaxLength,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
const TAGS = [
|
||||
@ -25,20 +15,9 @@ const TAGS = [
|
||||
export class UpdatePositionDto {
|
||||
@ApiPropertyOptional({ example: 15 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 260.0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
buyPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-15' })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
buyDate?: string;
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Докупка' })
|
||||
@IsString()
|
||||
@ -46,7 +25,7 @@ export class UpdatePositionDto {
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS, isArray: true })
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS })
|
||||
@IsArray()
|
||||
@IsIn(TAGS, { each: true })
|
||||
@IsOptional()
|
||||
|
||||
@ -1,123 +1,90 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiOkResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiExtraModels,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
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,
|
||||
PositionEnvelopeDto,
|
||||
} from './dto/portfolio-envelope.dto';
|
||||
|
||||
const nullDataEnvelopeSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: { type: 'null' },
|
||||
meta: { $ref: getSchemaPath(ApiResponseMeta) },
|
||||
},
|
||||
required: ['data', 'meta'],
|
||||
};
|
||||
|
||||
@ApiTags('Portfolios')
|
||||
@ApiBearerAuth()
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('portfolios')
|
||||
export class PortfolioController {
|
||||
constructor(private readonly portfolioService: PortfolioService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
|
||||
@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true })
|
||||
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')
|
||||
@ApiOperation({ summary: 'Update portfolio' })
|
||||
@ApiOkResponse({ type: PortfolioEnvelopeDto })
|
||||
async update(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Delete portfolio' })
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Add position to portfolio' })
|
||||
@ApiCreatedResponse({ type: PositionEnvelopeDto })
|
||||
async addPosition(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Update position' })
|
||||
@ApiOkResponse({ type: PositionEnvelopeDto })
|
||||
async updatePosition(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('positionId', ParseIntPipe) positionId: number,
|
||||
@Body() dto: UpdatePositionDto,
|
||||
) {
|
||||
return this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||||
}
|
||||
|
||||
@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 position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Delete(':id/positions/:positionId')
|
||||
@ApiOperation({ summary: 'Remove position from portfolio' })
|
||||
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
|
||||
async removePosition(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@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> = {}) => ({
|
||||
@ -34,8 +31,6 @@ describe('PortfolioService', () => {
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: null,
|
||||
buyDate: null,
|
||||
notes: null,
|
||||
tags: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
@ -59,7 +54,6 @@ describe('PortfolioService', () => {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
position: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@ -68,20 +62,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 +80,7 @@ describe('PortfolioService', () => {
|
||||
|
||||
service = module.get<PortfolioService>(PortfolioService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||
moexClient = module.get<MoexClientService>(MoexClientService);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@ -124,14 +111,12 @@ describe('PortfolioService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should enrich portfolios with market data from batch MOEX call and compute PnL', async () => {
|
||||
it('should enrich portfolios with market data from batch MOEX call', async () => {
|
||||
const sharePosition = mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 230,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
});
|
||||
const bondPosition = mockPosition({
|
||||
id: 2,
|
||||
@ -139,19 +124,17 @@ describe('PortfolioService', () => {
|
||||
secid: 'SU26238RMFS5',
|
||||
type: 'bond',
|
||||
quantity: 5,
|
||||
buyPrice: 950,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
});
|
||||
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
|
||||
]);
|
||||
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
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',
|
||||
@ -176,16 +159,8 @@ describe('PortfolioService', () => {
|
||||
expect(result[0].positionCount).toBe(2);
|
||||
expect(result[0].shareCount).toBe(1);
|
||||
expect(result[0].bondCount).toBe(1);
|
||||
|
||||
// SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925
|
||||
expect(result[0].totalValue).toBe(7425);
|
||||
|
||||
// Verify PnL for SBER share
|
||||
// totalCost = 230 * 10 = 2300
|
||||
// currentValue = 250 * 10 = 2500
|
||||
// pnl = 2500 - 2300 = 200
|
||||
// pnlPercent = 200 / 2300 * 100 ≈ 8.70
|
||||
// Verify via findOne which gives enriched positions
|
||||
});
|
||||
|
||||
it('should propagate MOEX errors to the caller', async () => {
|
||||
@ -213,337 +188,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);
|
||||
});
|
||||
|
||||
it('should return portfolio with enriched positions and analytics summary', async () => {
|
||||
const sharePosition = mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 200,
|
||||
});
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(
|
||||
mockPortfolio({ positions: [sharePosition] }) as any,
|
||||
);
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([sharePosition] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
||||
] as any);
|
||||
|
||||
const result = await service.findOne(1, 1);
|
||||
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.positions).toHaveLength(1);
|
||||
expect(result.positions[0].secid).toBe('SBER');
|
||||
expect(result.positions[0].weightPercent).toBe(100);
|
||||
expect(result.totalValue).toBe(2500);
|
||||
expect(result.analytics).toBeDefined();
|
||||
expect(result.analytics.totalInvested).toBe(2000);
|
||||
expect(result.analytics.totalValue).toBe(2500);
|
||||
expect(result.analytics.totalPnl).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPositionsWithPrices', () => {
|
||||
it('should return empty array when no positions exist', async () => {
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([]);
|
||||
const result = await service.getPositionsWithPrices(1);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return enriched positions with PnL for shares', async () => {
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([
|
||||
mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 230,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
}),
|
||||
] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||
] as any);
|
||||
|
||||
const result = await service.getPositionsWithPrices(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].secid).toBe('SBER');
|
||||
expect(result[0].buyPrice).toBe(230);
|
||||
expect(result[0].buyDate).toBe('2026-03-01T00:00:00.000Z');
|
||||
expect(result[0].totalCost).toBe(2300);
|
||||
expect(result[0].currentPrice).toBe(250);
|
||||
expect(result[0].currentValue).toBe(2500);
|
||||
expect(result[0].pnl).toBe(200);
|
||||
expect(result[0].pnlPercent).toBeCloseTo(8.6957, 1);
|
||||
expect(result[0].dividendIncome).toBe(0);
|
||||
expect(result[0].totalReturn).toBe(200);
|
||||
expect(result[0].totalReturnPercent).toBeCloseTo(8.6957, 1);
|
||||
});
|
||||
|
||||
it('should return enriched positions with PnL for bonds', async () => {
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([
|
||||
mockPosition({
|
||||
id: 2,
|
||||
portfolioId: 1,
|
||||
secid: 'SU26238RMFS5',
|
||||
type: 'bond',
|
||||
quantity: 5,
|
||||
buyPrice: 95, // 95% of face value
|
||||
buyDate: new Date('2026-03-01'),
|
||||
}),
|
||||
] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
shortName: 'OFZ 26238',
|
||||
price: 98.5,
|
||||
faceValue: 1000,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await service.getPositionsWithPrices(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].secid).toBe('SU26238RMFS5');
|
||||
// totalCost = 950 * 5 = 4750
|
||||
expect(result[0].totalCost).toBe(4750);
|
||||
// currentValue = (98.5 / 100) * 1000 * 5 = 4925
|
||||
expect(result[0].currentValue).toBe(4925);
|
||||
// pnl = 4925 - 4750 = 175
|
||||
expect(result[0].pnl).toBe(175);
|
||||
expect(result[0].pnlPercent).toBeCloseTo(3.6842, 1);
|
||||
expect(result[0].totalReturn).toBe(175);
|
||||
});
|
||||
|
||||
it('should set PnL to null when buyPrice is missing', async () => {
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([
|
||||
mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: null,
|
||||
buyDate: null,
|
||||
}),
|
||||
] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
||||
] as any);
|
||||
|
||||
const result = await service.getPositionsWithPrices(1);
|
||||
|
||||
expect(result[0].buyPrice).toBeNull();
|
||||
expect(result[0].totalCost).toBeNull();
|
||||
expect(result[0].currentValue).toBe(2500);
|
||||
expect(result[0].pnl).toBeNull();
|
||||
expect(result[0].pnlPercent).toBeNull();
|
||||
expect(result[0].totalReturn).toBeNull();
|
||||
expect(result[0].totalReturnPercent).toBeNull();
|
||||
});
|
||||
|
||||
it('should set PnL to null when market data is missing', async () => {
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([
|
||||
mockPosition({
|
||||
id: 1,
|
||||
secid: 'UNKNOWN',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 100,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
}),
|
||||
] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([] as any);
|
||||
|
||||
const result = await service.getPositionsWithPrices(1);
|
||||
|
||||
expect(result[0].currentPrice).toBeNull();
|
||||
expect(result[0].currentValue).toBeNull();
|
||||
expect(result[0].pnl).toBeNull();
|
||||
expect(result[0].totalReturn).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnalytics', () => {
|
||||
it('should return empty analytics when no positions', async () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any);
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await service.getAnalytics(1, 1);
|
||||
|
||||
expect(result.summary.totalInvested).toBe(0);
|
||||
expect(result.summary.totalValue).toBe(0);
|
||||
expect(result.summary.totalPnl).toBe(0);
|
||||
expect(result.summary.positionCount).toBe(0);
|
||||
expect(result.summary.weightedYield).toBeNull();
|
||||
});
|
||||
|
||||
it('should compute correct summary with share positions', async () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any);
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([
|
||||
mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 230,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
}),
|
||||
mockPosition({
|
||||
id: 2,
|
||||
portfolioId: 1,
|
||||
secid: 'GAZP',
|
||||
type: 'share',
|
||||
quantity: 5,
|
||||
buyPrice: 150,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
}),
|
||||
] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||
{ secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
|
||||
] as any);
|
||||
|
||||
const result = await service.getAnalytics(1, 1);
|
||||
|
||||
// totalInvested: 230*10 + 150*5 = 2300 + 750 = 3050
|
||||
expect(result.summary.totalInvested).toBe(3050);
|
||||
// totalValue: 250*10 + 160*5 = 2500 + 800 = 3300
|
||||
expect(result.summary.totalValue).toBe(3300);
|
||||
// totalPnl: 200 + 50 = 250
|
||||
expect(result.summary.totalPnl).toBe(250);
|
||||
// totalPnlPercent: 250 / 3050 * 100 ≈ 8.20
|
||||
expect(result.summary.totalPnlPercent).toBeCloseTo(8.1967, 1);
|
||||
expect(result.summary.positionCount).toBe(2);
|
||||
expect(result.summary.totalDividends).toBe(0);
|
||||
expect(result.summary.totalReturn).toBe(250);
|
||||
expect(result.summary.totalReturnPercent).toBeCloseTo(8.1967, 1);
|
||||
});
|
||||
|
||||
it('should compute weightedYield correctly', async () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio() as any);
|
||||
vi.mocked(prisma.position.findMany).mockResolvedValue([
|
||||
mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 100,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
}),
|
||||
mockPosition({
|
||||
id: 2,
|
||||
portfolioId: 1,
|
||||
secid: 'GAZP',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 200,
|
||||
buyDate: new Date('2026-03-01'),
|
||||
}),
|
||||
] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 120 },
|
||||
{ secid: 'GAZP', shortName: 'Gazprom', last: 180 },
|
||||
] as any);
|
||||
|
||||
const result = await service.getAnalytics(1, 1);
|
||||
|
||||
// SBER: pnlPercent=20%, cost=1000, weight=1000/3000=1/3
|
||||
// GAZP: pnlPercent=-10%, cost=2000, weight=2000/3000=2/3
|
||||
// weightedYield = 20*(1/3) + (-10)*(2/3) = 20/3 - 20/3 = 0
|
||||
expect(result.summary.weightedYield).toBeCloseTo(0, 1);
|
||||
});
|
||||
|
||||
it('should throw PortfolioAccessDeniedException if portfolio belongs to another user', async () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
|
||||
|
||||
await expect(service.getAnalytics(1, 1)).rejects.toThrow(PortfolioAccessDeniedException);
|
||||
});
|
||||
|
||||
it('should throw EntityNotFoundException if portfolio does not exist', async () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException);
|
||||
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,45 +1,29 @@
|
||||
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';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
import { AnalyticsResponseDto } from './dto/analytics-response.dto';
|
||||
|
||||
export interface EnrichedPosition {
|
||||
id: number;
|
||||
portfolioId: number;
|
||||
secid: string;
|
||||
shortName: string | null;
|
||||
type: string;
|
||||
quantity: number;
|
||||
buyPrice: number | null;
|
||||
buyDate: string | null;
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
currentPrice: number | null;
|
||||
totalCost: number | null;
|
||||
currentValue: number | null;
|
||||
weightPercent: number;
|
||||
pnl: number | null;
|
||||
pnlPercent: number | null;
|
||||
dividendIncome: number | null;
|
||||
totalReturn: number | null;
|
||||
totalReturnPercent: number | null;
|
||||
change?: number | null;
|
||||
changePercent?: number | null;
|
||||
yieldToMaturity?: number | null;
|
||||
@ -60,14 +44,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 +57,6 @@ export class PortfolioService {
|
||||
currency: dto.currency ?? 'RUB',
|
||||
},
|
||||
});
|
||||
|
||||
return { ...portfolio, targets: null };
|
||||
}
|
||||
|
||||
async findAll(userId: number) {
|
||||
@ -99,7 +79,6 @@ export class PortfolioService {
|
||||
positionCount: 0,
|
||||
shareCount: 0,
|
||||
bondCount: 0,
|
||||
targets: p.targets ? JSON.parse(p.targets) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -128,7 +107,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,10 +117,10 @@ 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);
|
||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions);
|
||||
|
||||
const totalValue = positionsWithPrices.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
|
||||
@ -154,8 +132,6 @@ export class PortfolioService {
|
||||
};
|
||||
});
|
||||
|
||||
const analytics = await this.getAnalytics(userId, id);
|
||||
|
||||
return {
|
||||
id: portfolio.id,
|
||||
name: portfolio.name,
|
||||
@ -165,36 +141,28 @@ export class PortfolioService {
|
||||
updatedAt: portfolio.updatedAt.toISOString(),
|
||||
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 +172,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 +181,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';
|
||||
@ -224,8 +192,6 @@ export class PortfolioService {
|
||||
secid: dto.secid,
|
||||
type,
|
||||
quantity: dto.quantity,
|
||||
buyPrice: dto.buyPrice ?? null,
|
||||
buyDate: dto.buyDate ? new Date(dto.buyDate) : null,
|
||||
notes: dto.notes ?? null,
|
||||
tags: dto.tags ? JSON.stringify(dto.tags) : null,
|
||||
},
|
||||
@ -239,20 +205,18 @@ 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({
|
||||
where: { id: positionId },
|
||||
data: {
|
||||
...(dto.quantity !== undefined && { quantity: dto.quantity }),
|
||||
...(dto.buyPrice !== undefined && { buyPrice: dto.buyPrice }),
|
||||
...(dto.buyDate !== undefined && { buyDate: new Date(dto.buyDate) }),
|
||||
...(dto.notes !== undefined && { notes: dto.notes }),
|
||||
...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }),
|
||||
},
|
||||
@ -261,12 +225,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 } });
|
||||
@ -279,22 +243,18 @@ export class PortfolioService {
|
||||
secid: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
buyPrice: number | null;
|
||||
buyDate: Date | null;
|
||||
notes: string | null;
|
||||
tags: string | null;
|
||||
}[],
|
||||
portfolioId?: number,
|
||||
): Promise<EnrichedPosition[]> {
|
||||
const sharePositions = positions.filter((p) => p.type === 'share');
|
||||
const bondPositions = positions.filter((p) => p.type === 'bond');
|
||||
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
|
||||
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
|
||||
|
||||
const [shareDataBySecid, bondDataBySecid, dividendsBySecid] = await Promise.all([
|
||||
this.fetchShareBatch(shareSecids, portfolioId),
|
||||
this.fetchBondBatch(bondSecids, portfolioId),
|
||||
this.fetchDividendsBatch(shareSecids, portfolioId),
|
||||
const [shareDataBySecid, bondDataBySecid] = await Promise.all([
|
||||
this.fetchShareBatch(shareSecids),
|
||||
this.fetchBondBatch(bondSecids),
|
||||
]);
|
||||
|
||||
const enriched: EnrichedPosition[] = [];
|
||||
@ -302,195 +262,79 @@ export class PortfolioService {
|
||||
for (const pos of positions) {
|
||||
const base = {
|
||||
id: pos.id,
|
||||
portfolioId: pos.portfolioId,
|
||||
secid: pos.secid,
|
||||
shortName: null as string | null,
|
||||
type: pos.type,
|
||||
quantity: pos.quantity,
|
||||
buyPrice: pos.buyPrice,
|
||||
buyDate: pos.buyDate ? pos.buyDate.toISOString() : null,
|
||||
notes: pos.notes,
|
||||
tags: pos.tags ? JSON.parse(pos.tags) : null,
|
||||
totalCost: null as number | null,
|
||||
weightPercent: 0,
|
||||
currentPrice: null as number | null,
|
||||
currentValue: null as number | null,
|
||||
pnl: null as number | null,
|
||||
pnlPercent: null as number | null,
|
||||
dividendIncome: null as number | null,
|
||||
totalReturn: null as number | null,
|
||||
totalReturnPercent: null as number | null,
|
||||
};
|
||||
|
||||
if (pos.type === 'bond') {
|
||||
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
||||
} else {
|
||||
enriched.push(
|
||||
this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid), dividendsBySecid.get(pos.secid)),
|
||||
);
|
||||
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
private async fetchShareBatch(
|
||||
secids: string[],
|
||||
portfolioId?: number,
|
||||
): Promise<Map<string, MoexShareMarketData>> {
|
||||
private async fetchShareBatch(secids: string[]): Promise<Map<string, MoexShareMarketData>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const cacheKey = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(',');
|
||||
const cacheKey = secids.join(',');
|
||||
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]));
|
||||
}
|
||||
|
||||
private async fetchBondBatch(
|
||||
secids: string[],
|
||||
portfolioId?: number,
|
||||
): Promise<Map<string, MoexBondPositionData>> {
|
||||
private async fetchBondBatch(secids: string[]): Promise<Map<string, MoexBondPositionData>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const cacheKey = portfolioId ? `pf:${portfolioId}:${secids.join(',')}` : secids.join(',');
|
||||
const cacheKey = secids.join(',');
|
||||
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;
|
||||
secid: string;
|
||||
quantity: number;
|
||||
buyPrice: number | null;
|
||||
buyDate: Date | null;
|
||||
},
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
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);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
...base,
|
||||
currentPrice: null,
|
||||
currentValue: null,
|
||||
totalCost,
|
||||
pnl: null,
|
||||
pnlPercent: null,
|
||||
dividendIncome,
|
||||
totalReturn: null,
|
||||
totalReturnPercent: null,
|
||||
};
|
||||
}
|
||||
|
||||
const currentPrice = data.last;
|
||||
const currentValue = currentPrice !== null ? currentPrice * pos.quantity : null;
|
||||
const pnl = currentValue !== null && totalCost !== null ? currentValue - totalCost : null;
|
||||
const pnlPercent =
|
||||
pnl !== null && totalCost !== null && totalCost !== 0 ? (pnl / totalCost) * 100 : null;
|
||||
const totalReturn = pnl !== null ? pnl + dividendIncome : null;
|
||||
const totalReturnPercent =
|
||||
totalReturn !== null && totalCost !== null && totalCost !== 0
|
||||
? (totalReturn / totalCost) * 100
|
||||
: null;
|
||||
|
||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
||||
return {
|
||||
...base,
|
||||
shortName: data.shortName,
|
||||
currentPrice,
|
||||
currentPrice: data.last,
|
||||
change: data.lastChange,
|
||||
changePercent: data.lastChangePrcnt,
|
||||
totalCost,
|
||||
currentValue,
|
||||
pnl,
|
||||
pnlPercent,
|
||||
dividendIncome,
|
||||
totalReturn,
|
||||
totalReturnPercent,
|
||||
currentValue: data.last !== null ? data.last * pos.quantity : null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildBondPosition(
|
||||
pos: {
|
||||
id: number;
|
||||
secid: string;
|
||||
quantity: number;
|
||||
buyPrice: number | null;
|
||||
buyDate: Date | null;
|
||||
},
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
base: EnrichedPosition,
|
||||
data: MoexBondPositionData | undefined,
|
||||
): EnrichedPosition {
|
||||
if (!data) {
|
||||
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; // Fallback for unknown faceValue
|
||||
return {
|
||||
...base,
|
||||
currentPrice: null,
|
||||
currentValue: null,
|
||||
totalCost,
|
||||
pnl: null,
|
||||
pnlPercent: null,
|
||||
dividendIncome: 0,
|
||||
totalReturn: null,
|
||||
totalReturnPercent: null,
|
||||
};
|
||||
}
|
||||
|
||||
const currentPrice = data.price;
|
||||
const totalCost =
|
||||
pos.buyPrice !== null ? (pos.buyPrice / 100) * data.faceValue * pos.quantity : null;
|
||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
||||
const currentValue =
|
||||
data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null;
|
||||
const pnl = currentValue !== null && totalCost !== null ? currentValue - totalCost : null;
|
||||
const pnlPercent =
|
||||
pnl !== null && totalCost !== null && totalCost !== 0 ? (pnl / totalCost) * 100 : null;
|
||||
const dividendIncome = 0;
|
||||
const totalReturn = pnl !== null ? pnl + dividendIncome : null;
|
||||
const totalReturnPercent =
|
||||
totalReturn !== null && totalCost !== null && totalCost !== 0
|
||||
? (totalReturn / totalCost) * 100
|
||||
: null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
shortName: data.shortName,
|
||||
currentPrice,
|
||||
currentPrice: data.price,
|
||||
yieldToMaturity: data.yieldToMaturity,
|
||||
duration: data.duration,
|
||||
couponValue: data.couponValue,
|
||||
@ -503,90 +347,7 @@ export class PortfolioService {
|
||||
couponPeriod: data.couponPeriod,
|
||||
bondType: data.bondType,
|
||||
offerDate: data.offerDate,
|
||||
totalCost,
|
||||
currentValue,
|
||||
pnl,
|
||||
pnlPercent,
|
||||
dividendIncome,
|
||||
totalReturn,
|
||||
totalReturnPercent,
|
||||
};
|
||||
}
|
||||
|
||||
async getPositionsWithPrices(portfolioId: number): Promise<EnrichedPosition[]> {
|
||||
const positions = await this.prisma.position.findMany({ where: { portfolioId } });
|
||||
if (positions.length === 0) return [];
|
||||
return this.enrichPositions(positions);
|
||||
}
|
||||
|
||||
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||||
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||
|
||||
const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
|
||||
|
||||
const totalInvested = enrichedPositions.reduce((sum, p) => sum + (p.totalCost ?? 0), 0);
|
||||
const totalValue = enrichedPositions.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
const totalPnl = enrichedPositions.reduce((sum, p) => sum + (p.pnl ?? 0), 0);
|
||||
const totalDividends = enrichedPositions.reduce((sum, p) => sum + (p.dividendIncome ?? 0), 0);
|
||||
const totalReturn = totalPnl + totalDividends;
|
||||
const totalPnlPercent = totalInvested > 0 ? (totalPnl / totalInvested) * 100 : null;
|
||||
const totalReturnPercent = totalInvested > 0 ? (totalReturn / totalInvested) * 100 : null;
|
||||
const positionCount = enrichedPositions.length;
|
||||
|
||||
const weightedYield =
|
||||
totalInvested > 0
|
||||
? enrichedPositions.reduce(
|
||||
(sum, p) => sum + ((p.pnlPercent ?? 0) * (p.totalCost ?? 0)) / totalInvested,
|
||||
0,
|
||||
)
|
||||
: null;
|
||||
|
||||
let targetSharesPercent: number | null = null;
|
||||
let targetBondsPercent: number | null = null;
|
||||
if (portfolio.targets) {
|
||||
const targets = JSON.parse(portfolio.targets);
|
||||
targetSharesPercent = targets.sharesPercent;
|
||||
targetBondsPercent = targets.bondsPercent;
|
||||
}
|
||||
|
||||
let actualSharesPercent = 0;
|
||||
let actualBondsPercent = 0;
|
||||
if (totalValue > 0) {
|
||||
const shareValue = enrichedPositions
|
||||
.filter((p) => p.type === 'share')
|
||||
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
const bondValue = enrichedPositions
|
||||
.filter((p) => p.type === 'bond')
|
||||
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
actualSharesPercent = Math.round((shareValue / totalValue) * 10000) / 100;
|
||||
actualBondsPercent = Math.round((bondValue / totalValue) * 10000) / 100;
|
||||
}
|
||||
|
||||
const sharesDeviation =
|
||||
targetSharesPercent !== null ? Math.round((actualSharesPercent - targetSharesPercent) * 100) / 100 : null;
|
||||
const bondsDeviation =
|
||||
targetBondsPercent !== null ? Math.round((actualBondsPercent - targetBondsPercent) * 100) / 100 : null;
|
||||
|
||||
const summary = {
|
||||
totalInvested,
|
||||
totalValue,
|
||||
totalPnl,
|
||||
totalPnlPercent,
|
||||
totalDividends,
|
||||
totalReturn,
|
||||
totalReturnPercent,
|
||||
positionCount,
|
||||
weightedYield,
|
||||
targetSharesPercent,
|
||||
targetBondsPercent,
|
||||
actualSharesPercent,
|
||||
actualBondsPercent,
|
||||
sharesDeviation,
|
||||
bondsDeviation,
|
||||
};
|
||||
|
||||
return { positions: enrichedPositions, summary };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsString, IsOptional, IsNumber, IsInt, Min, Max, IsEnum, IsIn } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum ScreenerType {
|
||||
SHARE = 'share',
|
||||
BOND = 'bond',
|
||||
}
|
||||
|
||||
export const SORTER_FIELDS = [
|
||||
'price',
|
||||
'changePercent',
|
||||
'volume',
|
||||
'listLevel',
|
||||
'capitalization',
|
||||
'yieldToMaturity',
|
||||
'duration',
|
||||
'couponValue',
|
||||
'couponPercent',
|
||||
] as const;
|
||||
|
||||
export class ScreenerQueryDto {
|
||||
@ApiProperty({ enum: ScreenerType })
|
||||
@IsEnum(ScreenerType)
|
||||
type!: ScreenerType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
priceMin?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
priceMax?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
volumeMin?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(3)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
listLevel?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
changePercentMin?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
changePercentMax?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
capitalizationMin?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
yieldMin?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
yieldMax?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
durationMin?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
durationMax?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
couponMin?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
couponMax?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
couponPercentMin?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
couponPercentMax?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
maturityBefore?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
maturityAfter?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
bondType?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'price' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'asc' })
|
||||
@IsString()
|
||||
@IsIn(['asc', 'desc'])
|
||||
@IsOptional()
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
pageSize?: number;
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||
|
||||
export class ScreenerItemDto {
|
||||
@ApiProperty({ example: 'SBER' })
|
||||
secid!: string;
|
||||
|
||||
@ApiProperty({ example: 'Сбербанк' })
|
||||
shortName!: string;
|
||||
|
||||
@ApiProperty({ example: 'RU0009029540' })
|
||||
isin!: string;
|
||||
|
||||
@ApiProperty({ enum: ['share', 'bond'] })
|
||||
type!: 'share' | 'bond';
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 })
|
||||
price!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 1.15 })
|
||||
change!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 0.36 })
|
||||
changePercent!: number | null;
|
||||
|
||||
@ApiProperty({ example: 1925163 })
|
||||
volume!: number;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
listLevel!: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 6958336818320 })
|
||||
capitalization!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 })
|
||||
yieldToMaturity!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 })
|
||||
duration!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 40.64 })
|
||||
couponValue!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 8.15 })
|
||||
couponPercent!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 29.48 })
|
||||
accruedInt!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, example: '2027-02-03' })
|
||||
matDate!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, example: 'ОФЗ-ПД' })
|
||||
bondType!: string | null;
|
||||
}
|
||||
|
||||
export class ScreenerResultDto {
|
||||
@ApiProperty({ type: [ScreenerItemDto] })
|
||||
items!: ScreenerItemDto[];
|
||||
|
||||
@ApiProperty()
|
||||
total!: number;
|
||||
|
||||
@ApiProperty()
|
||||
page!: number;
|
||||
|
||||
@ApiProperty()
|
||||
pageSize!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalPages!: number;
|
||||
}
|
||||
|
||||
export class ScreenerResponseDto {
|
||||
@ApiProperty({ type: ScreenerResultDto })
|
||||
data!: ScreenerResultDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
@ -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,125 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ScreenerService } from './screener.service';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { ScreenerType } from './dto/screener-query.dto';
|
||||
|
||||
describe('ScreenerService', () => {
|
||||
let service: ScreenerService;
|
||||
let cache: CacheService;
|
||||
const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ScreenerService,
|
||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||
{ provide: CacheService, useValue: { getOrFetch: vi.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ScreenerService>(ScreenerService);
|
||||
cache = module.get<CacheService>(CacheService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('screen', () => {
|
||||
it('should cache full dataset with screenerTtl config', async () => {
|
||||
const mockShares = [{
|
||||
secid: 'SBER', shortName: 'Sberbank', last: 250, volume: 1000000,
|
||||
lastChange: 5, lastChangePrcnt: 2, issueCapitalization: 1e9,
|
||||
}];
|
||||
|
||||
moexMarketData.getShareMarketDataBatch.mockResolvedValue(mockShares);
|
||||
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(async (_prefix, _keys, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}));
|
||||
|
||||
await service.screen({ type: ScreenerType.SHARE });
|
||||
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'screener',
|
||||
[ScreenerType.SHARE],
|
||||
expect.any(Function),
|
||||
'screenerTtl',
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter and sort shares', async () => {
|
||||
const mockShares = [
|
||||
{
|
||||
secid: 'SBER',
|
||||
shortName: 'Sberbank',
|
||||
price: 250,
|
||||
volume: 1000000,
|
||||
changePercent: 1,
|
||||
type: 'share',
|
||||
},
|
||||
{
|
||||
secid: 'GAZP',
|
||||
shortName: 'Gazprom',
|
||||
price: 150,
|
||||
volume: 500000,
|
||||
changePercent: -1,
|
||||
type: 'share',
|
||||
},
|
||||
{
|
||||
secid: 'LKOH',
|
||||
shortName: 'Lukoil',
|
||||
price: 5000,
|
||||
volume: 100000,
|
||||
changePercent: 0.5,
|
||||
type: 'share',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(async () => ({
|
||||
data: mockShares,
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}));
|
||||
|
||||
const result = await service.screen({
|
||||
type: ScreenerType.SHARE,
|
||||
priceMax: 300,
|
||||
sortOrder: 'desc',
|
||||
sortBy: 'price',
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0].secid).toBe('SBER'); // 250
|
||||
expect(result.items[1].secid).toBe('GAZP'); // 150
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it('should paginate results', async () => {
|
||||
const mockShares = Array.from({ length: 50 }, (_, i) => ({
|
||||
secid: `TICKER${i}`,
|
||||
last: i,
|
||||
}));
|
||||
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(async () => ({
|
||||
data: mockShares,
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}));
|
||||
|
||||
const result = await service.screen({
|
||||
type: ScreenerType.SHARE,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(10);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.totalPages).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,187 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
|
||||
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ScreenerService {
|
||||
constructor(
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async screen(q: ScreenerQueryDto): Promise<ScreenerResultDto> {
|
||||
const board = await this.fetchBoard(q.type);
|
||||
const filtered = board.filter((item) => this.matches(item, q));
|
||||
const sorted = this.sort(filtered, q.sortBy || 'price', q.sortOrder || 'asc');
|
||||
|
||||
const total = sorted.length;
|
||||
const page = q.page || 1;
|
||||
const pageSize = q.pageSize || 20;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = sorted.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchBoard(type: ScreenerType): Promise<ScreenerItemDto[]> {
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'screener',
|
||||
[type],
|
||||
async () => {
|
||||
if (type === ScreenerType.SHARE) {
|
||||
const shares = await this.moexMarketData.getShareMarketDataBatch([]);
|
||||
return shares.map(
|
||||
(s): ScreenerItemDto => ({
|
||||
secid: s.secid,
|
||||
shortName: s.shortName,
|
||||
isin: '', // MOEX batch doesn't return ISIN in securities table sometimes, but we can live without it for screener
|
||||
type: 'share',
|
||||
price: s.last,
|
||||
change: s.lastChange,
|
||||
changePercent: s.lastChangePrcnt,
|
||||
volume: s.volume,
|
||||
listLevel: 0,
|
||||
capitalization: s.issueCapitalization,
|
||||
yieldToMaturity: null,
|
||||
duration: null,
|
||||
couponValue: null,
|
||||
couponPercent: null,
|
||||
accruedInt: null,
|
||||
matDate: null,
|
||||
bondType: null,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const bonds = await this.moexMarketData.getBondPositionDataBatch([]);
|
||||
return bonds.map(
|
||||
(b): ScreenerItemDto => ({
|
||||
secid: b.secid,
|
||||
shortName: b.shortName,
|
||||
isin: '',
|
||||
type: 'bond',
|
||||
price: b.price,
|
||||
change: null,
|
||||
changePercent: null,
|
||||
volume: 0,
|
||||
listLevel: 0,
|
||||
capitalization: null,
|
||||
yieldToMaturity: b.yieldToMaturity,
|
||||
duration: b.duration,
|
||||
couponValue: b.couponValue,
|
||||
couponPercent: b.couponPercent,
|
||||
accruedInt: b.accruedInt,
|
||||
matDate: b.matDate,
|
||||
bondType: b.bondType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
'screenerTtl',
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private matches(item: ScreenerItemDto, q: ScreenerQueryDto): boolean {
|
||||
if (q.priceMin != null && (item.price == null || item.price < q.priceMin)) return false;
|
||||
if (q.priceMax != null && (item.price == null || item.price > q.priceMax)) return false;
|
||||
if (q.volumeMin != null && item.volume < q.volumeMin) return false;
|
||||
if (q.listLevel != null && item.listLevel !== q.listLevel) return false;
|
||||
|
||||
if (item.type === 'share') {
|
||||
if (
|
||||
q.changePercentMin != null &&
|
||||
(item.changePercent == null || item.changePercent < q.changePercentMin)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
q.changePercentMax != null &&
|
||||
(item.changePercent == null || item.changePercent > q.changePercentMax)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
q.capitalizationMin != null &&
|
||||
(item.capitalization == null || item.capitalization < q.capitalizationMin)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.type === 'bond') {
|
||||
if (q.yieldMin != null && (item.yieldToMaturity == null || item.yieldToMaturity < q.yieldMin))
|
||||
return false;
|
||||
if (q.yieldMax != null && (item.yieldToMaturity == null || item.yieldToMaturity > q.yieldMax))
|
||||
return false;
|
||||
if (q.durationMin != null && (item.duration == null || item.duration < q.durationMin))
|
||||
return false;
|
||||
if (q.durationMax != null && (item.duration == null || item.duration > q.durationMax))
|
||||
return false;
|
||||
if (q.couponMin != null && (item.couponValue == null || item.couponValue < q.couponMin))
|
||||
return false;
|
||||
if (q.couponMax != null && (item.couponValue == null || item.couponValue > q.couponMax))
|
||||
return false;
|
||||
if (
|
||||
q.couponPercentMin != null &&
|
||||
(item.couponPercent == null || item.couponPercent < q.couponPercentMin)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
q.couponPercentMax != null &&
|
||||
(item.couponPercent == null || item.couponPercent > q.couponPercentMax)
|
||||
)
|
||||
return false;
|
||||
if (q.maturityBefore != null && (item.matDate == null || item.matDate > q.maturityBefore))
|
||||
return false;
|
||||
if (q.maturityAfter != null && (item.matDate == null || item.matDate < q.maturityAfter))
|
||||
return false;
|
||||
if (q.bondType != null && item.bondType !== q.bondType) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sort(
|
||||
items: ScreenerItemDto[],
|
||||
sortBy: string,
|
||||
sortOrder: 'asc' | 'desc',
|
||||
): ScreenerItemDto[] {
|
||||
const allowedFields = new Set([
|
||||
'secid',
|
||||
'shortName',
|
||||
'price',
|
||||
'change',
|
||||
'changePercent',
|
||||
'volume',
|
||||
'listLevel',
|
||||
'capitalization',
|
||||
'yieldToMaturity',
|
||||
'duration',
|
||||
'couponValue',
|
||||
'couponPercent',
|
||||
'accruedInt',
|
||||
'matDate',
|
||||
]);
|
||||
if (!allowedFields.has(sortBy)) {
|
||||
sortBy = 'price';
|
||||
}
|
||||
return [...items].sort((a, b) => {
|
||||
const aVal = (a as any)[sortBy];
|
||||
const bVal = (b as any)[sortBy];
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
if (typeof aVal === 'string') {
|
||||
return sortOrder === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
||||
}
|
||||
return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SecuritiesController } from './securities.controller';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { ScreenerService } from './screener.service';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesController', () => {
|
||||
@ -24,17 +23,10 @@ describe('SecuritiesController', () => {
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
};
|
||||
|
||||
const mockScreenerService = {
|
||||
screen: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SecuritiesController],
|
||||
providers: [
|
||||
{ provide: SecuritiesService, useValue: mockService },
|
||||
{ provide: ScreenerService, useValue: mockScreenerService },
|
||||
],
|
||||
providers: [{ provide: SecuritiesService, useValue: mockService }],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SecuritiesController>(SecuritiesController);
|
||||
@ -47,7 +39,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,37 +1,21 @@
|
||||
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 } 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(
|
||||
private readonly securitiesService: SecuritiesService,
|
||||
private readonly screenerService: ScreenerService,
|
||||
) {}
|
||||
constructor(private readonly securitiesService: SecuritiesService) {}
|
||||
|
||||
@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,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('screener')
|
||||
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
|
||||
@ApiOkResponse({ type: ScreenerResponseDto })
|
||||
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
||||
return this.screenerService.screen(query);
|
||||
return { data: results, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
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],
|
||||
providers: [SecuritiesService],
|
||||
exports: [SecuritiesService],
|
||||
})
|
||||
export class SecuritiesModule {}
|
||||
|
||||
@ -1,174 +1,38 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
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 configuration from '../../config/configuration';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesService', () => {
|
||||
let service: SecuritiesService;
|
||||
let moexSecurities: Pick<MoexSecuritiesClient, 'searchSecurities'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexSecurities = {
|
||||
searchSecurities: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
SecuritiesService,
|
||||
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: {
|
||||
get: () => undefined,
|
||||
set: () => Promise.resolve(),
|
||||
del: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
CacheService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SecuritiesService>(SecuritiesService);
|
||||
});
|
||||
|
||||
it('returns supported securities only and normalizes SUR currency to RUB', async () => {
|
||||
vi.mocked(moexSecurities.searchSecurities).mockResolvedValue([
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
name: 'ОФЗ 26238',
|
||||
shortName: 'ОФЗ 26238',
|
||||
latName: null,
|
||||
listLevel: 1,
|
||||
issueSize: 100000000,
|
||||
faceValue: 1000,
|
||||
faceUnit: 'RUB',
|
||||
issueDate: '2021-06-23',
|
||||
typeName: 'Государственная облигация',
|
||||
group: 'stock_bonds',
|
||||
type: 'ofz_bond',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: false,
|
||||
eveningSession: false,
|
||||
},
|
||||
{
|
||||
secid: 'SiM6',
|
||||
isin: '',
|
||||
name: 'USD/RUB Futures',
|
||||
shortName: 'SiM6',
|
||||
latName: null,
|
||||
listLevel: 0,
|
||||
issueSize: 0,
|
||||
faceValue: 0,
|
||||
faceUnit: '',
|
||||
issueDate: '',
|
||||
typeName: 'Фьючерс',
|
||||
group: 'futures',
|
||||
type: 'futures',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: false,
|
||||
eveningSession: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const results = await service.search('SbEr', SecurityType.ALL, 10);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
shortName: 'Сбербанк',
|
||||
type: 'share',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
shortName: 'ОФЗ 26238',
|
||||
type: 'bond',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
]);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'search',
|
||||
['sber'],
|
||||
expect.any(Function),
|
||||
'searchTtl',
|
||||
);
|
||||
expect(moexSecurities.searchSecurities).toHaveBeenCalledWith('SbEr');
|
||||
});
|
||||
|
||||
it('filters by type and applies limit without live MOEX dependency', async () => {
|
||||
vi.mocked(cache.getOrFetch).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
shortName: 'Сбербанк',
|
||||
type: 'share',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
{
|
||||
secid: 'GAZP',
|
||||
isin: 'RU0007661625',
|
||||
shortName: 'Газпром',
|
||||
type: 'share',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
shortName: 'ОФЗ 26238',
|
||||
type: 'bond',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
],
|
||||
fromCache: true,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const results = await service.search('ru', SecurityType.SHARE, 1);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
shortName: 'Сбербанк',
|
||||
type: 'share',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
]);
|
||||
expect(moexSecurities.searchSecurities).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should return search results for SBER', async () => {
|
||||
const results = await service.search('SBER', SecurityType.ALL, 5);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
@ -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,144 +1,41 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SharesService } from './shares.service';
|
||||
import { 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';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe('SharesService', () => {
|
||||
let service: SharesService;
|
||||
let moexSecurities: Pick<MoexSecuritiesClient, 'getSecurityDescription'>;
|
||||
let moexMarketData: Pick<MoexMarketDataClient, 'getShareMarketData'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexSecurities = {
|
||||
getSecurityDescription: vi.fn(),
|
||||
};
|
||||
moexMarketData = {
|
||||
getShareMarketData: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
SharesService,
|
||||
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||
{ provide: MoexDividendsClient, useValue: { getDividends: vi.fn() } },
|
||||
{ provide: MoexHistoryClient, useValue: { getHistory: vi.fn() } },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: {
|
||||
get: () => undefined,
|
||||
set: () => Promise.resolve(),
|
||||
del: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
CacheService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SharesService>(SharesService);
|
||||
});
|
||||
|
||||
it('returns normalized SBER share spec and market data without live MOEX dependency', async () => {
|
||||
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
name: 'Сбербанк России ПАО ао',
|
||||
shortName: 'Сбербанк',
|
||||
latName: 'Sberbank',
|
||||
listLevel: 1,
|
||||
issueSize: 21586948000,
|
||||
faceValue: 3,
|
||||
faceUnit: 'SUR',
|
||||
issueDate: '2007-07-20',
|
||||
typeName: 'Акция обыкновенная',
|
||||
group: 'stock_shares',
|
||||
type: 'common_share',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: true,
|
||||
eveningSession: true,
|
||||
});
|
||||
vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({
|
||||
secid: 'SBER',
|
||||
boardid: 'TQBR',
|
||||
shortName: 'Сбербанк',
|
||||
bid: 320,
|
||||
offer: 321,
|
||||
open: 318,
|
||||
low: 317,
|
||||
high: 325,
|
||||
last: 323,
|
||||
lastChange: 4,
|
||||
lastChangePrcnt: 1.25,
|
||||
volume: 1500000,
|
||||
value: 480000000,
|
||||
waprice: 321,
|
||||
numtrades: 4200,
|
||||
issueCapitalization: 6900000000000,
|
||||
tradingStatus: 'T',
|
||||
updateTime: '18:45:00',
|
||||
});
|
||||
|
||||
const result = await service.getShare('SBER');
|
||||
|
||||
expect(moexSecurities.getSecurityDescription).toHaveBeenCalledWith('SBER');
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'marketdata',
|
||||
['shares', 'SBER'],
|
||||
expect.any(Function),
|
||||
'marketDataTtl',
|
||||
);
|
||||
expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER');
|
||||
expect(result.data).toMatchObject({
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
name: 'Сбербанк России ПАО ао',
|
||||
shortName: 'Сбербанк',
|
||||
latName: 'Sberbank',
|
||||
listLevel: 1,
|
||||
issueSize: 21586948000,
|
||||
faceValue: 3,
|
||||
faceUnit: 'RUB',
|
||||
type: 'common_share',
|
||||
marketData: {
|
||||
price: 323,
|
||||
change: 4,
|
||||
changePercent: 1.25,
|
||||
open: 318,
|
||||
high: 325,
|
||||
low: 317,
|
||||
volume: 1500000,
|
||||
value: 480000000,
|
||||
issueCapitalization: 6900000000000,
|
||||
},
|
||||
});
|
||||
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws EntityNotFoundException for non-share security', async () => {
|
||||
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
name: 'ОФЗ 26238',
|
||||
shortName: 'ОФЗ 26238',
|
||||
latName: null,
|
||||
listLevel: 1,
|
||||
issueSize: 100000000,
|
||||
faceValue: 1000,
|
||||
faceUnit: 'RUB',
|
||||
issueDate: '2021-06-23',
|
||||
typeName: 'Государственная облигация',
|
||||
group: 'stock_bonds',
|
||||
type: 'ofz_bond',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: false,
|
||||
eveningSession: false,
|
||||
});
|
||||
|
||||
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException);
|
||||
expect(cache.getOrFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should return SBER share data', async () => {
|
||||
const share = await service.getShare('SBER');
|
||||
expect(share.secid).toBe('SBER');
|
||||
expect(share.marketData).toBeDefined();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
@ -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,21 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class BrokerAccountResponseDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enum: ['brokerage', 'iis'] })
|
||||
type!: 'brokerage' | 'iis';
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
openedAt!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
accessLevel!: string | null;
|
||||
}
|
||||
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