Compare commits
295 Commits
feat/mvp-i
...
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 | |||
| 117f182851 | |||
| c062850e83 | |||
| 835686d886 | |||
| 143b4758c3 | |||
| 44e13cc67e | |||
| 974c83d67e | |||
| c994b6a2fb | |||
| bc2d2af141 | |||
| 2e79e69403 | |||
| ee88b29181 | |||
| 96f003852d | |||
| 1017fd4f08 | |||
| 2d2089915d | |||
| 7b3b77f35e | |||
| a8b3b55896 | |||
| f0e5e36b7d | |||
| 25a6d22a0c | |||
| 461a5b1ca6 | |||
| 38729249f3 | |||
| 60e456fbb5 | |||
| 4c15bda30e | |||
| a980520261 | |||
| ca4a8b35f3 | |||
| a14e24d8e8 | |||
| 5ff9cd6190 | |||
| 6a7706f7be | |||
| 515e59600f | |||
| 090a2b613f | |||
| 729e94b4db | |||
| c2478277e6 | |||
| aac5b2f873 |
@ -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
|
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -1,8 +1,17 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.superpowers/
|
||||||
|
.worktrees/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
vite.config.d.ts
|
vite.config.d.ts
|
||||||
vite.config.js
|
vite.config.js
|
||||||
|
apps/docs/.docusaurus/
|
||||||
|
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
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@ -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: []
|
||||||
524
AGENTS.md
524
AGENTS.md
@ -1,55 +1,448 @@
|
|||||||
# MoexVibe — Инструкция для агента
|
# MoexVibe — Инструкция для агента
|
||||||
|
|
||||||
## Репозиторий
|
## Содержание
|
||||||
|
|
||||||
npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite).
|
- [Обязательный подход к разработке](#обязательный-подход-к-разработке)
|
||||||
|
- [Процесс разработки](#процесс-разработки)
|
||||||
|
- [Структура документации](#структура-документации)
|
||||||
|
- [Назначение документов](#назначение-документов)
|
||||||
|
- [Правила разработки](#правила-разработки)
|
||||||
|
- [Определение бага](#определение-бага)
|
||||||
|
- [Процесс работы над фичей](#процесс-работы-над-фичей)
|
||||||
|
- [Работа с новыми идеями](#работа-с-новыми-идеями)
|
||||||
|
- [Работа с существующими фичами](#работа-с-существующими-фичами)
|
||||||
|
- [Поддержание документации](#поддержание-документации)
|
||||||
|
- [Поведение AI-агентов](#поведение-ai-агентов)
|
||||||
|
- [Anti-Loop: лимит на итерации](#anti-loop-лимит-на-итерации)
|
||||||
|
- [Приоритет источников информации](#приоритет-источников-информации)
|
||||||
|
- [Git workflow](#git-workflow)
|
||||||
|
- [Конвенция коммитов](#конвенция-коммитов)
|
||||||
|
- [Документация и SDD-артефакты](#документация-и-sdd-артефакты)
|
||||||
|
- [Definition of Done (DoD)](#definition-of-done-dod)
|
||||||
|
- [Технические регламенты](#технические-регламенты)
|
||||||
|
- [Правила тестирования](#правила-тестирования)
|
||||||
|
- [Работа с миграциями Prisma](#работа-с-миграциями-prisma)
|
||||||
|
- [Правила рефакторинга](#правила-рефакторинга)
|
||||||
|
- [Политики безопасности](#политики-безопасности)
|
||||||
|
- [ADR-процесс](#adr-процесс)
|
||||||
|
- [Правила обновления OpenAPI/типов](#правила-обновления-openapiтипов)
|
||||||
|
- [Цикл работы над API](#цикл-работы-над-api)
|
||||||
|
- [Инфраструктура проекта](#инфраструктура-проекта)
|
||||||
|
- [Команды](#команды)
|
||||||
|
- [Переменные окружения](#переменные-окружения)
|
||||||
|
- [Архитектура](#архитектура)
|
||||||
|
- [Бэкенд](#бэкенд)
|
||||||
|
- [Фронтенд](#фронтенд)
|
||||||
|
- [Стиль кода](#стиль-кода)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Обязательный подход к разработке
|
## Обязательный подход к разработке
|
||||||
|
|
||||||
- **SDD (Specification-Driven Development)**: перед написанием кода сначала сформировать спецификацию — PRD, доменную модель, ADR, 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 build:backend` | `nest build` |
|
|
||||||
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
|
|
||||||
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
|
|
||||||
| `npm run lint` | ESLint только для бэкенда |
|
|
||||||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
|
||||||
| `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` |
|
|
||||||
|
|
||||||
Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend`
|
Проект использует подход Specification-Driven Development (SDD).
|
||||||
|
|
||||||
## Переменные окружения
|
### Структура документации
|
||||||
|
|
||||||
| Переменная | По умолчанию | Описание |
|
```text
|
||||||
|---|---|---|
|
docs/
|
||||||
| `PORT` | 3000 | Порт бэкенда |
|
|
||||||
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
|
├── inbox.md
|
||||||
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
├── roadmap.md
|
||||||
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
│
|
||||||
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
├── research/
|
||||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
│
|
||||||
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
├── epics/
|
||||||
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
│ └── {epic-name}.md
|
||||||
|
│
|
||||||
|
└── features/
|
||||||
|
└── {feature-name}/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
└── tasks.md
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Полный набор `spec.md`, `plan.md` и `tasks.md` обязателен для новых фич. Исторические feature-каталоги могут быть неполными: отсутствующие артефакты не требуется восстанавливать задним числом, если это не нужно для текущего изменения.
|
||||||
|
|
||||||
|
### Назначение документов
|
||||||
|
|
||||||
|
#### inbox.md
|
||||||
|
|
||||||
|
Содержит идеи и мысли, которые появились во время работы над проектом.
|
||||||
|
|
||||||
|
Записи в inbox не являются требованиями и не должны реализовываться напрямую.
|
||||||
|
|
||||||
|
#### roadmap.md
|
||||||
|
|
||||||
|
Содержит список запланированных эпиков и фич.
|
||||||
|
|
||||||
|
Наличие задачи в roadmap не означает, что её нужно немедленно реализовать.
|
||||||
|
|
||||||
|
#### research/
|
||||||
|
|
||||||
|
Содержит результаты исследований и экспериментов.
|
||||||
|
|
||||||
|
Документы могут содержать гипотезы, предположения и открытые вопросы.
|
||||||
|
|
||||||
|
Результаты исследований необходимо проверять перед реализацией.
|
||||||
|
|
||||||
|
#### epics/
|
||||||
|
|
||||||
|
Эпик представляет собой крупную продуктовую возможность или модуль.
|
||||||
|
|
||||||
|
Эпик может состоять из нескольких фич.
|
||||||
|
|
||||||
|
#### features/{feature-name}/spec.md
|
||||||
|
|
||||||
|
Описывает **ЧТО** должно быть реализовано.
|
||||||
|
|
||||||
|
Спецификация должна содержать:
|
||||||
|
|
||||||
|
- цель
|
||||||
|
- требования
|
||||||
|
- ограничения
|
||||||
|
- критерии приемки (Acceptance Criteria)
|
||||||
|
|
||||||
|
Спецификация не должна содержать деталей реализации.
|
||||||
|
|
||||||
|
#### features/{feature-name}/plan.md
|
||||||
|
|
||||||
|
Описывает **КАК** будет реализована фича.
|
||||||
|
|
||||||
|
План может содержать:
|
||||||
|
|
||||||
|
- архитектурные решения
|
||||||
|
- API контракты
|
||||||
|
- потоки данных
|
||||||
|
- технический подход
|
||||||
|
|
||||||
|
#### features/{feature-name}/tasks.md
|
||||||
|
|
||||||
|
Содержит список задач для реализации.
|
||||||
|
|
||||||
|
Задачи должны быть:
|
||||||
|
|
||||||
|
- небольшими
|
||||||
|
- конкретными
|
||||||
|
- независимыми по возможности
|
||||||
|
|
||||||
|
### Правила разработки
|
||||||
|
|
||||||
|
#### Правило 1
|
||||||
|
|
||||||
|
Нельзя начинать реализацию новой фичи без спецификации.
|
||||||
|
|
||||||
|
Если спецификации нет:
|
||||||
|
|
||||||
|
- Провести исследование при необходимости.
|
||||||
|
- Создать spec.md.
|
||||||
|
- Уточнить требования.
|
||||||
|
- Только после этого переходить к реализации.
|
||||||
|
|
||||||
|
#### Правило 2
|
||||||
|
|
||||||
|
Реализация должна соответствовать spec.md.
|
||||||
|
|
||||||
|
Если в процессе разработки выясняется, что требования неполные или ошибочные:
|
||||||
|
|
||||||
|
- Не изменять поведение системы молча.
|
||||||
|
- Сначала обновить spec.md и plan.md.
|
||||||
|
- И только потом продолжать реализацию.
|
||||||
|
|
||||||
|
#### Правило 3
|
||||||
|
|
||||||
|
Спецификация является источником истины.
|
||||||
|
|
||||||
|
Если plan.md противоречит spec.md — приоритет имеет spec.md.
|
||||||
|
|
||||||
|
#### Правило 4
|
||||||
|
|
||||||
|
Не добавлять функциональность, которая отсутствует в спецификации.
|
||||||
|
|
||||||
|
Если появилась новая идея:
|
||||||
|
|
||||||
|
- обновить спецификацию;
|
||||||
|
- либо создать новую фичу.
|
||||||
|
|
||||||
|
#### Правило 5
|
||||||
|
|
||||||
|
Исправления ошибок можно выполнять напрямую. Новая функциональность должна проходить через спецификацию.
|
||||||
|
|
||||||
|
#### Определение бага
|
||||||
|
|
||||||
|
Баг — это поведение системы, противоречащее спецификации, acceptance criteria, API-контракту, зафиксированному тестами поведению или подтверждённому архитектурному инварианту.
|
||||||
|
|
||||||
|
Если желаемое поведение нигде не зафиксировано и не следует из существующего контракта или инварианта — это отсутствующая функциональность (new feature), а не баг.
|
||||||
|
|
||||||
|
Классификация:
|
||||||
|
|
||||||
|
- **Есть зафиксированный контракт, поведение не соответствует** → баг (можно чинить напрямую, Правило 5)
|
||||||
|
- **Нет зафиксированного контракта, требуется новое поведение** → новая фича (нужна спецификация)
|
||||||
|
- **Spec есть, но в нём неопределённость** → сначала уточнить spec, потом решать, баг это или фича
|
||||||
|
|
||||||
|
### Процесс работы над фичей
|
||||||
|
|
||||||
|
При реализации новой фичи необходимо:
|
||||||
|
|
||||||
|
1. Ознакомиться с эпиком, если он существует.
|
||||||
|
2. Прочитать spec.md.
|
||||||
|
3. Прочитать plan.md.
|
||||||
|
4. Прочитать tasks.md.
|
||||||
|
5. Выполнять задачи последовательно.
|
||||||
|
6. Отмечать выполненные задачи.
|
||||||
|
7. Обновлять plan.md при изменении технических решений.
|
||||||
|
8. Обновлять spec.md при изменении требований.
|
||||||
|
|
||||||
|
Для исторической фичи сначала прочитать все имеющиеся артефакты. Отсутствие старого `plan.md` или `tasks.md` само по себе не блокирует maintenance или исправление бага и не требует создавать их задним числом. Для нового расширения такой фичи сначала подготовить недостающие артефакты в объёме текущего изменения.
|
||||||
|
|
||||||
|
Если есть согласованный `plan.md` для многошаговой реализации, агент по умолчанию должен
|
||||||
|
предпочитать `superpowers:subagent-driven-development`. `superpowers:executing-plans` использовать
|
||||||
|
только когда пользователь явно просит inline-исполнение или когда задачи настолько тесно связаны,
|
||||||
|
что разбиение по subagent-циклам ухудшит надёжность и скорость.
|
||||||
|
|
||||||
|
### Работа с новыми идеями
|
||||||
|
|
||||||
|
Если во время реализации появилась новая идея:
|
||||||
|
|
||||||
|
Не реализовывать её автоматически. Необходимо определить, является ли она:
|
||||||
|
|
||||||
|
- багом;
|
||||||
|
- улучшением существующей функциональности;
|
||||||
|
- новой фичей.
|
||||||
|
|
||||||
|
Если это улучшение или новая фича — добавить её в inbox.md или создать отдельную фичу.
|
||||||
|
|
||||||
|
### Работа с существующими фичами
|
||||||
|
|
||||||
|
Улучшения существующей функциональности обычно остаются внутри текущего эпика.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```
|
||||||
|
Portfolio Dashboard
|
||||||
|
├── История операций
|
||||||
|
├── Пагинация истории операций
|
||||||
|
├── Фильтрация истории операций
|
||||||
|
└── Экспорт истории операций
|
||||||
|
```
|
||||||
|
|
||||||
|
Новый эпик создаётся только при появлении новой продуктовой возможности или нового домена.
|
||||||
|
|
||||||
|
### Поддержание документации
|
||||||
|
|
||||||
|
Документация должна соответствовать текущему состоянию проекта.
|
||||||
|
|
||||||
|
После значимых изменений необходимо обновлять:
|
||||||
|
|
||||||
|
- spec.md
|
||||||
|
- plan.md
|
||||||
|
- tasks.md
|
||||||
|
- ADR
|
||||||
|
- архитектурную документацию
|
||||||
|
|
||||||
|
Документация не должна отставать от реализации.
|
||||||
|
|
||||||
|
### Поведение AI-агентов
|
||||||
|
|
||||||
|
Перед написанием кода необходимо:
|
||||||
|
|
||||||
|
1. Изучить спецификацию фичи.
|
||||||
|
2. Проверить полноту требований.
|
||||||
|
3. Найти неоднозначности и противоречия.
|
||||||
|
4. При необходимости запросить уточнения.
|
||||||
|
5. Перед началом реализации агент должен кратко подтвердить понимание задачи и спецификации (одним сообщением).
|
||||||
|
|
||||||
|
**Запрещено:**
|
||||||
|
|
||||||
|
- придумывать требования;
|
||||||
|
- додумывать поведение системы;
|
||||||
|
- реализовывать неописанную функциональность.
|
||||||
|
|
||||||
|
Если информации недостаточно — остановиться и запросить уточнение вместо того, чтобы делать предположения.
|
||||||
|
|
||||||
|
### Pre-flight checklist (обязателен перед реализацией любой фичи)
|
||||||
|
|
||||||
|
Агент не имеет права начать реализацию, пока не выполнены все пункты:
|
||||||
|
|
||||||
|
- [ ] Feature branch создана: `codex/<feature-name>`
|
||||||
|
- [ ] spec.md написана и утверждена пользователем
|
||||||
|
- [ ] plan.md написан и утверждён пользователем
|
||||||
|
- [ ] tasks.md создан с чекбоксами до начала работы
|
||||||
|
- [ ] Все тесты проходят на текущем состоянии
|
||||||
|
|
||||||
|
Нарушение любого пункта = остановиться и вернуться к пропущенному шагу.
|
||||||
|
|
||||||
|
### Anti-Loop: лимит на итерации
|
||||||
|
|
||||||
|
Если после 3 последовательных неудачных попыток исправить одну и ту же проблему в рамках одной гипотезы симптом не изменился — остановиться и запросить помощь у пользователя.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- Каждая попытка = один цикл «сформулировал гипотезу → внёс изменение → проверил → тот же симптом сохранился»
|
||||||
|
- Сбор новой диагностической информации без изменения кода попыткой не считается
|
||||||
|
- Не начинать 4-ю попытку без явного указания пользователя
|
||||||
|
- При запросе помощи приложить: что пытался сделать, что пошло не так, последнее состояние кода/логов
|
||||||
|
|
||||||
|
### Приоритет источников информации
|
||||||
|
|
||||||
|
При возникновении противоречий использовать следующий порядок приоритетов:
|
||||||
|
|
||||||
|
1. Текущая задача пользователя (она может изменить требования, но соответствующие SDD-артефакты обновляются до реализации).
|
||||||
|
2. spec.md фичи.
|
||||||
|
3. plan.md фичи.
|
||||||
|
4. ADR.
|
||||||
|
5. Архитектурная документация.
|
||||||
|
6. roadmap.md.
|
||||||
|
7. inbox.md.
|
||||||
|
|
||||||
|
roadmap.md и inbox.md никогда не являются основанием для реализации функциональности.
|
||||||
|
|
||||||
|
### Git workflow
|
||||||
|
|
||||||
|
- Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё.
|
||||||
|
- Имя ветки по умолчанию начинать с `codex/`, если пользователь не попросил другой префикс.
|
||||||
|
- Не смешивать независимые фичи в одной ветке. Небольшие связанные docs/chore/test-правки можно держать в той же ветке, если они относятся к текущей задаче.
|
||||||
|
|
||||||
|
### Конвенция коммитов
|
||||||
|
|
||||||
|
Использовать [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
- `feat:` — новая функциональность
|
||||||
|
- `fix:` — исправление бага
|
||||||
|
- `chore:` — обслуживание (зависимости, конфиги, CI)
|
||||||
|
- `docs:` — документация
|
||||||
|
- `refactor:` — рефакторинг без изменения поведения
|
||||||
|
- `test:` — добавление или исправление тестов
|
||||||
|
- `style:` — форматирование, кодстайл (prettier)
|
||||||
|
- `perf:` — улучшение производительности
|
||||||
|
- `build:` — изменения сборки и зависимостей
|
||||||
|
- `ci:` — изменения CI/CD
|
||||||
|
|
||||||
|
Формат: `<тип>(<необязательный scope>): <краткое описание в настоящем времени>`
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `feat: add portfolio rebalancing endpoint`
|
||||||
|
- `fix: handle empty dividend list from MOEX`
|
||||||
|
- `docs: update API authentication section`
|
||||||
|
|
||||||
|
### Документация и SDD-артефакты
|
||||||
|
|
||||||
|
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
|
||||||
|
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
|
||||||
|
- OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`.
|
||||||
|
|
||||||
|
### Definition of Done (DoD)
|
||||||
|
|
||||||
|
- Все acceptance criteria реализованы
|
||||||
|
- Тесты проходят
|
||||||
|
- Lint проходит
|
||||||
|
- Для новой фичи созданы и обновлены spec/plan/tasks; для исторической фичи обновлены существующие и необходимые для текущего изменения артефакты
|
||||||
|
- Документация обновлена
|
||||||
|
- Нет TODO без согласования
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технические регламенты
|
||||||
|
|
||||||
|
### Правила тестирования
|
||||||
|
|
||||||
|
- Для новой бизнес-логики → обязательны unit-тесты
|
||||||
|
- Для API-контрактов → интеграционные тесты
|
||||||
|
- Не мокать собственный код без необходимости
|
||||||
|
- В unit-тестах мокать внешние API (MOEX, T-Bank) и Prisma
|
||||||
|
- При исправлении бага — сначала падающий тест (TDD)
|
||||||
|
- Тесты писать рядом с основным кодом
|
||||||
|
|
||||||
|
### Работа с миграциями Prisma
|
||||||
|
|
||||||
|
- Никогда не редактировать файлы в `prisma/migrations/` вручную
|
||||||
|
- После изменения `schema.prisma` → `npm exec -w apps/backend -- prisma migrate dev --name <name>`
|
||||||
|
- Изменять существующие миграции допустимо только до их публикации/мержа. После мержа создавать новую миграцию
|
||||||
|
- Всегда запускать `npm exec -w apps/backend -- prisma generate` после изменения схемы
|
||||||
|
|
||||||
|
### Правила рефакторинга
|
||||||
|
|
||||||
|
- Не выполнять крупный рефакторинг вне рамок задачи
|
||||||
|
- Допустимы: локальные улучшения, устранение техдолга рядом с изменяемым кодом, исправление архитектурных нарушений
|
||||||
|
- Запрещено: менять структуру проекта без ADR, переписывать модули без отдельной задачи
|
||||||
|
- Крупный рефакторинг требует отдельного эпика/фичи + ADR
|
||||||
|
|
||||||
|
### Политики безопасности
|
||||||
|
|
||||||
|
- Запрещено логировать токены, пароли, секреты
|
||||||
|
- Не отключать guard'ы
|
||||||
|
- Не хранить секреты в коде, не коммитить .env
|
||||||
|
- Использовать маскирование при выводе (например, `***`)
|
||||||
|
|
||||||
|
### ADR-процесс
|
||||||
|
|
||||||
|
- Создавать ADR при: выборе новой технологии, изменении архитектуры, изменении API-контрактов, изменении стратегии хранения данных
|
||||||
|
- ADR должен содержать: Контекст, Рассмотренные варианты, Решение, Последствия
|
||||||
|
|
||||||
|
### Правила обновления OpenAPI/типов
|
||||||
|
|
||||||
|
1. Обновить DTO/Controller на бэкенде
|
||||||
|
2. Обновить Swagger
|
||||||
|
3. Запустить `npm run codegen -w apps/frontend`
|
||||||
|
4. Использовать обновлённые типы из `src/api/types.ts`
|
||||||
|
5. Никогда не редактировать `types.ts` вручную
|
||||||
|
|
||||||
|
### Цикл работы над API
|
||||||
|
|
||||||
|
Стандартная процедура при любом изменении API-контракта:
|
||||||
|
|
||||||
|
1. **Бэкенд** — описать/обновить DTO и контроллер (NestJS)
|
||||||
|
2. **Swagger** — убедиться, что документация отдаётся корректно (`/api/docs-json`)
|
||||||
|
3. **Codegen** — `npm run codegen -w apps/frontend` (генерирует `src/api/types.ts`)
|
||||||
|
4. **Фронтенд** — использовать обновлённые типы, адаптировать вызовы
|
||||||
|
5. **Проверка** — убедиться, что `npm run build` проходит в обоих пакетах
|
||||||
|
|
||||||
|
Обновление типов вручную (`types.ts`) запрещено — всегда через codegen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инфраструктура проекта
|
||||||
|
|
||||||
|
### Команды
|
||||||
|
|
||||||
|
Основные команды проекта описаны в README.md.
|
||||||
|
|
||||||
|
Перед завершением задачи запускать тесты, lint и build затронутых пакетов.
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
Основные настройки находятся в .env.
|
||||||
|
|
||||||
|
Полный список переменных описан в README.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
|
### Бэкенд
|
||||||
|
|
||||||
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
|
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
|
||||||
- Feature-модули: `MoexClientModule` (глобальный), `CacheModule` (глобальный), `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `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()` для открытых эндпоинтов).
|
||||||
|
- БД: 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 для графиков цен.
|
||||||
@ -58,10 +451,75 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
|||||||
- Конвенция ключей запросов: `['stock', secid]`, `['securities', 'search', query]`, и т.д.
|
- Конвенция ключей запросов: `['stock', secid]`, `['securities', 'search', query]`, и т.д.
|
||||||
- CSS через `styles.css` (CSS custom properties, без CSS-in-JS или Tailwind).
|
- CSS через `styles.css` (CSS custom properties, без CSS-in-JS или Tailwind).
|
||||||
|
|
||||||
## Стиль кода
|
### Стиль кода
|
||||||
|
|
||||||
- Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
|
- Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
|
||||||
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
|
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
|
||||||
- Бэкенд использует SWC через `unplugin-swc` (vitest config).
|
- Бэкенд использует SWC через `unplugin-swc` (vitest config).
|
||||||
- Тесты фронтенда отсутствуют.
|
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
|
||||||
- CI/CD в репозитории нет.
|
- CI находится в `.gitea/workflows/ci.yml`.
|
||||||
|
- Pre-commit checks настроены через Husky и lint-staged.
|
||||||
|
|
||||||
|
## code-index-mcp
|
||||||
|
|
||||||
|
В проекте настроен `code-index-mcp` — MCP-сервер для быстрого поиска файлов и кода.
|
||||||
|
|
||||||
|
**Инструменты:**
|
||||||
|
- `find_files(pattern)` — поиск файлов по glob-паттерну через in-memory индекс
|
||||||
|
- `search_code_advanced(pattern)` — поиск кода с поддержкой regex, контекста, фильтрации по типу файла
|
||||||
|
- `get_file_summary(path)` — сводка по файлу (строки, функции, классы, импорты)
|
||||||
|
- `get_symbol_body(path, symbol_name)` — получить тело символа (функции/класса)
|
||||||
|
- `find_implementations(name_path, relative_path)` — найти реализации символа
|
||||||
|
- `find_referencing_symbols(name_path, relative_path)` — найти ссылки на символ
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Поиск файлов по имени или паттерну (glob)
|
||||||
|
- Быстрый grep по коду с контекстом
|
||||||
|
- Получение только тела функции/класса без всего файла
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## serena
|
||||||
|
|
||||||
|
В проекте настроена `serena` — MCP-сервер с LSP-символьным анализом кода. Предоставляет symbol-aware инструменты поверх TypeScript LSP.
|
||||||
|
|
||||||
|
**Инструменты:**
|
||||||
|
- `find_symbol(name_path_pattern)` — поиск символов (классы, функции, методы) по всему проекту
|
||||||
|
- `get_symbols_overview(relative_path)` — обзор символов в файле (группировка по типу)
|
||||||
|
- `find_referencing_symbols(name_path, relative_path)` — где используется символ
|
||||||
|
- `find_implementations(name_path, relative_path)` — реализации интерфейса/класса
|
||||||
|
- `find_declaration(relative_path, regex)` — найти объявление по вызову
|
||||||
|
- `replace_symbol_body(name_path, relative_path, body)` — заменить тело метода
|
||||||
|
- `rename_symbol(name_path, relative_path, new_name)` — рефакторинг-переименование
|
||||||
|
- `replace_content(relative_path, needle, repl, mode)` — regex-замена в файле
|
||||||
|
- `safe_delete_symbol(name_path, relative_path)` — удалить неиспользуемый символ
|
||||||
|
- `get_diagnostics_for_file(relative_path)` — ошибки/предупреждения в файле
|
||||||
|
- `write_memory/read_memory/list_memories` — сохранение контекста между сессиями
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Найти все использования функции/метода в коде
|
||||||
|
- Получить структуру файла (классы, методы)
|
||||||
|
- Безопасный рефакторинг (переименование, удаление)
|
||||||
|
- Получить LSP-диагностику (ошибки компиляции)
|
||||||
|
- Запомнить что-то между сессиями (memories)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## graphify
|
||||||
|
|
||||||
|
This project has a knowledge graph in `graphify-out/` with god nodes, community structure, and cross-file relationships. The graph is a local artifact, not a tracked repo asset.
|
||||||
|
|
||||||
|
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Используй `graphify` в первую очередь, когда задача связана с архитектурой, границами модулей, кросс-файловым влиянием или трассировкой потока данных.
|
||||||
|
- Для таких вопросов сначала запускай `graphify query "<question>"`, если существует `graphify-out/graph.json`. Для связей используй `graphify path "<A>" "<B>"`, для точечных концептов — `graphify explain "<concept>"`. Обычно это даёт гораздо более узкий подграф, чем `GRAPH_REPORT.md` или raw grep.
|
||||||
|
- Предпочитай `graphify query` перед raw grep, когда нужен кратчайший путь между концептами, мост между комьюнити или трассировка того, как один подсистемный блок достигает другого.
|
||||||
|
- Для отладки багов начинай с симптома и спрашивай у graphify путь зависимости, bridge nodes или модули, которые могут объяснить неожиданное поведение.
|
||||||
|
- Если `graphify` возвращает только общую структуру, переходи к `serena` за символ-уровневыми фактами и затем повторяй `graphify` с более узким вопросом, где названы конкретные файлы, модули или сервисы.
|
||||||
|
- Dirty `graphify-out/` после хуков или инкрементальных обновлений считаются нормой; грязные файлы графа не повод пропускать `graphify`. Пропускать его можно только если задача именно про устаревший или некорректный граф, либо если пользователь прямо попросил не использовать его.
|
||||||
|
- В новом `worktree` сначала заново создай локальный граф командой `graphify extract .`.
|
||||||
|
- После первой сборки в этом `worktree` обновляй граф командой `graphify update .`.
|
||||||
|
- Если существует `graphify-out/wiki/index.md`, используй его для широкого обзора вместо ручного просмотра исходников.
|
||||||
|
- `graphify-out/GRAPH_REPORT.md` читай только для широкого архитектурного обзора или когда `query/path/explain` не дают достаточно контекста.
|
||||||
|
- После изменений в коде запускай `graphify update .`, чтобы держать граф актуальным (только AST, без затрат на LLM).
|
||||||
|
|||||||
154
README.md
154
README.md
@ -2,50 +2,168 @@
|
|||||||
|
|
||||||
Веб-приложение для анализа ценных бумаг Московской биржи (MOEX).
|
Веб-приложение для анализа ценных бумаг Московской биржи (MOEX).
|
||||||
|
|
||||||
## Tech Stack
|
## Содержание
|
||||||
|
|
||||||
- **Backend:** NestJS, TypeScript, OpenAPI (Swagger)
|
- [О проекте](#о-проекте)
|
||||||
- **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts
|
- [Стек технологий](#стек-технологий)
|
||||||
- **Infrastructure:** Docker, docker-compose
|
- [Быстрый старт](#быстрый-старт)
|
||||||
|
- [Docker](#docker)
|
||||||
|
- [Тестирование](#тестирование)
|
||||||
|
- [Структура проекта](#структура-проекта)
|
||||||
|
- [Команды](#команды)
|
||||||
|
- [Переменные окружения](#переменные-окружения)
|
||||||
|
|
||||||
## Quick Start
|
---
|
||||||
|
|
||||||
|
## О проекте
|
||||||
|
|
||||||
|
npm workspaces монорепозиторий:
|
||||||
|
|
||||||
|
| Пакет | Назначение |
|
||||||
|
| --------------- | -------------------------------------------------- |
|
||||||
|
| `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) |
|
||||||
|
| `apps/frontend` | React SPA на Vite |
|
||||||
|
| `apps/docs` | Сайт документации Docusaurus |
|
||||||
|
| `packages/design-system` | Дизайн-система (Storybook, MUI-адаптер, UI-компоненты) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стек технологий
|
||||||
|
|
||||||
|
- **Бэкенд:** NestJS, TypeScript, OpenAPI (Swagger)
|
||||||
|
- **Фронтенд:** React, TypeScript, Vite, TanStack Query, lightweight-charts
|
||||||
|
- **Дизайн-система:** MUI v7, Storybook 10, lightweight-charts
|
||||||
|
- **Документация:** Docusaurus
|
||||||
|
- **Инфраструктура:** Docker, docker-compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Настройка локального окружения
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
Интеграционные тесты с MOEX — опциональны:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:integration -w apps/backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
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
|
||||||
|
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
|
||||||
10
apps/backend/.gitignore
vendored
Normal file
10
apps/backend/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Prisma generated client
|
||||||
|
node_modules/.prisma
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,26 +3,38 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "prisma generate",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,test}/**/*.ts\"",
|
"lint": "eslint \"{src,test}/**/*.ts\"",
|
||||||
"test": "vitest run",
|
"test": "VITE_CJS_IGNORE_WARNING=1 vitest run --exclude \"src/**/*.integration.spec.ts\"",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest --exclude \"src/**/*.integration.spec.ts\"",
|
||||||
|
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@grpc/grpc-js": "^1.14.4",
|
||||||
|
"@grpc/proto-loader": "^0.8.1",
|
||||||
|
"@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",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.0.0",
|
"@nestjs/config": "^3.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/swagger": "^7.0.0",
|
"@nestjs/swagger": "^7.0.0",
|
||||||
|
"@prisma/adapter-libsql": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"cache-manager": "^5.0.0",
|
"cache-manager": "^5.0.0",
|
||||||
"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",
|
||||||
|
"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"
|
||||||
@ -32,11 +44,14 @@
|
|||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
"@swc/core": "^1.15.41",
|
"@swc/core": "^1.15.41",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"eslint": "^8.0.0",
|
"eslint": "^8.0.0",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"unplugin-swc": "^1.5.9",
|
"unplugin-swc": "^1.5.9",
|
||||||
"vitest": "^1.0.0"
|
"vitest": "^1.0.0"
|
||||||
|
|||||||
14
apps/backend/prisma.config.ts
Normal file
14
apps/backend/prisma.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { defineConfig } from 'prisma/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
migrations: {
|
||||||
|
path: 'prisma/migrations',
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env['DATABASE_URL'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'user',
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Portfolio" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
"targets" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Portfolio_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Position" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"portfolioId" INTEGER NOT NULL,
|
||||||
|
"secid" TEXT NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"tags" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Position_portfolioId_fkey" FOREIGN KEY ("portfolioId") REFERENCES "Portfolio" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Portfolio_userId_name_key" ON "Portfolio"("userId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Position_portfolioId_secid_key" ON "Position"("portfolioId", "secid");
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Position" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"portfolioId" INTEGER NOT NULL,
|
||||||
|
"secid" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL DEFAULT 'share',
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"tags" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Position_portfolioId_fkey" FOREIGN KEY ("portfolioId") REFERENCES "Portfolio" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Position" ("createdAt", "id", "notes", "portfolioId", "quantity", "secid", "tags", "updatedAt") SELECT "createdAt", "id", "notes", "portfolioId", "quantity", "secid", "tags", "updatedAt" FROM "Position";
|
||||||
|
DROP TABLE "Position";
|
||||||
|
ALTER TABLE "new_Position" RENAME TO "Position";
|
||||||
|
CREATE UNIQUE INDEX "Position_portfolioId_secid_key" ON "Position"("portfolioId", "secid");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Position" ADD COLUMN "buyDate" DATETIME;
|
||||||
|
ALTER TABLE "Position" ADD COLUMN "buyPrice" REAL;
|
||||||
@ -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");
|
||||||
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
94
apps/backend/prisma/schema.prisma
Normal file
94
apps/backend/prisma/schema.prisma
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Portfolio {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
currency String @default("RUB")
|
||||||
|
targets String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
positions Position[]
|
||||||
|
|
||||||
|
@@unique([userId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Position {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
portfolioId Int
|
||||||
|
secid String
|
||||||
|
type String @default("share")
|
||||||
|
quantity Int
|
||||||
|
buyPrice Float?
|
||||||
|
buyDate DateTime?
|
||||||
|
notes String?
|
||||||
|
tags String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([portfolioId, secid])
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
name String?
|
||||||
|
role String @default("user")
|
||||||
|
refreshToken String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
portfolios Portfolio[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model BrokerOperation {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
accountId String
|
||||||
|
cursor String?
|
||||||
|
operationId String?
|
||||||
|
parentOperationId String?
|
||||||
|
date DateTime?
|
||||||
|
type String
|
||||||
|
category String
|
||||||
|
state String?
|
||||||
|
instrumentUid String?
|
||||||
|
figi String?
|
||||||
|
ticker String?
|
||||||
|
classCode String?
|
||||||
|
payment String?
|
||||||
|
price String?
|
||||||
|
commission String?
|
||||||
|
yield String?
|
||||||
|
accruedInt String?
|
||||||
|
quantity Int?
|
||||||
|
quantityDone Int?
|
||||||
|
raw String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([accountId, cursor])
|
||||||
|
@@index([accountId, date])
|
||||||
|
@@index([accountId, type])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BrokerOperationSyncState {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
accountId String @unique
|
||||||
|
lastCursor String?
|
||||||
|
lastSyncedFrom DateTime?
|
||||||
|
lastSyncedTo DateTime?
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { 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';
|
||||||
@ -7,18 +7,31 @@ import { SecuritiesModule } from './modules/securities/securities.module';
|
|||||||
import { SharesModule } from './modules/shares/shares.module';
|
import { SharesModule } from './modules/shares/shares.module';
|
||||||
import { BondsModule } from './modules/bonds/bonds.module';
|
import { BondsModule } from './modules/bonds/bonds.module';
|
||||||
import { CandlesModule } from './modules/candles/candles.module';
|
import { CandlesModule } from './modules/candles/candles.module';
|
||||||
|
import { PortfolioModule } from './modules/portfolio/portfolio.module';
|
||||||
|
import { PrismaModule } from './modules/prisma/prisma.module';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { TBankModule } from './modules/tbank/tbank.module';
|
||||||
|
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
|
ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }),
|
||||||
|
PrismaModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
MoexClientModule,
|
MoexClientModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
AuthModule,
|
||||||
SecuritiesModule,
|
SecuritiesModule,
|
||||||
SharesModule,
|
SharesModule,
|
||||||
BondsModule,
|
BondsModule,
|
||||||
CandlesModule,
|
CandlesModule,
|
||||||
|
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,7 +1,19 @@
|
|||||||
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: {
|
||||||
|
url: process.env.DATABASE_URL || 'file:./dev.db',
|
||||||
|
},
|
||||||
moex: {
|
moex: {
|
||||||
baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss',
|
baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss',
|
||||||
rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10),
|
rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10),
|
||||||
@ -11,12 +23,37 @@ 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: {
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||||
|
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',
|
||||||
|
jwtAccessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
|
||||||
|
jwtRefreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
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,8 +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 { 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);
|
||||||
@ -15,12 +44,28 @@ async function bootstrap() {
|
|||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new TransformInterceptor());
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
const reqLogMiddleware = new RequestLoggingMiddleware();
|
app.use(cookieParser());
|
||||||
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
|
||||||
|
|
||||||
app.enableCors();
|
const configService = app.get(ConfigService);
|
||||||
|
const runtimeConfig: BackendRuntimeConfig = {
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
jwtSecret: configService.get<string>('app.auth.jwtSecret', ''),
|
||||||
|
jwtRefreshSecret: configService.get<string>('app.auth.jwtRefreshSecret', ''),
|
||||||
|
corsOrigins: configService.get<string[]>('app.cors.origins', []),
|
||||||
|
};
|
||||||
|
|
||||||
const config = new DocumentBuilder().setTitle('MoexVibe API').setVersion('1.0.0').build();
|
assertSafeProductionConfig(runtimeConfig);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins),
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('MoexVibe API')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
@ -29,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();
|
||||||
|
}
|
||||||
|
|||||||
95
apps/backend/src/modules/auth/auth.controller.ts
Normal file
95
apps/backend/src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
import {
|
||||||
|
AuthLogoutResponseDto,
|
||||||
|
AuthProfileResponseDto,
|
||||||
|
AuthTokenResponseDto,
|
||||||
|
} from './dto/auth-response.dto';
|
||||||
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
|
import { Public } from './decorators/public.decorator';
|
||||||
|
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
|
const COOKIE_OPTIONS = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/api/v1/auth',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiTags('Auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
@ApiOperation({ summary: 'Register new user' })
|
||||||
|
@ApiCreatedResponse({ type: AuthTokenResponseDto })
|
||||||
|
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const result = await this.authService.register(dto);
|
||||||
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
|
@ApiCreatedResponse({ type: AuthTokenResponseDto })
|
||||||
|
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const result = await this.authService.login(dto);
|
||||||
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Refresh access token' })
|
||||||
|
@ApiOkResponse({ type: AuthTokenResponseDto })
|
||||||
|
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const token = req.cookies?.[REFRESH_COOKIE];
|
||||||
|
const result = await this.authService.refresh(token);
|
||||||
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Logout user' })
|
||||||
|
@ApiOkResponse({ type: AuthLogoutResponseDto })
|
||||||
|
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
||||||
|
await this.authService.logout(user.sub);
|
||||||
|
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
|
||||||
|
return { message: 'Logged out successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
|
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||||
|
async getProfile(@CurrentUser() user: JwtPayload) {
|
||||||
|
return this.authService.getProfile(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Update current user profile' })
|
||||||
|
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||||
|
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
||||||
|
return this.authService.updateProfile(user.sub, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/backend/src/modules/auth/auth.module.ts
Normal file
20
apps/backend/src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from './guards/roles.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [JwtModule.register({})],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
JwtAuthGuard,
|
||||||
|
RolesGuard,
|
||||||
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
180
apps/backend/src/modules/auth/auth.service.spec.ts
Normal file
180
apps/backend/src/modules/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const testUser = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'testPass123',
|
||||||
|
name: 'Test User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrismaUser = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
email: testUser.email,
|
||||||
|
password: '',
|
||||||
|
name: testUser.name,
|
||||||
|
role: 'user',
|
||||||
|
refreshToken: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [
|
||||||
|
JwtModule.register({
|
||||||
|
secret: 'test-secret',
|
||||||
|
signOptions: { expiresIn: '15m' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
const config: Record<string, string> = {
|
||||||
|
'app.auth.jwtSecret': 'test-secret',
|
||||||
|
'app.auth.jwtRefreshSecret': 'test-refresh-secret',
|
||||||
|
'app.auth.jwtAccessExpires': '15m',
|
||||||
|
'app.auth.jwtRefreshExpires': '7d',
|
||||||
|
};
|
||||||
|
return config[key];
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('should register a new user and return tokens', async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||||
|
vi.mocked(prisma.user.create).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
const result = await service.register(testUser);
|
||||||
|
|
||||||
|
expect(result.user.id).toBe(1);
|
||||||
|
expect(result.user.email).toBe(testUser.email);
|
||||||
|
expect(result.user.name).toBe(testUser.name);
|
||||||
|
expect(result.user.role).toBe('user');
|
||||||
|
expect(result.accessToken).toBeDefined();
|
||||||
|
expect(result.refreshToken).toBeDefined();
|
||||||
|
expect(result.refreshToken).not.toBe(testUser.password);
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { email: testUser.email },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictException if email already exists', async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
await expect(service.register(testUser)).rejects.toThrow(ConflictException);
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register a user without name', async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||||
|
vi.mocked(prisma.user.create).mockResolvedValue(mockPrismaUser({ name: null, id: 2 }) as any);
|
||||||
|
|
||||||
|
const result = await service.register({ email: testUser.email, password: testUser.password });
|
||||||
|
|
||||||
|
expect(result.user.name).toBeNull();
|
||||||
|
expect(result.accessToken).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should login with valid credentials', async () => {
|
||||||
|
const passwordHash = await bcrypt.hash(testUser.password, 12);
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||||
|
mockPrismaUser({ password: passwordHash }) as any,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
const result = await service.login({ email: testUser.email, password: testUser.password });
|
||||||
|
|
||||||
|
expect(result.user.id).toBe(1);
|
||||||
|
expect(result.accessToken).toBeDefined();
|
||||||
|
expect(result.refreshToken).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException for wrong password', async () => {
|
||||||
|
const passwordHash = await bcrypt.hash(testUser.password, 12);
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||||
|
mockPrismaUser({ password: passwordHash }) as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.login({ email: testUser.email, password: 'wrongPassword' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException for non-existent email', async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.login({ email: 'nonexistent@example.com', password: 'pass' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('should clear refreshToken', async () => {
|
||||||
|
vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
await service.logout(1);
|
||||||
|
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 1 },
|
||||||
|
data: { refreshToken: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateProfile', () => {
|
||||||
|
it('should update user name', async () => {
|
||||||
|
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||||
|
mockPrismaUser({ name: 'Updated Name' }) as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.updateProfile(1, { name: 'Updated Name' });
|
||||||
|
|
||||||
|
expect(result.name).toBe('Updated Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return user without changes if no fields provided', async () => {
|
||||||
|
vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any);
|
||||||
|
|
||||||
|
const result = await service.updateProfile(1, {});
|
||||||
|
|
||||||
|
expect(result.name).toBe(testUser.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
143
apps/backend/src/modules/auth/auth.service.ts
Normal file
143
apps/backend/src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(dto: RegisterDto) {
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Email already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, SALT_ROUNDS);
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: dto.email,
|
||||||
|
password: passwordHash,
|
||||||
|
name: dto.name ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.generateTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(dto.password, user.password);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new UnauthorizedException('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(refreshToken: string) {
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<{ sub: number; jti: string }>(
|
||||||
|
refreshToken,
|
||||||
|
{
|
||||||
|
secret: this.configService.get('app.auth.jwtRefreshSecret'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||||
|
if (!user?.refreshToken) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(refreshToken, user.refreshToken);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateTokens(user);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(userId: number) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { refreshToken: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(userId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not found');
|
||||||
|
}
|
||||||
|
return this.sanitizeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(userId: number, dto: UpdateProfileDto) {
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.sanitizeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateTokens(user: {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
role: string;
|
||||||
|
}) {
|
||||||
|
const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
|
||||||
|
const accessToken = await this.jwtService.signAsync(accessPayload, {
|
||||||
|
secret: this.configService.get('app.auth.jwtSecret'),
|
||||||
|
expiresIn: this.configService.get('app.auth.jwtAccessExpires'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jti = crypto.randomUUID();
|
||||||
|
const refreshPayload = { sub: user.id, jti };
|
||||||
|
const refreshToken = await this.jwtService.signAsync(refreshPayload, {
|
||||||
|
secret: this.configService.get('app.auth.jwtRefreshSecret'),
|
||||||
|
expiresIn: this.configService.get('app.auth.jwtRefreshExpires'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshHash = await bcrypt.hash(refreshToken, SALT_ROUNDS);
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { refreshToken: refreshHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: this.sanitizeUser(user),
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeUser(user: { id: number; email: string; name: string | null; role: string }) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.user;
|
||||||
|
});
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
53
apps/backend/src/modules/auth/dto/auth-response.dto.ts
Normal file
53
apps/backend/src/modules/auth/dto/auth-response.dto.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
|
|
||||||
|
class AuthUserDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
name!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
role!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthTokenDataDto {
|
||||||
|
@ApiProperty({ type: AuthUserDto })
|
||||||
|
user!: AuthUserDto;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
accessToken!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogoutDataDto {
|
||||||
|
@ApiProperty()
|
||||||
|
message!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthTokenResponseDto {
|
||||||
|
@ApiProperty({ type: AuthTokenDataDto })
|
||||||
|
data!: AuthTokenDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthProfileResponseDto {
|
||||||
|
@ApiProperty({ type: AuthUserDto })
|
||||||
|
data!: AuthUserDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthLogoutResponseDto {
|
||||||
|
@ApiProperty({ type: LogoutDataDto })
|
||||||
|
data!: LogoutDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
12
apps/backend/src/modules/auth/dto/login.dto.ts
Normal file
12
apps/backend/src/modules/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IsEmail, IsString } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'securePass123' })
|
||||||
|
@IsString()
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
21
apps/backend/src/modules/auth/dto/register.dto.ts
Normal file
21
apps/backend/src/modules/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'securePass123' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(100)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'John' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
11
apps/backend/src/modules/auth/dto/update-profile.dto.ts
Normal file
11
apps/backend/src/modules/auth/dto/update-profile.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateProfileDto {
|
||||||
|
@ApiPropertyOptional({ example: 'John Doe' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
50
apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
50
apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const token = this.extractToken(request);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||||
|
secret: this.configService.get('app.auth.jwtSecret'),
|
||||||
|
});
|
||||||
|
request.user = payload;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid or expired token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(request: { headers?: Record<string, string> }): string | null {
|
||||||
|
const auth = request.headers?.authorization;
|
||||||
|
if (!auth) return null;
|
||||||
|
const [type, token] = auth.split(' ');
|
||||||
|
return type === 'Bearer' ? token : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/backend/src/modules/auth/guards/roles.guard.ts
Normal file
22
apps/backend/src/modules/auth/guards/roles.guard.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
return requiredRoles.includes(request.user?.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export interface JwtPayload {
|
||||||
|
sub: number;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
@ -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,41 +1,147 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
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';
|
||||||
import configuration from '../../config/configuration';
|
|
||||||
|
|
||||||
describe('BondsService', () => {
|
describe('BondsService', () => {
|
||||||
let service: BondsService;
|
let service: BondsService;
|
||||||
|
let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>;
|
||||||
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
moexMarketData = {
|
||||||
|
getBondData: vi.fn(),
|
||||||
|
getBondMarketData: vi.fn(),
|
||||||
|
};
|
||||||
|
cache = {
|
||||||
|
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||||
|
data: await fetchFn(),
|
||||||
|
fromCache: false,
|
||||||
|
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
|
||||||
providers: [
|
providers: [
|
||||||
BondsService,
|
BondsService,
|
||||||
MoexClientService,
|
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||||
{
|
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
|
||||||
provide: 'CACHE_MANAGER',
|
{ provide: CacheService, useValue: cache },
|
||||||
useValue: {
|
|
||||||
get: () => undefined,
|
|
||||||
set: () => Promise.resolve(),
|
|
||||||
del: () => Promise.resolve(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CacheService,
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<BondsService>(BondsService);
|
service = module.get<BondsService>(BondsService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
|
||||||
expect(service).toBeDefined();
|
vi.mocked(moexMarketData.getBondData).mockResolvedValue({
|
||||||
|
secid: 'SU26238RMFS5',
|
||||||
|
boardid: 'TQCB',
|
||||||
|
shortName: 'ОФЗ 26238',
|
||||||
|
prevWaprice: 73.2,
|
||||||
|
yieldAtPrevWaprice: 14.1,
|
||||||
|
couponValue: 35.4,
|
||||||
|
nextCoupon: '2026-06-24',
|
||||||
|
accruedInt: 34.1,
|
||||||
|
prevPrice: 73,
|
||||||
|
lotSize: 1,
|
||||||
|
faceValue: 1000,
|
||||||
|
matDate: '2041-05-15',
|
||||||
|
couponPeriod: 182,
|
||||||
|
issueSize: 150000000,
|
||||||
|
isin: 'RU000A1038V6',
|
||||||
|
couponPercent: 7.1,
|
||||||
|
offerDate: null,
|
||||||
|
buybackDate: null,
|
||||||
|
bondType: 'ofz',
|
||||||
|
bondSubType: 'fixed',
|
||||||
|
listLevel: 1,
|
||||||
|
});
|
||||||
|
vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({
|
||||||
|
secid: 'SU26238RMFS5',
|
||||||
|
bid: 72.9,
|
||||||
|
offer: 73.1,
|
||||||
|
open: 72.8,
|
||||||
|
low: 72.5,
|
||||||
|
high: 73.4,
|
||||||
|
last: 73.05,
|
||||||
|
yield: 14.2,
|
||||||
|
waprice: 73,
|
||||||
|
yieldAtWaprice: 14.15,
|
||||||
|
duration: 2250,
|
||||||
|
volume: 10000,
|
||||||
|
value: 7305000,
|
||||||
|
numtrades: 450,
|
||||||
|
tradingStatus: 'T',
|
||||||
|
updateTime: '18:45:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getBond('SU26238RMFS5');
|
||||||
|
|
||||||
|
expect(cache.getOrFetch).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'bond',
|
||||||
|
['SU26238RMFS5'],
|
||||||
|
expect.any(Function),
|
||||||
|
'securityTtl',
|
||||||
|
);
|
||||||
|
expect(cache.getOrFetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'marketdata',
|
||||||
|
['bonds', 'SU26238RMFS5'],
|
||||||
|
expect.any(Function),
|
||||||
|
'marketDataTtl',
|
||||||
|
);
|
||||||
|
expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||||
|
expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
data: {
|
||||||
|
secid: 'SU26238RMFS5',
|
||||||
|
isin: 'RU000A1038V6',
|
||||||
|
name: 'ОФЗ 26238',
|
||||||
|
shortName: 'ОФЗ 26238',
|
||||||
|
latName: null,
|
||||||
|
listLevel: 1,
|
||||||
|
issueSize: 150000000,
|
||||||
|
faceValue: 1000,
|
||||||
|
faceUnit: 'RUB',
|
||||||
|
matDate: '2041-05-15',
|
||||||
|
couponValue: 35.4,
|
||||||
|
couponPercent: 7.1,
|
||||||
|
couponPeriod: 182,
|
||||||
|
nextCoupon: '2026-06-24',
|
||||||
|
accruedInt: 34.1,
|
||||||
|
bondType: 'ofz',
|
||||||
|
bondSubType: 'fixed',
|
||||||
|
offerDate: null,
|
||||||
|
buybackDate: null,
|
||||||
|
marketData: {
|
||||||
|
price: 73.05,
|
||||||
|
yieldToMaturity: 14.2,
|
||||||
|
duration: 2250,
|
||||||
|
accruedInt: 34.1,
|
||||||
|
couponValue: 35.4,
|
||||||
|
couponPercent: 7.1,
|
||||||
|
nextCouponDate: '2026-06-24',
|
||||||
|
open: 72.8,
|
||||||
|
high: 73.4,
|
||||||
|
low: 72.5,
|
||||||
|
volume: 10000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fromCache: false,
|
||||||
|
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return OFZ bond data for SU26207RMFS9', async () => {
|
it('throws EntityNotFoundException when bond data is missing', async () => {
|
||||||
const result = await service.getBond('SU26207RMFS9');
|
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null);
|
||||||
expect(result.data.secid).toBe('SU26207RMFS9');
|
|
||||||
expect(result.data.marketData).toBeDefined();
|
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException);
|
||||||
}, 15000);
|
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
|
||||||
|
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,40 +1,51 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { CandlesService } from './candles.service';
|
import { CandlesService } from './candles.service';
|
||||||
import { 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 configuration from '../../config/configuration';
|
|
||||||
import { CandleInterval } from './dto/candles-query.dto';
|
import { CandleInterval } from './dto/candles-query.dto';
|
||||||
|
|
||||||
describe('CandlesService', () => {
|
describe('CandlesService', () => {
|
||||||
let service: CandlesService;
|
let service: CandlesService;
|
||||||
|
let moexCandles: Pick<MoexCandlesClient, 'getCandles'>;
|
||||||
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
moexCandles = {
|
||||||
|
getCandles: vi.fn(),
|
||||||
|
};
|
||||||
|
cache = {
|
||||||
|
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||||
|
data: await fetchFn(),
|
||||||
|
fromCache: false,
|
||||||
|
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
|
||||||
providers: [
|
providers: [
|
||||||
CandlesService,
|
CandlesService,
|
||||||
MoexClientService,
|
{ provide: MoexCandlesClient, useValue: moexCandles },
|
||||||
{
|
{ provide: CacheService, useValue: cache },
|
||||||
provide: 'CACHE_MANAGER',
|
|
||||||
useValue: {
|
|
||||||
get: () => undefined,
|
|
||||||
set: () => Promise.resolve(),
|
|
||||||
del: () => Promise.resolve(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CacheService,
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<CandlesService>(CandlesService);
|
service = module.get<CandlesService>(CandlesService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
|
||||||
expect(service).toBeDefined();
|
vi.mocked(moexCandles.getCandles).mockResolvedValue([
|
||||||
});
|
{
|
||||||
|
open: 320,
|
||||||
|
high: 325,
|
||||||
|
low: 318,
|
||||||
|
close: 323,
|
||||||
|
volume: 1500000,
|
||||||
|
value: 480000000,
|
||||||
|
begin: '2026-05-01 00:00:00',
|
||||||
|
end: '2026-05-01 23:59:59',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
it('should return daily candles for SBER', async () => {
|
|
||||||
const result = await service.getCandles(
|
const result = await service.getCandles(
|
||||||
'shares',
|
'shares',
|
||||||
'SBER',
|
'SBER',
|
||||||
@ -42,7 +53,63 @@ describe('CandlesService', () => {
|
|||||||
'2026-05-01',
|
'2026-05-01',
|
||||||
'2026-06-01',
|
'2026-06-01',
|
||||||
);
|
);
|
||||||
expect(result.data.length).toBeGreaterThan(0);
|
|
||||||
expect(result.data[0].open).toBeDefined();
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
}, 15000);
|
'candles',
|
||||||
|
['shares', 'SBER', '24', '2026-05-01', '2026-06-01'],
|
||||||
|
expect.any(Function),
|
||||||
|
'candlesTtl',
|
||||||
|
);
|
||||||
|
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||||
|
'stock',
|
||||||
|
'shares',
|
||||||
|
'SBER',
|
||||||
|
24,
|
||||||
|
'2026-05-01',
|
||||||
|
'2026-06-01',
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
open: 320,
|
||||||
|
high: 325,
|
||||||
|
low: 318,
|
||||||
|
close: 323,
|
||||||
|
volume: 1500000,
|
||||||
|
value: 480000000,
|
||||||
|
begin: '2026-05-01 00:00:00',
|
||||||
|
end: '2026-05-01 23:59:59',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fromCache: false,
|
||||||
|
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
|
||||||
|
vi.mocked(moexCandles.getCandles).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.getCandles(
|
||||||
|
'bonds',
|
||||||
|
'SU26238RMFS5',
|
||||||
|
CandleInterval.HOUR,
|
||||||
|
'2026-05-01',
|
||||||
|
'2026-06-01',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
|
'candles',
|
||||||
|
['bonds', 'SU26238RMFS5', '60', '2026-05-01', '2026-06-01'],
|
||||||
|
expect.any(Function),
|
||||||
|
'candlesTtl',
|
||||||
|
);
|
||||||
|
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||||
|
'stock',
|
||||||
|
'bonds',
|
||||||
|
'SU26238RMFS5',
|
||||||
|
60,
|
||||||
|
'2026-05-01',
|
||||||
|
'2026-06-01',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,16 +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 { 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()
|
||||||
@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 {}
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { MoexClientModule } from './moex-client.module';
|
||||||
|
import { MoexSecuritiesClient } from './moex-securities.client';
|
||||||
|
import { MoexMarketDataClient } from './moex-market-data.client';
|
||||||
|
import configuration from '../../config/configuration';
|
||||||
|
|
||||||
|
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
|
||||||
|
'MoexClient live MOEX integration',
|
||||||
|
() => {
|
||||||
|
let moexSecurities: MoexSecuritiesClient;
|
||||||
|
let moexMarketData: MoexMarketDataClient;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient);
|
||||||
|
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
||||||
|
const results = await moexSecurities.searchSecurities('SBER');
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(results[0].secid).toBeDefined();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it('возвращает рыночные данные SBER из live MOEX', async () => {
|
||||||
|
const data = await moexMarketData.getShareMarketData('SBER');
|
||||||
|
|
||||||
|
expect(data).toBeDefined();
|
||||||
|
expect(data!.secid).toBe('SBER');
|
||||||
|
}, 15000);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { MoexClientService } from './moex-client.service';
|
|
||||||
import configuration from '../../config/configuration';
|
|
||||||
|
|
||||||
describe('MoexClientService', () => {
|
|
||||||
let service: MoexClientService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
|
||||||
providers: [MoexClientService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<MoexClientService>(MoexClientService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('searchSecurities', () => {
|
|
||||||
it('should return results for SBER query', async () => {
|
|
||||||
const results = await service.searchSecurities('SBER');
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results[0].secid).toBeDefined();
|
|
||||||
}, 15000);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getShareMarketData', () => {
|
|
||||||
it('should return market data for SBER', async () => {
|
|
||||||
const data = await service.getShareMarketData('SBER');
|
|
||||||
expect(data).toBeDefined();
|
|
||||||
expect(data!.secid).toBe('SBER');
|
|
||||||
}, 15000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,314 +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,
|
|
||||||
MoexDividend,
|
|
||||||
MoexCandle,
|
|
||||||
MoexHistoryEntry,
|
|
||||||
MoexBondHistoryEntry,
|
|
||||||
} from './moex-client.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MoexClientService {
|
|
||||||
private readonly logger = new Logger(MoexClientService.name);
|
|
||||||
private readonly client: AxiosInstance;
|
|
||||||
private readonly queue: PQueue;
|
|
||||||
private circuitOpen = false;
|
|
||||||
private circuitErrorCount = 0;
|
|
||||||
private readonly threshold: number;
|
|
||||||
private readonly resetMs: number;
|
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
|
||||||
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
|
|
||||||
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
|
|
||||||
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
|
|
||||||
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
|
|
||||||
|
|
||||||
this.client = axios.create({
|
|
||||||
baseURL: baseUrl,
|
|
||||||
timeout: 10000,
|
|
||||||
paramsSerializer: { indexes: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.queue = new PQueue({
|
|
||||||
interval: 1000,
|
|
||||||
intervalCap: rateLimit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
||||||
if (this.circuitOpen) {
|
|
||||||
throw new Error('Circuit breaker is open — MOEX requests paused');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.queue.add(async () => {
|
|
||||||
try {
|
|
||||||
const jsonPath = path + '.json';
|
|
||||||
const response = await this.client.get(jsonPath, {
|
|
||||||
params: { ...params, 'iss.meta': 'off' },
|
|
||||||
});
|
|
||||||
this.circuitErrorCount = 0;
|
|
||||||
return response.data as T;
|
|
||||||
} catch (error) {
|
|
||||||
this.circuitErrorCount++;
|
|
||||||
if (this.circuitErrorCount >= this.threshold) {
|
|
||||||
this.circuitOpen = true;
|
|
||||||
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.circuitOpen = false;
|
|
||||||
this.circuitErrorCount = 0;
|
|
||||||
this.logger.log('Circuit breaker reset');
|
|
||||||
}, this.resetMs);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}) as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
|
|
||||||
const table = data[name] as Record<string, unknown> | undefined;
|
|
||||||
if (!table || !table.columns || !table.data) return [];
|
|
||||||
const columns = table.columns as string[];
|
|
||||||
const rows = table.data as unknown[][];
|
|
||||||
return rows.map((row) => {
|
|
||||||
const obj: Record<string, unknown> = {};
|
|
||||||
columns.forEach((col, i) => {
|
|
||||||
obj[col] = row[i];
|
|
||||||
});
|
|
||||||
return obj;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
|
|
||||||
const data = await this.request<Record<string, unknown>>('/securities', {
|
|
||||||
q: query,
|
|
||||||
});
|
|
||||||
return this.extractTable(data, 'securities').map((s) => ({
|
|
||||||
secid: s.secid as string,
|
|
||||||
isin: s.isin as string,
|
|
||||||
name: s.name as string,
|
|
||||||
shortName: s.shortName as string,
|
|
||||||
latName: (s.latName as string) || null,
|
|
||||||
listLevel: parseInt(s.listLevel as string, 10) || 0,
|
|
||||||
issueSize: parseInt(s.issuesize as string, 10) || 0,
|
|
||||||
faceValue: parseFloat(s.facevalue as string) || 0,
|
|
||||||
faceUnit: (s.faceunit as string) || '',
|
|
||||||
issueDate: (s.issuedate as string) || '',
|
|
||||||
typeName: (s.typename as string) || '',
|
|
||||||
group: (s.group as string) || '',
|
|
||||||
type: (s.type as string) || '',
|
|
||||||
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
|
|
||||||
morningSession: (s.morningsession as string) === '1',
|
|
||||||
eveningSession: (s.eveningsession as string) === '1',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(`/securities/${secid}`);
|
|
||||||
const rows = this.extractTable(data, 'description');
|
|
||||||
if (rows.length === 0) return null;
|
|
||||||
const map = new Map(rows.map((r) => [r.name, r.value]));
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
isin: (map.get('ISIN') as string) || '',
|
|
||||||
name: (map.get('NAME') as string) || '',
|
|
||||||
shortName: (map.get('SHORTNAME') as string) || '',
|
|
||||||
latName: (map.get('LATNAME') as string) || null,
|
|
||||||
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
|
|
||||||
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
|
|
||||||
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
|
|
||||||
faceUnit: (map.get('FACEUNIT') as string) || '',
|
|
||||||
issueDate: (map.get('ISSUEDATE') as string) || '',
|
|
||||||
typeName: (map.get('TYPENAME') as string) || '',
|
|
||||||
group: (map.get('GROUP') as string) || '',
|
|
||||||
type: (map.get('TYPE') as string) || '',
|
|
||||||
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
|
|
||||||
morningSession: (map.get('MORNINGSESSION') as string) === '1',
|
|
||||||
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/shares/securities/${secid}`,
|
|
||||||
{ boards: boardId },
|
|
||||||
);
|
|
||||||
const rows = this.extractTable(data, 'securities');
|
|
||||||
const share = rows.find((r) => r.BOARDID === boardId);
|
|
||||||
if (!share) return null;
|
|
||||||
|
|
||||||
const mktRows = this.extractTable(data, 'marketdata');
|
|
||||||
const mkt = mktRows.find((r) => r.BOARDID === boardId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: boardId,
|
|
||||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
|
||||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
|
||||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
|
||||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
|
||||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
|
||||||
last: mkt
|
|
||||||
? parseFloat((mkt.LAST as string) || '')
|
|
||||||
: parseFloat((share.PREVPRICE as string) || ''),
|
|
||||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
|
||||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
|
||||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
|
||||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
|
||||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
|
||||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
|
||||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
|
||||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
|
||||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
|
|
||||||
const data = await this.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
||||||
{ boards: boardId },
|
|
||||||
);
|
|
||||||
const rows = this.extractTable(data, 'securities');
|
|
||||||
const bond = rows.find((r) => r.BOARDID === boardId) || 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.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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -20,6 +20,7 @@ export interface MoexSecurityDescription {
|
|||||||
export interface MoexShareMarketData {
|
export interface MoexShareMarketData {
|
||||||
secid: string;
|
secid: string;
|
||||||
boardid: string;
|
boardid: string;
|
||||||
|
shortName: string;
|
||||||
bid: number | null;
|
bid: number | null;
|
||||||
offer: number | null;
|
offer: number | null;
|
||||||
open: number | null;
|
open: number | null;
|
||||||
@ -37,6 +38,26 @@ export interface MoexShareMarketData {
|
|||||||
updateTime: string;
|
updateTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MoexBondPositionData {
|
||||||
|
secid: string;
|
||||||
|
boardid: string;
|
||||||
|
shortName: string;
|
||||||
|
price: number | null;
|
||||||
|
yieldToMaturity: number | null;
|
||||||
|
duration: number | null;
|
||||||
|
couponValue: number | null;
|
||||||
|
couponPercent: number | null;
|
||||||
|
nextCouponDate: string | null;
|
||||||
|
matDate: string | null;
|
||||||
|
accruedInt: number | null;
|
||||||
|
faceValue: number;
|
||||||
|
bid: number | null;
|
||||||
|
offer: number | null;
|
||||||
|
couponPeriod: number | null;
|
||||||
|
bondType: string | null;
|
||||||
|
offerDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MoexBondData {
|
export interface MoexBondData {
|
||||||
secid: string;
|
secid: string;
|
||||||
boardid: string;
|
boardid: string;
|
||||||
|
|||||||
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/backend/src/modules/portfolio/dto/add-position.dto.ts
Normal file
60
apps/backend/src/modules/portfolio/dto/add-position.dto.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
Min,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
IsDateString,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
const TAGS = [
|
||||||
|
'DIVIDEND',
|
||||||
|
'GROWTH',
|
||||||
|
'DEFENSIVE',
|
||||||
|
'SPECULATIVE',
|
||||||
|
'BOND',
|
||||||
|
'ETF',
|
||||||
|
'GOVERNMENT',
|
||||||
|
'CASH',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class AddPositionDto {
|
||||||
|
@ApiProperty({ example: 'SBER' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(50)
|
||||||
|
secid!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 250.5 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
buyPrice?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2026-06-01' })
|
||||||
|
@IsDateString()
|
||||||
|
@IsOptional()
|
||||||
|
buyDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Покупка на дип' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(500)
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS, isArray: true })
|
||||||
|
@IsArray()
|
||||||
|
@IsIn(TAGS, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { PositionWithPriceDto } from './position-with-price.dto';
|
||||||
|
|
||||||
|
export class PortfolioSummaryDto {
|
||||||
|
@ApiProperty() totalInvested!: number;
|
||||||
|
@ApiProperty() totalValue!: number;
|
||||||
|
@ApiProperty() totalPnl!: number;
|
||||||
|
@ApiProperty({ type: Number, nullable: true }) totalPnlPercent!: number | null;
|
||||||
|
@ApiProperty() totalDividends!: number;
|
||||||
|
@ApiProperty() totalReturn!: number;
|
||||||
|
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
||||||
|
@ApiProperty() positionCount!: number;
|
||||||
|
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
targetSharesPercent?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
targetBondsPercent?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualSharesPercent!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualBondsPercent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
sharesDeviation?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
bondsDeviation?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnalyticsResponseDto {
|
||||||
|
@ApiProperty({ type: [PositionWithPriceDto] }) positions!: PositionWithPriceDto[];
|
||||||
|
@ApiProperty() summary!: PortfolioSummaryDto;
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
|
||||||
|
|
||||||
|
export class CreatePortfolioDto {
|
||||||
|
@ApiProperty({ example: 'Мой портфель' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Описание портфеля' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(500)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 'RUB', enum: CURRENCIES })
|
||||||
|
@IsString()
|
||||||
|
@IsIn(CURRENCIES)
|
||||||
|
@IsOptional()
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
||||||
|
import { AnalyticsResponseDto } from './analytics-response.dto';
|
||||||
|
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
|
||||||
|
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
|
||||||
|
import { PositionResponseDto } from './position-response.dto';
|
||||||
|
|
||||||
|
export class PortfolioListEnvelopeDto {
|
||||||
|
@ApiProperty({ type: [PortfolioListResponseDto] })
|
||||||
|
data!: PortfolioListResponseDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortfolioEnvelopeDto {
|
||||||
|
@ApiProperty({ type: PortfolioResponseDto })
|
||||||
|
data!: PortfolioResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortfolioDetailEnvelopeDto {
|
||||||
|
@ApiProperty({ type: PortfolioDetailResponseDto })
|
||||||
|
data!: PortfolioDetailResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PositionEnvelopeDto {
|
||||||
|
@ApiProperty({ type: PositionResponseDto })
|
||||||
|
data!: PositionResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnalyticsEnvelopeDto {
|
||||||
|
@ApiProperty({ type: AnalyticsResponseDto })
|
||||||
|
data!: AnalyticsResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { PortfolioResponseDto } from './portfolio-response.dto';
|
||||||
|
|
||||||
|
export class PortfolioListResponseDto extends PortfolioResponseDto {
|
||||||
|
@ApiProperty({ description: 'Total market value of all positions' })
|
||||||
|
totalValue!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Total number of positions' })
|
||||||
|
positionCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Number of share positions' })
|
||||||
|
shareCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Number of bond positions' })
|
||||||
|
bondCount!: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { PortfolioSummaryDto } from './analytics-response.dto';
|
||||||
|
import { PositionWithPriceDto } from './position-with-price.dto';
|
||||||
|
|
||||||
|
export class PortfolioResponseDto {
|
||||||
|
@ApiProperty() id!: number;
|
||||||
|
@ApiProperty() name!: string;
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true }) description!: string | null;
|
||||||
|
@ApiProperty({ default: 'RUB' }) currency!: string;
|
||||||
|
@ApiProperty() createdAt!: string;
|
||||||
|
@ApiProperty() updatedAt!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sharesPercent: { type: 'number' },
|
||||||
|
bondsPercent: { type: 'number' },
|
||||||
|
},
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
targets!: { sharesPercent: number; bondsPercent: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
||||||
|
@ApiProperty({ type: [PositionWithPriceDto] })
|
||||||
|
positions!: PositionWithPriceDto[];
|
||||||
|
|
||||||
|
@ApiProperty() totalValue!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PortfolioSummaryDto })
|
||||||
|
analytics!: PortfolioSummaryDto;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class PositionResponseDto {
|
||||||
|
@ApiProperty() id!: number;
|
||||||
|
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||||||
|
@ApiProperty({ example: 10 }) quantity!: number;
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true }) notes!: string | null;
|
||||||
|
@ApiPropertyOptional({ type: String, isArray: true, nullable: true }) tags!: string[] | null;
|
||||||
|
@ApiProperty() portfolioId!: number;
|
||||||
|
@ApiProperty() createdAt!: string;
|
||||||
|
@ApiProperty() updatedAt!: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class PositionWithPriceDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SBER' })
|
||||||
|
secid!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
shortName!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'share', enum: ['share', 'bond'] })
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
buyPrice!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
buyDate!: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
notes!: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, isArray: true, nullable: true })
|
||||||
|
tags!: string[] | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
currentPrice!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
totalCost!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
currentValue!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
weightPercent!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
pnl!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
pnlPercent!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
dividendIncome!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
totalReturn!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
totalReturnPercent!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
change?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
changePercent?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
yieldToMaturity?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
duration?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
couponValue?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
couponPercent?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
nextCouponDate?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
matDate?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
accruedInt?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
bid?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
offer?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
couponPeriod?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
bondType?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
offerDate?: string | null;
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
IsNumber,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
|
||||||
|
|
||||||
|
export class PortfolioTargetsDto {
|
||||||
|
@ApiProperty({ example: 70 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
sharesPercent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 30 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
bondsPercent!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePortfolioDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Мой портфель' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Обновлённое описание' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(500)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 'RUB', enum: CURRENCIES })
|
||||||
|
@IsString()
|
||||||
|
@IsIn(CURRENCIES)
|
||||||
|
@IsOptional()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: { sharesPercent: 70, bondsPercent: 30 } })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => PortfolioTargetsDto)
|
||||||
|
targets?: PortfolioTargetsDto;
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
Min,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
MaxLength,
|
||||||
|
IsDateString,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
const TAGS = [
|
||||||
|
'DIVIDEND',
|
||||||
|
'GROWTH',
|
||||||
|
'DEFENSIVE',
|
||||||
|
'SPECULATIVE',
|
||||||
|
'BOND',
|
||||||
|
'ETF',
|
||||||
|
'GOVERNMENT',
|
||||||
|
'CASH',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class UpdatePositionDto {
|
||||||
|
@ApiPropertyOptional({ example: 15 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
quantity?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 260.0 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
buyPrice?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2026-06-15' })
|
||||||
|
@IsDateString()
|
||||||
|
@IsOptional()
|
||||||
|
buyDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Докупка' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(500)
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS, isArray: true })
|
||||||
|
@IsArray()
|
||||||
|
@IsIn(TAGS, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
tags?: 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