Compare commits
264 Commits
codex/tech
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 40ef3e9059 | |||
| d8070209ed | |||
| 3281866c43 | |||
| 8b24d8b82b | |||
| d21e2411b8 | |||
| ff0e11b303 | |||
| c4b93f9f0c | |||
| 3fdceb9438 | |||
| fee179d8e9 | |||
| 58b8075583 | |||
| d7b65e256f | |||
| b12c2741cd | |||
| a1377fa5c2 | |||
| 27fb6e77be | |||
| 94f8bb876d | |||
| 3bad694dce | |||
| 21df354df6 | |||
| 0a1ae0552c | |||
| 1a2937ad58 | |||
| af0aaeda9d | |||
| ae207b0cb3 | |||
| d4d1dd980e | |||
| 0116c34a9f | |||
| d804d43616 | |||
| 8df94a51dd | |||
| c429d6a43a | |||
| 49dac140ff | |||
| 8dab915edd | |||
| bbbdca30f6 | |||
| 99a917392a | |||
| 47e65c9b46 | |||
| 7af8691e0c | |||
| 4cb0b8d450 | |||
| 293838ba68 | |||
| b27d6ad836 | |||
| 53bab79c5f | |||
| 0e7ecbb1ef | |||
| b87ed761ed | |||
| fe938b2706 | |||
| af0c93fd34 | |||
| db28f46481 | |||
| 0e8f65c457 | |||
| 120db3c2ab | |||
| 58ddb10a92 | |||
| 4ed6e0ce20 | |||
| 75fead68b8 | |||
| d2457d13af | |||
| 238836c850 | |||
| 6e8efd2b80 | |||
| 9f85dc5dde | |||
| 3b919ecdc6 | |||
| ccb1082535 | |||
| 8d6410b37b | |||
| 1fc7386568 | |||
| e69f183372 | |||
| b092caf8d6 | |||
| 3a89cef76c | |||
| 659be4636c | |||
| de776f477c | |||
| 194a8be3e4 | |||
| 95e9e7f71f | |||
| ba0a4cbfef | |||
| f2fe6a4829 | |||
| f0b6785540 | |||
| 2af2ff32c1 | |||
| 2ed356fbcd | |||
| a72f951033 | |||
| 3dfcf5aaa8 | |||
| ef4bc48e50 | |||
| a1e68fb13a | |||
| 3f0fb42d99 | |||
| abf3676ba1 | |||
| 5cd56b239a | |||
| 5184d34262 | |||
| 61e3398c71 | |||
| 15427be384 | |||
| 0c4250fa6c | |||
| d529d51612 | |||
| 9ad0f571d5 | |||
| 410f35bbd7 | |||
| afa0ec05e7 | |||
| 41668ea452 | |||
| 2a6cb1cec9 | |||
| 5b8bf1c4b4 | |||
| 3e054ee6c4 | |||
| 204152907c | |||
| a13b5145f7 | |||
| b26021016c | |||
| 76cffc061d | |||
| aea74bda3b | |||
| 9932278b64 | |||
| 063e80c375 | |||
| 5b794c0419 | |||
| cbb5d09bc3 | |||
| c7a8993b4a | |||
| 7e10b4b8aa | |||
| cfadb2adbe | |||
| 6d3601a8f4 | |||
| 203b7cbf20 | |||
| 462212c95e | |||
| 62a8cffc96 | |||
| f7dc338719 | |||
| c71ba090ad | |||
| 6f4b46c964 | |||
| 0ddd9b5f80 | |||
| 3027b4f0f0 | |||
| 595d059151 | |||
| 7b1d649853 | |||
| 723250f0b2 | |||
| 6c768ef6a9 | |||
| 5ea1fcfe14 | |||
| 1cabe2d71e | |||
| 6debf66ac8 | |||
| b41ee9fce4 | |||
| 85fbfcdb0c | |||
| aa56c8a48d | |||
| 8ad5f3c70c | |||
| e044b64de4 | |||
| da656330b3 | |||
| 9a94f4e3a9 | |||
| a9a95dae69 | |||
| ef97fd7136 | |||
| 3682f43aa6 | |||
| bd27bba66d | |||
| 860bed159c | |||
| 62a3389bfb | |||
| a8741cfeab | |||
| bd33412691 | |||
| 3534ec4f77 | |||
| 339845dd57 | |||
| e6cf852741 | |||
| 53659a36b1 | |||
| 55221dbf22 | |||
| 74ca576310 | |||
| 886ac7b87a | |||
| 1107a1fc8b | |||
| fdffe204e4 | |||
| 129282d634 | |||
| ec13fa8ca2 | |||
| e8dcb27a03 | |||
| 1b6fd39be3 | |||
| 2744608d06 | |||
| ad196164ee | |||
| d1f9128441 | |||
| 8e70691bde | |||
| 943a6aec2d | |||
| 79011c779f | |||
| 1774c7ffd6 | |||
| 1f426e9734 | |||
| 2fd2f0611b | |||
| b9d7a6ad69 | |||
| 055bc23097 | |||
| 9b208481dd | |||
| 2fc98341a6 | |||
| 2619a27b80 | |||
| 605a4e8d00 | |||
| 16f5e1752c | |||
| 1ef1a3c902 | |||
| cfa890fdbe | |||
| 36f39d2d3f | |||
| 49dcbb4e33 | |||
| b069575fbb | |||
| 6f9e368126 | |||
| 40d7792b9e | |||
| 20f0efa083 | |||
| 8e9fdbe70a | |||
| af36d2e7cf | |||
| ad8ff6a875 | |||
| 946383bde0 | |||
| 9c115f0e16 | |||
| dd727f9fb2 | |||
| 93d18f1218 | |||
| 23a077536c | |||
| d95b2df460 | |||
| 9816bc6b96 | |||
| 5355451e35 | |||
| a9132109da | |||
| df4b778d95 | |||
| 4db95146a7 | |||
| b02ee9cd74 | |||
| 00f01fae4f | |||
| 09213624c3 | |||
| 8e578e7a91 | |||
| 1db1763b36 | |||
| 55cc479abb | |||
| a03e3c0240 | |||
| 9287b97880 | |||
| 3b618d3da0 | |||
| 52ebe3256d | |||
| 1e007de5be | |||
| 706bb12b6c | |||
| b7b627a0f3 | |||
| a95308762f | |||
| 1dc27a6e9b | |||
| d8b3886130 | |||
| 2223463dd3 | |||
| 910494a4e5 | |||
| b82771107d | |||
| a19ad67a89 | |||
| 1c26d2a3eb | |||
| 633def5ebb | |||
| dc4b6ddf1b | |||
| 6d2df6a12b | |||
| 2462f2122c | |||
| 5cb51e44f2 | |||
| 8aea56cbbf | |||
| da6d05e425 | |||
| be24184e4f | |||
| 62cef9b1c4 | |||
| 30922aaa28 | |||
| 0c12e6d610 | |||
| 5b9d7f3a27 | |||
| dca8418843 | |||
| af135d5640 | |||
| 2d671669d4 | |||
| 26371381e0 | |||
| 90e9c468de | |||
| 510ee91fab | |||
| ce538ff443 | |||
| b9ace3ed5e | |||
| feaff2103e | |||
| 49ee364856 | |||
| 5ccd421259 | |||
| e25daa931a | |||
| 28579321a9 | |||
| 2670097a5e | |||
| e0c9d97ded | |||
| d7b5a376a0 | |||
| 608e521674 | |||
| 33199a8749 | |||
| 4b87eccba4 | |||
| 8b202ef7d3 | |||
| 6b3bdd2aec | |||
| bb7fef8deb | |||
| 05f1e792c5 | |||
| 953c7c29a6 | |||
| 0517501415 | |||
| cc8ff0a088 | |||
| a8bce856d9 | |||
| 2e0c0e7df8 | |||
| 8e2151fc34 | |||
| 4682586002 | |||
| d16078fce9 | |||
| bd8d6c11fe | |||
| a8715258ca | |||
| 8c2a6c9e3c | |||
| ea916dfec9 | |||
| b10a2cfb0d | |||
| 3fe4e7a4ec | |||
| 6e30177294 | |||
| ead40bff6f | |||
| 50bff3dbe7 | |||
| b848d256d1 | |||
| d01d2b9f7f | |||
| bd6b2589c0 | |||
| ddeab01b9e | |||
| 4ef2f8d05e | |||
| 825095106c | |||
| 7a8bc7c90a | |||
| 526e5dc5f3 | |||
| a971d0c8bf | |||
| a3817edfc7 | |||
| 106467e5c4 | |||
| 739405a597 |
@ -9,46 +9,57 @@ env:
|
|||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
ci:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 'Checkout repository'
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 'Setup dependencies'
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- run: npm ci
|
- name: Install dependencies
|
||||||
- run: npm run lint
|
run: npm ci
|
||||||
- run: npx prettier --check "**/*.{ts,tsx}"
|
|
||||||
|
|
||||||
test:
|
- name: Build design system
|
||||||
runs-on: ubuntu-latest
|
run: npm run build:design-system
|
||||||
steps:
|
|
||||||
- name: 'Checkout repository'
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: 'Setup dependencies'
|
- name: Lint
|
||||||
uses: actions/setup-node@v4
|
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
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
name: storybook-static
|
||||||
|
path: packages/design-system/storybook-static
|
||||||
- run: npm ci
|
retention-days: 3
|
||||||
- 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,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.worktrees/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@ -9,3 +10,8 @@ vite.config.d.ts
|
|||||||
vite.config.js
|
vite.config.js
|
||||||
apps/docs/.docusaurus/
|
apps/docs/.docusaurus/
|
||||||
apps/docs/build/
|
apps/docs/build/
|
||||||
|
.idea
|
||||||
|
.playwright-mcp
|
||||||
|
.opencode
|
||||||
|
dev.db
|
||||||
|
graphify-out/
|
||||||
|
|||||||
139
.husky/post-checkout
Executable file
139
.husky/post-checkout
Executable file
@ -0,0 +1,139 @@
|
|||||||
|
#!/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
|
||||||
150
.husky/post-commit
Executable file
150
.husky/post-commit
Executable file
@ -0,0 +1,150 @@
|
|||||||
|
#!/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
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"printWidth": 100,
|
|
||||||
"semi": true
|
|
||||||
}
|
|
||||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
33
.serena/memories/memory_maintenance.md
Normal file
33
.serena/memories/memory_maintenance.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# 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.
|
||||||
133
.serena/project.yml
Normal file
133
.serena/project.yml
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# 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: []
|
||||||
527
AGENTS.md
527
AGENTS.md
@ -1,67 +1,448 @@
|
|||||||
# MoexVibe — Инструкция для агента
|
# MoexVibe — Инструкция для агента
|
||||||
|
|
||||||
## Репозиторий
|
## Содержание
|
||||||
|
|
||||||
npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite), `apps/docs` (Docusaurus).
|
- [Обязательный подход к разработке](#обязательный-подход-к-разработке)
|
||||||
|
- [Процесс разработки](#процесс-разработки)
|
||||||
|
- [Структура документации](#структура-документации)
|
||||||
|
- [Назначение документов](#назначение-документов)
|
||||||
|
- [Правила разработки](#правила-разработки)
|
||||||
|
- [Определение бага](#определение-бага)
|
||||||
|
- [Процесс работы над фичей](#процесс-работы-над-фичей)
|
||||||
|
- [Работа с новыми идеями](#работа-с-новыми-идеями)
|
||||||
|
- [Работа с существующими фичами](#работа-с-существующими-фичами)
|
||||||
|
- [Поддержание документации](#поддержание-документации)
|
||||||
|
- [Поведение AI-агентов](#поведение-ai-агентов)
|
||||||
|
- [Anti-Loop: лимит на итерации](#anti-loop-лимит-на-итерации)
|
||||||
|
- [Приоритет источников информации](#приоритет-источников-информации)
|
||||||
|
- [Git workflow](#git-workflow)
|
||||||
|
- [Конвенция коммитов](#конвенция-коммитов)
|
||||||
|
- [Документация и SDD-артефакты](#документация-и-sdd-артефакты)
|
||||||
|
- [Definition of Done (DoD)](#definition-of-done-dod)
|
||||||
|
- [Технические регламенты](#технические-регламенты)
|
||||||
|
- [Правила тестирования](#правила-тестирования)
|
||||||
|
- [Работа с миграциями Prisma](#работа-с-миграциями-prisma)
|
||||||
|
- [Правила рефакторинга](#правила-рефакторинга)
|
||||||
|
- [Политики безопасности](#политики-безопасности)
|
||||||
|
- [ADR-процесс](#adr-процесс)
|
||||||
|
- [Правила обновления OpenAPI/типов](#правила-обновления-openapiтипов)
|
||||||
|
- [Цикл работы над API](#цикл-работы-над-api)
|
||||||
|
- [Инфраструктура проекта](#инфраструктура-проекта)
|
||||||
|
- [Команды](#команды)
|
||||||
|
- [Переменные окружения](#переменные-окружения)
|
||||||
|
- [Архитектура](#архитектура)
|
||||||
|
- [Бэкенд](#бэкенд)
|
||||||
|
- [Фронтенд](#фронтенд)
|
||||||
|
- [Стиль кода](#стиль-кода)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Обязательный подход к разработке
|
## Обязательный подход к разработке
|
||||||
|
|
||||||
- **SDD (Specification-Driven Development)**: перед написанием кода сначала сформировать спецификацию — PRD, доменную модель, ADR, OpenAPI-контракт, архитектуру фронтенда и бэкенда, план реализации по этапам.
|
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
|
||||||
- **Superpowers**: обязательно использовать скиллы (Skills) при старте любой задачи — brainstorming, frontend-design, test-driven-development, writing-plans, executing-plans, requesting-code-review.
|
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans для крупных многошаговых работ, subagent-driven-development как предпочтительный способ исполнения плана, executing-plans как fallback для явно связанных inline-задач, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
|
||||||
- **MCP-инструменты**: использовать MCP для анализа и генерации дизайна, работы с API, генерации кода.
|
- **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 — инструмент, а не отдельный режим работы.
|
||||||
|
|
||||||
## Команды
|
---
|
||||||
|
|
||||||
| Команда | Что делает |
|
## Процесс разработки
|
||||||
|---|---|
|
|
||||||
| `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 test:backend` | `vitest run` (SWC, не ts-jest) |
|
|
||||||
| `npm run test:frontend` | Frontend Vitest suite |
|
|
||||||
| `npm run lint` | ESLint для backend и frontend |
|
|
||||||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
|
||||||
| `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` |
|
|
||||||
|
|
||||||
Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
|
Проект использует подход Specification-Driven Development (SDD).
|
||||||
|
|
||||||
Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend`
|
### Структура документации
|
||||||
|
|
||||||
## Переменные окружения
|
```text
|
||||||
|
docs/
|
||||||
|
|
||||||
| Переменная | По умолчанию | Описание |
|
├── inbox.md
|
||||||
|---|---|---|
|
├── roadmap.md
|
||||||
| `PORT` | 3000 | Порт бэкенда |
|
│
|
||||||
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
|
├── research/
|
||||||
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
│
|
||||||
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
├── epics/
|
||||||
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
│ └── {epic-name}.md
|
||||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
│
|
||||||
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
└── features/
|
||||||
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
└── {feature-name}/
|
||||||
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
|
├── spec.md
|
||||||
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
|
├── plan.md
|
||||||
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
|
└── tasks.md
|
||||||
| `JWT_ACCESS_EXPIRES` | `15m` | TTL access token |
|
|
||||||
| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token |
|
```
|
||||||
|
|
||||||
|
Полный набор `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
|
### Бэкенд
|
||||||
|
|
||||||
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
|
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
|
||||||
- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `PortfolioModule`, `HealthModule`.
|
- Актуальная композиция backend-модулей определяется в `apps/backend/src/app.module.ts`; не дублировать динамический список модулей в инструкциях. Опубликованное описание архитектуры находится в `apps/docs/docs/backend/modules.md`.
|
||||||
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
|
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
|
||||||
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
|
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
|
||||||
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов).
|
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов).
|
||||||
- БД: SQLite через Prisma ORM. Prisma client генерируется в `src/generated/prisma/`.
|
- БД: SQLite через Prisma ORM. Prisma client используется из `@prisma/client`; схема и миграции находятся в `apps/backend/prisma/`.
|
||||||
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
|
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
|
||||||
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
|
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
|
||||||
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
||||||
- Алиасы: `@/*` → `src/*` в обоих пакетах.
|
- Алиасы: `@/*` → `src/*` в обоих пакетах.
|
||||||
|
|
||||||
## Фронтенд
|
### Фронтенд
|
||||||
|
|
||||||
- React 18 + react-router-dom v6 + TanStack Query v5.
|
- React 18 + react-router-dom v6 + TanStack Query v5.
|
||||||
- `lightweight-charts` v4 для графиков цен.
|
- `lightweight-charts` v4 для графиков цен.
|
||||||
@ -70,7 +451,7 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
|
|||||||
- Конвенция ключей запросов: `['stock', secid]`, `['securities', 'search', query]`, и т.д.
|
- Конвенция ключей запросов: `['stock', secid]`, `['securities', 'search', query]`, и т.д.
|
||||||
- CSS через `styles.css` (CSS custom properties, без CSS-in-JS или Tailwind).
|
- CSS через `styles.css` (CSS custom properties, без CSS-in-JS или Tailwind).
|
||||||
|
|
||||||
## Стиль кода
|
### Стиль кода
|
||||||
|
|
||||||
- Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
|
- Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
|
||||||
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
|
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
|
||||||
@ -78,3 +459,67 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
|
|||||||
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
|
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
|
||||||
- CI находится в `.gitea/workflows/ci.yml`.
|
- CI находится в `.gitea/workflows/ci.yml`.
|
||||||
- Pre-commit checks настроены через Husky и lint-staged.
|
- Pre-commit checks настроены через Husky и lint-staged.
|
||||||
|
|
||||||
|
## code-index-mcp
|
||||||
|
|
||||||
|
В проекте настроен `code-index-mcp` — MCP-сервер для быстрого поиска файлов и кода.
|
||||||
|
|
||||||
|
**Инструменты:**
|
||||||
|
- `find_files(pattern)` — поиск файлов по glob-паттерну через in-memory индекс
|
||||||
|
- `search_code_advanced(pattern)` — поиск кода с поддержкой regex, контекста, фильтрации по типу файла
|
||||||
|
- `get_file_summary(path)` — сводка по файлу (строки, функции, классы, импорты)
|
||||||
|
- `get_symbol_body(path, symbol_name)` — получить тело символа (функции/класса)
|
||||||
|
- `find_implementations(name_path, relative_path)` — найти реализации символа
|
||||||
|
- `find_referencing_symbols(name_path, relative_path)` — найти ссылки на символ
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Поиск файлов по имени или паттерну (glob)
|
||||||
|
- Быстрый grep по коду с контекстом
|
||||||
|
- Получение только тела функции/класса без всего файла
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## serena
|
||||||
|
|
||||||
|
В проекте настроена `serena` — MCP-сервер с LSP-символьным анализом кода. Предоставляет symbol-aware инструменты поверх TypeScript LSP.
|
||||||
|
|
||||||
|
**Инструменты:**
|
||||||
|
- `find_symbol(name_path_pattern)` — поиск символов (классы, функции, методы) по всему проекту
|
||||||
|
- `get_symbols_overview(relative_path)` — обзор символов в файле (группировка по типу)
|
||||||
|
- `find_referencing_symbols(name_path, relative_path)` — где используется символ
|
||||||
|
- `find_implementations(name_path, relative_path)` — реализации интерфейса/класса
|
||||||
|
- `find_declaration(relative_path, regex)` — найти объявление по вызову
|
||||||
|
- `replace_symbol_body(name_path, relative_path, body)` — заменить тело метода
|
||||||
|
- `rename_symbol(name_path, relative_path, new_name)` — рефакторинг-переименование
|
||||||
|
- `replace_content(relative_path, needle, repl, mode)` — regex-замена в файле
|
||||||
|
- `safe_delete_symbol(name_path, relative_path)` — удалить неиспользуемый символ
|
||||||
|
- `get_diagnostics_for_file(relative_path)` — ошибки/предупреждения в файле
|
||||||
|
- `write_memory/read_memory/list_memories` — сохранение контекста между сессиями
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Найти все использования функции/метода в коде
|
||||||
|
- Получить структуру файла (классы, методы)
|
||||||
|
- Безопасный рефакторинг (переименование, удаление)
|
||||||
|
- Получить LSP-диагностику (ошибки компиляции)
|
||||||
|
- Запомнить что-то между сессиями (memories)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## graphify
|
||||||
|
|
||||||
|
This project has a knowledge graph in `graphify-out/` with god nodes, community structure, and cross-file relationships. The graph is a local artifact, not a tracked repo asset.
|
||||||
|
|
||||||
|
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Используй `graphify` в первую очередь, когда задача связана с архитектурой, границами модулей, кросс-файловым влиянием или трассировкой потока данных.
|
||||||
|
- Для таких вопросов сначала запускай `graphify query "<question>"`, если существует `graphify-out/graph.json`. Для связей используй `graphify path "<A>" "<B>"`, для точечных концептов — `graphify explain "<concept>"`. Обычно это даёт гораздо более узкий подграф, чем `GRAPH_REPORT.md` или raw grep.
|
||||||
|
- Предпочитай `graphify query` перед raw grep, когда нужен кратчайший путь между концептами, мост между комьюнити или трассировка того, как один подсистемный блок достигает другого.
|
||||||
|
- Для отладки багов начинай с симптома и спрашивай у graphify путь зависимости, bridge nodes или модули, которые могут объяснить неожиданное поведение.
|
||||||
|
- Если `graphify` возвращает только общую структуру, переходи к `serena` за символ-уровневыми фактами и затем повторяй `graphify` с более узким вопросом, где названы конкретные файлы, модули или сервисы.
|
||||||
|
- Dirty `graphify-out/` после хуков или инкрементальных обновлений считаются нормой; грязные файлы графа не повод пропускать `graphify`. Пропускать его можно только если задача именно про устаревший или некорректный граф, либо если пользователь прямо попросил не использовать его.
|
||||||
|
- В новом `worktree` сначала заново создай локальный граф командой `graphify extract .`.
|
||||||
|
- После первой сборки в этом `worktree` обновляй граф командой `graphify update .`.
|
||||||
|
- Если существует `graphify-out/wiki/index.md`, используй его для широкого обзора вместо ручного просмотра исходников.
|
||||||
|
- `graphify-out/GRAPH_REPORT.md` читай только для широкого архитектурного обзора или когда `query/path/explain` не дают достаточно контекста.
|
||||||
|
- После изменений в коде запускай `graphify update .`, чтобы держать граф актуальным (только AST, без затрат на LLM).
|
||||||
|
|||||||
151
README.md
151
README.md
@ -2,59 +2,168 @@
|
|||||||
|
|
||||||
Веб-приложение для анализа ценных бумаг Московской биржи (MOEX).
|
Веб-приложение для анализа ценных бумаг Московской биржи (MOEX).
|
||||||
|
|
||||||
## Tech Stack
|
## Содержание
|
||||||
|
|
||||||
- **Backend:** NestJS, TypeScript, OpenAPI (Swagger)
|
- [О проекте](#о-проекте)
|
||||||
- **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts
|
- [Стек технологий](#стек-технологий)
|
||||||
- **Docs:** Docusaurus
|
- [Быстрый старт](#быстрый-старт)
|
||||||
- **Infrastructure:** Docker, docker-compose
|
- [Docker](#docker)
|
||||||
|
- [Тестирование](#тестирование)
|
||||||
|
- [Структура проекта](#структура-проекта)
|
||||||
|
- [Команды](#команды)
|
||||||
|
- [Переменные окружения](#переменные-окружения)
|
||||||
|
|
||||||
## Quick Start
|
---
|
||||||
|
|
||||||
|
## О проекте
|
||||||
|
|
||||||
|
npm workspaces монорепозиторий:
|
||||||
|
|
||||||
|
| Пакет | Назначение |
|
||||||
|
| --------------- | -------------------------------------------------- |
|
||||||
|
| `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) |
|
||||||
|
| `apps/frontend` | React SPA на Vite |
|
||||||
|
| `apps/docs` | Сайт документации Docusaurus |
|
||||||
|
| `packages/design-system` | Дизайн-система (Storybook, MUI-адаптер, UI-компоненты) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стек технологий
|
||||||
|
|
||||||
|
- **Бэкенд:** NestJS, TypeScript, OpenAPI (Swagger)
|
||||||
|
- **Фронтенд:** React, TypeScript, Vite, TanStack Query, lightweight-charts
|
||||||
|
- **Дизайн-система:** MUI v7, Storybook 10, lightweight-charts
|
||||||
|
- **Документация:** Docusaurus
|
||||||
|
- **Инфраструктура:** Docker, docker-compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Настройка локального окружения
|
||||||
npm install
|
cp apps/backend/.env.example apps/backend/.env
|
||||||
|
|
||||||
# Start backend (http://localhost:3000)
|
# Установка зависимостей и подготовка базы данных
|
||||||
|
npm install
|
||||||
|
npm exec -w apps/backend -- prisma migrate dev
|
||||||
|
|
||||||
|
# Запуск бэкенда (http://localhost:3000)
|
||||||
npm run dev:backend
|
npm run dev:backend
|
||||||
|
|
||||||
# Start frontend (http://localhost:5173)
|
# Запуск фронтенда (http://localhost:5173)
|
||||||
npm run dev:frontend
|
npm run dev:frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Swagger UI: http://localhost:3000/api/docs
|
Swagger UI: http://localhost:3000/api/docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
- Frontend: http://localhost:80
|
- Фронтенд: http://localhost:80
|
||||||
- Backend: http://localhost:3000
|
- Бэкенд: http://localhost:3000
|
||||||
|
|
||||||
## Tests
|
---
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:backend
|
npm run test:backend
|
||||||
npm run test:frontend
|
npm run test:frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Live MOEX integration checks are opt-in:
|
Интеграционные тесты с MOEX — опциональны:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:integration -w apps/backend
|
npm run test:integration -w apps/backend
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/
|
apps/
|
||||||
backend/ — NestJS API (single point of access to MOEX ISS)
|
backend/ — NestJS API, единая точка доступа к MOEX ISS
|
||||||
frontend/ — React SPA with Vite
|
frontend/ — React SPA на Vite
|
||||||
docs/ — Docusaurus documentation site
|
docs/ — сайт документации Docusaurus
|
||||||
|
packages/
|
||||||
|
design-system/ — дизайн-система (MUI-адаптер, UI-компоненты, Storybook)
|
||||||
docs/
|
docs/
|
||||||
architecture/ — ADR documents and diagrams
|
features/ — спецификации и планы реализации (SDD)
|
||||||
openapi/ — OpenAPI specification
|
epics/ — продуктовые эпики
|
||||||
superpowers/ — Design specs and implementation plans
|
inbox.md — идеи и заметки
|
||||||
|
roadmap.md — запланированные эпики и фичи
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
| Команда | Что делает |
|
||||||
|
| ---------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
|
||||||
|
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
|
||||||
|
| `npm run dev:docs` | Docusaurus dev-сервер (опубликованная документация) |
|
||||||
|
| `npm run build:backend` | `nest build` |
|
||||||
|
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
|
||||||
|
| `npm run build:docs` | `docusaurus build` |
|
||||||
|
| `npm run build:design-system` | Сборка дизайн-системы (`tsc`) |
|
||||||
|
| `npm run build:storybook` | Статическая сборка Storybook |
|
||||||
|
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
|
||||||
|
| `npm run test:frontend` | Frontend Vitest suite |
|
||||||
|
| `npm run test:design-system` | Unit-тесты дизайн-системы (Vitest) |
|
||||||
|
| `npm run test:storybook` | Браузерные тесты Storybook (Vitest browser mode + Playwright) |
|
||||||
|
| `npm run storybook` | Storybook dev-сервер на :6006 (инженерный workbench, не docs) |
|
||||||
|
| `npm run lint` | ESLint для backend, frontend и design-system |
|
||||||
|
| `npm run lint:design-system` | ESLint для дизайн-системы |
|
||||||
|
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||||||
|
| `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` |
|
||||||
|
|
||||||
|
`graphify-out/` — локальный артефакт знания, он не хранится в git. В новом `worktree` сначала собери его заново: `graphify extract .`; дальше обновляй инкрементально: `graphify update .`. Для вопросов по коду используй `graphify query "..."`.
|
||||||
|
|
||||||
|
Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов.
|
||||||
|
|
||||||
|
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.
|
||||||
|
|
||||||
|
Один backend-тест: `npm exec -w apps/backend -- vitest run src/path/to/test.spec.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
| ------------------------------------ | -------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| `PORT` | 3000 | Порт бэкенда |
|
||||||
|
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Адрес MOEX ISS |
|
||||||
|
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
||||||
|
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Ошибок до открытия circuit breaker |
|
||||||
|
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Секунд до попытки закрыть circuit breaker |
|
||||||
|
| `T_BANK_TOKEN` | `''` | Токен T-Bank Invest (серверный) |
|
||||||
|
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
||||||
|
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS |
|
||||||
|
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Имя приложения для T-Bank |
|
||||||
|
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Rate limiter для OperationsService и UsersService (запросов/с) |
|
||||||
|
| `T_BANK_INSTRUMENTS_RATE_LIMIT` | 20 | Rate limiter для InstrumentsService (запросов/с) |
|
||||||
|
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Таймаут gRPC-запроса (мс) |
|
||||||
|
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
||||||
|
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
||||||
|
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||||
|
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
||||||
|
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
||||||
|
| `CACHE_DIVIDENDS_TTL` | 86400 | TTL дивидендных данных (с) |
|
||||||
|
| `CACHE_TBANK_ACCOUNTS_TTL` | 3600 | TTL брокерских счетов T-Bank (с) |
|
||||||
|
| `CACHE_TBANK_PORTFOLIO_TTL` | 60 | TTL брокерского портфеля T-Bank (с) |
|
||||||
|
| `CACHE_TBANK_OPERATIONS_TTL` | 300 | TTL брокерских операций T-Bank (с) |
|
||||||
|
| `CACHE_TBANK_POSITIONS_TTL` | 60 | TTL брокерских позиций T-Bank (с) |
|
||||||
|
| `CACHE_TBANK_INSTRUMENT_TTL` | 86400 | TTL инструментов T-Bank (с) |
|
||||||
|
| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma |
|
||||||
|
| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token |
|
||||||
|
| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token |
|
||||||
|
| `JWT_ACCESS_EXPIRES` | `15m` | TTL access token |
|
||||||
|
| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token |
|
||||||
|
|||||||
32
apps/backend/.env.example
Normal file
32
apps/backend/.env.example
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
33
apps/backend/certs/russian-trusted-root-ca.pem
Normal file
33
apps/backend/certs/russian-trusted-root-ca.pem
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
-----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,4 +1,13 @@
|
|||||||
{
|
{
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src"
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"include": "modules/tbank/proto/contracts/**/*",
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"watchAssets": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,8 @@
|
|||||||
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
|
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@grpc/grpc-js": "^1.14.4",
|
||||||
|
"@grpc/proto-loader": "^0.8.1",
|
||||||
"@libsql/client": "^0.17.3",
|
"@libsql/client": "^0.17.3",
|
||||||
"@nestjs/axios": "^3.0.0",
|
"@nestjs/axios": "^3.0.0",
|
||||||
"@nestjs/cache-manager": "^2.0.0",
|
"@nestjs/cache-manager": "^2.0.0",
|
||||||
@ -30,7 +32,9 @@
|
|||||||
"class-transformer": "^0.5.0",
|
"class-transformer": "^0.5.0",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"long": "^5.3.2",
|
||||||
"p-queue": "^7.3.0",
|
"p-queue": "^7.3.0",
|
||||||
|
"protobufjs": "^8.6.4",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"swagger-ui-express": "^5.0.0"
|
"swagger-ui-express": "^5.0.0"
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
-- 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,35 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Portfolio {
|
model Portfolio {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
currency String @default("RUB")
|
currency String @default("RUB")
|
||||||
targets String?
|
targets String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
positions Position[]
|
positions Position[]
|
||||||
|
|
||||||
@@unique([userId, name])
|
@@unique([userId, name])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Position {
|
model Position {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
portfolioId Int
|
portfolioId Int
|
||||||
secid String
|
secid String
|
||||||
type String @default("share")
|
type String @default("share")
|
||||||
quantity Int
|
quantity Int
|
||||||
buyPrice Float?
|
buyPrice Float?
|
||||||
buyDate DateTime?
|
buyDate DateTime?
|
||||||
notes String?
|
notes String?
|
||||||
tags String?
|
tags String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([portfolioId, secid])
|
@@unique([portfolioId, secid])
|
||||||
}
|
}
|
||||||
@ -51,3 +51,44 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
portfolios Portfolio[]
|
portfolios Portfolio[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BrokerOperation {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
accountId String
|
||||||
|
cursor String?
|
||||||
|
operationId String?
|
||||||
|
parentOperationId String?
|
||||||
|
date DateTime?
|
||||||
|
type String
|
||||||
|
category String
|
||||||
|
state String?
|
||||||
|
instrumentUid String?
|
||||||
|
figi String?
|
||||||
|
ticker String?
|
||||||
|
classCode String?
|
||||||
|
payment String?
|
||||||
|
price String?
|
||||||
|
commission String?
|
||||||
|
yield String?
|
||||||
|
accruedInt String?
|
||||||
|
quantity Int?
|
||||||
|
quantityDone Int?
|
||||||
|
raw String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([accountId, cursor])
|
||||||
|
@@index([accountId, date])
|
||||||
|
@@index([accountId, type])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BrokerOperationSyncState {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
accountId String @unique
|
||||||
|
lastCursor String?
|
||||||
|
lastSyncedFrom DateTime?
|
||||||
|
lastSyncedTo DateTime?
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { CacheModule } from './modules/cache/cache.module';
|
import { CacheModule } from './modules/cache/cache.module';
|
||||||
import { MoexClientModule } from './modules/moex-client/moex-client.module';
|
import { MoexClientModule } from './modules/moex-client/moex-client.module';
|
||||||
@ -10,6 +10,8 @@ import { CandlesModule } from './modules/candles/candles.module';
|
|||||||
import { PortfolioModule } from './modules/portfolio/portfolio.module';
|
import { PortfolioModule } from './modules/portfolio/portfolio.module';
|
||||||
import { PrismaModule } from './modules/prisma/prisma.module';
|
import { PrismaModule } from './modules/prisma/prisma.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { TBankModule } from './modules/tbank/tbank.module';
|
||||||
|
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -25,6 +27,11 @@ import configuration from './config/configuration';
|
|||||||
BondsModule,
|
BondsModule,
|
||||||
CandlesModule,
|
CandlesModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
|
TBankModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer.apply(RequestLoggingMiddleware).forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class ApiResponseMeta {
|
export class ApiResponseMeta {
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ type: String, nullable: true })
|
||||||
cachedAt: string | null;
|
cachedAt: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@ -13,6 +13,14 @@ export class ApiResponseMeta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApiEnvelopePayload<T> {
|
||||||
|
constructor(
|
||||||
|
public readonly data: T,
|
||||||
|
public readonly fromCache: boolean,
|
||||||
|
public readonly cachedAt: string | null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiResponse<T> {
|
export class ApiResponse<T> {
|
||||||
data: T;
|
data: T;
|
||||||
meta: ApiResponseMeta;
|
meta: ApiResponseMeta;
|
||||||
|
|||||||
8
apps/backend/src/common/exceptions/domain.exception.ts
Normal file
8
apps/backend/src/common/exceptions/domain.exception.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/backend/src/common/exceptions/moex-api.exception.ts
Normal file
8
apps/backend/src/common/exceptions/moex-api.exception.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/backend/src/common/exceptions/tbank-api.exception.ts
Normal file
14
apps/backend/src/common/exceptions/tbank-api.exception.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
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,8 +1,10 @@
|
|||||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
@ -24,7 +26,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
error = (r.error as string) || exception.name;
|
error = (r.error as string) || exception.name;
|
||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
message = exception.message;
|
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
||||||
|
} else {
|
||||||
|
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { ApiResponse } from '../dto/api-response.dto';
|
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||||
@ -9,6 +9,9 @@ export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T
|
|||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data) => {
|
map((data) => {
|
||||||
if (data instanceof ApiResponse) return data;
|
if (data instanceof ApiResponse) return data;
|
||||||
|
if (data instanceof ApiEnvelopePayload) {
|
||||||
|
return new ApiResponse(data.data, data.fromCache, data.cachedAt);
|
||||||
|
}
|
||||||
return new ApiResponse(data, false, null);
|
return new ApiResponse(data, false, null);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
76
apps/backend/src/config/backend-runtime-config.spec.ts
Normal file
76
apps/backend/src/config/backend-runtime-config.spec.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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,5 +1,14 @@
|
|||||||
import { registerAs } from '@nestjs/config';
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export const DEV_JWT_SECRET = 'dev-jwt-secret-change-in-production';
|
||||||
|
export const DEV_JWT_REFRESH_SECRET = 'dev-refresh-secret-change-in-production';
|
||||||
|
|
||||||
|
const parseCsv = (value: string | undefined): string[] =>
|
||||||
|
(value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
export default registerAs('app', () => ({
|
export default registerAs('app', () => ({
|
||||||
port: parseInt(process.env.PORT || '3000', 10),
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
database: {
|
database: {
|
||||||
@ -14,13 +23,32 @@ export default registerAs('app', () => ({
|
|||||||
10,
|
10,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
tbank: {
|
||||||
|
token: process.env.T_BANK_TOKEN || '',
|
||||||
|
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
|
||||||
|
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
|
||||||
|
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
||||||
|
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
||||||
|
instrumentsRateLimitPerSecond: parseInt(process.env.T_BANK_INSTRUMENTS_RATE_LIMIT || '20', 10),
|
||||||
|
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
||||||
|
},
|
||||||
cache: {
|
cache: {
|
||||||
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
||||||
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
||||||
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
|
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
|
||||||
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
|
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
|
||||||
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
||||||
|
screenerTtl: parseInt(process.env.CACHE_SCREENER_TTL || '900', 10),
|
||||||
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
|
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
|
||||||
|
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
|
||||||
|
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
|
||||||
|
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
|
||||||
|
tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10),
|
||||||
|
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
|
||||||
|
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
|
||||||
|
},
|
||||||
|
cors: {
|
||||||
|
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||||
|
|||||||
63
apps/backend/src/envelope-contract.spec.ts
Normal file
63
apps/backend/src/envelope-contract.spec.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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,9 +4,37 @@ import { AppModule } from './app.module';
|
|||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
import { DEV_JWT_SECRET, DEV_JWT_REFRESH_SECRET } from './config/configuration';
|
||||||
|
|
||||||
|
export type BackendRuntimeConfig = {
|
||||||
|
nodeEnv: string;
|
||||||
|
jwtSecret: string;
|
||||||
|
jwtRefreshSecret: string;
|
||||||
|
corsOrigins: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function assertSafeProductionConfig(config: BackendRuntimeConfig): void {
|
||||||
|
if (config.nodeEnv !== 'production') return;
|
||||||
|
|
||||||
|
if (!config.jwtSecret || config.jwtSecret === DEV_JWT_SECRET) {
|
||||||
|
throw new Error('JWT_SECRET must be set to a non-default value in production');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.jwtRefreshSecret || config.jwtRefreshSecret === DEV_JWT_REFRESH_SECRET) {
|
||||||
|
throw new Error('JWT_REFRESH_SECRET must be set to a non-default value in production');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.corsOrigins.length === 0) {
|
||||||
|
throw new Error('BACKEND_CORS_ORIGINS must contain at least one origin in production');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCorsOrigin(nodeEnv: string, corsOrigins: string[]): boolean | string[] {
|
||||||
|
return nodeEnv === 'production' ? corsOrigins : true;
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@ -18,10 +46,20 @@ async function bootstrap() {
|
|||||||
app.useGlobalInterceptors(new TransformInterceptor());
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
const reqLogMiddleware = new RequestLoggingMiddleware();
|
const configService = app.get(ConfigService);
|
||||||
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
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', []),
|
||||||
|
};
|
||||||
|
|
||||||
app.enableCors({ origin: true, credentials: true });
|
assertSafeProductionConfig(runtimeConfig);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins),
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('MoexVibe API')
|
.setTitle('MoexVibe API')
|
||||||
@ -36,4 +74,7 @@ async function bootstrap() {
|
|||||||
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
||||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||||
}
|
}
|
||||||
bootstrap();
|
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
void bootstrap();
|
||||||
|
}
|
||||||
|
|||||||
@ -41,13 +41,7 @@ export class AuthController {
|
|||||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||||
const result = await this.authService.register(dto);
|
const result = await this.authService.register(dto);
|
||||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
return {
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
data: {
|
|
||||||
user: result.user,
|
|
||||||
accessToken: result.accessToken,
|
|
||||||
},
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -57,13 +51,7 @@ export class AuthController {
|
|||||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||||
const result = await this.authService.login(dto);
|
const result = await this.authService.login(dto);
|
||||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
return {
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
data: {
|
|
||||||
user: result.user,
|
|
||||||
accessToken: result.accessToken,
|
|
||||||
},
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -75,13 +63,7 @@ export class AuthController {
|
|||||||
const token = req.cookies?.[REFRESH_COOKIE];
|
const token = req.cookies?.[REFRESH_COOKIE];
|
||||||
const result = await this.authService.refresh(token);
|
const result = await this.authService.refresh(token);
|
||||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
return {
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
data: {
|
|
||||||
user: result.user,
|
|
||||||
accessToken: result.accessToken,
|
|
||||||
},
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@ -92,10 +74,7 @@ export class AuthController {
|
|||||||
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
||||||
await this.authService.logout(user.sub);
|
await this.authService.logout(user.sub);
|
||||||
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
|
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
|
||||||
return {
|
return { message: 'Logged out successfully' };
|
||||||
data: { message: 'Logged out successfully' },
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@ -103,11 +82,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Get current user profile' })
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||||
async getProfile(@CurrentUser() user: JwtPayload) {
|
async getProfile(@CurrentUser() user: JwtPayload) {
|
||||||
const profile = await this.authService.getProfile(user.sub);
|
return this.authService.getProfile(user.sub);
|
||||||
return {
|
|
||||||
data: profile,
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('me')
|
@Patch('me')
|
||||||
@ -115,10 +90,6 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Update current user profile' })
|
@ApiOperation({ summary: 'Update current user profile' })
|
||||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||||
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
||||||
const profile = await this.authService.updateProfile(user.sub, dto);
|
return this.authService.updateProfile(user.sub, dto);
|
||||||
return {
|
|
||||||
data: profile,
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
class AuthResponseMetaDto {
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
|
||||||
cachedAt!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
fromCache!: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthUserDto {
|
class AuthUserDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@ -39,22 +32,22 @@ export class AuthTokenResponseDto {
|
|||||||
@ApiProperty({ type: AuthTokenDataDto })
|
@ApiProperty({ type: AuthTokenDataDto })
|
||||||
data!: AuthTokenDataDto;
|
data!: AuthTokenDataDto;
|
||||||
|
|
||||||
@ApiProperty({ type: AuthResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: AuthResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthProfileResponseDto {
|
export class AuthProfileResponseDto {
|
||||||
@ApiProperty({ type: AuthUserDto })
|
@ApiProperty({ type: AuthUserDto })
|
||||||
data!: AuthUserDto;
|
data!: AuthUserDto;
|
||||||
|
|
||||||
@ApiProperty({ type: AuthResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: AuthResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthLogoutResponseDto {
|
export class AuthLogoutResponseDto {
|
||||||
@ApiProperty({ type: LogoutDataDto })
|
@ApiProperty({ type: LogoutDataDto })
|
||||||
data!: LogoutDataDto;
|
data!: LogoutDataDto;
|
||||||
|
|
||||||
@ApiProperty({ type: AuthResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: AuthResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,32 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||||
import { BondsService } from './bonds.service';
|
import { BondsService } from './bonds.service';
|
||||||
|
import { BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto } from './dto/bonds-envelope.dto';
|
||||||
|
|
||||||
@ApiTags('Bonds')
|
@ApiTags('Bonds')
|
||||||
|
@ApiExtraModels(ApiResponseMeta)
|
||||||
@Controller('securities/bonds')
|
@Controller('securities/bonds')
|
||||||
export class BondsController {
|
export class BondsController {
|
||||||
constructor(private readonly bondsService: BondsService) {}
|
constructor(private readonly bondsService: BondsService) {}
|
||||||
|
|
||||||
@Get(':secid')
|
@Get(':secid')
|
||||||
@ApiOperation({ summary: 'Получить спецификацию облигации' })
|
@ApiOperation({ summary: 'Получить спецификацию облигации' })
|
||||||
|
@ApiOkResponse({ type: BondEnvelopeDto })
|
||||||
async getBond(@Param('secid') secid: string) {
|
async getBond(@Param('secid') secid: string) {
|
||||||
return this.bondsService.getBond(secid);
|
return this.bondsService.getBond(secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':secid/marketdata')
|
@Get(':secid/marketdata')
|
||||||
@ApiOperation({ summary: 'Получить рыночные данные облигации' })
|
@ApiOperation({ summary: 'Получить рыночные данные облигации' })
|
||||||
|
@ApiOkResponse({ type: BondMarketDataEnvelopeDto })
|
||||||
async getMarketData(@Param('secid') secid: string) {
|
async getMarketData(@Param('secid') secid: string) {
|
||||||
return this.bondsService.getMarketData(secid);
|
return this.bondsService.getMarketData(secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':secid/history')
|
@Get(':secid/history')
|
||||||
@ApiOperation({ summary: 'Получить дневную историю торгов облигации' })
|
@ApiOperation({ summary: 'Получить дневную историю торгов облигации' })
|
||||||
|
@ApiOkResponse({ type: BondHistoryEnvelopeDto })
|
||||||
async getHistory(
|
async getHistory(
|
||||||
@Param('secid') secid: string,
|
@Param('secid') secid: string,
|
||||||
@Query('from') from: string,
|
@Query('from') from: string,
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||||
import { BondsController } from './bonds.controller';
|
import { BondsController } from './bonds.controller';
|
||||||
import { BondsService } from './bonds.service';
|
import { BondsService } from './bonds.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [MoexClientModule],
|
||||||
controllers: [BondsController],
|
controllers: [BondsController],
|
||||||
providers: [BondsService],
|
providers: [BondsService],
|
||||||
exports: [BondsService],
|
exports: [BondsService],
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||||
import { BondsService } from './bonds.service';
|
import { BondsService } from './bonds.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||||
|
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
|
|
||||||
describe('BondsService', () => {
|
describe('BondsService', () => {
|
||||||
let service: BondsService;
|
let service: BondsService;
|
||||||
let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
|
let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>;
|
||||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
moexClient = {
|
moexMarketData = {
|
||||||
getBondData: vi.fn(),
|
getBondData: vi.fn(),
|
||||||
getBondMarketData: vi.fn(),
|
getBondMarketData: vi.fn(),
|
||||||
};
|
};
|
||||||
@ -25,7 +26,8 @@ describe('BondsService', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
BondsService,
|
BondsService,
|
||||||
{ provide: MoexClientService, useValue: moexClient },
|
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||||
|
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
|
||||||
{ provide: CacheService, useValue: cache },
|
{ provide: CacheService, useValue: cache },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@ -34,7 +36,7 @@ describe('BondsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
|
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
|
||||||
vi.mocked(moexClient.getBondData).mockResolvedValue({
|
vi.mocked(moexMarketData.getBondData).mockResolvedValue({
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
boardid: 'TQCB',
|
boardid: 'TQCB',
|
||||||
shortName: 'ОФЗ 26238',
|
shortName: 'ОФЗ 26238',
|
||||||
@ -57,7 +59,7 @@ describe('BondsService', () => {
|
|||||||
bondSubType: 'fixed',
|
bondSubType: 'fixed',
|
||||||
listLevel: 1,
|
listLevel: 1,
|
||||||
});
|
});
|
||||||
vi.mocked(moexClient.getBondMarketData).mockResolvedValue({
|
vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
bid: 72.9,
|
bid: 72.9,
|
||||||
offer: 73.1,
|
offer: 73.1,
|
||||||
@ -92,8 +94,8 @@ describe('BondsService', () => {
|
|||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||||
expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
data: {
|
data: {
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
@ -129,19 +131,17 @@ describe('BondsService', () => {
|
|||||||
volume: 10000,
|
volume: 10000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: {
|
fromCache: false,
|
||||||
fromCache: false,
|
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when bond data is missing', async () => {
|
it('throws EntityNotFoundException when bond data is missing', async () => {
|
||||||
vi.mocked(moexClient.getBondData).mockResolvedValue(null);
|
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException);
|
||||||
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
|
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
|
||||||
expect(moexClient.getBondMarketData).not.toHaveBeenCalled();
|
expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||||
|
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
|
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||||
|
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondsService {
|
export class BondsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexMarketData: MoexMarketDataClient,
|
||||||
|
private readonly moexHistory: MoexHistoryClient,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -17,23 +21,23 @@ export class BondsService {
|
|||||||
} = await this.cache.getOrFetch(
|
} = await this.cache.getOrFetch(
|
||||||
'bond',
|
'bond',
|
||||||
[secid],
|
[secid],
|
||||||
() => this.moexClient.getBondData(secid),
|
() => this.moexMarketData.getBondData(secid),
|
||||||
'securityTtl',
|
'securityTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!bond) {
|
if (!bond) {
|
||||||
throw new NotFoundException(`Bond ${secid} not found`);
|
throw new EntityNotFoundException('Bond', secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: mkt } = await this.cache.getOrFetch(
|
const { data: mkt } = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['bonds', secid],
|
['bonds', secid],
|
||||||
() => this.moexClient.getBondMarketData(secid),
|
() => this.moexMarketData.getBondMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: {
|
{
|
||||||
secid: bond.secid,
|
secid: bond.secid,
|
||||||
isin: bond.isin,
|
isin: bond.isin,
|
||||||
name: bond.shortName,
|
name: bond.shortName,
|
||||||
@ -70,8 +74,9 @@ export class BondsService {
|
|||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMarketData(secid: string) {
|
async getMarketData(secid: string) {
|
||||||
@ -82,16 +87,16 @@ export class BondsService {
|
|||||||
} = await this.cache.getOrFetch(
|
} = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['bonds', secid],
|
['bonds', secid],
|
||||||
() => this.moexClient.getBondMarketData(secid),
|
() => this.moexMarketData.getBondMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mkt) {
|
if (!mkt) {
|
||||||
throw new NotFoundException(`Market data for bond ${secid} not found`);
|
throw new EntityNotFoundException('MarketData', `bond ${secid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: {
|
{
|
||||||
price: mkt.last ?? 0,
|
price: mkt.last ?? 0,
|
||||||
yieldToMaturity: mkt.yield ?? null,
|
yieldToMaturity: mkt.yield ?? null,
|
||||||
duration: mkt.duration ?? null,
|
duration: mkt.duration ?? null,
|
||||||
@ -107,26 +112,28 @@ export class BondsService {
|
|||||||
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(secid: string, from: string, till: string) {
|
async getHistory(secid: string, from: string, till: string) {
|
||||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||||
'history',
|
'history',
|
||||||
['bonds', secid, from, till],
|
['bonds', secid, from, till],
|
||||||
() => this.moexClient.getBondHistory(secid, from, till),
|
() => this.moexHistory.getBondHistory(secid, from, till),
|
||||||
'historyTtl',
|
'historyTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: data.map((h) => ({
|
data.map((h) => ({
|
||||||
date: h.tradeDate,
|
date: h.tradeDate,
|
||||||
closePrice: h.legalClosePrice ?? h.close ?? 0,
|
closePrice: h.legalClosePrice ?? h.close ?? 0,
|
||||||
yieldClose: h.yieldClose ?? null,
|
yieldClose: h.yieldClose ?? null,
|
||||||
duration: h.duration ?? null,
|
duration: h.duration ?? null,
|
||||||
})),
|
})),
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
apps/backend/src/modules/bonds/dto/bonds-envelope.dto.ts
Normal file
28
apps/backend/src/modules/bonds/dto/bonds-envelope.dto.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
15
apps/backend/src/modules/bonds/dto/history-item.dto.ts
Normal file
15
apps/backend/src/modules/bonds/dto/history-item.dto.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
65
apps/backend/src/modules/cache/cache.service.spec.ts
vendored
Normal file
65
apps/backend/src/modules/cache/cache.service.spec.ts
vendored
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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,6 +3,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|||||||
import { Cache } from 'cache-manager';
|
import { Cache } from 'cache-manager';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
type CacheEntry<T> = {
|
||||||
|
data: T;
|
||||||
|
cachedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CacheService {
|
export class CacheService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -18,6 +23,16 @@ export class CacheService {
|
|||||||
await this.cacheManager.set(key, value, ttl);
|
await this.cacheManager.set(key, value, ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCacheEntry<T>(value: unknown): value is CacheEntry<T> {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'data' in value &&
|
||||||
|
'cachedAt' in value &&
|
||||||
|
typeof (value as { cachedAt?: unknown }).cachedAt === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private buildKey(...parts: string[]): string {
|
private buildKey(...parts: string[]): string {
|
||||||
return parts.join(':');
|
return parts.join(':');
|
||||||
}
|
}
|
||||||
@ -31,14 +46,19 @@ export class CacheService {
|
|||||||
const key = this.buildKey(keyPrefix, ...keyParts);
|
const key = this.buildKey(keyPrefix, ...keyParts);
|
||||||
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
||||||
|
|
||||||
const cached = await this.get<T>(key);
|
const cached = await this.get<CacheEntry<T> | T>(key);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return { data: cached, fromCache: true, cachedAt: null };
|
if (this.isCacheEntry<T>(cached)) {
|
||||||
|
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: cached as T, fromCache: true, cachedAt: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await fetchFn();
|
const data = await fetchFn();
|
||||||
await this.set(key, data, ttl);
|
const cachedAt = new Date().toISOString();
|
||||||
|
await this.set(key, { data, cachedAt }, ttl);
|
||||||
|
|
||||||
return { data, fromCache: false, cachedAt: new Date().toISOString() };
|
return { data, fromCache: false, cachedAt };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
|
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||||
import { CandlesService } from './candles.service';
|
import { CandlesService } from './candles.service';
|
||||||
import { CandlesQueryDto } from './dto/candles-query.dto';
|
import { CandlesQueryDto } from './dto/candles-query.dto';
|
||||||
|
import { CandleEnvelopeDto } from './dto/candles-envelope.dto';
|
||||||
|
|
||||||
@ApiTags('Candles')
|
@ApiTags('Candles')
|
||||||
|
@ApiExtraModels(ApiResponseMeta)
|
||||||
@Controller('securities')
|
@Controller('securities')
|
||||||
export class CandlesController {
|
export class CandlesController {
|
||||||
constructor(private readonly candlesService: CandlesService) {}
|
constructor(private readonly candlesService: CandlesService) {}
|
||||||
|
|
||||||
@Get('shares/:secid/candles')
|
@Get('shares/:secid/candles')
|
||||||
@ApiOperation({ summary: 'Получить свечи акции' })
|
@ApiOperation({ summary: 'Получить свечи акции' })
|
||||||
|
@ApiOkResponse({ type: CandleEnvelopeDto })
|
||||||
async getShareCandles(
|
async getShareCandles(
|
||||||
@Param('secid') secid: string,
|
@Param('secid') secid: string,
|
||||||
@Query(ValidationPipe) query: CandlesQueryDto,
|
@Query(ValidationPipe) query: CandlesQueryDto,
|
||||||
@ -19,6 +23,7 @@ export class CandlesController {
|
|||||||
|
|
||||||
@Get('bonds/:secid/candles')
|
@Get('bonds/:secid/candles')
|
||||||
@ApiOperation({ summary: 'Получить свечи облигации' })
|
@ApiOperation({ summary: 'Получить свечи облигации' })
|
||||||
|
@ApiOkResponse({ type: CandleEnvelopeDto })
|
||||||
async getBondCandles(
|
async getBondCandles(
|
||||||
@Param('secid') secid: string,
|
@Param('secid') secid: string,
|
||||||
@Query(ValidationPipe) query: CandlesQueryDto,
|
@Query(ValidationPipe) query: CandlesQueryDto,
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||||
import { CandlesController } from './candles.controller';
|
import { CandlesController } from './candles.controller';
|
||||||
import { CandlesService } from './candles.service';
|
import { CandlesService } from './candles.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [MoexClientModule],
|
||||||
controllers: [CandlesController],
|
controllers: [CandlesController],
|
||||||
providers: [CandlesService],
|
providers: [CandlesService],
|
||||||
exports: [CandlesService],
|
exports: [CandlesService],
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { CandlesService } from './candles.service';
|
import { CandlesService } from './candles.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexCandlesClient } from '../moex-client/moex-candles.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { CandleInterval } from './dto/candles-query.dto';
|
import { CandleInterval } from './dto/candles-query.dto';
|
||||||
|
|
||||||
describe('CandlesService', () => {
|
describe('CandlesService', () => {
|
||||||
let service: CandlesService;
|
let service: CandlesService;
|
||||||
let moexClient: Pick<MoexClientService, 'getCandles'>;
|
let moexCandles: Pick<MoexCandlesClient, 'getCandles'>;
|
||||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
moexClient = {
|
moexCandles = {
|
||||||
getCandles: vi.fn(),
|
getCandles: vi.fn(),
|
||||||
};
|
};
|
||||||
cache = {
|
cache = {
|
||||||
@ -24,7 +24,7 @@ describe('CandlesService', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
CandlesService,
|
CandlesService,
|
||||||
{ provide: MoexClientService, useValue: moexClient },
|
{ provide: MoexCandlesClient, useValue: moexCandles },
|
||||||
{ provide: CacheService, useValue: cache },
|
{ provide: CacheService, useValue: cache },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@ -33,7 +33,7 @@ describe('CandlesService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
|
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
|
||||||
vi.mocked(moexClient.getCandles).mockResolvedValue([
|
vi.mocked(moexCandles.getCandles).mockResolvedValue([
|
||||||
{
|
{
|
||||||
open: 320,
|
open: 320,
|
||||||
high: 325,
|
high: 325,
|
||||||
@ -60,7 +60,7 @@ describe('CandlesService', () => {
|
|||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
'candlesTtl',
|
'candlesTtl',
|
||||||
);
|
);
|
||||||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||||
'stock',
|
'stock',
|
||||||
'shares',
|
'shares',
|
||||||
'SBER',
|
'SBER',
|
||||||
@ -81,15 +81,13 @@ describe('CandlesService', () => {
|
|||||||
end: '2026-05-01 23:59:59',
|
end: '2026-05-01 23:59:59',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
meta: {
|
fromCache: false,
|
||||||
fromCache: false,
|
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
|
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
|
||||||
vi.mocked(moexClient.getCandles).mockResolvedValue([]);
|
vi.mocked(moexCandles.getCandles).mockResolvedValue([]);
|
||||||
|
|
||||||
await service.getCandles(
|
await service.getCandles(
|
||||||
'bonds',
|
'bonds',
|
||||||
@ -105,7 +103,7 @@ describe('CandlesService', () => {
|
|||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
'candlesTtl',
|
'candlesTtl',
|
||||||
);
|
);
|
||||||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||||
'stock',
|
'stock',
|
||||||
'bonds',
|
'bonds',
|
||||||
'SU26238RMFS5',
|
'SU26238RMFS5',
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexCandlesClient } from '../moex-client/moex-candles.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { CandleInterval } from './dto/candles-query.dto';
|
import { CandleInterval } from './dto/candles-query.dto';
|
||||||
|
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CandlesService {
|
export class CandlesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexCandles: MoexCandlesClient,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -25,12 +26,12 @@ export class CandlesService {
|
|||||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||||
'candles',
|
'candles',
|
||||||
[market, secid, String(moexInterval), from, till],
|
[market, secid, String(moexInterval), from, till],
|
||||||
() => this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
|
() => this.moexCandles.getCandles('stock', market, secid, moexInterval, from, till),
|
||||||
'candlesTtl',
|
'candlesTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: data.map((c) => ({
|
data.map((c) => ({
|
||||||
open: c.open,
|
open: c.open,
|
||||||
high: c.high,
|
high: c.high,
|
||||||
low: c.low,
|
low: c.low,
|
||||||
@ -40,7 +41,8 @@ export class CandlesService {
|
|||||||
begin: c.begin,
|
begin: c.begin,
|
||||||
end: c.end,
|
end: c.end,
|
||||||
})),
|
})),
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/backend/src/modules/candles/dto/candle-item.dto.ts
Normal file
27
apps/backend/src/modules/candles/dto/candle-item.dto.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
11
apps/backend/src/modules/candles/dto/candles-envelope.dto.ts
Normal file
11
apps/backend/src/modules/candles/dto/candles-envelope.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
11
apps/backend/src/modules/health/dto/health-envelope.dto.ts
Normal file
11
apps/backend/src/modules/health/dto/health-envelope.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
26
apps/backend/src/modules/health/dto/health-response.dto.ts
Normal file
26
apps/backend/src/modules/health/dto/health-response.dto.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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,18 +1,21 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
|
import { HealthEnvelopeDto } from './dto/health-envelope.dto';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
@ApiTags('Health')
|
@ApiTags('Health')
|
||||||
|
@ApiExtraModels(ApiResponseMeta)
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
|
constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Проверка состояния сервиса' })
|
@ApiOperation({ summary: 'Проверка состояния сервиса' })
|
||||||
check() {
|
@ApiOkResponse({ type: HealthEnvelopeDto })
|
||||||
return {
|
async check() {
|
||||||
status: 'ok',
|
return this.healthService.check();
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
providers: [HealthService],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
68
apps/backend/src/modules/health/health.service.spec.ts
Normal file
68
apps/backend/src/modules/health/health.service.spec.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
72
apps/backend/src/modules/health/health.service.ts
Normal file
72
apps/backend/src/modules/health/health.service.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
apps/backend/src/modules/moex-client/moex-candles.client.ts
Normal file
36
apps/backend/src/modules/moex-client/moex-candles.client.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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,9 +1,26 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MoexClientService } from './moex-client.service';
|
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';
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [MoexClientService],
|
providers: [
|
||||||
exports: [MoexClientService],
|
MoexHttpClient,
|
||||||
|
MoexSecuritiesClient,
|
||||||
|
MoexMarketDataClient,
|
||||||
|
MoexCandlesClient,
|
||||||
|
MoexHistoryClient,
|
||||||
|
MoexDividendsClient,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
MoexSecuritiesClient,
|
||||||
|
MoexMarketDataClient,
|
||||||
|
MoexCandlesClient,
|
||||||
|
MoexHistoryClient,
|
||||||
|
MoexDividendsClient,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class MoexClientModule {}
|
export class MoexClientModule {}
|
||||||
|
|||||||
@ -1,32 +1,35 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { MoexClientService } from './moex-client.service';
|
import { MoexClientModule } from './moex-client.module';
|
||||||
|
import { MoexSecuritiesClient } from './moex-securities.client';
|
||||||
|
import { MoexMarketDataClient } from './moex-market-data.client';
|
||||||
import configuration from '../../config/configuration';
|
import configuration from '../../config/configuration';
|
||||||
|
|
||||||
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
|
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
|
||||||
'MoexClientService live MOEX integration',
|
'MoexClient live MOEX integration',
|
||||||
() => {
|
() => {
|
||||||
let service: MoexClientService;
|
let moexSecurities: MoexSecuritiesClient;
|
||||||
|
let moexMarketData: MoexMarketDataClient;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule],
|
||||||
providers: [MoexClientService],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<MoexClientService>(MoexClientService);
|
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient);
|
||||||
|
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
||||||
const results = await service.searchSecurities('SBER');
|
const results = await moexSecurities.searchSecurities('SBER');
|
||||||
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
expect(results.length).toBeGreaterThan(0);
|
||||||
expect(results[0].secid).toBeDefined();
|
expect(results[0].secid).toBeDefined();
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
it('возвращает рыночные данные SBER из live MOEX', async () => {
|
it('возвращает рыночные данные SBER из live MOEX', async () => {
|
||||||
const data = await service.getShareMarketData('SBER');
|
const data = await moexMarketData.getShareMarketData('SBER');
|
||||||
|
|
||||||
expect(data).toBeDefined();
|
expect(data).toBeDefined();
|
||||||
expect(data!.secid).toBe('SBER');
|
expect(data!.secid).toBe('SBER');
|
||||||
|
|||||||
@ -1,186 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { MoexClientService } from './moex-client.service';
|
|
||||||
|
|
||||||
vi.mock('axios', () => ({
|
|
||||||
default: {
|
|
||||||
create: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('MoexClientService', () => {
|
|
||||||
let service: MoexClientService;
|
|
||||||
let getMock: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
getMock = vi.fn();
|
|
||||||
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
|
|
||||||
|
|
||||||
service = new MoexClientService({
|
|
||||||
get: vi.fn((key: string, fallback?: unknown) => {
|
|
||||||
const values: Record<string, unknown> = {
|
|
||||||
'app.moex.baseUrl': 'https://iss.moex.test/iss',
|
|
||||||
'app.moex.circuitBreakerThreshold': 5,
|
|
||||||
'app.moex.circuitBreakerResetSeconds': 30,
|
|
||||||
'app.moex.rateLimit': 10,
|
|
||||||
};
|
|
||||||
return values[key] ?? fallback;
|
|
||||||
}),
|
|
||||||
} as unknown as ConfigService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('создаётся с настроенным MOEX client', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
expect(axios.create).toHaveBeenCalledWith({
|
|
||||||
baseURL: 'https://iss.moex.test/iss',
|
|
||||||
timeout: 10000,
|
|
||||||
paramsSerializer: { indexes: null },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('нормализует результаты поиска из ISS table format', async () => {
|
|
||||||
getMock.mockResolvedValueOnce({
|
|
||||||
data: {
|
|
||||||
securities: {
|
|
||||||
columns: [
|
|
||||||
'secid',
|
|
||||||
'isin',
|
|
||||||
'name',
|
|
||||||
'shortName',
|
|
||||||
'latName',
|
|
||||||
'listLevel',
|
|
||||||
'issuesize',
|
|
||||||
'facevalue',
|
|
||||||
'faceunit',
|
|
||||||
'issuedate',
|
|
||||||
'typename',
|
|
||||||
'group',
|
|
||||||
'type',
|
|
||||||
'isqualifiedinvestors',
|
|
||||||
'morningsession',
|
|
||||||
'eveningsession',
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
[
|
|
||||||
'SBER',
|
|
||||||
'RU0009029540',
|
|
||||||
'Сбербанк России ПАО ао',
|
|
||||||
'Сбербанк',
|
|
||||||
'Sberbank',
|
|
||||||
'1',
|
|
||||||
'21586948000',
|
|
||||||
'3',
|
|
||||||
'SUR',
|
|
||||||
'2007-07-20',
|
|
||||||
'Акция обыкновенная',
|
|
||||||
'stock_shares',
|
|
||||||
'common_share',
|
|
||||||
'0',
|
|
||||||
'1',
|
|
||||||
'1',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await service.searchSecurities('SBER');
|
|
||||||
|
|
||||||
expect(getMock).toHaveBeenCalledWith('/securities.json', {
|
|
||||||
params: { q: 'SBER', 'iss.meta': 'off' },
|
|
||||||
});
|
|
||||||
expect(results).toEqual([
|
|
||||||
{
|
|
||||||
secid: 'SBER',
|
|
||||||
isin: 'RU0009029540',
|
|
||||||
name: 'Сбербанк России ПАО ао',
|
|
||||||
shortName: 'Сбербанк',
|
|
||||||
latName: 'Sberbank',
|
|
||||||
listLevel: 1,
|
|
||||||
issueSize: 21586948000,
|
|
||||||
faceValue: 3,
|
|
||||||
faceUnit: 'SUR',
|
|
||||||
issueDate: '2007-07-20',
|
|
||||||
typeName: 'Акция обыкновенная',
|
|
||||||
group: 'stock_shares',
|
|
||||||
type: 'common_share',
|
|
||||||
isQualifiedInvestors: false,
|
|
||||||
morningSession: true,
|
|
||||||
eveningSession: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('нормализует market data акции без live MOEX запроса', async () => {
|
|
||||||
getMock.mockResolvedValueOnce({
|
|
||||||
data: {
|
|
||||||
securities: {
|
|
||||||
columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'],
|
|
||||||
data: [['SBER', 'TQBR', 'Сбербанк', '320.10']],
|
|
||||||
},
|
|
||||||
marketdata: {
|
|
||||||
columns: [
|
|
||||||
'SECID',
|
|
||||||
'BOARDID',
|
|
||||||
'BID',
|
|
||||||
'OFFER',
|
|
||||||
'OPEN',
|
|
||||||
'LOW',
|
|
||||||
'HIGH',
|
|
||||||
'LAST',
|
|
||||||
'LASTCHANGE',
|
|
||||||
'LASTCHANGEPRCNT',
|
|
||||||
'VOLTODAY',
|
|
||||||
'VALTODAY',
|
|
||||||
'WAPRICE',
|
|
||||||
'NUMTRADES',
|
|
||||||
'ISSUECAPITALIZATION',
|
|
||||||
'TRADINGSTATUS',
|
|
||||||
'UPDATETIME',
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
[
|
|
||||||
'SBER',
|
|
||||||
'TQBR',
|
|
||||||
'321',
|
|
||||||
'322',
|
|
||||||
'320',
|
|
||||||
'319',
|
|
||||||
'323',
|
|
||||||
'322.35',
|
|
||||||
'1.15',
|
|
||||||
'0.36',
|
|
||||||
'1925163',
|
|
||||||
'620184479',
|
|
||||||
'321.9',
|
|
||||||
'12345',
|
|
||||||
'6958336818320',
|
|
||||||
'T',
|
|
||||||
'10:30:00',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await service.getShareMarketData('SBER');
|
|
||||||
|
|
||||||
expect(getMock).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER.json', {
|
|
||||||
params: { boards: 'TQBR', 'iss.meta': 'off' },
|
|
||||||
});
|
|
||||||
expect(data).toMatchObject({
|
|
||||||
secid: 'SBER',
|
|
||||||
boardid: 'TQBR',
|
|
||||||
shortName: 'Сбербанк',
|
|
||||||
last: 322.35,
|
|
||||||
lastChange: 1.15,
|
|
||||||
lastChangePrcnt: 0.36,
|
|
||||||
volume: 1925163,
|
|
||||||
value: 620184479,
|
|
||||||
issueCapitalization: 6958336818320,
|
|
||||||
tradingStatus: 'T',
|
|
||||||
updateTime: '10:30:00',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,423 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import axios, { AxiosInstance } from 'axios';
|
|
||||||
import PQueue from 'p-queue';
|
|
||||||
import {
|
|
||||||
MoexSecurityDescription,
|
|
||||||
MoexShareMarketData,
|
|
||||||
MoexBondData,
|
|
||||||
MoexBondMarketData,
|
|
||||||
MoexBondPositionData,
|
|
||||||
MoexDividend,
|
|
||||||
MoexCandle,
|
|
||||||
MoexHistoryEntry,
|
|
||||||
MoexBondHistoryEntry,
|
|
||||||
} from './moex-client.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MoexClientService {
|
|
||||||
private readonly logger = new Logger(MoexClientService.name);
|
|
||||||
private readonly client: AxiosInstance;
|
|
||||||
private readonly queue: PQueue;
|
|
||||||
private circuitOpen = false;
|
|
||||||
private circuitErrorCount = 0;
|
|
||||||
private readonly threshold: number;
|
|
||||||
private readonly resetMs: number;
|
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
|
||||||
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
|
|
||||||
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
|
|
||||||
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
|
|
||||||
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
|
|
||||||
|
|
||||||
this.client = axios.create({
|
|
||||||
baseURL: baseUrl,
|
|
||||||
timeout: 10000,
|
|
||||||
paramsSerializer: { indexes: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.queue = new PQueue({
|
|
||||||
interval: 1000,
|
|
||||||
intervalCap: rateLimit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
||||||
if (this.circuitOpen) {
|
|
||||||
throw new Error('Circuit breaker is open — MOEX requests paused');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.queue.add(async () => {
|
|
||||||
try {
|
|
||||||
const jsonPath = path + '.json';
|
|
||||||
const response = await this.client.get(jsonPath, {
|
|
||||||
params: { ...params, 'iss.meta': 'off' },
|
|
||||||
});
|
|
||||||
this.circuitErrorCount = 0;
|
|
||||||
return response.data as T;
|
|
||||||
} catch (error) {
|
|
||||||
this.circuitErrorCount++;
|
|
||||||
if (this.circuitErrorCount >= this.threshold) {
|
|
||||||
this.circuitOpen = true;
|
|
||||||
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.circuitOpen = false;
|
|
||||||
this.circuitErrorCount = 0;
|
|
||||||
this.logger.log('Circuit breaker reset');
|
|
||||||
}, this.resetMs);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}) as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
|
|
||||||
const table = data[name] as Record<string, unknown> | undefined;
|
|
||||||
if (!table || !table.columns || !table.data) return [];
|
|
||||||
const columns = table.columns as string[];
|
|
||||||
const rows = table.data as unknown[][];
|
|
||||||
return rows.map((row) => {
|
|
||||||
const obj: Record<string, unknown> = {};
|
|
||||||
columns.forEach((col, i) => {
|
|
||||||
obj[col] = row[i];
|
|
||||||
});
|
|
||||||
return obj;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
|
|
||||||
const data = await this.request<Record<string, unknown>>('/securities', {
|
|
||||||
q: query,
|
|
||||||
});
|
|
||||||
return this.extractTable(data, 'securities').map((s) => ({
|
|
||||||
secid: s.secid as string,
|
|
||||||
isin: s.isin as string,
|
|
||||||
name: s.name as string,
|
|
||||||
shortName: s.shortName as string,
|
|
||||||
latName: (s.latName as string) || null,
|
|
||||||
listLevel: parseInt(s.listLevel as string, 10) || 0,
|
|
||||||
issueSize: parseInt(s.issuesize as string, 10) || 0,
|
|
||||||
faceValue: parseFloat(s.facevalue as string) || 0,
|
|
||||||
faceUnit: (s.faceunit as string) || '',
|
|
||||||
issueDate: (s.issuedate as string) || '',
|
|
||||||
typeName: (s.typename as string) || '',
|
|
||||||
group: (s.group as string) || '',
|
|
||||||
type: (s.type as string) || '',
|
|
||||||
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
|
|
||||||
morningSession: (s.morningsession as string) === '1',
|
|
||||||
eveningSession: (s.eveningsession as string) === '1',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(`/securities/${secid}`);
|
|
||||||
const rows = this.extractTable(data, 'description');
|
|
||||||
if (rows.length === 0) return null;
|
|
||||||
const map = new Map(rows.map((r) => [r.name, r.value]));
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
isin: (map.get('ISIN') as string) || '',
|
|
||||||
name: (map.get('NAME') as string) || '',
|
|
||||||
shortName: (map.get('SHORTNAME') as string) || '',
|
|
||||||
latName: (map.get('LATNAME') as string) || null,
|
|
||||||
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
|
|
||||||
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
|
|
||||||
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
|
|
||||||
faceUnit: (map.get('FACEUNIT') as string) || '',
|
|
||||||
issueDate: (map.get('ISSUEDATE') as string) || '',
|
|
||||||
typeName: (map.get('TYPENAME') as string) || '',
|
|
||||||
group: (map.get('GROUP') as string) || '',
|
|
||||||
type: (map.get('TYPE') as string) || '',
|
|
||||||
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
|
|
||||||
morningSession: (map.get('MORNINGSESSION') as string) === '1',
|
|
||||||
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/shares/securities/${secid}`,
|
|
||||||
{ boards: boardId },
|
|
||||||
);
|
|
||||||
const rows = this.extractTable(data, 'securities');
|
|
||||||
const share = rows.find((r) => r.BOARDID === boardId);
|
|
||||||
if (!share) return null;
|
|
||||||
|
|
||||||
const mktRows = this.extractTable(data, 'marketdata');
|
|
||||||
const mkt = mktRows.find((r) => r.BOARDID === boardId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: boardId,
|
|
||||||
shortName: (share?.SHORTNAME as string) || '',
|
|
||||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
|
||||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
|
||||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
|
||||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
|
||||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
|
||||||
last: mkt
|
|
||||||
? parseFloat((mkt.LAST as string) || '')
|
|
||||||
: parseFloat((share.PREVPRICE as string) || ''),
|
|
||||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
|
||||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
|
||||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
|
||||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
|
||||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
|
||||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
|
||||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
|
||||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
|
||||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getShareMarketDataBatch(
|
|
||||||
secids: string[],
|
|
||||||
boardId = 'TQBR',
|
|
||||||
): Promise<MoexShareMarketData[]> {
|
|
||||||
const params: Record<string, string> = { boards: boardId };
|
|
||||||
if (secids.length > 0) {
|
|
||||||
params.securities = secids.join(',');
|
|
||||||
}
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/shares/securities`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
const securities = this.extractTable(data, 'securities');
|
|
||||||
const marketdata = this.extractTable(data, 'marketdata');
|
|
||||||
|
|
||||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
|
||||||
const filteredSecurities = secidSet
|
|
||||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
|
||||||
: securities;
|
|
||||||
|
|
||||||
return filteredSecurities.map((sec) => {
|
|
||||||
const secid = sec.SECID as string;
|
|
||||||
const mkt =
|
|
||||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
|
||||||
marketdata.find((r) => r.SECID === secid);
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: boardId,
|
|
||||||
shortName: (sec?.SHORTNAME as string) || '',
|
|
||||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
|
||||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
|
||||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
|
||||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
|
||||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
|
||||||
last: mkt
|
|
||||||
? parseFloat((mkt.LAST as string) || '')
|
|
||||||
: parseFloat((sec?.PREVPRICE as string) || ''),
|
|
||||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
|
||||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
|
||||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
|
||||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
|
||||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
|
||||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
|
||||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
|
||||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
|
||||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBondPositionDataBatch(
|
|
||||||
secids: string[],
|
|
||||||
boardId = 'TQCB',
|
|
||||||
): Promise<MoexBondPositionData[]> {
|
|
||||||
const params: Record<string, string> = { boards: boardId };
|
|
||||||
if (secids.length > 0) {
|
|
||||||
params.securities = secids.join(',');
|
|
||||||
}
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/bonds/securities`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
const securities = this.extractTable(data, 'securities');
|
|
||||||
const marketdata = this.extractTable(data, 'marketdata');
|
|
||||||
|
|
||||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
|
||||||
const filteredSecurities = secidSet
|
|
||||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
|
||||||
: securities;
|
|
||||||
|
|
||||||
return filteredSecurities.map((bond) => {
|
|
||||||
const secid = bond.SECID as string;
|
|
||||||
const mkt =
|
|
||||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
|
|
||||||
marketdata.find((r) => r.SECID === secid && r.LAST != null) ||
|
|
||||||
marketdata.find((r) => r.SECID === secid);
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: (bond.BOARDID as string) || boardId,
|
|
||||||
shortName: (bond?.SHORTNAME as string) || '',
|
|
||||||
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
|
||||||
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
|
||||||
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
|
||||||
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
|
||||||
couponPercent:
|
|
||||||
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
|
||||||
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
|
|
||||||
matDate: (bond?.MATDATE as string) || null,
|
|
||||||
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
|
||||||
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
|
|
||||||
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
|
|
||||||
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
|
||||||
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
|
|
||||||
bondType: (bond?.BONDTYPE as string) || null,
|
|
||||||
offerDate: (bond?.OFFERDATE as string) || null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
||||||
{ boards: boardId },
|
|
||||||
);
|
|
||||||
const rows = this.extractTable(data, 'securities');
|
|
||||||
const bond =
|
|
||||||
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
|
|
||||||
rows.find((r) => r.PREVWAPRICE != null) ||
|
|
||||||
rows[0];
|
|
||||||
if (!bond) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: boardId,
|
|
||||||
shortName: (bond.SHORTNAME as string) || '',
|
|
||||||
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
|
|
||||||
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
|
|
||||||
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
|
||||||
nextCoupon: (bond.NEXTCOUPON as string) || null,
|
|
||||||
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
|
||||||
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
|
|
||||||
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
|
|
||||||
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
|
|
||||||
matDate: (bond.MATDATE as string) || '',
|
|
||||||
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
|
|
||||||
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
|
|
||||||
isin: (bond.ISIN as string) || '',
|
|
||||||
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
|
||||||
offerDate: (bond.OFFERDATE as string) || null,
|
|
||||||
buybackDate: (bond.BUYBACKDATE as string) || null,
|
|
||||||
bondType: (bond.BONDTYPE as string) || '',
|
|
||||||
bondSubType: (bond.BONDSUBTYPE as string) || '',
|
|
||||||
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
||||||
{ boards: boardId },
|
|
||||||
);
|
|
||||||
const mktRows = this.extractTable(data, 'marketdata');
|
|
||||||
const mkt =
|
|
||||||
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
|
|
||||||
mktRows.find((r) => r.LAST != null) ||
|
|
||||||
mktRows.find((r) => r.SECID === secid);
|
|
||||||
if (!mkt) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
|
|
||||||
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
|
||||||
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
|
|
||||||
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
|
|
||||||
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
|
|
||||||
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
|
||||||
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
|
||||||
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
|
|
||||||
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
|
|
||||||
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
|
||||||
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
|
|
||||||
value: parseFloat((mkt.VALTODAY as string) || '0'),
|
|
||||||
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
|
|
||||||
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
|
|
||||||
updateTime: (mkt.UPDATETIME as string) || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDividends(secid: string): Promise<MoexDividend[]> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
|
|
||||||
return this.extractTable(data, 'dividends').map((d) => ({
|
|
||||||
secid: d.secid as string,
|
|
||||||
isin: d.isin as string,
|
|
||||||
registryCloseDate: d.registryclosedate as string,
|
|
||||||
value: parseFloat(d.value as string),
|
|
||||||
currencyId: (d.currencyid as string) || 'RUB',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCandles(
|
|
||||||
engine: 'stock',
|
|
||||||
market: 'shares' | 'bonds',
|
|
||||||
secid: string,
|
|
||||||
interval: 1 | 10 | 60 | 24,
|
|
||||||
from: string,
|
|
||||||
till: string,
|
|
||||||
): Promise<MoexCandle[]> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
|
|
||||||
{
|
|
||||||
interval: String(interval),
|
|
||||||
from,
|
|
||||||
till,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return this.extractTable(data, 'candles').map((c) => ({
|
|
||||||
open: parseFloat(c.open as string),
|
|
||||||
close: parseFloat(c.close as string),
|
|
||||||
high: parseFloat(c.high as string),
|
|
||||||
low: parseFloat(c.low as string),
|
|
||||||
value: parseFloat(c.value as string),
|
|
||||||
volume: parseInt(c.volume as string, 10),
|
|
||||||
begin: c.begin as string,
|
|
||||||
end: c.end as string,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/shares/securities/${secid}`,
|
|
||||||
{ from, till },
|
|
||||||
);
|
|
||||||
const tableName = Object.keys(data).find(
|
|
||||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
|
||||||
);
|
|
||||||
if (!tableName) return [];
|
|
||||||
return this.extractTable(data, tableName).map((h) => ({
|
|
||||||
tradeDate: h.TRADEDATE as string,
|
|
||||||
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
|
|
||||||
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
|
|
||||||
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
|
|
||||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
|
||||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
|
||||||
volume: parseInt((h.VOLUME as string) || '0', 10),
|
|
||||||
value: parseFloat((h.VALUE as string) || '0'),
|
|
||||||
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
||||||
{ from, till },
|
|
||||||
);
|
|
||||||
const tableName = Object.keys(data).find(
|
|
||||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
|
||||||
);
|
|
||||||
if (!tableName) return [];
|
|
||||||
return this.extractTable(data, tableName).map((h) => ({
|
|
||||||
tradeDate: h.TRADEDATE as string,
|
|
||||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
|
||||||
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
|
|
||||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
|
||||||
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
|
|
||||||
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
|
|
||||||
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
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',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
50
apps/backend/src/modules/moex-client/moex-history.client.ts
Normal file
50
apps/backend/src/modules/moex-client/moex-history.client.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
132
apps/backend/src/modules/moex-client/moex-http.client.spec.ts
Normal file
132
apps/backend/src/modules/moex-client/moex-http.client.spec.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
76
apps/backend/src/modules/moex-client/moex-http.client.ts
Normal file
76
apps/backend/src/modules/moex-client/moex-http.client.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
219
apps/backend/src/modules/moex-client/moex-market-data.client.ts
Normal file
219
apps/backend/src/modules/moex-client/moex-market-data.client.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { MoexHttpClient } from './moex-http.client';
|
||||||
|
import { MoexSecurityDescription } from './moex-client.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MoexSecuritiesClient {
|
||||||
|
constructor(private readonly http: MoexHttpClient) {}
|
||||||
|
|
||||||
|
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
|
||||||
|
const data = await this.http.request<Record<string, unknown>>('/securities', { q: query });
|
||||||
|
return this.http.extractTable(data, 'securities').map((s) => ({
|
||||||
|
secid: s.secid as string,
|
||||||
|
isin: s.isin as string,
|
||||||
|
name: s.name as string,
|
||||||
|
shortName: s.shortName as string,
|
||||||
|
latName: (s.latName as string) || null,
|
||||||
|
listLevel: parseInt(s.listLevel as string, 10) || 0,
|
||||||
|
issueSize: parseInt(s.issuesize as string, 10) || 0,
|
||||||
|
faceValue: parseFloat(s.facevalue as string) || 0,
|
||||||
|
faceUnit: (s.faceunit as string) || '',
|
||||||
|
issueDate: (s.issuedate as string) || '',
|
||||||
|
typeName: (s.typename as string) || '',
|
||||||
|
group: (s.group as string) || '',
|
||||||
|
type: (s.type as string) || '',
|
||||||
|
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
|
||||||
|
morningSession: (s.morningsession as string) === '1',
|
||||||
|
eveningSession: (s.eveningsession as string) === '1',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
|
||||||
|
const data = await this.http.request<Record<string, unknown>>(`/securities/${secid}`);
|
||||||
|
const rows = this.http.extractTable(data, 'description');
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const map = new Map(rows.map((r) => [r.name, r.value]));
|
||||||
|
return {
|
||||||
|
secid,
|
||||||
|
isin: (map.get('ISIN') as string) || '',
|
||||||
|
name: (map.get('NAME') as string) || '',
|
||||||
|
shortName: (map.get('SHORTNAME') as string) || '',
|
||||||
|
latName: (map.get('LATNAME') as string) || null,
|
||||||
|
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
|
||||||
|
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
|
||||||
|
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
|
||||||
|
faceUnit: (map.get('FACEUNIT') as string) || '',
|
||||||
|
issueDate: (map.get('ISSUEDATE') as string) || '',
|
||||||
|
typeName: (map.get('TYPENAME') as string) || '',
|
||||||
|
group: (map.get('GROUP') as string) || '',
|
||||||
|
type: (map.get('TYPE') as string) || '',
|
||||||
|
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
|
||||||
|
morningSession: (map.get('MORNINGSESSION') as string) === '1',
|
||||||
|
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
IsIn,
|
IsIn,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
IsDateString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ -31,7 +32,7 @@ export class AddPositionDto {
|
|||||||
|
|
||||||
@ApiProperty({ example: 10 })
|
@ApiProperty({ example: 10 })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(1)
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 250.5 })
|
@ApiPropertyOptional({ example: 250.5 })
|
||||||
@ -41,7 +42,7 @@ export class AddPositionDto {
|
|||||||
buyPrice?: number;
|
buyPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-06-01' })
|
@ApiPropertyOptional({ example: '2026-06-01' })
|
||||||
@IsString()
|
@IsDateString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
buyDate?: string;
|
buyDate?: string;
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,24 @@ export class PortfolioSummaryDto {
|
|||||||
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
||||||
@ApiProperty() positionCount!: number;
|
@ApiProperty() positionCount!: number;
|
||||||
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
|
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
targetSharesPercent?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
targetBondsPercent?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualSharesPercent!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualBondsPercent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
sharesDeviation?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
bondsDeviation?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnalyticsResponseDto {
|
export class AnalyticsResponseDto {
|
||||||
|
|||||||
@ -1,53 +1,46 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
import { AnalyticsResponseDto } from './analytics-response.dto';
|
import { AnalyticsResponseDto } from './analytics-response.dto';
|
||||||
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
|
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
|
||||||
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
|
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
|
||||||
import { PositionResponseDto } from './position-response.dto';
|
import { PositionResponseDto } from './position-response.dto';
|
||||||
|
|
||||||
export class PortfolioResponseMetaDto {
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
|
||||||
cachedAt!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
fromCache!: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PortfolioListEnvelopeDto {
|
export class PortfolioListEnvelopeDto {
|
||||||
@ApiProperty({ type: [PortfolioListResponseDto] })
|
@ApiProperty({ type: [PortfolioListResponseDto] })
|
||||||
data!: PortfolioListResponseDto[];
|
data!: PortfolioListResponseDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioEnvelopeDto {
|
export class PortfolioEnvelopeDto {
|
||||||
@ApiProperty({ type: PortfolioResponseDto })
|
@ApiProperty({ type: PortfolioResponseDto })
|
||||||
data!: PortfolioResponseDto;
|
data!: PortfolioResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioDetailEnvelopeDto {
|
export class PortfolioDetailEnvelopeDto {
|
||||||
@ApiProperty({ type: PortfolioDetailResponseDto })
|
@ApiProperty({ type: PortfolioDetailResponseDto })
|
||||||
data!: PortfolioDetailResponseDto;
|
data!: PortfolioDetailResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PositionEnvelopeDto {
|
export class PositionEnvelopeDto {
|
||||||
@ApiProperty({ type: PositionResponseDto })
|
@ApiProperty({ type: PositionResponseDto })
|
||||||
data!: PositionResponseDto;
|
data!: PositionResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnalyticsEnvelopeDto {
|
export class AnalyticsEnvelopeDto {
|
||||||
@ApiProperty({ type: AnalyticsResponseDto })
|
@ApiProperty({ type: AnalyticsResponseDto })
|
||||||
data!: AnalyticsResponseDto;
|
data!: AnalyticsResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,16 @@ export class PortfolioResponseDto {
|
|||||||
@ApiProperty({ default: 'RUB' }) currency!: string;
|
@ApiProperty({ default: 'RUB' }) currency!: string;
|
||||||
@ApiProperty() createdAt!: string;
|
@ApiProperty() createdAt!: string;
|
||||||
@ApiProperty() updatedAt!: string;
|
@ApiProperty() updatedAt!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sharesPercent: { type: 'number' },
|
||||||
|
bondsPercent: { type: 'number' },
|
||||||
|
},
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
targets!: { sharesPercent: number; bondsPercent: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
export class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
||||||
|
|||||||
43
apps/backend/src/modules/portfolio/dto/position.dto.spec.ts
Normal file
43
apps/backend/src/modules/portfolio/dto/position.dto.spec.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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,8 +1,34 @@
|
|||||||
import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator';
|
import {
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
IsNumber,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
|
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
|
||||||
|
|
||||||
|
export class PortfolioTargetsDto {
|
||||||
|
@ApiProperty({ example: 70 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
sharesPercent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 30 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
bondsPercent!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdatePortfolioDto {
|
export class UpdatePortfolioDto {
|
||||||
@ApiPropertyOptional({ example: 'Мой портфель' })
|
@ApiPropertyOptional({ example: 'Мой портфель' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -22,4 +48,11 @@ export class UpdatePortfolioDto {
|
|||||||
@IsIn(CURRENCIES)
|
@IsIn(CURRENCIES)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: { sharesPercent: 70, bondsPercent: 30 } })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => PortfolioTargetsDto)
|
||||||
|
targets?: PortfolioTargetsDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
IsDateString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ -24,7 +25,7 @@ const TAGS = [
|
|||||||
export class UpdatePositionDto {
|
export class UpdatePositionDto {
|
||||||
@ApiPropertyOptional({ example: 15 })
|
@ApiPropertyOptional({ example: 15 })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(1)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
|
|
||||||
@ -35,7 +36,7 @@ export class UpdatePositionDto {
|
|||||||
buyPrice?: number;
|
buyPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-06-15' })
|
@ApiPropertyOptional({ example: '2026-06-15' })
|
||||||
@IsString()
|
@IsDateString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
buyDate?: string;
|
buyDate?: string;
|
||||||
|
|
||||||
|
|||||||
@ -13,32 +13,28 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
|||||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||||
import { AddPositionDto } from './dto/add-position.dto';
|
import { AddPositionDto } from './dto/add-position.dto';
|
||||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||||
|
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import {
|
import {
|
||||||
AnalyticsEnvelopeDto,
|
AnalyticsEnvelopeDto,
|
||||||
PortfolioDetailEnvelopeDto,
|
PortfolioDetailEnvelopeDto,
|
||||||
PortfolioEnvelopeDto,
|
PortfolioEnvelopeDto,
|
||||||
PortfolioListEnvelopeDto,
|
PortfolioListEnvelopeDto,
|
||||||
PortfolioResponseMetaDto,
|
|
||||||
PositionEnvelopeDto,
|
PositionEnvelopeDto,
|
||||||
} from './dto/portfolio-envelope.dto';
|
} from './dto/portfolio-envelope.dto';
|
||||||
|
|
||||||
const nullDataEnvelopeSchema = {
|
const nullDataEnvelopeSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
data: {
|
data: { type: 'null' },
|
||||||
type: 'null',
|
meta: { $ref: getSchemaPath(ApiResponseMeta) },
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
$ref: getSchemaPath(PortfolioResponseMetaDto),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
required: ['data', 'meta'],
|
required: ['data', 'meta'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiTags('Portfolios')
|
@ApiTags('Portfolios')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiExtraModels(PortfolioResponseMetaDto)
|
@ApiExtraModels(ApiResponseMeta)
|
||||||
@Controller('portfolios')
|
@Controller('portfolios')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
constructor(private readonly portfolioService: PortfolioService) {}
|
constructor(private readonly portfolioService: PortfolioService) {}
|
||||||
@ -47,24 +43,21 @@ export class PortfolioController {
|
|||||||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||||
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
|
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
|
||||||
async findAll(@CurrentUser() user: { sub: number }) {
|
async findAll(@CurrentUser() user: { sub: number }) {
|
||||||
const portfolios = await this.portfolioService.findAll(user.sub);
|
return this.portfolioService.findAll(user.sub);
|
||||||
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new portfolio' })
|
@ApiOperation({ summary: 'Create a new portfolio' })
|
||||||
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
|
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
|
||||||
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
|
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
|
||||||
const portfolio = await this.portfolioService.create(user.sub, dto);
|
return this.portfolioService.create(user.sub, dto);
|
||||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
||||||
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
|
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
|
||||||
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||||
const portfolio = await this.portfolioService.findOne(user.sub, id);
|
return this.portfolioService.findOne(user.sub, id);
|
||||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ -75,8 +68,7 @@ export class PortfolioController {
|
|||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdatePortfolioDto,
|
@Body() dto: UpdatePortfolioDto,
|
||||||
) {
|
) {
|
||||||
const portfolio = await this.portfolioService.update(user.sub, id, dto);
|
return this.portfolioService.update(user.sub, id, dto);
|
||||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ -84,7 +76,7 @@ export class PortfolioController {
|
|||||||
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
|
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
|
||||||
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||||
await this.portfolioService.remove(user.sub, id);
|
await this.portfolioService.remove(user.sub, id);
|
||||||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/positions')
|
@Post(':id/positions')
|
||||||
@ -95,8 +87,7 @@ export class PortfolioController {
|
|||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: AddPositionDto,
|
@Body() dto: AddPositionDto,
|
||||||
) {
|
) {
|
||||||
const position = await this.portfolioService.addPosition(user.sub, id, dto);
|
return this.portfolioService.addPosition(user.sub, id, dto);
|
||||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/positions/:positionId')
|
@Patch(':id/positions/:positionId')
|
||||||
@ -108,16 +99,14 @@ export class PortfolioController {
|
|||||||
@Param('positionId', ParseIntPipe) positionId: number,
|
@Param('positionId', ParseIntPipe) positionId: number,
|
||||||
@Body() dto: UpdatePositionDto,
|
@Body() dto: UpdatePositionDto,
|
||||||
) {
|
) {
|
||||||
const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
return this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/analytics')
|
@Get(':id/analytics')
|
||||||
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
|
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
|
||||||
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
|
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
|
||||||
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||||
const result = await this.portfolioService.getAnalytics(user.sub, id);
|
return this.portfolioService.getAnalytics(user.sub, id);
|
||||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/positions/:positionId')
|
@Delete(':id/positions/:positionId')
|
||||||
@ -129,6 +118,6 @@ export class PortfolioController {
|
|||||||
@Param('positionId', ParseIntPipe) positionId: number,
|
@Param('positionId', ParseIntPipe) positionId: number,
|
||||||
) {
|
) {
|
||||||
await this.portfolioService.removePosition(user.sub, id, positionId);
|
await this.portfolioService.removePosition(user.sub, id, positionId);
|
||||||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [MoexClientModule],
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
providers: [PortfolioService],
|
providers: [PortfolioService],
|
||||||
exports: [PortfolioService],
|
exports: [PortfolioService],
|
||||||
|
|||||||
@ -2,15 +2,18 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||||
|
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import configuration from '../../config/configuration';
|
import configuration from '../../config/configuration';
|
||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||||
|
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
|
||||||
|
|
||||||
describe('PortfolioService', () => {
|
describe('PortfolioService', () => {
|
||||||
let service: PortfolioService;
|
let service: PortfolioService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let moexClient: MoexClientService;
|
let moexMarketData: MoexMarketDataClient;
|
||||||
let module: TestingModule;
|
let module: TestingModule;
|
||||||
|
|
||||||
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
|
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
|
||||||
@ -65,13 +68,20 @@ describe('PortfolioService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MoexClientService,
|
provide: MoexSecuritiesClient,
|
||||||
|
useValue: { getSecurityDescription: vi.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MoexMarketDataClient,
|
||||||
useValue: {
|
useValue: {
|
||||||
getShareMarketDataBatch: vi.fn(),
|
getShareMarketDataBatch: vi.fn(),
|
||||||
getBondPositionDataBatch: vi.fn(),
|
getBondPositionDataBatch: vi.fn(),
|
||||||
getSecurityDescription: vi.fn(),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: MoexDividendsClient,
|
||||||
|
useValue: { getDividends: vi.fn() },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: CacheService,
|
provide: CacheService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@ -83,7 +93,7 @@ describe('PortfolioService', () => {
|
|||||||
|
|
||||||
service = module.get<PortfolioService>(PortfolioService);
|
service = module.get<PortfolioService>(PortfolioService);
|
||||||
prisma = module.get<PrismaService>(PrismaService);
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
moexClient = module.get<MoexClientService>(MoexClientService);
|
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -137,11 +147,11 @@ describe('PortfolioService', () => {
|
|||||||
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
|
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||||
] as any);
|
] as any);
|
||||||
|
|
||||||
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||||
{
|
{
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
shortName: 'OFZ 26238',
|
shortName: 'OFZ 26238',
|
||||||
@ -203,14 +213,14 @@ describe('PortfolioService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('findOne', () => {
|
describe('findOne', () => {
|
||||||
it('should throw NotFoundException for non-existent portfolio', async () => {
|
it('should throw EntityNotFoundException for non-existent portfolio', async () => {
|
||||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
|
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
|
||||||
await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException);
|
await expect(service.findOne(1, 999)).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw ForbiddenException for wrong user', async () => {
|
it('should throw PortfolioAccessDeniedException for wrong user', async () => {
|
||||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
|
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
|
||||||
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
|
await expect(service.findOne(1, 1)).rejects.toThrow(PortfolioAccessDeniedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return portfolio with enriched positions and analytics summary', async () => {
|
it('should return portfolio with enriched positions and analytics summary', async () => {
|
||||||
@ -235,7 +245,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
||||||
] as any);
|
] as any);
|
||||||
|
|
||||||
@ -281,7 +291,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||||
] as any);
|
] as any);
|
||||||
|
|
||||||
@ -323,7 +333,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||||
{
|
{
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
shortName: 'OFZ 26238',
|
shortName: 'OFZ 26238',
|
||||||
@ -367,7 +377,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
||||||
] as any);
|
] as any);
|
||||||
|
|
||||||
@ -403,7 +413,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any);
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([] as any);
|
||||||
|
|
||||||
const result = await service.getPositionsWithPrices(1);
|
const result = await service.getPositionsWithPrices(1);
|
||||||
|
|
||||||
@ -459,7 +469,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||||
{ secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
|
{ secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
|
||||||
] as any);
|
] as any);
|
||||||
@ -511,7 +521,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||||
{ secid: 'SBER', shortName: 'Sberbank', last: 120 },
|
{ secid: 'SBER', shortName: 'Sberbank', last: 120 },
|
||||||
{ secid: 'GAZP', shortName: 'Gazprom', last: 180 },
|
{ secid: 'GAZP', shortName: 'Gazprom', last: 180 },
|
||||||
] as any);
|
] as any);
|
||||||
@ -524,16 +534,16 @@ describe('PortfolioService', () => {
|
|||||||
expect(result.summary.weightedYield).toBeCloseTo(0, 1);
|
expect(result.summary.weightedYield).toBeCloseTo(0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw ForbiddenException if portfolio belongs to another user', async () => {
|
it('should throw PortfolioAccessDeniedException if portfolio belongs to another user', async () => {
|
||||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
|
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
|
||||||
|
|
||||||
await expect(service.getAnalytics(1, 1)).rejects.toThrow(ForbiddenException);
|
await expect(service.getAnalytics(1, 1)).rejects.toThrow(PortfolioAccessDeniedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if portfolio does not exist', async () => {
|
it('should throw EntityNotFoundException if portfolio does not exist', async () => {
|
||||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
|
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException);
|
await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||||
|
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import type { MoexShareMarketData, MoexBondPositionData } from '../moex-client/moex-client.types';
|
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||||
|
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
|
||||||
|
import type {
|
||||||
|
MoexShareMarketData,
|
||||||
|
MoexBondPositionData,
|
||||||
|
MoexDividend,
|
||||||
|
} from '../moex-client/moex-client.types';
|
||||||
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||||
import { AddPositionDto } from './dto/add-position.dto';
|
import { AddPositionDto } from './dto/add-position.dto';
|
||||||
@ -54,12 +60,14 @@ export interface EnrichedPosition {
|
|||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexSecurities: MoexSecuritiesClient,
|
||||||
|
private readonly moexMarketData: MoexMarketDataClient,
|
||||||
|
private readonly moexDividends: MoexDividendsClient,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: number, dto: CreatePortfolioDto) {
|
async create(userId: number, dto: CreatePortfolioDto) {
|
||||||
return this.prisma.portfolio.create({
|
const portfolio = await this.prisma.portfolio.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
@ -67,6 +75,8 @@ export class PortfolioService {
|
|||||||
currency: dto.currency ?? 'RUB',
|
currency: dto.currency ?? 'RUB',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { ...portfolio, targets: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(userId: number) {
|
async findAll(userId: number) {
|
||||||
@ -89,6 +99,7 @@ export class PortfolioService {
|
|||||||
positionCount: 0,
|
positionCount: 0,
|
||||||
shareCount: 0,
|
shareCount: 0,
|
||||||
bondCount: 0,
|
bondCount: 0,
|
||||||
|
targets: p.targets ? JSON.parse(p.targets) : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,6 +128,7 @@ export class PortfolioService {
|
|||||||
positionCount: positions.length,
|
positionCount: positions.length,
|
||||||
shareCount: positions.filter((pos) => pos.type === 'share').length,
|
shareCount: positions.filter((pos) => pos.type === 'share').length,
|
||||||
bondCount: positions.filter((pos) => pos.type === 'bond').length,
|
bondCount: positions.filter((pos) => pos.type === 'bond').length,
|
||||||
|
targets: p.targets ? JSON.parse(p.targets) : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -127,8 +139,8 @@ export class PortfolioService {
|
|||||||
include: { positions: true },
|
include: { positions: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
|
||||||
|
|
||||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
|
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
|
||||||
|
|
||||||
@ -154,28 +166,35 @@ export class PortfolioService {
|
|||||||
positions: positionsWithWeights,
|
positions: positionsWithWeights,
|
||||||
totalValue: Math.round(totalValue * 100) / 100,
|
totalValue: Math.round(totalValue * 100) / 100,
|
||||||
analytics: analytics.summary,
|
analytics: analytics.summary,
|
||||||
|
targets: portfolio.targets ? JSON.parse(portfolio.targets) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(userId: number, id: number, dto: UpdatePortfolioDto) {
|
async update(userId: number, id: number, dto: UpdatePortfolioDto) {
|
||||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
||||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
|
||||||
|
|
||||||
return this.prisma.portfolio.update({
|
const updated = await this.prisma.portfolio.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(dto.name !== undefined && { name: dto.name }),
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
...(dto.description !== undefined && { description: dto.description }),
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
...(dto.currency !== undefined && { currency: dto.currency }),
|
...(dto.currency !== undefined && { currency: dto.currency }),
|
||||||
|
...(dto.targets !== undefined && { targets: JSON.stringify(dto.targets) }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updated,
|
||||||
|
targets: updated.targets ? JSON.parse(updated.targets) : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(userId: number, id: number) {
|
async remove(userId: number, id: number) {
|
||||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
||||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
|
||||||
|
|
||||||
await this.prisma.portfolio.delete({ where: { id } });
|
await this.prisma.portfolio.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
@ -185,8 +204,8 @@ export class PortfolioService {
|
|||||||
where: { id: portfolioId },
|
where: { id: portfolioId },
|
||||||
include: { positions: true },
|
include: { positions: true },
|
||||||
});
|
});
|
||||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||||
|
|
||||||
const exists = portfolio.positions.find((p) => p.secid === dto.secid);
|
const exists = portfolio.positions.find((p) => p.secid === dto.secid);
|
||||||
if (exists)
|
if (exists)
|
||||||
@ -194,7 +213,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
|
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
|
||||||
|
|
||||||
const desc = await this.moexClient.getSecurityDescription(dto.secid);
|
const desc = await this.moexSecurities.getSecurityDescription(dto.secid);
|
||||||
if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`);
|
if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`);
|
||||||
|
|
||||||
const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
|
const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
|
||||||
@ -220,12 +239,12 @@ export class PortfolioService {
|
|||||||
dto: UpdatePositionDto,
|
dto: UpdatePositionDto,
|
||||||
) {
|
) {
|
||||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||||
|
|
||||||
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
||||||
if (!position || position.portfolioId !== portfolioId) {
|
if (!position || position.portfolioId !== portfolioId) {
|
||||||
throw new NotFoundException(`Position ${positionId} not found`);
|
throw new EntityNotFoundException('Position', positionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.position.update({
|
return this.prisma.position.update({
|
||||||
@ -242,12 +261,12 @@ export class PortfolioService {
|
|||||||
|
|
||||||
async removePosition(userId: number, portfolioId: number, positionId: number) {
|
async removePosition(userId: number, portfolioId: number, positionId: number) {
|
||||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||||
|
|
||||||
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
||||||
if (!position || position.portfolioId !== portfolioId) {
|
if (!position || position.portfolioId !== portfolioId) {
|
||||||
throw new NotFoundException(`Position ${positionId} not found`);
|
throw new EntityNotFoundException('Position', positionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.position.delete({ where: { id: positionId } });
|
await this.prisma.position.delete({ where: { id: positionId } });
|
||||||
@ -272,9 +291,10 @@ export class PortfolioService {
|
|||||||
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
|
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
|
||||||
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
|
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
|
||||||
|
|
||||||
const [shareDataBySecid, bondDataBySecid] = await Promise.all([
|
const [shareDataBySecid, bondDataBySecid, dividendsBySecid] = await Promise.all([
|
||||||
this.fetchShareBatch(shareSecids, portfolioId),
|
this.fetchShareBatch(shareSecids, portfolioId),
|
||||||
this.fetchBondBatch(bondSecids, portfolioId),
|
this.fetchBondBatch(bondSecids, portfolioId),
|
||||||
|
this.fetchDividendsBatch(shareSecids, portfolioId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const enriched: EnrichedPosition[] = [];
|
const enriched: EnrichedPosition[] = [];
|
||||||
@ -305,7 +325,9 @@ export class PortfolioService {
|
|||||||
if (pos.type === 'bond') {
|
if (pos.type === 'bond') {
|
||||||
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
||||||
} else {
|
} else {
|
||||||
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
|
enriched.push(
|
||||||
|
this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid), dividendsBySecid.get(pos.secid)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,7 +343,7 @@ export class PortfolioService {
|
|||||||
const { data } = await this.cache.getOrFetch(
|
const { data } = await this.cache.getOrFetch(
|
||||||
'batchdata',
|
'batchdata',
|
||||||
['shares', cacheKey],
|
['shares', cacheKey],
|
||||||
() => this.moexClient.getShareMarketDataBatch(secids),
|
() => this.moexMarketData.getShareMarketDataBatch(secids),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
return new Map(data.map((d) => [d.secid, d]));
|
return new Map(data.map((d) => [d.secid, d]));
|
||||||
@ -336,12 +358,32 @@ export class PortfolioService {
|
|||||||
const { data } = await this.cache.getOrFetch(
|
const { data } = await this.cache.getOrFetch(
|
||||||
'batchdata',
|
'batchdata',
|
||||||
['bonds', cacheKey],
|
['bonds', cacheKey],
|
||||||
() => this.moexClient.getBondPositionDataBatch(secids),
|
() => this.moexMarketData.getBondPositionDataBatch(secids),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
return new Map(data.map((d) => [d.secid, d]));
|
return new Map(data.map((d) => [d.secid, d]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchDividendsBatch(
|
||||||
|
secids: string[],
|
||||||
|
portfolioId?: number,
|
||||||
|
): Promise<Map<string, MoexDividend[]>> {
|
||||||
|
if (secids.length === 0) return new Map();
|
||||||
|
const results = await Promise.all(
|
||||||
|
secids.map(async (secid) => {
|
||||||
|
const cacheKey = portfolioId ? `pf:${portfolioId}:${secid}` : secid;
|
||||||
|
const { data } = await this.cache.getOrFetch(
|
||||||
|
'dividends',
|
||||||
|
[cacheKey],
|
||||||
|
() => this.moexDividends.getDividends(secid),
|
||||||
|
'marketDataTtl',
|
||||||
|
);
|
||||||
|
return { secid, dividends: data };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return new Map(results.map((r) => [r.secid, r.dividends]));
|
||||||
|
}
|
||||||
|
|
||||||
private buildSharePosition(
|
private buildSharePosition(
|
||||||
pos: {
|
pos: {
|
||||||
id: number;
|
id: number;
|
||||||
@ -352,9 +394,16 @@ export class PortfolioService {
|
|||||||
},
|
},
|
||||||
base: EnrichedPosition,
|
base: EnrichedPosition,
|
||||||
data: MoexShareMarketData | undefined,
|
data: MoexShareMarketData | undefined,
|
||||||
|
dividends?: MoexDividend[],
|
||||||
): EnrichedPosition {
|
): EnrichedPosition {
|
||||||
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null;
|
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null;
|
||||||
const dividendIncome = 0;
|
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) {
|
if (!data) {
|
||||||
return {
|
return {
|
||||||
@ -472,8 +521,8 @@ export class PortfolioService {
|
|||||||
|
|
||||||
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
|
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
|
||||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException();
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||||
|
|
||||||
const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
|
const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
|
||||||
|
|
||||||
@ -494,6 +543,32 @@ export class PortfolioService {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
let targetSharesPercent: number | null = null;
|
||||||
|
let targetBondsPercent: number | null = null;
|
||||||
|
if (portfolio.targets) {
|
||||||
|
const targets = JSON.parse(portfolio.targets);
|
||||||
|
targetSharesPercent = targets.sharesPercent;
|
||||||
|
targetBondsPercent = targets.bondsPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualSharesPercent = 0;
|
||||||
|
let actualBondsPercent = 0;
|
||||||
|
if (totalValue > 0) {
|
||||||
|
const shareValue = enrichedPositions
|
||||||
|
.filter((p) => p.type === 'share')
|
||||||
|
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||||
|
const bondValue = enrichedPositions
|
||||||
|
.filter((p) => p.type === 'bond')
|
||||||
|
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||||
|
actualSharesPercent = Math.round((shareValue / totalValue) * 10000) / 100;
|
||||||
|
actualBondsPercent = Math.round((bondValue / totalValue) * 10000) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharesDeviation =
|
||||||
|
targetSharesPercent !== null ? Math.round((actualSharesPercent - targetSharesPercent) * 100) / 100 : null;
|
||||||
|
const bondsDeviation =
|
||||||
|
targetBondsPercent !== null ? Math.round((actualBondsPercent - targetBondsPercent) * 100) / 100 : null;
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
totalInvested,
|
totalInvested,
|
||||||
totalValue,
|
totalValue,
|
||||||
@ -504,6 +579,12 @@ export class PortfolioService {
|
|||||||
totalReturnPercent,
|
totalReturnPercent,
|
||||||
positionCount,
|
positionCount,
|
||||||
weightedYield,
|
weightedYield,
|
||||||
|
targetSharesPercent,
|
||||||
|
targetBondsPercent,
|
||||||
|
actualSharesPercent,
|
||||||
|
actualBondsPercent,
|
||||||
|
sharesDeviation,
|
||||||
|
bondsDeviation,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { positions: enrichedPositions, summary };
|
return { positions: enrichedPositions, summary };
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
|
|
||||||
export class ScreenerItemDto {
|
export class ScreenerItemDto {
|
||||||
@ApiProperty({ example: 'SBER' })
|
@ApiProperty({ example: 'SBER' })
|
||||||
@ -70,18 +71,10 @@ export class ScreenerResultDto {
|
|||||||
totalPages!: number;
|
totalPages!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScreenerResponseMetaDto {
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
|
||||||
cachedAt!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
fromCache!: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ScreenerResponseDto {
|
export class ScreenerResponseDto {
|
||||||
@ApiProperty({ type: ScreenerResultDto })
|
@ApiProperty({ type: ScreenerResultDto })
|
||||||
data!: ScreenerResultDto;
|
data!: ScreenerResultDto;
|
||||||
|
|
||||||
@ApiProperty({ type: ScreenerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: ScreenerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
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,30 +1,21 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ScreenerService } from './screener.service';
|
import { ScreenerService } from './screener.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { ScreenerType } from './dto/screener-query.dto';
|
import { ScreenerType } from './dto/screener-query.dto';
|
||||||
|
|
||||||
describe('ScreenerService', () => {
|
describe('ScreenerService', () => {
|
||||||
let service: ScreenerService;
|
let service: ScreenerService;
|
||||||
let cache: CacheService;
|
let cache: CacheService;
|
||||||
|
const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
ScreenerService,
|
ScreenerService,
|
||||||
{
|
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||||
provide: MoexClientService,
|
{ provide: CacheService, useValue: { getOrFetch: vi.fn() } },
|
||||||
useValue: {
|
|
||||||
getShareMarketDataBatch: vi.fn(),
|
|
||||||
getBondPositionDataBatch: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: CacheService,
|
|
||||||
useValue: {
|
|
||||||
getOrFetch: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@ -37,6 +28,30 @@ describe('ScreenerService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('screen', () => {
|
describe('screen', () => {
|
||||||
|
it('should cache full dataset with screenerTtl config', async () => {
|
||||||
|
const mockShares = [{
|
||||||
|
secid: 'SBER', shortName: 'Sberbank', last: 250, volume: 1000000,
|
||||||
|
lastChange: 5, lastChangePrcnt: 2, issueCapitalization: 1e9,
|
||||||
|
}];
|
||||||
|
|
||||||
|
moexMarketData.getShareMarketDataBatch.mockResolvedValue(mockShares);
|
||||||
|
|
||||||
|
vi.mocked(cache.getOrFetch).mockImplementation(async (_prefix, _keys, fetchFn) => ({
|
||||||
|
data: await fetchFn(),
|
||||||
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await service.screen({ type: ScreenerType.SHARE });
|
||||||
|
|
||||||
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
|
'screener',
|
||||||
|
[ScreenerType.SHARE],
|
||||||
|
expect.any(Function),
|
||||||
|
'screenerTtl',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should filter and sort shares', async () => {
|
it('should filter and sort shares', async () => {
|
||||||
const mockShares = [
|
const mockShares = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
|
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
|
||||||
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';
|
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';
|
||||||
@ -7,7 +7,7 @@ import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ScreenerService {
|
export class ScreenerService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexMarketData: MoexMarketDataClient,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ export class ScreenerService {
|
|||||||
[type],
|
[type],
|
||||||
async () => {
|
async () => {
|
||||||
if (type === ScreenerType.SHARE) {
|
if (type === ScreenerType.SHARE) {
|
||||||
const shares = await this.moexClient.getShareMarketDataBatch([]);
|
const shares = await this.moexMarketData.getShareMarketDataBatch([]);
|
||||||
return shares.map(
|
return shares.map(
|
||||||
(s): ScreenerItemDto => ({
|
(s): ScreenerItemDto => ({
|
||||||
secid: s.secid,
|
secid: s.secid,
|
||||||
@ -61,7 +61,7 @@ export class ScreenerService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const bonds = await this.moexClient.getBondPositionDataBatch([]);
|
const bonds = await this.moexMarketData.getBondPositionDataBatch([]);
|
||||||
return bonds.map(
|
return bonds.map(
|
||||||
(b): ScreenerItemDto => ({
|
(b): ScreenerItemDto => ({
|
||||||
secid: b.secid,
|
secid: b.secid,
|
||||||
@ -85,7 +85,7 @@ export class ScreenerService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'marketDataTtl',
|
'screenerTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@ -47,7 +47,7 @@ describe('SecuritiesController', () => {
|
|||||||
|
|
||||||
it('should return search results', async () => {
|
it('should return search results', async () => {
|
||||||
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
|
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
|
||||||
expect(result.data).toEqual(mockResults);
|
expect(result).toEqual(mockResults);
|
||||||
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
|
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||||
import { SecuritiesService } from './securities.service';
|
import { SecuritiesService } from './securities.service';
|
||||||
import { ScreenerService } from './screener.service';
|
import { ScreenerService } from './screener.service';
|
||||||
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
|
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
|
||||||
import { ScreenerQueryDto } from './dto/screener-query.dto';
|
import { ScreenerQueryDto } from './dto/screener-query.dto';
|
||||||
import { ScreenerResponseDto } from './dto/screener-response.dto';
|
import { ScreenerResponseDto } from './dto/screener-response.dto';
|
||||||
|
import { SearchEnvelopeDto } from './dto/search-response.dto';
|
||||||
|
|
||||||
@ApiTags('Securities')
|
@ApiTags('Securities')
|
||||||
|
@ApiExtraModels(ApiResponseMeta)
|
||||||
@Controller('securities')
|
@Controller('securities')
|
||||||
export class SecuritiesController {
|
export class SecuritiesController {
|
||||||
constructor(
|
constructor(
|
||||||
@ -16,20 +19,19 @@ export class SecuritiesController {
|
|||||||
|
|
||||||
@Get('search')
|
@Get('search')
|
||||||
@ApiOperation({ summary: 'Поиск по инструментам' })
|
@ApiOperation({ summary: 'Поиск по инструментам' })
|
||||||
|
@ApiOkResponse({ type: SearchEnvelopeDto })
|
||||||
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||||||
const results = await this.securitiesService.search(
|
return this.securitiesService.search(
|
||||||
query.q,
|
query.q,
|
||||||
query.type || SecurityType.ALL,
|
query.type || SecurityType.ALL,
|
||||||
query.limit || 20,
|
query.limit || 20,
|
||||||
);
|
);
|
||||||
return { data: results, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('screener')
|
@Get('screener')
|
||||||
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
|
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
|
||||||
@ApiOkResponse({ type: ScreenerResponseDto })
|
@ApiOkResponse({ type: ScreenerResponseDto })
|
||||||
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
||||||
const result = await this.screenerService.screen(query);
|
return this.screenerService.screen(query);
|
||||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CacheModule } from '../cache/cache.module';
|
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||||
import { SecuritiesController } from './securities.controller';
|
import { SecuritiesController } from './securities.controller';
|
||||||
import { SecuritiesService } from './securities.service';
|
import { SecuritiesService } from './securities.service';
|
||||||
import { ScreenerService } from './screener.service';
|
import { ScreenerService } from './screener.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CacheModule],
|
imports: [MoexClientModule],
|
||||||
controllers: [SecuritiesController],
|
controllers: [SecuritiesController],
|
||||||
providers: [SecuritiesService, ScreenerService],
|
providers: [SecuritiesService, ScreenerService],
|
||||||
exports: [SecuritiesService],
|
exports: [SecuritiesService],
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { SecuritiesService } from './securities.service';
|
import { SecuritiesService } from './securities.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { SecurityType } from './dto/search-query.dto';
|
import { SecurityType } from './dto/search-query.dto';
|
||||||
|
|
||||||
describe('SecuritiesService', () => {
|
describe('SecuritiesService', () => {
|
||||||
let service: SecuritiesService;
|
let service: SecuritiesService;
|
||||||
let moexClient: Pick<MoexClientService, 'searchSecurities'>;
|
let moexSecurities: Pick<MoexSecuritiesClient, 'searchSecurities'>;
|
||||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
moexClient = {
|
moexSecurities = {
|
||||||
searchSecurities: vi.fn(),
|
searchSecurities: vi.fn(),
|
||||||
};
|
};
|
||||||
cache = {
|
cache = {
|
||||||
@ -24,7 +24,7 @@ describe('SecuritiesService', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
SecuritiesService,
|
SecuritiesService,
|
||||||
{ provide: MoexClientService, useValue: moexClient },
|
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||||
{ provide: CacheService, useValue: cache },
|
{ provide: CacheService, useValue: cache },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@ -33,7 +33,7 @@ describe('SecuritiesService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns supported securities only and normalizes SUR currency to RUB', async () => {
|
it('returns supported securities only and normalizes SUR currency to RUB', async () => {
|
||||||
vi.mocked(moexClient.searchSecurities).mockResolvedValue([
|
vi.mocked(moexSecurities.searchSecurities).mockResolvedValue([
|
||||||
{
|
{
|
||||||
secid: 'SBER',
|
secid: 'SBER',
|
||||||
isin: 'RU0009029540',
|
isin: 'RU0009029540',
|
||||||
@ -118,7 +118,7 @@ describe('SecuritiesService', () => {
|
|||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
'searchTtl',
|
'searchTtl',
|
||||||
);
|
);
|
||||||
expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr');
|
expect(moexSecurities.searchSecurities).toHaveBeenCalledWith('SbEr');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by type and applies limit without live MOEX dependency', async () => {
|
it('filters by type and applies limit without live MOEX dependency', async () => {
|
||||||
@ -169,6 +169,6 @@ describe('SecuritiesService', () => {
|
|||||||
price: null,
|
price: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(moexClient.searchSecurities).not.toHaveBeenCalled();
|
expect(moexSecurities.searchSecurities).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { SecurityType } from './dto/search-query.dto';
|
import { SecurityType } from './dto/search-query.dto';
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ export interface SearchResultItem {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SecuritiesService {
|
export class SecuritiesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexSecurities: MoexSecuritiesClient,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ export class SecuritiesService {
|
|||||||
'search',
|
'search',
|
||||||
[query.toLowerCase()],
|
[query.toLowerCase()],
|
||||||
async () => {
|
async () => {
|
||||||
const results = await this.moexClient.searchSecurities(query);
|
const results = await this.moexSecurities.searchSecurities(query);
|
||||||
return results
|
return results
|
||||||
.map((s): SearchResultItem | null => {
|
.map((s): SearchResultItem | null => {
|
||||||
const type =
|
const type =
|
||||||
@ -64,7 +64,7 @@ export class SecuritiesService {
|
|||||||
|
|
||||||
async getShareBrief(secid: string): Promise<SearchResultItem | null> {
|
async getShareBrief(secid: string): Promise<SearchResultItem | null> {
|
||||||
try {
|
try {
|
||||||
const desc = await this.moexClient.getSecurityDescription(secid);
|
const desc = await this.moexSecurities.getSecurityDescription(secid);
|
||||||
if (!desc) return null;
|
if (!desc) return null;
|
||||||
return {
|
return {
|
||||||
secid: desc.secid,
|
secid: desc.secid,
|
||||||
|
|||||||
12
apps/backend/src/modules/shares/dto/dividend-item.dto.ts
Normal file
12
apps/backend/src/modules/shares/dto/dividend-item.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
24
apps/backend/src/modules/shares/dto/history-item.dto.ts
Normal file
24
apps/backend/src/modules/shares/dto/history-item.dto.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
38
apps/backend/src/modules/shares/dto/shares-envelope.dto.ts
Normal file
38
apps/backend/src/modules/shares/dto/shares-envelope.dto.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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,33 +1,44 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||||
import { SharesService } from './shares.service';
|
import { SharesService } from './shares.service';
|
||||||
|
import {
|
||||||
|
ShareEnvelopeDto,
|
||||||
|
ShareMarketDataEnvelopeDto,
|
||||||
|
DividendsEnvelopeDto,
|
||||||
|
ShareHistoryEnvelopeDto,
|
||||||
|
} from './dto/shares-envelope.dto';
|
||||||
|
|
||||||
@ApiTags('Shares')
|
@ApiTags('Shares')
|
||||||
|
@ApiExtraModels(ApiResponseMeta)
|
||||||
@Controller('securities/shares')
|
@Controller('securities/shares')
|
||||||
export class SharesController {
|
export class SharesController {
|
||||||
constructor(private readonly sharesService: SharesService) {}
|
constructor(private readonly sharesService: SharesService) {}
|
||||||
|
|
||||||
@Get(':secid')
|
@Get(':secid')
|
||||||
@ApiOperation({ summary: 'Получить спецификацию акции' })
|
@ApiOperation({ summary: 'Получить спецификацию акции' })
|
||||||
|
@ApiOkResponse({ type: ShareEnvelopeDto })
|
||||||
async getShare(@Param('secid') secid: string) {
|
async getShare(@Param('secid') secid: string) {
|
||||||
const share = await this.sharesService.getShare(secid);
|
return this.sharesService.getShare(secid);
|
||||||
return { data: share, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':secid/marketdata')
|
@Get(':secid/marketdata')
|
||||||
@ApiOperation({ summary: 'Получить рыночные данные акции' })
|
@ApiOperation({ summary: 'Получить рыночные данные акции' })
|
||||||
|
@ApiOkResponse({ type: ShareMarketDataEnvelopeDto })
|
||||||
async getMarketData(@Param('secid') secid: string) {
|
async getMarketData(@Param('secid') secid: string) {
|
||||||
return this.sharesService.getMarketData(secid);
|
return this.sharesService.getMarketData(secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':secid/dividends')
|
@Get(':secid/dividends')
|
||||||
@ApiOperation({ summary: 'Получить дивиденды' })
|
@ApiOperation({ summary: 'Получить дивиденды' })
|
||||||
|
@ApiOkResponse({ type: DividendsEnvelopeDto })
|
||||||
async getDividends(@Param('secid') secid: string) {
|
async getDividends(@Param('secid') secid: string) {
|
||||||
return this.sharesService.getDividends(secid);
|
return this.sharesService.getDividends(secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':secid/history')
|
@Get(':secid/history')
|
||||||
@ApiOperation({ summary: 'Получить дневную историю торгов акции' })
|
@ApiOperation({ summary: 'Получить дневную историю торгов акции' })
|
||||||
|
@ApiOkResponse({ type: ShareHistoryEnvelopeDto })
|
||||||
async getHistory(
|
async getHistory(
|
||||||
@Param('secid') secid: string,
|
@Param('secid') secid: string,
|
||||||
@Query('from') from: string,
|
@Query('from') from: string,
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||||
import { SharesController } from './shares.controller';
|
import { SharesController } from './shares.controller';
|
||||||
import { SharesService } from './shares.service';
|
import { SharesService } from './shares.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [MoexClientModule],
|
||||||
controllers: [SharesController],
|
controllers: [SharesController],
|
||||||
providers: [SharesService],
|
providers: [SharesService],
|
||||||
exports: [SharesService],
|
exports: [SharesService],
|
||||||
|
|||||||
@ -1,17 +1,23 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||||
import { SharesService } from './shares.service';
|
import { SharesService } from './shares.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.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 { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
|
|
||||||
describe('SharesService', () => {
|
describe('SharesService', () => {
|
||||||
let service: SharesService;
|
let service: SharesService;
|
||||||
let moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
|
let moexSecurities: Pick<MoexSecuritiesClient, 'getSecurityDescription'>;
|
||||||
|
let moexMarketData: Pick<MoexMarketDataClient, 'getShareMarketData'>;
|
||||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
moexClient = {
|
moexSecurities = {
|
||||||
getSecurityDescription: vi.fn(),
|
getSecurityDescription: vi.fn(),
|
||||||
|
};
|
||||||
|
moexMarketData = {
|
||||||
getShareMarketData: vi.fn(),
|
getShareMarketData: vi.fn(),
|
||||||
};
|
};
|
||||||
cache = {
|
cache = {
|
||||||
@ -25,7 +31,10 @@ describe('SharesService', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
SharesService,
|
SharesService,
|
||||||
{ provide: MoexClientService, useValue: moexClient },
|
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||||
|
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||||
|
{ provide: MoexDividendsClient, useValue: { getDividends: vi.fn() } },
|
||||||
|
{ provide: MoexHistoryClient, useValue: { getHistory: vi.fn() } },
|
||||||
{ provide: CacheService, useValue: cache },
|
{ provide: CacheService, useValue: cache },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@ -34,7 +43,7 @@ describe('SharesService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns normalized SBER share spec and market data without live MOEX dependency', async () => {
|
it('returns normalized SBER share spec and market data without live MOEX dependency', async () => {
|
||||||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
|
||||||
secid: 'SBER',
|
secid: 'SBER',
|
||||||
isin: 'RU0009029540',
|
isin: 'RU0009029540',
|
||||||
name: 'Сбербанк России ПАО ао',
|
name: 'Сбербанк России ПАО ао',
|
||||||
@ -52,7 +61,7 @@ describe('SharesService', () => {
|
|||||||
morningSession: true,
|
morningSession: true,
|
||||||
eveningSession: true,
|
eveningSession: true,
|
||||||
});
|
});
|
||||||
vi.mocked(moexClient.getShareMarketData).mockResolvedValue({
|
vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({
|
||||||
secid: 'SBER',
|
secid: 'SBER',
|
||||||
boardid: 'TQBR',
|
boardid: 'TQBR',
|
||||||
shortName: 'Сбербанк',
|
shortName: 'Сбербанк',
|
||||||
@ -75,15 +84,15 @@ describe('SharesService', () => {
|
|||||||
|
|
||||||
const result = await service.getShare('SBER');
|
const result = await service.getShare('SBER');
|
||||||
|
|
||||||
expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER');
|
expect(moexSecurities.getSecurityDescription).toHaveBeenCalledWith('SBER');
|
||||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['shares', 'SBER'],
|
['shares', 'SBER'],
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
|
expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER');
|
||||||
expect(result).toMatchObject({
|
expect(result.data).toMatchObject({
|
||||||
secid: 'SBER',
|
secid: 'SBER',
|
||||||
isin: 'RU0009029540',
|
isin: 'RU0009029540',
|
||||||
name: 'Сбербанк России ПАО ао',
|
name: 'Сбербанк России ПАО ао',
|
||||||
@ -106,11 +115,11 @@ describe('SharesService', () => {
|
|||||||
issueCapitalization: 6900000000000,
|
issueCapitalization: 6900000000000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.marketData.updatedAt).toMatch(/T18:45:00$/);
|
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException for non-share security', async () => {
|
it('throws EntityNotFoundException for non-share security', async () => {
|
||||||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
isin: 'RU000A1038V6',
|
isin: 'RU000A1038V6',
|
||||||
name: 'ОФЗ 26238',
|
name: 'ОФЗ 26238',
|
||||||
@ -129,7 +138,7 @@ describe('SharesService', () => {
|
|||||||
eveningSession: false,
|
eveningSession: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException);
|
||||||
expect(cache.getOrFetch).not.toHaveBeenCalled();
|
expect(cache.getOrFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,16 +1,24 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.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 { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
|
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||||
|
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SharesService {
|
export class SharesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexSecurities: MoexSecuritiesClient,
|
||||||
|
private readonly moexMarketData: MoexMarketDataClient,
|
||||||
|
private readonly moexDividends: MoexDividendsClient,
|
||||||
|
private readonly moexHistory: MoexHistoryClient,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getShare(secid: string) {
|
async getShare(secid: string) {
|
||||||
const desc = await this.moexClient.getSecurityDescription(secid);
|
const desc = await this.moexSecurities.getSecurityDescription(secid);
|
||||||
if (
|
if (
|
||||||
!desc ||
|
!desc ||
|
||||||
!(
|
!(
|
||||||
@ -19,13 +27,17 @@ export class SharesService {
|
|||||||
desc.type === 'preferred_share'
|
desc.type === 'preferred_share'
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new NotFoundException(`Share ${secid} not found`);
|
throw new EntityNotFoundException('Share', secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: marketData } = await this.cache.getOrFetch(
|
const {
|
||||||
|
data: marketData,
|
||||||
|
fromCache,
|
||||||
|
cachedAt,
|
||||||
|
} = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['shares', secid],
|
['shares', secid],
|
||||||
() => this.moexClient.getShareMarketData(secid),
|
() => this.moexMarketData.getShareMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -33,32 +45,36 @@ export class SharesService {
|
|||||||
const change = marketData?.lastChange ?? 0;
|
const change = marketData?.lastChange ?? 0;
|
||||||
const changePercent = marketData?.lastChangePrcnt ?? 0;
|
const changePercent = marketData?.lastChangePrcnt ?? 0;
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
secid: desc.secid,
|
{
|
||||||
isin: desc.isin,
|
secid: desc.secid,
|
||||||
name: desc.name,
|
isin: desc.isin,
|
||||||
shortName: desc.shortName,
|
name: desc.name,
|
||||||
latName: desc.latName,
|
shortName: desc.shortName,
|
||||||
listLevel: desc.listLevel,
|
latName: desc.latName,
|
||||||
issueSize: desc.issueSize,
|
listLevel: desc.listLevel,
|
||||||
faceValue: desc.faceValue,
|
issueSize: desc.issueSize,
|
||||||
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
faceValue: desc.faceValue,
|
||||||
type: desc.type,
|
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
||||||
marketData: {
|
type: desc.type,
|
||||||
price: price ?? 0,
|
marketData: {
|
||||||
change,
|
price: price ?? 0,
|
||||||
changePercent,
|
change,
|
||||||
open: marketData?.open ?? 0,
|
changePercent,
|
||||||
high: marketData?.high ?? null,
|
open: marketData?.open ?? 0,
|
||||||
low: marketData?.low ?? null,
|
high: marketData?.high ?? null,
|
||||||
volume: marketData?.volume ?? 0,
|
low: marketData?.low ?? null,
|
||||||
value: marketData?.value ?? 0,
|
volume: marketData?.volume ?? 0,
|
||||||
issueCapitalization: marketData?.issueCapitalization ?? null,
|
value: marketData?.value ?? 0,
|
||||||
updatedAt: marketData?.updateTime
|
issueCapitalization: marketData?.issueCapitalization ?? null,
|
||||||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
updatedAt: marketData?.updateTime
|
||||||
: new Date().toISOString(),
|
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||||||
|
: new Date().toISOString(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
fromCache,
|
||||||
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMarketData(secid: string) {
|
async getMarketData(secid: string) {
|
||||||
@ -69,16 +85,16 @@ export class SharesService {
|
|||||||
} = await this.cache.getOrFetch(
|
} = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['shares', secid],
|
['shares', secid],
|
||||||
() => this.moexClient.getShareMarketData(secid),
|
() => this.moexMarketData.getShareMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!marketData) {
|
if (!marketData) {
|
||||||
throw new NotFoundException(`Market data for ${secid} not found`);
|
throw new EntityNotFoundException('MarketData', secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: {
|
{
|
||||||
price: marketData.last ?? 0,
|
price: marketData.last ?? 0,
|
||||||
change: marketData.lastChange ?? 0,
|
change: marketData.lastChange ?? 0,
|
||||||
changePercent: marketData.lastChangePrcnt ?? 0,
|
changePercent: marketData.lastChangePrcnt ?? 0,
|
||||||
@ -92,38 +108,40 @@ export class SharesService {
|
|||||||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDividends(secid: string) {
|
async getDividends(secid: string) {
|
||||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||||
'dividends',
|
'dividends',
|
||||||
[secid],
|
[secid],
|
||||||
() => this.moexClient.getDividends(secid),
|
() => this.moexDividends.getDividends(secid),
|
||||||
'dividendsTtl',
|
'dividendsTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: data.map((d) => ({
|
data.map((d) => ({
|
||||||
registryCloseDate: d.registryCloseDate,
|
registryCloseDate: d.registryCloseDate,
|
||||||
value: d.value,
|
value: d.value,
|
||||||
currency: d.currencyId,
|
currency: d.currencyId,
|
||||||
})),
|
})),
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(secid: string, from: string, till: string) {
|
async getHistory(secid: string, from: string, till: string) {
|
||||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||||
'history',
|
'history',
|
||||||
['shares', secid, from, till],
|
['shares', secid, from, till],
|
||||||
() => this.moexClient.getHistory(secid, from, till),
|
() => this.moexHistory.getHistory(secid, from, till),
|
||||||
'historyTtl',
|
'historyTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: data.map((h) => ({
|
data.map((h) => ({
|
||||||
date: h.tradeDate,
|
date: h.tradeDate,
|
||||||
open: h.open ?? 0,
|
open: h.open ?? 0,
|
||||||
high: h.high ?? 0,
|
high: h.high ?? 0,
|
||||||
@ -132,7 +150,8 @@ export class SharesService {
|
|||||||
volume: h.volume,
|
volume: h.volume,
|
||||||
value: h.value,
|
value: h.value,
|
||||||
})),
|
})),
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class BrokerAnalyticsDto {
|
||||||
|
@ApiProperty()
|
||||||
|
totalDeposits!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalWithdrawn!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
netInvested!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalDividends!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalCoupons!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalReceived!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalFees!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalTaxesPaid!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
totalReturnPercent!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
currency!: string;
|
||||||
|
}
|
||||||
74
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
Normal file
74
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
|
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||||
|
import { BrokerEventsDataDto } from './broker-events-response.dto';
|
||||||
|
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
|
||||||
|
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
|
||||||
|
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
|
||||||
|
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||||
|
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
|
||||||
|
import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto';
|
||||||
|
|
||||||
|
export class BrokerAccountsEnvelopeDto {
|
||||||
|
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
||||||
|
data!: BrokerAccountResponseDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerPortfolioEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerPortfolioResponseDto })
|
||||||
|
data!: BrokerPortfolioResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerOperationsEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerOperationsPageResponseDto })
|
||||||
|
data!: BrokerOperationsPageResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerPositionsEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerPositionsPageResponseDto })
|
||||||
|
data!: BrokerPositionsPageResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerOperationSyncEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
||||||
|
data!: BrokerOperationSyncResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerAnalyticsEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerAnalyticsDto })
|
||||||
|
data!: BrokerAnalyticsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerEventsEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerEventsDataDto })
|
||||||
|
data!: BrokerEventsDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerPortfolioHistoryEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
|
||||||
|
data!: BrokerPortfolioHistoryDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class BrokerEventsQueryDto {
|
||||||
|
@ApiProperty({ example: '2026-06-22', description: 'Start date inclusive (YYYY-MM-DD)' })
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'from must be YYYY-MM-DD' })
|
||||||
|
from!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-07-29', description: 'End date inclusive (YYYY-MM-DD)' })
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'to must be YYYY-MM-DD' })
|
||||||
|
to!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
example: 'dividend,coupon,maturity,offer',
|
||||||
|
description: 'Comma-separated event types to include',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^(dividend|coupon|maturity|offer)(,(dividend|coupon|maturity|offer))*$/, {
|
||||||
|
message: 'types must be a comma-separated list of known event types',
|
||||||
|
})
|
||||||
|
types?: string;
|
||||||
|
}
|
||||||
102
apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts
Normal file
102
apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
const eventTypes = ['dividend', 'coupon', 'maturity', 'offer'] as const;
|
||||||
|
const eventSources = ['forecast', 'actual'] as const;
|
||||||
|
const eventCategories = ['cashflow', 'corporate'] as const;
|
||||||
|
const instrumentTypes = ['share', 'bond', 'other'] as const;
|
||||||
|
|
||||||
|
export class BrokerEventItemDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: eventTypes })
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: eventSources })
|
||||||
|
source!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: eventCategories })
|
||||||
|
category!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
eventDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
paymentDate!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
ticker!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
name!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
instrumentUid!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: instrumentTypes })
|
||||||
|
instrumentType!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
quantitySnapshot!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
payoutPerUnit!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
estimatedAmount!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
actualAmount!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
currency!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true, enum: ['current_position'] })
|
||||||
|
estimateMode!: 'current_position' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerEventsSummaryDto {
|
||||||
|
@ApiProperty({ minimum: 0 })
|
||||||
|
eventCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
nearestEventDate!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalEstimatedCashflow!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualCashflow!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
forecastEstimatedCashflow!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
dividendsTotal!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
couponsTotal!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
principalRepaymentTotal!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualDividendsTotal!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualCouponsTotal!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualPrincipalRepaymentTotal!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrokerEventsDataDto {
|
||||||
|
@ApiProperty({ type: [BrokerEventItemDto] })
|
||||||
|
items!: BrokerEventItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerEventsSummaryDto })
|
||||||
|
summary!: BrokerEventsSummaryDto;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
asOf!: string;
|
||||||
|
}
|
||||||
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