Compare commits
No commits in common. "main" and "feat/mvp-implementation" have entirely different histories.
main
...
feat/mvp-i
@ -9,57 +9,46 @@ env:
|
|||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
lint:
|
||||||
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 Node.js
|
- name: 'Setup dependencies'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- run: npm ci
|
||||||
run: npm ci
|
- run: npm run lint
|
||||||
|
- run: npx prettier --check "**/*.{ts,tsx}"
|
||||||
|
|
||||||
- name: Build design system
|
test:
|
||||||
run: npm run build:design-system
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 'Checkout repository'
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Lint
|
- name: 'Setup dependencies'
|
||||||
run: npm run lint
|
uses: actions/setup-node@v4
|
||||||
|
|
||||||
- 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:
|
||||||
name: storybook-static
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
path: packages/design-system/storybook-static
|
|
||||||
retention-days: 3
|
- run: npm ci
|
||||||
|
- run: npm run test:backend
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 'Checkout repository'
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 'Setup dependencies'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build:backend
|
||||||
|
- run: npm run build:frontend
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -1,17 +1,8 @@
|
|||||||
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/
|
|
||||||
|
|||||||
@ -1,139 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# graphify-checkout-hook-start
|
|
||||||
# Auto-rebuilds the knowledge graph (code only) when switching branches.
|
|
||||||
# Installed by: graphify hook install
|
|
||||||
|
|
||||||
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
|
|
||||||
# order is randomized per-process by PYTHONHASHSEED, so community assignments
|
|
||||||
# churn run-to-run. Pinning it makes graphify-out reproducible.
|
|
||||||
export PYTHONHASHSEED=0
|
|
||||||
|
|
||||||
PREV_HEAD=$1
|
|
||||||
NEW_HEAD=$2
|
|
||||||
BRANCH_SWITCH=$3
|
|
||||||
|
|
||||||
# Only run on branch switches, not file checkouts
|
|
||||||
if [ "$BRANCH_SWITCH" != "1" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Only run if graphify-out/ exists (graph has been built before)
|
|
||||||
if [ ! -d "graphify-out" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Skip during rebase/merge/cherry-pick
|
|
||||||
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
|
|
||||||
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
|
|
||||||
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
|
|
||||||
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
|
|
||||||
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
|
|
||||||
|
|
||||||
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
|
|
||||||
# _PINNED was recorded at hook-install time; tried first so the hook works even
|
|
||||||
# when the graphify launcher is not on PATH (common in GUI clients and CI).
|
|
||||||
GRAPHIFY_PYTHON=""
|
|
||||||
_PINNED='/Users/ksv741/.local/share/uv/tools/graphifyy/bin/python'
|
|
||||||
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON="$_PINNED"
|
|
||||||
fi
|
|
||||||
# Second probe: read graphify-out/.graphify_python (written by the skill and
|
|
||||||
# CLI; survives uv-tool reinstalls and is the same source the README documents).
|
|
||||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
|
||||||
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
|
|
||||||
if [ -f "$_GFY_PYTHON_FILE" ]; then
|
|
||||||
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
||||||
case "$_FROM_FILE" in
|
|
||||||
*[!a-zA-Z0-9/_.@:\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
|
|
||||||
esac
|
|
||||||
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON="$_FROM_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# Third probe: resolve via the graphify launcher on PATH (shebang probe).
|
|
||||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
|
||||||
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
|
|
||||||
if [ -n "$GRAPHIFY_BIN" ]; then
|
|
||||||
case "$GRAPHIFY_BIN" in
|
|
||||||
*.exe) _SHEBANG="" ;;
|
|
||||||
*) _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | sed 's/^#![[:space:]]*//') ;;
|
|
||||||
esac
|
|
||||||
case "$_SHEBANG" in
|
|
||||||
*/env\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
|
|
||||||
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
|
|
||||||
esac
|
|
||||||
# Allowlist: only keep characters valid in a filesystem path to prevent
|
|
||||||
# injection if the shebang contains shell metacharacters.
|
|
||||||
case "$GRAPHIFY_PYTHON" in
|
|
||||||
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
|
|
||||||
esac
|
|
||||||
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON=""
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# Last resort: try python3 / python (works for system/venv installs on PATH).
|
|
||||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
|
||||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON="python3"
|
|
||||||
elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON="python"
|
|
||||||
else
|
|
||||||
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
|
|
||||||
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
|
|
||||||
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
|
|
||||||
echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)"
|
|
||||||
"$GRAPHIFY_PYTHON" -c "import os, subprocess, sys
|
|
||||||
_src = '''
|
|
||||||
from graphify.watch import _rebuild_code, _apply_resource_limits
|
|
||||||
from pathlib import Path
|
|
||||||
import os, signal, sys
|
|
||||||
try:
|
|
||||||
_apply_resource_limits()
|
|
||||||
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
|
|
||||||
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
|
|
||||||
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
|
|
||||||
signal.alarm(_timeout)
|
|
||||||
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
|
|
||||||
# post-checkout: branch switch can touch arbitrary files; full rebuild path
|
|
||||||
# (no changed_paths) is correct here. The flock inside _rebuild_code still
|
|
||||||
# prevents pile-ups when commit + checkout fire back-to-back.
|
|
||||||
_root = Path('.')
|
|
||||||
_saved = Path('graphify-out/.graphify_root')
|
|
||||||
if _saved.exists():
|
|
||||||
_txt = _saved.read_text(encoding='utf-8').strip()
|
|
||||||
if _txt:
|
|
||||||
_root = Path(_txt)
|
|
||||||
_rebuild_code(_root, force=_force)
|
|
||||||
except TimeoutError as exc:
|
|
||||||
print(f'[graphify] {exc}')
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f'[graphify] Rebuild failed: {exc}')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
'''
|
|
||||||
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(_log), exist_ok=True)
|
|
||||||
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
|
|
||||||
except OSError:
|
|
||||||
_out = subprocess.DEVNULL
|
|
||||||
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
|
|
||||||
_cmd = [sys.executable, '-c', _src]
|
|
||||||
if os.name == 'nt':
|
|
||||||
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
|
||||||
try:
|
|
||||||
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
|
|
||||||
except OSError:
|
|
||||||
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
|
|
||||||
else:
|
|
||||||
subprocess.Popen(_cmd, start_new_session=True, **_kw)
|
|
||||||
"
|
|
||||||
# graphify-checkout-hook-end
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# graphify-hook-start
|
|
||||||
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
|
|
||||||
# Installed by: graphify hook install
|
|
||||||
|
|
||||||
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
|
|
||||||
# order is randomized per-process by PYTHONHASHSEED, so community assignments
|
|
||||||
# churn run-to-run. Pinning it makes graphify-out reproducible.
|
|
||||||
export PYTHONHASHSEED=0
|
|
||||||
|
|
||||||
# Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes
|
|
||||||
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
|
|
||||||
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
|
|
||||||
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
|
|
||||||
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
|
|
||||||
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
|
|
||||||
|
|
||||||
[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
|
|
||||||
|
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
|
|
||||||
if [ -z "$CHANGED" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Skip when only graphify-out/ artifacts changed (avoids rebuild loop when graph outputs are tracked in git)
|
|
||||||
_NON_GRAPH=$(echo "$CHANGED" | grep -v '^graphify-out/' || true)
|
|
||||||
if [ -z "$_NON_GRAPH" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
|
|
||||||
# _PINNED was recorded at hook-install time; tried first so the hook works even
|
|
||||||
# when the graphify launcher is not on PATH (common in GUI clients and CI).
|
|
||||||
GRAPHIFY_PYTHON=""
|
|
||||||
_PINNED='/Users/ksv741/.local/share/uv/tools/graphifyy/bin/python'
|
|
||||||
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON="$_PINNED"
|
|
||||||
fi
|
|
||||||
# Second probe: read graphify-out/.graphify_python (written by the skill and
|
|
||||||
# CLI; survives uv-tool reinstalls and is the same source the README documents).
|
|
||||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
|
||||||
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
|
|
||||||
if [ -f "$_GFY_PYTHON_FILE" ]; then
|
|
||||||
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
|
|
||||||
case "$_FROM_FILE" in
|
|
||||||
*[!a-zA-Z0-9/_.@:\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
|
|
||||||
esac
|
|
||||||
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON="$_FROM_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# Third probe: resolve via the graphify launcher on PATH (shebang probe).
|
|
||||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
|
||||||
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
|
|
||||||
if [ -n "$GRAPHIFY_BIN" ]; then
|
|
||||||
case "$GRAPHIFY_BIN" in
|
|
||||||
*.exe) _SHEBANG="" ;;
|
|
||||||
*) _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | sed 's/^#![[:space:]]*//') ;;
|
|
||||||
esac
|
|
||||||
case "$_SHEBANG" in
|
|
||||||
*/env\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
|
|
||||||
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
|
|
||||||
esac
|
|
||||||
# Allowlist: only keep characters valid in a filesystem path to prevent
|
|
||||||
# injection if the shebang contains shell metacharacters.
|
|
||||||
case "$GRAPHIFY_PYTHON" in
|
|
||||||
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
|
|
||||||
esac
|
|
||||||
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON=""
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# Last resort: try python3 / python (works for system/venv installs on PATH).
|
|
||||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
|
||||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON="python3"
|
|
||||||
elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then
|
|
||||||
GRAPHIFY_PYTHON="python"
|
|
||||||
else
|
|
||||||
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
export GRAPHIFY_CHANGED="$CHANGED"
|
|
||||||
|
|
||||||
# Run the rebuild detached so git commit returns immediately. Full-repo rebuilds
|
|
||||||
# can take hours; blocking the post-commit hook stalls the shell. The Python
|
|
||||||
# launcher below detaches the child cross-platform, so it works on Git for
|
|
||||||
# Windows' shell too (which lacks the coreutils backgrounding tools) (#1161).
|
|
||||||
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
|
|
||||||
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
|
|
||||||
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
|
|
||||||
echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
|
|
||||||
"$GRAPHIFY_PYTHON" -c "import os, subprocess, sys
|
|
||||||
_src = '''
|
|
||||||
import os, signal, sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
|
|
||||||
changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
|
|
||||||
|
|
||||||
if not changed:
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
|
|
||||||
|
|
||||||
try:
|
|
||||||
from graphify.watch import _rebuild_code, _apply_resource_limits
|
|
||||||
_apply_resource_limits()
|
|
||||||
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
|
|
||||||
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
|
|
||||||
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
|
|
||||||
signal.alarm(_timeout)
|
|
||||||
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
|
|
||||||
_root = Path('.')
|
|
||||||
_saved = Path('graphify-out/.graphify_root')
|
|
||||||
if _saved.exists():
|
|
||||||
_txt = _saved.read_text(encoding='utf-8').strip()
|
|
||||||
if _txt:
|
|
||||||
_root = Path(_txt)
|
|
||||||
_rebuild_code(_root, changed_paths=changed, force=_force)
|
|
||||||
except TimeoutError as exc:
|
|
||||||
print(f'[graphify hook] {exc}')
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f'[graphify hook] Rebuild failed: {exc}')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
'''
|
|
||||||
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(_log), exist_ok=True)
|
|
||||||
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
|
|
||||||
except OSError:
|
|
||||||
_out = subprocess.DEVNULL
|
|
||||||
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
|
|
||||||
_cmd = [sys.executable, '-c', _src]
|
|
||||||
if os.name == 'nt':
|
|
||||||
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
|
||||||
try:
|
|
||||||
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
|
|
||||||
except OSError:
|
|
||||||
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
|
|
||||||
else:
|
|
||||||
subprocess.Popen(_cmd, start_new_session=True, **_kw)
|
|
||||||
"
|
|
||||||
# graphify-hook-end
|
|
||||||
@ -1 +0,0 @@
|
|||||||
npx lint-staged
|
|
||||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"semi": true
|
||||||
|
}
|
||||||
2
.serena/.gitignore
vendored
2
.serena/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
/cache
|
|
||||||
/project.local.yml
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
# Memory Maintenance
|
|
||||||
|
|
||||||
## Discovery Model
|
|
||||||
|
|
||||||
- Core principle: progressive discovery through references, building a graph of memories.
|
|
||||||
- Initially, agents are provided with the list of all memories (names only).
|
|
||||||
- Agents should read `mem:core` as the top-level entry point (graph root).
|
|
||||||
This memory should contain references to other memories covering major project domains.
|
|
||||||
The referenced memories shall, in turn, shall contain references to even more specific memories, and so on.
|
|
||||||
The depth of the graph shall depend on the project complexity.
|
|
||||||
- Use topics/folders to group related memories in order to make the content structure explicit.
|
|
||||||
Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc.
|
|
||||||
- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`.
|
|
||||||
The surrounding text should clearly indicate when to read the memory/which content to expect.
|
|
||||||
The text should provide more precise guidance than the memory name alone,
|
|
||||||
i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered.
|
|
||||||
- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory.
|
|
||||||
|
|
||||||
## Style
|
|
||||||
|
|
||||||
Dense agent notes, not prose docs. Prefer invariants, terse bullets.
|
|
||||||
Avoid obvious context, rationale, and examples unless they prevent likely mistakes.
|
|
||||||
Keep guidance durable and generalizable, not task-local.
|
|
||||||
|
|
||||||
## Add/update threshold
|
|
||||||
|
|
||||||
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
|
|
||||||
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
|
|
||||||
|
|
||||||
## Maintenance Actions
|
|
||||||
|
|
||||||
- Renaming memories: References are updated automatically if handled via Serena's memory rename tool.
|
|
||||||
- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report.
|
|
||||||
@ -1,133 +0,0 @@
|
|||||||
# the name by which the project can be referenced within Serena
|
|
||||||
project_name: "moex-vibe"
|
|
||||||
|
|
||||||
|
|
||||||
# list of languages for which language servers are started; choose from:
|
|
||||||
# al angular ansible bash clojure
|
|
||||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
|
||||||
# dart elixir elm erlang fortran
|
|
||||||
# fsharp go groovy haskell haxe
|
|
||||||
# hlsl html java json julia
|
|
||||||
# kotlin lean4 lua luau markdown
|
|
||||||
# matlab msl nix ocaml pascal
|
|
||||||
# perl php php_phpactor powershell python
|
|
||||||
# python_jedi python_ty r rego ruby
|
|
||||||
# ruby_solargraph rust scala scss solidity
|
|
||||||
# svelte swift systemverilog terraform toml
|
|
||||||
# typescript typescript_vts vue yaml zig
|
|
||||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
|
||||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
|
||||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
|
||||||
# Note:
|
|
||||||
# - For C, use cpp
|
|
||||||
# - For JavaScript, use typescript
|
|
||||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
|
||||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
|
||||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
|
||||||
# - For Free Pascal/Lazarus, use pascal
|
|
||||||
# Special requirements:
|
|
||||||
# Some languages require additional setup/installations.
|
|
||||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
|
||||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
|
||||||
# The first language is the default language and the respective language server will be used as a fallback.
|
|
||||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
|
||||||
languages:
|
|
||||||
- typescript
|
|
||||||
|
|
||||||
# the encoding used by text files in the project
|
|
||||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
|
||||||
encoding: "utf-8"
|
|
||||||
|
|
||||||
# line ending convention to use when writing source files.
|
|
||||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
|
||||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
|
||||||
line_ending:
|
|
||||||
|
|
||||||
# The language backend to use for this project.
|
|
||||||
# If not set, the global setting from serena_config.yml is used.
|
|
||||||
# Valid values: LSP, JetBrains
|
|
||||||
# Note: the backend is fixed at startup. If a project with a different backend
|
|
||||||
# is activated post-init, an error will be returned.
|
|
||||||
language_backend:
|
|
||||||
|
|
||||||
# whether to use project's .gitignore files to ignore files
|
|
||||||
ignore_all_files_in_gitignore: true
|
|
||||||
|
|
||||||
# advanced configuration option allowing to configure language server-specific options.
|
|
||||||
# Maps the language key to the options.
|
|
||||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
|
||||||
# No documentation on options means no options are available.
|
|
||||||
ls_specific_settings: {}
|
|
||||||
|
|
||||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
|
||||||
# Paths can be absolute or relative to the project root.
|
|
||||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
|
||||||
# symbols and references across package boundaries.
|
|
||||||
# Currently supported for: TypeScript.
|
|
||||||
# Example:
|
|
||||||
# additional_workspace_folders:
|
|
||||||
# - ../sibling-package
|
|
||||||
# - ../shared-lib
|
|
||||||
additional_workspace_folders: []
|
|
||||||
|
|
||||||
# list of additional paths to ignore in this project.
|
|
||||||
# Same syntax as gitignore, so you can use * and **.
|
|
||||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
|
||||||
ignored_paths: []
|
|
||||||
|
|
||||||
# whether the project is in read-only mode
|
|
||||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
|
||||||
# Added on 2025-04-18
|
|
||||||
read_only: false
|
|
||||||
|
|
||||||
# list of tool names to exclude.
|
|
||||||
# This extends the existing exclusions (e.g. from the global configuration)
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
excluded_tools: []
|
|
||||||
|
|
||||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
|
||||||
# This extends the existing inclusions (e.g. from the global configuration).
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
included_optional_tools: []
|
|
||||||
|
|
||||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
|
||||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
fixed_tools: []
|
|
||||||
|
|
||||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
|
||||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
|
||||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
|
||||||
# for this project.
|
|
||||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
default_modes:
|
|
||||||
|
|
||||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
added_modes:
|
|
||||||
|
|
||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
|
||||||
# (contrary to the memories, which are loaded on demand).
|
|
||||||
initial_prompt: ""
|
|
||||||
|
|
||||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
|
||||||
# such as docstrings or parameter information.
|
|
||||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
|
||||||
# If null or missing, use the setting from the global configuration.
|
|
||||||
symbol_info_budget:
|
|
||||||
|
|
||||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
read_only_memory_patterns: []
|
|
||||||
|
|
||||||
# list of regex patterns for memories to completely ignore.
|
|
||||||
# Matching memories will not appear in list_memories or activate_project output
|
|
||||||
# and cannot be accessed via read_memory or write_memory.
|
|
||||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
# Example: ["_archive/.*", "_episodes/.*"]
|
|
||||||
ignored_memory_patterns: []
|
|
||||||
534
AGENTS.md
534
AGENTS.md
@ -1,448 +1,55 @@
|
|||||||
# 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, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
|
- **SDD (Specification-Driven Development)**: перед написанием кода сначала сформировать спецификацию — PRD, доменную модель, ADR, OpenAPI-контракт, архитектуру фронтенда и бэкенда, план реализации по этапам.
|
||||||
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans для крупных многошаговых работ, subagent-driven-development как предпочтительный способ исполнения плана, executing-plans как fallback для явно связанных inline-задач, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
|
- **Superpowers**: обязательно использовать скиллы (Skills) при старте любой задачи — brainstorming, frontend-design, test-driven-development, writing-plans, executing-plans, requesting-code-review.
|
||||||
- **MCP-инструменты**: в проекте настроены `code-index-mcp` (файловый поиск/индексация), `serena` (LSP-символьный анализ кода) и `graphify` (knowledge graph). Использовать для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
|
- **MCP-инструменты**: использовать MCP для анализа и генерации дизайна, работы с API, генерации кода.
|
||||||
- **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 |
|
||||||
Проект использует подход Specification-Driven Development (SDD).
|
| `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) |
|
||||||
```text
|
| `npm run lint` | ESLint только для бэкенда |
|
||||||
docs/
|
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||||||
|
| `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` |
|
||||||
├── inbox.md
|
|
||||||
├── roadmap.md
|
Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend`
|
||||||
│
|
|
||||||
├── research/
|
## Переменные окружения
|
||||||
│
|
|
||||||
├── epics/
|
| Переменная | По умолчанию | Описание |
|
||||||
│ └── {epic-name}.md
|
|---|---|---|
|
||||||
│
|
| `PORT` | 3000 | Порт бэкенда |
|
||||||
└── features/
|
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
|
||||||
└── {feature-name}/
|
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
||||||
├── spec.md
|
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
||||||
├── plan.md
|
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
||||||
└── tasks.md
|
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||||
|
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
|
||||||
```
|
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
|
||||||
|
|
||||||
Полный набор `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 напрямую.
|
||||||
- Актуальная композиция backend-модулей определяется в `apps/backend/src/app.module.ts`; не дублировать динамический список модулей в инструкциях. Опубликованное описание архитектуры находится в `apps/docs/docs/backend/modules.md`.
|
- Feature-модули: `MoexClientModule` (глобальный), `CacheModule` (глобальный), `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`.
|
||||||
- `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 для графиков цен.
|
||||||
@ -451,75 +58,10 @@ roadmap.md и inbox.md никогда не являются основанием
|
|||||||
- Конвенция ключей запросов: `['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 находится в `.gitea/workflows/ci.yml`.
|
- CI/CD в репозитории нет.
|
||||||
- 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).
|
|
||||||
|
|||||||
152
README.md
152
README.md
@ -2,168 +2,50 @@
|
|||||||
|
|
||||||
Веб-приложение для анализа ценных бумаг Московской биржи (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
|
||||||
cp apps/backend/.env.example apps/backend/.env
|
|
||||||
|
|
||||||
# Установка зависимостей и подготовка базы данных
|
|
||||||
npm install
|
npm install
|
||||||
npm exec -w apps/backend -- prisma migrate dev
|
|
||||||
|
|
||||||
# Запуск бэкенда (http://localhost:3000)
|
# Start backend (http://localhost:3000)
|
||||||
npm run dev:backend
|
npm run dev:backend
|
||||||
|
|
||||||
# Запуск фронтенда (http://localhost:5173)
|
# Start frontend (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
|
||||||
```
|
```
|
||||||
|
|
||||||
- Фронтенд: http://localhost:80
|
- Frontend: http://localhost:80
|
||||||
- Бэкенд: http://localhost:3000
|
- Backend: http://localhost:3000
|
||||||
|
|
||||||
---
|
## Tests
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:backend
|
npm run test:backend
|
||||||
npm run test:frontend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Интеграционные тесты с MOEX — опциональны:
|
## Project Structure
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test:integration -w apps/backend
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/
|
apps/
|
||||||
backend/ — NestJS API, единая точка доступа к MOEX ISS
|
backend/ — NestJS API (single point of access to MOEX ISS)
|
||||||
frontend/ — React SPA на Vite
|
frontend/ — React SPA with Vite
|
||||||
docs/ — сайт документации Docusaurus
|
|
||||||
packages/
|
|
||||||
design-system/ — дизайн-система (MUI-адаптер, UI-компоненты, Storybook)
|
|
||||||
docs/
|
docs/
|
||||||
features/ — спецификации и планы реализации (SDD)
|
architecture/ — ADR documents and diagrams
|
||||||
epics/ — продуктовые эпики
|
openapi/ — OpenAPI specification
|
||||||
inbox.md — идеи и заметки
|
superpowers/ — Design specs and implementation plans
|
||||||
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 |
|
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
PORT=3000
|
|
||||||
DATABASE_URL=file:./dev.db
|
|
||||||
|
|
||||||
MOEX_BASE_URL=https://iss.moex.com/iss
|
|
||||||
MOEX_RATE_LIMIT=10
|
|
||||||
MOEX_CIRCUIT_BREAKER_THRESHOLD=5
|
|
||||||
MOEX_CIRCUIT_BREAKER_RESET_SECONDS=30
|
|
||||||
|
|
||||||
T_BANK_TOKEN=
|
|
||||||
T_BANK_BASE_URL=invest-public-api.tbank.ru:443
|
|
||||||
T_BANK_CA_CERT_PATH=
|
|
||||||
T_BANK_APP_NAME=ksv741.moex-vibe
|
|
||||||
T_BANK_RATE_LIMIT_PER_SECOND=5
|
|
||||||
T_BANK_INSTRUMENTS_RATE_LIMIT=20
|
|
||||||
T_BANK_REQUEST_TIMEOUT_MS=10000
|
|
||||||
|
|
||||||
CACHE_MARKET_DATA_TTL=900
|
|
||||||
CACHE_HISTORY_TTL=3600
|
|
||||||
CACHE_CANDLES_TTL=3600
|
|
||||||
CACHE_SECURITY_TTL=86400
|
|
||||||
CACHE_SEARCH_TTL=3600
|
|
||||||
CACHE_DIVIDENDS_TTL=86400
|
|
||||||
CACHE_TBANK_ACCOUNTS_TTL=3600
|
|
||||||
CACHE_TBANK_PORTFOLIO_TTL=60
|
|
||||||
CACHE_TBANK_OPERATIONS_TTL=300
|
|
||||||
CACHE_TBANK_POSITIONS_TTL=60
|
|
||||||
CACHE_TBANK_INSTRUMENT_TTL=86400
|
|
||||||
|
|
||||||
JWT_SECRET=dev-jwt-secret-change-in-production
|
|
||||||
JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production
|
|
||||||
JWT_ACCESS_EXPIRES=15m
|
|
||||||
JWT_REFRESH_EXPIRES=7d
|
|
||||||
10
apps/backend/.gitignore
vendored
10
apps/backend/.gitignore
vendored
@ -1,10 +0,0 @@
|
|||||||
node_modules
|
|
||||||
# Keep environment variables out of version control
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Prisma generated client
|
|
||||||
node_modules/.prisma
|
|
||||||
|
|
||||||
# SQLite database
|
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
|
||||||
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
|
||||||
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
|
||||||
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
|
|
||||||
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
|
||||||
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
|
|
||||||
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
|
|
||||||
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
|
|
||||||
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
|
|
||||||
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
|
|
||||||
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
|
|
||||||
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
|
|
||||||
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
|
|
||||||
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
|
|
||||||
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
|
|
||||||
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
|
|
||||||
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
|
|
||||||
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
|
|
||||||
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
|
|
||||||
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
|
|
||||||
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
|
|
||||||
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
|
|
||||||
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
|
|
||||||
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
|
|
||||||
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
|
|
||||||
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
|
|
||||||
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
|
|
||||||
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
|
|
||||||
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
|
|
||||||
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
|
|
||||||
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@ -1,13 +1,4 @@
|
|||||||
{
|
{
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src"
|
||||||
"compilerOptions": {
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"include": "modules/tbank/proto/contracts/**/*",
|
|
||||||
"outDir": "dist"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"watchAssets": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,38 +3,26 @@
|
|||||||
"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": "VITE_CJS_IGNORE_WARNING=1 vitest run --exclude \"src/**/*.integration.spec.ts\"",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest --exclude \"src/**/*.integration.spec.ts\"",
|
"test:watch": "vitest"
|
||||||
"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"
|
||||||
@ -44,14 +32,11 @@
|
|||||||
"@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"
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
// 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'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Position" ADD COLUMN "buyDate" DATETIME;
|
|
||||||
ALTER TABLE "Position" ADD COLUMN "buyPrice" REAL;
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "BrokerOperation" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"accountId" TEXT NOT NULL,
|
|
||||||
"cursor" TEXT,
|
|
||||||
"operationId" TEXT,
|
|
||||||
"parentOperationId" TEXT,
|
|
||||||
"date" DATETIME,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"category" TEXT NOT NULL,
|
|
||||||
"state" TEXT,
|
|
||||||
"instrumentUid" TEXT,
|
|
||||||
"figi" TEXT,
|
|
||||||
"ticker" TEXT,
|
|
||||||
"classCode" TEXT,
|
|
||||||
"payment" TEXT,
|
|
||||||
"price" TEXT,
|
|
||||||
"commission" TEXT,
|
|
||||||
"yield" TEXT,
|
|
||||||
"accruedInt" TEXT,
|
|
||||||
"quantity" INTEGER,
|
|
||||||
"quantityDone" INTEGER,
|
|
||||||
"raw" TEXT NOT NULL,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "BrokerOperationSyncState" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"accountId" TEXT NOT NULL,
|
|
||||||
"lastCursor" TEXT,
|
|
||||||
"lastSyncedFrom" DATETIME,
|
|
||||||
"lastSyncedTo" DATETIME,
|
|
||||||
"syncedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "BrokerOperation_accountId_date_idx" ON "BrokerOperation"("accountId", "date");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "BrokerOperation_accountId_type_idx" ON "BrokerOperation"("accountId", "type");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "BrokerOperation_accountId_cursor_key" ON "BrokerOperation"("accountId", "cursor");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "BrokerOperationSyncState_accountId_key" ON "BrokerOperationSyncState"("accountId");
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "sqlite"
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
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 { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { CacheModule } from './modules/cache/cache.module';
|
import { CacheModule } from './modules/cache/cache.module';
|
||||||
import { MoexClientModule } from './modules/moex-client/moex-client.module';
|
import { MoexClientModule } from './modules/moex-client/moex-client.module';
|
||||||
@ -7,31 +7,18 @@ 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, envFilePath: '.env' }),
|
ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
|
||||||
PrismaModule,
|
|
||||||
CacheModule,
|
CacheModule,
|
||||||
MoexClientModule,
|
MoexClientModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
AuthModule,
|
|
||||||
SecuritiesModule,
|
SecuritiesModule,
|
||||||
SharesModule,
|
SharesModule,
|
||||||
BondsModule,
|
BondsModule,
|
||||||
CandlesModule,
|
CandlesModule,
|
||||||
PortfolioModule,
|
|
||||||
TBankModule,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule {}
|
||||||
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({ type: String, nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
cachedAt: string | null;
|
cachedAt: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@ -13,14 +13,6 @@ export class ApiResponseMeta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiEnvelopePayload<T> {
|
|
||||||
constructor(
|
|
||||||
public readonly data: T,
|
|
||||||
public readonly fromCache: boolean,
|
|
||||||
public readonly cachedAt: string | null,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiResponse<T> {
|
export class ApiResponse<T> {
|
||||||
data: T;
|
data: T;
|
||||||
meta: ApiResponseMeta;
|
meta: ApiResponseMeta;
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
|
||||||
|
|
||||||
export abstract class DomainException extends HttpException {
|
|
||||||
constructor(message: string, status: HttpStatus) {
|
|
||||||
super(message, status);
|
|
||||||
this.name = this.constructor.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { HttpStatus } from '@nestjs/common';
|
|
||||||
import { DomainException } from './domain.exception';
|
|
||||||
|
|
||||||
export class EntityNotFoundException extends DomainException {
|
|
||||||
constructor(entity: string, id: string | number) {
|
|
||||||
super(`${entity} ${id} not found`, HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { HttpStatus } from '@nestjs/common';
|
|
||||||
import { DomainException } from './domain.exception';
|
|
||||||
|
|
||||||
export class MoexApiException extends DomainException {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(`MOEX API error: ${message}`, HttpStatus.BAD_GATEWAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { HttpStatus } from '@nestjs/common';
|
|
||||||
import { DomainException } from './domain.exception';
|
|
||||||
|
|
||||||
export class PortfolioAccessDeniedException extends DomainException {
|
|
||||||
constructor(portfolioId: number) {
|
|
||||||
super(`Access denied to portfolio ${portfolioId}`, HttpStatus.FORBIDDEN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { HttpStatus } from '@nestjs/common';
|
|
||||||
import { DomainException } from './domain.exception';
|
|
||||||
|
|
||||||
export class TBankApiException extends DomainException {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(`T-Bank API error: ${message}`, HttpStatus.BAD_GATEWAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TBankNotConfiguredException extends DomainException {
|
|
||||||
constructor() {
|
|
||||||
super('T-Bank integration is not configured', HttpStatus.SERVICE_UNAVAILABLE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { ArgumentsHost, BadRequestException, HttpStatus } from '@nestjs/common';
|
|
||||||
import { HttpExceptionFilter } from './http-exception.filter';
|
|
||||||
|
|
||||||
describe('HttpExceptionFilter', () => {
|
|
||||||
const createHost = () => {
|
|
||||||
const json = vi.fn();
|
|
||||||
const status = vi.fn(() => ({ json }));
|
|
||||||
const host = {
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getResponse: () => ({ status }),
|
|
||||||
getRequest: () => ({ url: '/api/v1/test' }),
|
|
||||||
}),
|
|
||||||
} as unknown as ArgumentsHost;
|
|
||||||
|
|
||||||
return { host, status, json };
|
|
||||||
};
|
|
||||||
|
|
||||||
it('does not expose internal Error.message for unhandled exceptions', () => {
|
|
||||||
const filter = new HttpExceptionFilter();
|
|
||||||
const { host, status, json } = createHost();
|
|
||||||
|
|
||||||
filter.catch(new Error('Prisma failed at file:///secret/path'), host);
|
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
expect(json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
message: 'Internal server error',
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
path: '/api/v1/test',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(json.mock.calls[0][0].message).not.toContain('Prisma failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns safe defaults for non-Error thrown values', () => {
|
|
||||||
const filter = new HttpExceptionFilter();
|
|
||||||
const { host, status, json } = createHost();
|
|
||||||
|
|
||||||
filter.catch('some string error', host);
|
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
expect(json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
message: 'Internal server error',
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps HttpException response messages intact', () => {
|
|
||||||
const filter = new HttpExceptionFilter();
|
|
||||||
const { host, status, json } = createHost();
|
|
||||||
|
|
||||||
filter.catch(new BadRequestException('Invalid request'), host);
|
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
|
||||||
expect(json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
statusCode: HttpStatus.BAD_REQUEST,
|
|
||||||
message: 'Invalid request',
|
|
||||||
error: 'Bad Request',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,10 +1,8 @@
|
|||||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
@ -26,9 +24,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
error = (r.error as string) || exception.name;
|
error = (r.error as string) || exception.name;
|
||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
message = exception.message;
|
||||||
} else {
|
|
||||||
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
|
import { ApiResponse } from '../dto/api-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||||
@ -9,9 +9,6 @@ export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T
|
|||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data) => {
|
map((data) => {
|
||||||
if (data instanceof ApiResponse) return data;
|
if (data instanceof ApiResponse) return data;
|
||||||
if (data instanceof ApiEnvelopePayload) {
|
|
||||||
return new ApiResponse(data.data, data.fromCache, data.cachedAt);
|
|
||||||
}
|
|
||||||
return new ApiResponse(data, false, null);
|
return new ApiResponse(data, false, null);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('backend runtime configuration', () => {
|
|
||||||
const OLD_ENV = process.env;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetModules();
|
|
||||||
process.env = { ...OLD_ENV };
|
|
||||||
delete process.env.JWT_SECRET;
|
|
||||||
delete process.env.JWT_REFRESH_SECRET;
|
|
||||||
delete process.env.BACKEND_CORS_ORIGINS;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = OLD_ENV;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps dev auth defaults outside production', async () => {
|
|
||||||
const configuration = (await import('./configuration')).default;
|
|
||||||
|
|
||||||
expect(configuration().auth).toMatchObject({
|
|
||||||
jwtSecret: 'dev-jwt-secret-change-in-production',
|
|
||||||
jwtRefreshSecret: 'dev-refresh-secret-change-in-production',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses backend CORS origins from comma-separated env', async () => {
|
|
||||||
process.env.BACKEND_CORS_ORIGINS = 'https://app.example.com, http://localhost:5173 ';
|
|
||||||
const configuration = (await import('./configuration')).default;
|
|
||||||
|
|
||||||
expect(configuration().cors.origins).toEqual([
|
|
||||||
'https://app.example.com',
|
|
||||||
'http://localhost:5173',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects production defaults for JWT secrets', async () => {
|
|
||||||
const { assertSafeProductionConfig } = await import('../main');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
assertSafeProductionConfig({
|
|
||||||
nodeEnv: 'production',
|
|
||||||
jwtSecret: 'dev-jwt-secret-change-in-production',
|
|
||||||
jwtRefreshSecret: 'custom-refresh-secret',
|
|
||||||
corsOrigins: ['https://app.example.com'],
|
|
||||||
}),
|
|
||||||
).toThrow('JWT_SECRET must be set to a non-default value in production');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects production credentialed CORS without explicit origins', async () => {
|
|
||||||
const { assertSafeProductionConfig } = await import('../main');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
assertSafeProductionConfig({
|
|
||||||
nodeEnv: 'production',
|
|
||||||
jwtSecret: 'custom-access-secret',
|
|
||||||
jwtRefreshSecret: 'custom-refresh-secret',
|
|
||||||
corsOrigins: [],
|
|
||||||
}),
|
|
||||||
).toThrow('BACKEND_CORS_ORIGINS must contain at least one origin in production');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows development with reflected CORS', async () => {
|
|
||||||
const { buildCorsOrigin } = await import('../main');
|
|
||||||
|
|
||||||
expect(buildCorsOrigin('development', [])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses explicit production CORS origins', async () => {
|
|
||||||
const { buildCorsOrigin } = await import('../main');
|
|
||||||
|
|
||||||
expect(buildCorsOrigin('production', ['https://app.example.com'])).toEqual([
|
|
||||||
'https://app.example.com',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,19 +1,7 @@
|
|||||||
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),
|
||||||
@ -23,37 +11,12 @@ 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',
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
import 'reflect-metadata'
|
|
||||||
import { Test, type TestingModule } from '@nestjs/testing'
|
|
||||||
import type { INestApplication } from '@nestjs/common'
|
|
||||||
import { ConfigModule } from '@nestjs/config'
|
|
||||||
import { HealthModule } from './modules/health/health.module'
|
|
||||||
import { PrismaModule } from './modules/prisma/prisma.module'
|
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor'
|
|
||||||
import configuration from './config/configuration'
|
|
||||||
|
|
||||||
describe('API envelope contract', () => {
|
|
||||||
let app: INestApplication
|
|
||||||
let baseUrl: string
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }), PrismaModule, HealthModule],
|
|
||||||
}).compile()
|
|
||||||
|
|
||||||
app = module.createNestApplication()
|
|
||||||
app.setGlobalPrefix('api/v1')
|
|
||||||
app.useGlobalInterceptors(new TransformInterceptor())
|
|
||||||
await app.init()
|
|
||||||
await app.listen(0)
|
|
||||||
|
|
||||||
const address = app.getHttpServer().address()
|
|
||||||
if (typeof address === 'object' && address && 'port' in address) {
|
|
||||||
baseUrl = `http://127.0.0.1:${address.port}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns a proper envelope with checks from the public health endpoint', async () => {
|
|
||||||
const response = await fetch(`${baseUrl}/api/v1/health`)
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
const body = (await response.json()) as {
|
|
||||||
data: { status: string; timestamp: string; uptime: number; checks: Array<{ name: string; status: string }> }
|
|
||||||
meta: { fromCache: boolean; cachedAt: string | null }
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
data: {
|
|
||||||
status: expect.any(String),
|
|
||||||
timestamp: expect.any(String),
|
|
||||||
uptime: expect.any(Number),
|
|
||||||
checks: expect.arrayContaining([
|
|
||||||
expect.objectContaining({ name: 'prisma', status: expect.any(String) }),
|
|
||||||
expect.objectContaining({ name: 'moex', status: expect.any(String) }),
|
|
||||||
expect.objectContaining({ name: 'tbank', status: expect.any(String) }),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
fromCache: false,
|
|
||||||
cachedAt: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(body.data).not.toHaveProperty('data')
|
|
||||||
expect(body.data).not.toHaveProperty('meta')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -4,37 +4,8 @@ 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);
|
||||||
@ -44,28 +15,12 @@ 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());
|
||||||
app.use(cookieParser());
|
const reqLogMiddleware = new RequestLoggingMiddleware();
|
||||||
|
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
||||||
|
|
||||||
const configService = app.get(ConfigService);
|
app.enableCors();
|
||||||
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', []),
|
|
||||||
};
|
|
||||||
|
|
||||||
assertSafeProductionConfig(runtimeConfig);
|
const config = new DocumentBuilder().setTitle('MoexVibe API').setVersion('1.0.0').build();
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@ -74,7 +29,4 @@ async function bootstrap() {
|
|||||||
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
||||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||||
}
|
}
|
||||||
|
bootstrap();
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
|
||||||
void bootstrap();
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
|
||||||
const request = ctx.switchToHttp().getRequest();
|
|
||||||
return request.user;
|
|
||||||
});
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const IS_PUBLIC_KEY = 'isPublic';
|
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const ROLES_KEY = 'roles';
|
|
||||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
|
||||||
|
|
||||||
class AuthUserDto {
|
|
||||||
@ApiProperty()
|
|
||||||
id!: number;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
|
||||||
name!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
role!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthTokenDataDto {
|
|
||||||
@ApiProperty({ type: AuthUserDto })
|
|
||||||
user!: AuthUserDto;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
accessToken!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class LogoutDataDto {
|
|
||||||
@ApiProperty()
|
|
||||||
message!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthTokenResponseDto {
|
|
||||||
@ApiProperty({ type: AuthTokenDataDto })
|
|
||||||
data!: AuthTokenDataDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthProfileResponseDto {
|
|
||||||
@ApiProperty({ type: AuthUserDto })
|
|
||||||
data!: AuthUserDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthLogoutResponseDto {
|
|
||||||
@ApiProperty({ type: LogoutDataDto })
|
|
||||||
data!: LogoutDataDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export interface JwtPayload {
|
|
||||||
sub: number;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
@ -1,32 +1,26 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } 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,10 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
|
||||||
import { BondsController } from './bonds.controller';
|
import { BondsController } from './bonds.controller';
|
||||||
import { BondsService } from './bonds.service';
|
import { BondsService } from './bonds.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MoexClientModule],
|
|
||||||
controllers: [BondsController],
|
controllers: [BondsController],
|
||||||
providers: [BondsService],
|
providers: [BondsService],
|
||||||
exports: [BondsService],
|
exports: [BondsService],
|
||||||
|
|||||||
@ -1,147 +1,41 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { BondsService } from './bonds.service';
|
import { BondsService } from './bonds.service';
|
||||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
|
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,
|
||||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
MoexClientService,
|
||||||
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
|
{
|
||||||
{ provide: CacheService, useValue: cache },
|
provide: 'CACHE_MANAGER',
|
||||||
|
useValue: {
|
||||||
|
get: () => undefined,
|
||||||
|
set: () => Promise.resolve(),
|
||||||
|
del: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CacheService,
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<BondsService>(BondsService);
|
service = module.get<BondsService>(BondsService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
|
it('should be defined', () => {
|
||||||
vi.mocked(moexMarketData.getBondData).mockResolvedValue({
|
expect(service).toBeDefined();
|
||||||
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');
|
it('should return OFZ bond data for SU26207RMFS9', async () => {
|
||||||
|
const result = await service.getBond('SU26207RMFS9');
|
||||||
expect(cache.getOrFetch).toHaveBeenNthCalledWith(
|
expect(result.data.secid).toBe('SU26207RMFS9');
|
||||||
1,
|
expect(result.data.marketData).toBeDefined();
|
||||||
'bond',
|
}, 15000);
|
||||||
['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('throws EntityNotFoundException when bond data is missing', async () => {
|
|
||||||
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException);
|
|
||||||
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
|
|
||||||
expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
|
||||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondsService {
|
export class BondsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly moexMarketData: MoexMarketDataClient,
|
private readonly moexClient: MoexClientService,
|
||||||
private readonly moexHistory: MoexHistoryClient,
|
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -21,23 +17,23 @@ export class BondsService {
|
|||||||
} = await this.cache.getOrFetch(
|
} = await this.cache.getOrFetch(
|
||||||
'bond',
|
'bond',
|
||||||
[secid],
|
[secid],
|
||||||
() => this.moexMarketData.getBondData(secid),
|
() => this.moexClient.getBondData(secid),
|
||||||
'securityTtl',
|
'securityTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!bond) {
|
if (!bond) {
|
||||||
throw new EntityNotFoundException('Bond', secid);
|
throw new NotFoundException(`Bond ${secid} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: mkt } = await this.cache.getOrFetch(
|
const { data: mkt } = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['bonds', secid],
|
['bonds', secid],
|
||||||
() => this.moexMarketData.getBondMarketData(secid),
|
() => this.moexClient.getBondMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return new ApiEnvelopePayload(
|
return {
|
||||||
{
|
data: {
|
||||||
secid: bond.secid,
|
secid: bond.secid,
|
||||||
isin: bond.isin,
|
isin: bond.isin,
|
||||||
name: bond.shortName,
|
name: bond.shortName,
|
||||||
@ -74,9 +70,8 @@ export class BondsService {
|
|||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fromCache,
|
meta: { fromCache, cachedAt },
|
||||||
cachedAt,
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMarketData(secid: string) {
|
async getMarketData(secid: string) {
|
||||||
@ -87,16 +82,16 @@ export class BondsService {
|
|||||||
} = await this.cache.getOrFetch(
|
} = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['bonds', secid],
|
['bonds', secid],
|
||||||
() => this.moexMarketData.getBondMarketData(secid),
|
() => this.moexClient.getBondMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mkt) {
|
if (!mkt) {
|
||||||
throw new EntityNotFoundException('MarketData', `bond ${secid}`);
|
throw new NotFoundException(`Market data for bond ${secid} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ApiEnvelopePayload(
|
return {
|
||||||
{
|
data: {
|
||||||
price: mkt.last ?? 0,
|
price: mkt.last ?? 0,
|
||||||
yieldToMaturity: mkt.yield ?? null,
|
yieldToMaturity: mkt.yield ?? null,
|
||||||
duration: mkt.duration ?? null,
|
duration: mkt.duration ?? null,
|
||||||
@ -112,28 +107,26 @@ export class BondsService {
|
|||||||
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
fromCache,
|
meta: { fromCache, cachedAt },
|
||||||
cachedAt,
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(secid: string, from: string, till: string) {
|
async getHistory(secid: string, from: string, till: string) {
|
||||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||||
'history',
|
'history',
|
||||||
['bonds', secid, from, till],
|
['bonds', secid, from, till],
|
||||||
() => this.moexHistory.getBondHistory(secid, from, till),
|
() => this.moexClient.getBondHistory(secid, from, till),
|
||||||
'historyTtl',
|
'historyTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return new ApiEnvelopePayload(
|
return {
|
||||||
data.map((h) => ({
|
data: data.map((h) => ({
|
||||||
date: h.tradeDate,
|
date: h.tradeDate,
|
||||||
closePrice: h.legalClosePrice ?? h.close ?? 0,
|
closePrice: h.legalClosePrice ?? h.close ?? 0,
|
||||||
yieldClose: h.yieldClose ?? null,
|
yieldClose: h.yieldClose ?? null,
|
||||||
duration: h.duration ?? null,
|
duration: h.duration ?? null,
|
||||||
})),
|
})),
|
||||||
fromCache,
|
meta: { fromCache, cachedAt },
|
||||||
cachedAt,
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
|
||||||
import { BondMarketDataDto, BondResponseDto } from './bond-response.dto';
|
|
||||||
import { BondHistoryItemDto } from './history-item.dto';
|
|
||||||
|
|
||||||
export class BondEnvelopeDto {
|
|
||||||
@ApiProperty({ type: BondResponseDto })
|
|
||||||
data!: BondResponseDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BondMarketDataEnvelopeDto {
|
|
||||||
@ApiProperty({ type: BondMarketDataDto })
|
|
||||||
data!: BondMarketDataDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BondHistoryEnvelopeDto {
|
|
||||||
@ApiProperty({ type: [BondHistoryItemDto] })
|
|
||||||
data!: BondHistoryItemDto[];
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class BondHistoryItemDto {
|
|
||||||
@ApiProperty({ example: '2026-06-01' })
|
|
||||||
date!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 100.45 })
|
|
||||||
closePrice!: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 })
|
|
||||||
yieldClose!: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 })
|
|
||||||
duration!: number | null;
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { CacheService } from './cache.service';
|
|
||||||
|
|
||||||
describe('CacheService', () => {
|
|
||||||
const configService = {
|
|
||||||
get: vi.fn((_key: string, fallback?: unknown) => fallback),
|
|
||||||
} as unknown as ConfigService;
|
|
||||||
|
|
||||||
const createCache = () => ({
|
|
||||||
get: vi.fn(),
|
|
||||||
set: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores data with cachedAt metadata on cache miss', async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date('2026-06-25T10:00:00.000Z'));
|
|
||||||
const cache = createCache();
|
|
||||||
cache.get.mockResolvedValue(undefined);
|
|
||||||
const service = new CacheService(cache as never, configService);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 1 }), 'ttlKey');
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
data: { value: 1 },
|
|
||||||
fromCache: false,
|
|
||||||
cachedAt: '2026-06-25T10:00:00.000Z',
|
|
||||||
});
|
|
||||||
expect(cache.set).toHaveBeenCalledWith(
|
|
||||||
'prefix:a',
|
|
||||||
{ data: { value: 1 }, cachedAt: '2026-06-25T10:00:00.000Z' },
|
|
||||||
900,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cachedAt metadata on cache hit', async () => {
|
|
||||||
const cache = createCache();
|
|
||||||
cache.get.mockResolvedValue({
|
|
||||||
data: { value: 1 },
|
|
||||||
cachedAt: '2026-06-25T10:00:00.000Z',
|
|
||||||
});
|
|
||||||
const service = new CacheService(cache as never, configService);
|
|
||||||
|
|
||||||
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
data: { value: 1 },
|
|
||||||
fromCache: true,
|
|
||||||
cachedAt: '2026-06-25T10:00:00.000Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports legacy raw cache values during rollout', async () => {
|
|
||||||
const cache = createCache();
|
|
||||||
cache.get.mockResolvedValue({ value: 1 });
|
|
||||||
const service = new CacheService(cache as never, configService);
|
|
||||||
|
|
||||||
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
|
|
||||||
|
|
||||||
expect(result).toEqual({ data: { value: 1 }, fromCache: true, cachedAt: null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
28
apps/backend/src/modules/cache/cache.service.ts
vendored
28
apps/backend/src/modules/cache/cache.service.ts
vendored
@ -3,11 +3,6 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|||||||
import { Cache } from 'cache-manager';
|
import { Cache } from 'cache-manager';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
type CacheEntry<T> = {
|
|
||||||
data: T;
|
|
||||||
cachedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CacheService {
|
export class CacheService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -23,16 +18,6 @@ export class CacheService {
|
|||||||
await this.cacheManager.set(key, value, ttl);
|
await this.cacheManager.set(key, value, ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isCacheEntry<T>(value: unknown): value is CacheEntry<T> {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'data' in value &&
|
|
||||||
'cachedAt' in value &&
|
|
||||||
typeof (value as { cachedAt?: unknown }).cachedAt === 'string'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildKey(...parts: string[]): string {
|
private buildKey(...parts: string[]): string {
|
||||||
return parts.join(':');
|
return parts.join(':');
|
||||||
}
|
}
|
||||||
@ -46,19 +31,14 @@ export class CacheService {
|
|||||||
const key = this.buildKey(keyPrefix, ...keyParts);
|
const key = this.buildKey(keyPrefix, ...keyParts);
|
||||||
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
||||||
|
|
||||||
const cached = await this.get<CacheEntry<T> | T>(key);
|
const cached = await this.get<T>(key);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
if (this.isCacheEntry<T>(cached)) {
|
return { data: cached, fromCache: true, cachedAt: null };
|
||||||
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: cached as T, fromCache: true, cachedAt: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await fetchFn();
|
const data = await fetchFn();
|
||||||
const cachedAt = new Date().toISOString();
|
await this.set(key, data, ttl);
|
||||||
await this.set(key, { data, cachedAt }, ttl);
|
|
||||||
|
|
||||||
return { data, fromCache: false, cachedAt };
|
return { data, fromCache: false, cachedAt: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
|
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } 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,
|
||||||
@ -23,7 +19,6 @@ 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,10 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
|
||||||
import { CandlesController } from './candles.controller';
|
import { CandlesController } from './candles.controller';
|
||||||
import { CandlesService } from './candles.service';
|
import { CandlesService } from './candles.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MoexClientModule],
|
|
||||||
controllers: [CandlesController],
|
controllers: [CandlesController],
|
||||||
providers: [CandlesService],
|
providers: [CandlesService],
|
||||||
exports: [CandlesService],
|
exports: [CandlesService],
|
||||||
|
|||||||
@ -1,51 +1,40 @@
|
|||||||
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 { MoexCandlesClient } from '../moex-client/moex-candles.client';
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
|
import 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,
|
||||||
{ provide: MoexCandlesClient, useValue: moexCandles },
|
MoexClientService,
|
||||||
{ 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('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
|
it('should be defined', () => {
|
||||||
vi.mocked(moexCandles.getCandles).mockResolvedValue([
|
expect(service).toBeDefined();
|
||||||
{
|
});
|
||||||
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',
|
||||||
@ -53,63 +42,7 @@ describe('CandlesService', () => {
|
|||||||
'2026-05-01',
|
'2026-05-01',
|
||||||
'2026-06-01',
|
'2026-06-01',
|
||||||
);
|
);
|
||||||
|
expect(result.data.length).toBeGreaterThan(0);
|
||||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
expect(result.data[0].open).toBeDefined();
|
||||||
'candles',
|
}, 15000);
|
||||||
['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,13 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexCandlesClient } from '../moex-client/moex-candles.client';
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { CandleInterval } from './dto/candles-query.dto';
|
import { CandleInterval } from './dto/candles-query.dto';
|
||||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CandlesService {
|
export class CandlesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly moexCandles: MoexCandlesClient,
|
private readonly moexClient: MoexClientService,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -26,12 +25,12 @@ export class CandlesService {
|
|||||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||||
'candles',
|
'candles',
|
||||||
[market, secid, String(moexInterval), from, till],
|
[market, secid, String(moexInterval), from, till],
|
||||||
() => this.moexCandles.getCandles('stock', market, secid, moexInterval, from, till),
|
() => this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
|
||||||
'candlesTtl',
|
'candlesTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return new ApiEnvelopePayload(
|
return {
|
||||||
data.map((c) => ({
|
data: data.map((c) => ({
|
||||||
open: c.open,
|
open: c.open,
|
||||||
high: c.high,
|
high: c.high,
|
||||||
low: c.low,
|
low: c.low,
|
||||||
@ -41,8 +40,7 @@ export class CandlesService {
|
|||||||
begin: c.begin,
|
begin: c.begin,
|
||||||
end: c.end,
|
end: c.end,
|
||||||
})),
|
})),
|
||||||
fromCache,
|
meta: { fromCache, cachedAt },
|
||||||
cachedAt,
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class CandleItemDto {
|
|
||||||
@ApiProperty({ example: 321.3 })
|
|
||||||
open!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 322.66 })
|
|
||||||
high!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 321.2 })
|
|
||||||
low!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 322.35 })
|
|
||||||
close!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 1925163 })
|
|
||||||
volume!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 620184479 })
|
|
||||||
value!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ example: '2026-06-01T10:00:00' })
|
|
||||||
begin!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: '2026-06-01T10:59:00' })
|
|
||||||
end!: string;
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
|
||||||
import { CandleItemDto } from './candle-item.dto';
|
|
||||||
|
|
||||||
export class CandleEnvelopeDto {
|
|
||||||
@ApiProperty({ type: [CandleItemDto] })
|
|
||||||
data!: CandleItemDto[];
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
|
||||||
import { HealthResponseDto } from './health-response.dto';
|
|
||||||
|
|
||||||
export class HealthEnvelopeDto {
|
|
||||||
@ApiProperty({ type: HealthResponseDto })
|
|
||||||
data!: HealthResponseDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
class HealthCheckResultDto {
|
|
||||||
@ApiProperty({ example: 'prisma' })
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ enum: ['ok', 'error'] })
|
|
||||||
status!: 'ok' | 'error';
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true })
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HealthResponseDto {
|
|
||||||
@ApiProperty({ example: 'ok' })
|
|
||||||
status!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: '2026-06-23T06:00:00.000Z' })
|
|
||||||
timestamp!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 12345 })
|
|
||||||
uptime!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [HealthCheckResultDto] })
|
|
||||||
checks!: HealthCheckResultDto[];
|
|
||||||
}
|
|
||||||
@ -1,21 +1,16 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } 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: 'Проверка состояния сервиса' })
|
||||||
@ApiOkResponse({ type: HealthEnvelopeDto })
|
check() {
|
||||||
async check() {
|
return {
|
||||||
return this.healthService.check();
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { HealthService } from './health.service';
|
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [HealthService],
|
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { HealthService } from './health.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
|
|
||||||
describe('HealthService', () => {
|
|
||||||
let service: HealthService;
|
|
||||||
const prisma = { $queryRaw: vi.fn() } as any;
|
|
||||||
const config = {
|
|
||||||
get: vi.fn((key: string, fallback?: unknown) => {
|
|
||||||
const values: Record<string, unknown> = {
|
|
||||||
'app.moex.baseUrl': 'https://iss.moex.test/iss',
|
|
||||||
'app.tbank.token': 'token-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
return values[key] ?? fallback;
|
|
||||||
}),
|
|
||||||
} as unknown as ConfigService;
|
|
||||||
const fetchMock = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
fetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
HealthService,
|
|
||||||
{ provide: PrismaService, useValue: prisma },
|
|
||||||
{ provide: ConfigService, useValue: config },
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<HealthService>(HealthService);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns ok when all dependencies are healthy', async () => {
|
|
||||||
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
|
|
||||||
|
|
||||||
const result = await service.check();
|
|
||||||
|
|
||||||
expect(result.status).toBe('ok');
|
|
||||||
expect(result.checks).toHaveLength(3);
|
|
||||||
expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns degraded when prisma is down', async () => {
|
|
||||||
prisma.$queryRaw.mockRejectedValue(new Error('connection refused'));
|
|
||||||
|
|
||||||
const result = await service.check();
|
|
||||||
|
|
||||||
expect(result.status).toBe('degraded');
|
|
||||||
expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes timestamp and uptime', async () => {
|
|
||||||
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
|
|
||||||
|
|
||||||
const result = await service.check();
|
|
||||||
|
|
||||||
expect(result.timestamp).toEqual(expect.any(String));
|
|
||||||
expect(result.uptime).toEqual(expect.any(Number));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
|
|
||||||
export interface HealthCheckResult {
|
|
||||||
name: string;
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class HealthService {
|
|
||||||
private readonly logger = new Logger(HealthService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly config: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async check(): Promise<{ status: string; timestamp: string; uptime: number; checks: HealthCheckResult[] }> {
|
|
||||||
const checks = await Promise.all([
|
|
||||||
this.checkPrisma(),
|
|
||||||
this.checkMoex(),
|
|
||||||
this.checkTBank(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allOk = checks.every((c) => c.status === 'ok');
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: allOk ? 'ok' : 'degraded',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
checks,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkPrisma(): Promise<HealthCheckResult> {
|
|
||||||
try {
|
|
||||||
await this.prisma.$queryRaw`SELECT 1`;
|
|
||||||
return { name: 'prisma', status: 'ok' };
|
|
||||||
} catch {
|
|
||||||
return { name: 'prisma', status: 'error', error: 'Database unreachable' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkMoex(): Promise<HealthCheckResult> {
|
|
||||||
try {
|
|
||||||
const baseUrl = this.config.get<string>('app.moex.baseUrl', 'https://iss.moex.com/iss');
|
|
||||||
const res = await fetch(`${baseUrl}/engines/stock/quotes.json?iss.meta=off&limit=1`, {
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { name: 'moex', status: 'error', error: `HTTP ${res.status}` };
|
|
||||||
}
|
|
||||||
return { name: 'moex', status: 'ok' };
|
|
||||||
} catch (err) {
|
|
||||||
return { name: 'moex', status: 'error', error: 'MOEX API unreachable' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkTBank(): Promise<HealthCheckResult> {
|
|
||||||
try {
|
|
||||||
const token = this.config.get<string>('app.tbank.token', '');
|
|
||||||
if (!token) {
|
|
||||||
return { name: 'tbank', status: 'error', error: 'Not configured' };
|
|
||||||
}
|
|
||||||
return { name: 'tbank', status: 'ok' };
|
|
||||||
} catch {
|
|
||||||
return { name: 'tbank', status: 'error', error: 'T-Bank API unreachable' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import { MoexCandlesClient } from './moex-candles.client';
|
|
||||||
|
|
||||||
describe('MoexCandlesClient', () => {
|
|
||||||
let client: MoexCandlesClient;
|
|
||||||
let request: ReturnType<typeof vi.fn>;
|
|
||||||
let extractTable: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
request = vi.fn();
|
|
||||||
extractTable = vi.fn();
|
|
||||||
client = new MoexCandlesClient({ request, extractTable } as unknown as MoexHttpClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает свечи для заданного инструмента', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable.mockReturnValue([
|
|
||||||
{ open: '320', close: '322', high: '323', low: '319', value: '100000', volume: '3000', begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await client.getCandles('stock', 'shares', 'SBER', 60, '2025-01-10', '2025-01-11');
|
|
||||||
|
|
||||||
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER/candles', {
|
|
||||||
interval: '60', from: '2025-01-10', till: '2025-01-11',
|
|
||||||
});
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ open: 320, close: 322, high: 323, low: 319, value: 100000, volume: 3000, begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import { MoexCandle } from './moex-client.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MoexCandlesClient {
|
|
||||||
constructor(private readonly http: MoexHttpClient) {}
|
|
||||||
|
|
||||||
async getCandles(
|
|
||||||
engine: 'stock',
|
|
||||||
market: 'shares' | 'bonds',
|
|
||||||
secid: string,
|
|
||||||
interval: 1 | 10 | 60 | 24,
|
|
||||||
from: string,
|
|
||||||
till: string,
|
|
||||||
): Promise<MoexCandle[]> {
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(
|
|
||||||
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
|
|
||||||
{
|
|
||||||
interval: String(interval),
|
|
||||||
from,
|
|
||||||
till,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return this.http.extractTable(data, 'candles').map((c) => ({
|
|
||||||
open: parseFloat(c.open as string),
|
|
||||||
close: parseFloat(c.close as string),
|
|
||||||
high: parseFloat(c.high as string),
|
|
||||||
low: parseFloat(c.low as string),
|
|
||||||
value: parseFloat(c.value as string),
|
|
||||||
volume: parseInt(c.volume as string, 10),
|
|
||||||
begin: c.begin as string,
|
|
||||||
end: c.end as string,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
import { MoexClientService } from './moex-client.service';
|
||||||
import { MoexSecuritiesClient } from './moex-securities.client';
|
|
||||||
import { MoexMarketDataClient } from './moex-market-data.client';
|
|
||||||
import { MoexCandlesClient } from './moex-candles.client';
|
|
||||||
import { MoexHistoryClient } from './moex-history.client';
|
|
||||||
import { MoexDividendsClient } from './moex-dividends.client';
|
|
||||||
|
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [MoexClientService],
|
||||||
MoexHttpClient,
|
exports: [MoexClientService],
|
||||||
MoexSecuritiesClient,
|
|
||||||
MoexMarketDataClient,
|
|
||||||
MoexCandlesClient,
|
|
||||||
MoexHistoryClient,
|
|
||||||
MoexDividendsClient,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
MoexSecuritiesClient,
|
|
||||||
MoexMarketDataClient,
|
|
||||||
MoexCandlesClient,
|
|
||||||
MoexHistoryClient,
|
|
||||||
MoexDividendsClient,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class MoexClientModule {}
|
export class MoexClientModule {}
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { MoexClientModule } from './moex-client.module';
|
|
||||||
import { MoexSecuritiesClient } from './moex-securities.client';
|
|
||||||
import { MoexMarketDataClient } from './moex-market-data.client';
|
|
||||||
import configuration from '../../config/configuration';
|
|
||||||
|
|
||||||
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
|
|
||||||
'MoexClient live MOEX integration',
|
|
||||||
() => {
|
|
||||||
let moexSecurities: MoexSecuritiesClient;
|
|
||||||
let moexMarketData: MoexMarketDataClient;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient);
|
|
||||||
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
|
||||||
const results = await moexSecurities.searchSecurities('SBER');
|
|
||||||
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results[0].secid).toBeDefined();
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
it('возвращает рыночные данные SBER из live MOEX', async () => {
|
|
||||||
const data = await moexMarketData.getShareMarketData('SBER');
|
|
||||||
|
|
||||||
expect(data).toBeDefined();
|
|
||||||
expect(data!.secid).toBe('SBER');
|
|
||||||
}, 15000);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { MoexClientService } from './moex-client.service';
|
||||||
|
import configuration from '../../config/configuration';
|
||||||
|
|
||||||
|
describe('MoexClientService', () => {
|
||||||
|
let service: MoexClientService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||||
|
providers: [MoexClientService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<MoexClientService>(MoexClientService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchSecurities', () => {
|
||||||
|
it('should return results for SBER query', async () => {
|
||||||
|
const results = await service.searchSecurities('SBER');
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(results[0].secid).toBeDefined();
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getShareMarketData', () => {
|
||||||
|
it('should return market data for SBER', async () => {
|
||||||
|
const data = await service.getShareMarketData('SBER');
|
||||||
|
expect(data).toBeDefined();
|
||||||
|
expect(data!.secid).toBe('SBER');
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
});
|
||||||
314
apps/backend/src/modules/moex-client/moex-client.service.ts
Normal file
314
apps/backend/src/modules/moex-client/moex-client.service.ts
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
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,7 +20,6 @@ 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;
|
||||||
@ -38,26 +37,6 @@ 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;
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import { MoexDividendsClient } from './moex-dividends.client';
|
|
||||||
|
|
||||||
describe('MoexDividendsClient', () => {
|
|
||||||
let client: MoexDividendsClient;
|
|
||||||
let request: ReturnType<typeof vi.fn>;
|
|
||||||
let extractTable: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
request = vi.fn();
|
|
||||||
extractTable = vi.fn();
|
|
||||||
client = new MoexDividendsClient({ request, extractTable } as unknown as MoexHttpClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает дивиденды для бумаги', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable.mockReturnValue([
|
|
||||||
{ secid: 'SBER', isin: 'RU0009029540', registryclosedate: '2025-07-10', value: '33.3', currencyid: 'RUB' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await client.getDividends('SBER');
|
|
||||||
|
|
||||||
expect(request).toHaveBeenCalledWith('/securities/SBER/dividends');
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ secid: 'SBER', isin: 'RU0009029540', registryCloseDate: '2025-07-10', value: 33.3, currencyId: 'RUB' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import { MoexDividend } from './moex-client.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MoexDividendsClient {
|
|
||||||
constructor(private readonly http: MoexHttpClient) {}
|
|
||||||
|
|
||||||
async getDividends(secid: string): Promise<MoexDividend[]> {
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
|
|
||||||
return this.http.extractTable(data, 'dividends').map((d) => ({
|
|
||||||
secid: d.secid as string,
|
|
||||||
isin: d.isin as string,
|
|
||||||
registryCloseDate: d.registryclosedate as string,
|
|
||||||
value: parseFloat(d.value as string),
|
|
||||||
currencyId: (d.currencyid as string) || 'RUB',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import { MoexHistoryClient } from './moex-history.client';
|
|
||||||
|
|
||||||
describe('MoexHistoryClient', () => {
|
|
||||||
let client: MoexHistoryClient;
|
|
||||||
let request: ReturnType<typeof vi.fn>;
|
|
||||||
let extractTable: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
request = vi.fn();
|
|
||||||
extractTable = vi.fn();
|
|
||||||
client = new MoexHistoryClient({ request, extractTable } as unknown as MoexHttpClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getHistory', () => {
|
|
||||||
it('возвращает историю торгов для акции', async () => {
|
|
||||||
request.mockResolvedValue({ history: { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '322']] } });
|
|
||||||
extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '322' }]);
|
|
||||||
|
|
||||||
const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11');
|
|
||||||
|
|
||||||
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { from: '2025-01-10', till: '2025-01-11' });
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ tradeDate: '2025-01-10', open: null, low: null, high: null, close: 322, waprice: null, volume: 0, value: 0, numtrades: 0 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает пустой массив если history таблица не найдена', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
|
|
||||||
const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11');
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getBondHistory', () => {
|
|
||||||
it('возвращает историю торгов для облигации', async () => {
|
|
||||||
request.mockResolvedValue({ 'history:': { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '98.5']] } });
|
|
||||||
extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '98.5' }]);
|
|
||||||
|
|
||||||
const result = await client.getBondHistory('SU26238RMFS4', '2025-01-10', '2025-01-11');
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ tradeDate: '2025-01-10', close: 98.5, legalClosePrice: null, waprice: null, yieldClose: null, duration: null, accruedInt: null },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import { MoexHistoryEntry, MoexBondHistoryEntry } from './moex-client.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MoexHistoryClient {
|
|
||||||
constructor(private readonly http: MoexHttpClient) {}
|
|
||||||
|
|
||||||
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/shares/securities/${secid}`,
|
|
||||||
{ from, till },
|
|
||||||
);
|
|
||||||
const tableName = Object.keys(data).find(
|
|
||||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
|
||||||
);
|
|
||||||
if (!tableName) return [];
|
|
||||||
return this.http.extractTable(data, tableName).map((h) => ({
|
|
||||||
tradeDate: h.TRADEDATE as string,
|
|
||||||
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
|
|
||||||
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
|
|
||||||
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
|
|
||||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
|
||||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
|
||||||
volume: parseInt((h.VOLUME as string) || '0', 10),
|
|
||||||
value: parseFloat((h.VALUE as string) || '0'),
|
|
||||||
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
||||||
{ from, till },
|
|
||||||
);
|
|
||||||
const tableName = Object.keys(data).find(
|
|
||||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
|
||||||
);
|
|
||||||
if (!tableName) return [];
|
|
||||||
return this.http.extractTable(data, tableName).map((h) => ({
|
|
||||||
tradeDate: h.TRADEDATE as string,
|
|
||||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
|
||||||
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
|
|
||||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
|
||||||
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
|
|
||||||
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
|
|
||||||
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
|
|
||||||
vi.mock('axios', () => ({
|
|
||||||
default: {
|
|
||||||
create: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('MoexHttpClient', () => {
|
|
||||||
let client: MoexHttpClient;
|
|
||||||
let getMock: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
const mockConfig = {
|
|
||||||
get: vi.fn((key: string, fallback?: unknown) => {
|
|
||||||
const values: Record<string, unknown> = {
|
|
||||||
'app.moex.baseUrl': 'https://iss.moex.test/iss',
|
|
||||||
'app.moex.circuitBreakerThreshold': 5,
|
|
||||||
'app.moex.circuitBreakerResetSeconds': 30,
|
|
||||||
'app.moex.rateLimit': 10,
|
|
||||||
};
|
|
||||||
return values[key] ?? fallback;
|
|
||||||
}),
|
|
||||||
} as unknown as ConfigService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
getMock = vi.fn();
|
|
||||||
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
|
|
||||||
client = new MoexHttpClient(mockConfig);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('создаёт axios instance с параметрами из конфига', () => {
|
|
||||||
expect(axios.create).toHaveBeenCalledWith({
|
|
||||||
baseURL: 'https://iss.moex.test/iss',
|
|
||||||
timeout: 10000,
|
|
||||||
paramsSerializer: { indexes: null },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('request', () => {
|
|
||||||
it('выполняет GET запрос с .json суффиксом и iss.meta=off', async () => {
|
|
||||||
getMock.mockResolvedValueOnce({ data: { some: 'data' } });
|
|
||||||
|
|
||||||
const result = await client.request<{ some: string }>('/securities', { q: 'SBER' });
|
|
||||||
|
|
||||||
expect(getMock).toHaveBeenCalledWith('/securities.json', {
|
|
||||||
params: { q: 'SBER', 'iss.meta': 'off' },
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ some: 'data' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('открывает circuit breaker после заданного числа ошибок', async () => {
|
|
||||||
getMock.mockRejectedValue(new Error('Network error'));
|
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
await expect(client.request('/test')).rejects.toThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open');
|
|
||||||
expect(getMock).toHaveBeenCalledTimes(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('закрывает circuit breaker после resetMs', async () => {
|
|
||||||
getMock.mockRejectedValue(new Error('Network error'));
|
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
await expect(client.request('/test')).rejects.toThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open');
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(30000);
|
|
||||||
|
|
||||||
getMock.mockResolvedValue({ data: 'ok' });
|
|
||||||
const result = await client.request('/test');
|
|
||||||
expect(result).toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('сбрасывает errorCount при успешном запросе', async () => {
|
|
||||||
getMock
|
|
||||||
.mockRejectedValueOnce(new Error('fail'))
|
|
||||||
.mockRejectedValueOnce(new Error('fail'))
|
|
||||||
.mockResolvedValueOnce({ data: 'ok' });
|
|
||||||
|
|
||||||
await expect(client.request('/test')).rejects.toThrow('fail');
|
|
||||||
await expect(client.request('/test')).rejects.toThrow('fail');
|
|
||||||
const result = await client.request('/test');
|
|
||||||
expect(result).toBe('ok');
|
|
||||||
expect(getMock).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('extractTable', () => {
|
|
||||||
it('преобразует ISS columns/data формат в массив объектов', () => {
|
|
||||||
const data = {
|
|
||||||
securities: {
|
|
||||||
columns: ['secid', 'name'],
|
|
||||||
data: [
|
|
||||||
['SBER', 'Сбербанк'],
|
|
||||||
['VTBR', 'ВТБ'],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = client.extractTable(data as Record<string, unknown>, 'securities');
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ secid: 'SBER', name: 'Сбербанк' },
|
|
||||||
{ secid: 'VTBR', name: 'ВТБ' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает пустой массив если таблица не найдена', () => {
|
|
||||||
const result = client.extractTable({}, 'nonexistent');
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает пустой массив если нет columns', () => {
|
|
||||||
const result = client.extractTable({ securities: { data: [] } } as unknown as Record<string, unknown>, 'securities');
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import axios, { AxiosInstance } from 'axios';
|
|
||||||
import PQueue from 'p-queue';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MoexHttpClient {
|
|
||||||
private readonly logger = new Logger(MoexHttpClient.name);
|
|
||||||
private readonly client: AxiosInstance;
|
|
||||||
private readonly queue: PQueue;
|
|
||||||
private circuitOpen = false;
|
|
||||||
private circuitErrorCount = 0;
|
|
||||||
private readonly threshold: number;
|
|
||||||
private readonly resetMs: number;
|
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
|
||||||
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
|
|
||||||
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
|
|
||||||
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
|
|
||||||
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
|
|
||||||
|
|
||||||
this.client = axios.create({
|
|
||||||
baseURL: baseUrl,
|
|
||||||
timeout: 10000,
|
|
||||||
paramsSerializer: { indexes: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.queue = new PQueue({
|
|
||||||
interval: 1000,
|
|
||||||
intervalCap: rateLimit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
||||||
if (this.circuitOpen) {
|
|
||||||
throw new Error('Circuit breaker is open — MOEX requests paused');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.queue.add(async () => {
|
|
||||||
try {
|
|
||||||
const jsonPath = path + '.json';
|
|
||||||
const response = await this.client.get(jsonPath, {
|
|
||||||
params: { ...params, 'iss.meta': 'off' },
|
|
||||||
});
|
|
||||||
this.circuitErrorCount = 0;
|
|
||||||
return response.data as T;
|
|
||||||
} catch (error) {
|
|
||||||
this.circuitErrorCount++;
|
|
||||||
if (this.circuitErrorCount >= this.threshold) {
|
|
||||||
this.circuitOpen = true;
|
|
||||||
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.circuitOpen = false;
|
|
||||||
this.circuitErrorCount = 0;
|
|
||||||
this.logger.log('Circuit breaker reset');
|
|
||||||
}, this.resetMs);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}) as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
|
|
||||||
const table = data[name] as Record<string, unknown> | undefined;
|
|
||||||
if (!table || !table.columns || !table.data) return [];
|
|
||||||
const columns = table.columns as string[];
|
|
||||||
const rows = table.data as unknown[][];
|
|
||||||
return rows.map((row) => {
|
|
||||||
const obj: Record<string, unknown> = {};
|
|
||||||
columns.forEach((col, i) => {
|
|
||||||
obj[col] = row[i];
|
|
||||||
});
|
|
||||||
return obj;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import { MoexMarketDataClient } from './moex-market-data.client';
|
|
||||||
|
|
||||||
describe('MoexMarketDataClient', () => {
|
|
||||||
let client: MoexMarketDataClient;
|
|
||||||
let request: ReturnType<typeof vi.fn>;
|
|
||||||
let extractTable: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
request = vi.fn();
|
|
||||||
extractTable = vi.fn();
|
|
||||||
client = new MoexMarketDataClient({ request, extractTable } as unknown as MoexHttpClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getShareMarketData', () => {
|
|
||||||
it('возвращает рыночные данные акции из securities и marketdata таблиц', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable
|
|
||||||
.mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' }])
|
|
||||||
.mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', BID: '321', OFFER: '322', OPEN: '320', LOW: '319', HIGH: '323', LAST: '322.35', LASTCHANGE: '1.15', LASTCHANGEPRCNT: '0.36', VOLTODAY: '1925163', VALTODAY: '620184479', WAPRICE: '321.9', NUMTRADES: '12345', ISSUECAPITALIZATION: '6958336818320', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]);
|
|
||||||
|
|
||||||
const result = await client.getShareMarketData('SBER');
|
|
||||||
|
|
||||||
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { boards: 'TQBR' });
|
|
||||||
expect(result).toMatchObject({ secid: 'SBER', boardid: 'TQBR', shortName: 'Сбербанк', last: 322.35, bid: 321, offer: 322 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает null если бумага не найдена', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable.mockReturnValueOnce([]).mockReturnValueOnce([]);
|
|
||||||
|
|
||||||
const result = await client.getShareMarketData('INVALID');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getShareMarketDataBatch', () => {
|
|
||||||
it('возвращает массив рыночных данных для нескольких бумаг', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable
|
|
||||||
.mockReturnValueOnce([
|
|
||||||
{ SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' },
|
|
||||||
{ SECID: 'VTBR', BOARDID: 'TQBR', SHORTNAME: 'ВТБ', PREVPRICE: '50' },
|
|
||||||
])
|
|
||||||
.mockReturnValueOnce([
|
|
||||||
{ SECID: 'SBER', BOARDID: 'TQBR', LAST: '322', BID: '321', OFFER: '323' },
|
|
||||||
{ SECID: 'VTBR', BOARDID: 'TQBR', LAST: '50.5', BID: '50.1', OFFER: '50.8' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const results = await client.getShareMarketDataBatch(['SBER', 'VTBR']);
|
|
||||||
|
|
||||||
expect(results).toHaveLength(2);
|
|
||||||
expect(results[0].secid).toBe('SBER');
|
|
||||||
expect(results[1].secid).toBe('VTBR');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getBondData', () => {
|
|
||||||
it('возвращает данные облигации из securities таблицы', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable.mockReturnValueOnce([
|
|
||||||
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', PREVWAPRICE: '98.5', COUPONVALUE: '34.5', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await client.getBondData('SU26238RMFS4');
|
|
||||||
|
|
||||||
expect(request).toHaveBeenCalledWith('/engines/stock/markets/bonds/securities/SU26238RMFS4', { boards: 'TQCB' });
|
|
||||||
expect(result).toMatchObject({ secid: 'SU26238RMFS4', shortName: 'ОФЗ 26238' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает null если облигация не найдена', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable.mockReturnValueOnce([]);
|
|
||||||
|
|
||||||
const result = await client.getBondData('INVALID');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getBondMarketData', () => {
|
|
||||||
it('возвращает рыночные данные облигации из marketdata таблицы', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable.mockReturnValueOnce([{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', BID: '98', OFFER: '99', YIELD: '7.5', DURATION: '1500', VOLTODAY: '1000', VALTODAY: '98500', NUMTRADES: '50', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]);
|
|
||||||
|
|
||||||
const result = await client.getBondMarketData('SU26238RMFS4');
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ secid: 'SU26238RMFS4', last: 98.5, bid: 98, offer: 99, yield: 7.5 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает null если marketdata не найдена', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable.mockReturnValueOnce([]);
|
|
||||||
|
|
||||||
const result = await client.getBondMarketData('INVALID');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getBondPositionDataBatch', () => {
|
|
||||||
it('возвращает массив позиций по облигациям', async () => {
|
|
||||||
request.mockResolvedValue({});
|
|
||||||
extractTable
|
|
||||||
.mockReturnValueOnce([
|
|
||||||
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', COUPONVALUE: '34.5', COUPONPERCENT: '7', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' },
|
|
||||||
])
|
|
||||||
.mockReturnValueOnce([
|
|
||||||
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', YIELD: '7.5', DURATION: '1500', BID: '98', OFFER: '99' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const results = await client.getBondPositionDataBatch(['SU26238RMFS4']);
|
|
||||||
|
|
||||||
expect(results).toHaveLength(1);
|
|
||||||
expect(results[0].secid).toBe('SU26238RMFS4');
|
|
||||||
expect(results[0].price).toBe(98.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import {
|
|
||||||
MoexShareMarketData,
|
|
||||||
MoexBondData,
|
|
||||||
MoexBondMarketData,
|
|
||||||
MoexBondPositionData,
|
|
||||||
} from './moex-client.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MoexMarketDataClient {
|
|
||||||
constructor(private readonly http: MoexHttpClient) {}
|
|
||||||
|
|
||||||
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/shares/securities/${secid}`,
|
|
||||||
{ boards: boardId },
|
|
||||||
);
|
|
||||||
const rows = this.http.extractTable(data, 'securities');
|
|
||||||
const share = rows.find((r) => r.BOARDID === boardId);
|
|
||||||
if (!share) return null;
|
|
||||||
|
|
||||||
const mktRows = this.http.extractTable(data, 'marketdata');
|
|
||||||
const mkt = mktRows.find((r) => r.BOARDID === boardId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: boardId,
|
|
||||||
shortName: (share?.SHORTNAME as string) || '',
|
|
||||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
|
||||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
|
||||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
|
||||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
|
||||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
|
||||||
last: mkt
|
|
||||||
? parseFloat((mkt.LAST as string) || '')
|
|
||||||
: parseFloat((share.PREVPRICE as string) || ''),
|
|
||||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
|
||||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
|
||||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
|
||||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
|
||||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
|
||||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
|
||||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
|
||||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
|
||||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getShareMarketDataBatch(
|
|
||||||
secids: string[],
|
|
||||||
boardId = 'TQBR',
|
|
||||||
): Promise<MoexShareMarketData[]> {
|
|
||||||
const params: Record<string, string> = { boards: boardId };
|
|
||||||
if (secids.length > 0) {
|
|
||||||
params.securities = secids.join(',');
|
|
||||||
}
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/shares/securities`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
const securities = this.http.extractTable(data, 'securities');
|
|
||||||
const marketdata = this.http.extractTable(data, 'marketdata');
|
|
||||||
|
|
||||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
|
||||||
const filteredSecurities = secidSet
|
|
||||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
|
||||||
: securities;
|
|
||||||
|
|
||||||
return filteredSecurities.map((sec) => {
|
|
||||||
const secid = sec.SECID as string;
|
|
||||||
const mkt =
|
|
||||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
|
||||||
marketdata.find((r) => r.SECID === secid);
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: boardId,
|
|
||||||
shortName: (sec?.SHORTNAME as string) || '',
|
|
||||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
|
||||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
|
||||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
|
||||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
|
||||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
|
||||||
last: mkt
|
|
||||||
? parseFloat((mkt.LAST as string) || '')
|
|
||||||
: parseFloat((sec?.PREVPRICE as string) || ''),
|
|
||||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
|
||||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
|
||||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
|
||||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
|
||||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
|
||||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
|
||||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
|
||||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
|
||||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
||||||
{ boards: boardId },
|
|
||||||
);
|
|
||||||
const rows = this.http.extractTable(data, 'securities');
|
|
||||||
const bond =
|
|
||||||
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
|
|
||||||
rows.find((r) => r.PREVWAPRICE != null) ||
|
|
||||||
rows[0];
|
|
||||||
if (!bond) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: boardId,
|
|
||||||
shortName: (bond.SHORTNAME as string) || '',
|
|
||||||
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
|
|
||||||
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
|
|
||||||
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
|
||||||
nextCoupon: (bond.NEXTCOUPON as string) || null,
|
|
||||||
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
|
||||||
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
|
|
||||||
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
|
|
||||||
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
|
|
||||||
matDate: (bond.MATDATE as string) || '',
|
|
||||||
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
|
|
||||||
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
|
|
||||||
isin: (bond.ISIN as string) || '',
|
|
||||||
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
|
||||||
offerDate: (bond.OFFERDATE as string) || null,
|
|
||||||
buybackDate: (bond.BUYBACKDATE as string) || null,
|
|
||||||
bondType: (bond.BONDTYPE as string) || '',
|
|
||||||
bondSubType: (bond.BONDSUBTYPE as string) || '',
|
|
||||||
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
||||||
{ boards: boardId },
|
|
||||||
);
|
|
||||||
const mktRows = this.http.extractTable(data, 'marketdata');
|
|
||||||
const mkt =
|
|
||||||
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
|
|
||||||
mktRows.find((r) => r.LAST != null) ||
|
|
||||||
mktRows.find((r) => r.SECID === secid);
|
|
||||||
if (!mkt) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
|
|
||||||
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
|
||||||
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
|
|
||||||
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
|
|
||||||
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
|
|
||||||
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
|
||||||
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
|
||||||
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
|
|
||||||
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
|
|
||||||
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
|
||||||
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
|
|
||||||
value: parseFloat((mkt.VALTODAY as string) || '0'),
|
|
||||||
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
|
|
||||||
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
|
|
||||||
updateTime: (mkt.UPDATETIME as string) || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBondPositionDataBatch(
|
|
||||||
secids: string[],
|
|
||||||
boardId = 'TQCB',
|
|
||||||
): Promise<MoexBondPositionData[]> {
|
|
||||||
const params: Record<string, string> = { boards: boardId };
|
|
||||||
if (secids.length > 0) {
|
|
||||||
params.securities = secids.join(',');
|
|
||||||
}
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(
|
|
||||||
`/engines/stock/markets/bonds/securities`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
const securities = this.http.extractTable(data, 'securities');
|
|
||||||
const marketdata = this.http.extractTable(data, 'marketdata');
|
|
||||||
|
|
||||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
|
||||||
const filteredSecurities = secidSet
|
|
||||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
|
||||||
: securities;
|
|
||||||
|
|
||||||
return filteredSecurities.map((bond) => {
|
|
||||||
const secid = bond.SECID as string;
|
|
||||||
const mkt =
|
|
||||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
|
|
||||||
marketdata.find((r) => r.SECID === secid && r.LAST != null) ||
|
|
||||||
marketdata.find((r) => r.SECID === secid);
|
|
||||||
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: (bond.BOARDID as string) || boardId,
|
|
||||||
shortName: (bond?.SHORTNAME as string) || '',
|
|
||||||
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
|
||||||
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
|
||||||
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
|
||||||
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
|
||||||
couponPercent:
|
|
||||||
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
|
||||||
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
|
|
||||||
matDate: (bond?.MATDATE as string) || null,
|
|
||||||
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
|
||||||
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
|
|
||||||
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
|
|
||||||
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
|
||||||
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
|
|
||||||
bondType: (bond?.BONDTYPE as string) || null,
|
|
||||||
offerDate: (bond?.OFFERDATE as string) || null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import { MoexSecuritiesClient } from './moex-securities.client';
|
|
||||||
|
|
||||||
describe('MoexSecuritiesClient', () => {
|
|
||||||
let client: MoexSecuritiesClient;
|
|
||||||
let httpMock: { request: ReturnType<typeof vi.fn>; extractTable: ReturnType<typeof vi.fn> };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
httpMock = {
|
|
||||||
request: vi.fn(),
|
|
||||||
extractTable: vi.fn(),
|
|
||||||
};
|
|
||||||
client = new MoexSecuritiesClient(httpMock as unknown as MoexHttpClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('searchSecurities', () => {
|
|
||||||
it('выполняет поиск по запросу и нормализует результаты', async () => {
|
|
||||||
httpMock.request.mockResolvedValue({});
|
|
||||||
httpMock.extractTable.mockReturnValue([
|
|
||||||
{
|
|
||||||
secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао',
|
|
||||||
shortName: 'Сбербанк', latName: 'Sberbank', listLevel: '1', issuesize: '21586948000',
|
|
||||||
facevalue: '3', faceunit: 'SUR', issuedate: '2007-07-20', typename: 'Акция обыкновенная',
|
|
||||||
group: 'stock_shares', type: 'common_share', isqualifiedinvestors: '0',
|
|
||||||
morningsession: '1', eveningsession: '1',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const results = await client.searchSecurities('SBER');
|
|
||||||
|
|
||||||
expect(httpMock.request).toHaveBeenCalledWith('/securities', { q: 'SBER' });
|
|
||||||
expect(results).toEqual([
|
|
||||||
{
|
|
||||||
secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао',
|
|
||||||
shortName: 'Сбербанк', latName: 'Sberbank', listLevel: 1, issueSize: 21586948000,
|
|
||||||
faceValue: 3, faceUnit: 'SUR', issueDate: '2007-07-20', typeName: 'Акция обыкновенная',
|
|
||||||
group: 'stock_shares', type: 'common_share', isQualifiedInvestors: false,
|
|
||||||
morningSession: true, eveningSession: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getSecurityDescription', () => {
|
|
||||||
it('возвращает описание бумаги из description таблицы', async () => {
|
|
||||||
httpMock.request.mockResolvedValue({});
|
|
||||||
httpMock.extractTable.mockReturnValue([
|
|
||||||
{ name: 'ISIN', value: 'RU0009029540' },
|
|
||||||
{ name: 'SHORTNAME', value: 'Сбербанк' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await client.getSecurityDescription('SBER');
|
|
||||||
|
|
||||||
expect(httpMock.request).toHaveBeenCalledWith('/securities/SBER');
|
|
||||||
expect(result).toMatchObject({ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбербанк' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('возвращает null если description пуст', async () => {
|
|
||||||
httpMock.request.mockResolvedValue({});
|
|
||||||
httpMock.extractTable.mockReturnValue([]);
|
|
||||||
|
|
||||||
const result = await client.getSecurityDescription('INVALID');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { MoexHttpClient } from './moex-http.client';
|
|
||||||
import { MoexSecurityDescription } from './moex-client.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MoexSecuritiesClient {
|
|
||||||
constructor(private readonly http: MoexHttpClient) {}
|
|
||||||
|
|
||||||
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
|
|
||||||
const data = await this.http.request<Record<string, unknown>>('/securities', { q: query });
|
|
||||||
return this.http.extractTable(data, 'securities').map((s) => ({
|
|
||||||
secid: s.secid as string,
|
|
||||||
isin: s.isin as string,
|
|
||||||
name: s.name as string,
|
|
||||||
shortName: s.shortName as string,
|
|
||||||
latName: (s.latName as string) || null,
|
|
||||||
listLevel: parseInt(s.listLevel as string, 10) || 0,
|
|
||||||
issueSize: parseInt(s.issuesize as string, 10) || 0,
|
|
||||||
faceValue: parseFloat(s.facevalue as string) || 0,
|
|
||||||
faceUnit: (s.faceunit as string) || '',
|
|
||||||
issueDate: (s.issuedate as string) || '',
|
|
||||||
typeName: (s.typename as string) || '',
|
|
||||||
group: (s.group as string) || '',
|
|
||||||
type: (s.type as string) || '',
|
|
||||||
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
|
|
||||||
morningSession: (s.morningsession as string) === '1',
|
|
||||||
eveningSession: (s.eveningsession as string) === '1',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
|
|
||||||
const data = await this.http.request<Record<string, unknown>>(`/securities/${secid}`);
|
|
||||||
const rows = this.http.extractTable(data, 'description');
|
|
||||||
if (rows.length === 0) return null;
|
|
||||||
const map = new Map(rows.map((r) => [r.name, r.value]));
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
isin: (map.get('ISIN') as string) || '',
|
|
||||||
name: (map.get('NAME') as string) || '',
|
|
||||||
shortName: (map.get('SHORTNAME') as string) || '',
|
|
||||||
latName: (map.get('LATNAME') as string) || null,
|
|
||||||
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
|
|
||||||
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
|
|
||||||
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
|
|
||||||
faceUnit: (map.get('FACEUNIT') as string) || '',
|
|
||||||
issueDate: (map.get('ISSUEDATE') as string) || '',
|
|
||||||
typeName: (map.get('TYPENAME') as string) || '',
|
|
||||||
group: (map.get('GROUP') as string) || '',
|
|
||||||
type: (map.get('TYPE') as string) || '',
|
|
||||||
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
|
|
||||||
morningSession: (map.get('MORNINGSESSION') as string) === '1',
|
|
||||||
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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[];
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { PositionWithPriceDto } from './position-with-price.dto';
|
|
||||||
|
|
||||||
export class PortfolioSummaryDto {
|
|
||||||
@ApiProperty() totalInvested!: number;
|
|
||||||
@ApiProperty() totalValue!: number;
|
|
||||||
@ApiProperty() totalPnl!: number;
|
|
||||||
@ApiProperty({ type: Number, nullable: true }) totalPnlPercent!: number | null;
|
|
||||||
@ApiProperty() totalDividends!: number;
|
|
||||||
@ApiProperty() totalReturn!: number;
|
|
||||||
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
|
||||||
@ApiProperty() positionCount!: number;
|
|
||||||
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
|
|
||||||
|
|
||||||
@ApiProperty({ type: Number, nullable: true })
|
|
||||||
targetSharesPercent?: number | null;
|
|
||||||
|
|
||||||
@ApiProperty({ type: Number, nullable: true })
|
|
||||||
targetBondsPercent?: number | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
actualSharesPercent!: number;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
actualBondsPercent!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ type: Number, nullable: true })
|
|
||||||
sharesDeviation?: number | null;
|
|
||||||
|
|
||||||
@ApiProperty({ type: Number, nullable: true })
|
|
||||||
bondsDeviation?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AnalyticsResponseDto {
|
|
||||||
@ApiProperty({ type: [PositionWithPriceDto] }) positions!: PositionWithPriceDto[];
|
|
||||||
@ApiProperty() summary!: PortfolioSummaryDto;
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
|
|
||||||
import { AnalyticsResponseDto } from './analytics-response.dto';
|
|
||||||
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
|
|
||||||
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
|
|
||||||
import { PositionResponseDto } from './position-response.dto';
|
|
||||||
|
|
||||||
export class PortfolioListEnvelopeDto {
|
|
||||||
@ApiProperty({ type: [PortfolioListResponseDto] })
|
|
||||||
data!: PortfolioListResponseDto[];
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PortfolioEnvelopeDto {
|
|
||||||
@ApiProperty({ type: PortfolioResponseDto })
|
|
||||||
data!: PortfolioResponseDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PortfolioDetailEnvelopeDto {
|
|
||||||
@ApiProperty({ type: PortfolioDetailResponseDto })
|
|
||||||
data!: PortfolioDetailResponseDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PositionEnvelopeDto {
|
|
||||||
@ApiProperty({ type: PositionResponseDto })
|
|
||||||
data!: PositionResponseDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AnalyticsEnvelopeDto {
|
|
||||||
@ApiProperty({ type: AnalyticsResponseDto })
|
|
||||||
data!: AnalyticsResponseDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class PositionWithPriceDto {
|
|
||||||
@ApiProperty()
|
|
||||||
id!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 'SBER' })
|
|
||||||
secid!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true })
|
|
||||||
shortName!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 'share', enum: ['share', 'bond'] })
|
|
||||||
type!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 10 })
|
|
||||||
quantity!: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
buyPrice!: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true })
|
|
||||||
buyDate!: string | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true })
|
|
||||||
notes!: string | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, isArray: true, nullable: true })
|
|
||||||
tags!: string[] | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
currentPrice!: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
totalCost!: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
currentValue!: number | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
weightPercent!: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
pnl!: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
pnlPercent!: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
dividendIncome!: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
totalReturn!: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
totalReturnPercent!: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
change?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
changePercent?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
yieldToMaturity?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
duration?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
couponValue?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
couponPercent?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true })
|
|
||||||
nextCouponDate?: string | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true })
|
|
||||||
matDate?: string | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
accruedInt?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
bid?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
offer?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
|
||||||
couponPeriod?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true })
|
|
||||||
bondType?: string | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String, nullable: true })
|
|
||||||
offerDate?: string | null;
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { plainToInstance } from 'class-transformer';
|
|
||||||
import { validate } from 'class-validator';
|
|
||||||
import { AddPositionDto } from './add-position.dto';
|
|
||||||
import { UpdatePositionDto } from './update-position.dto';
|
|
||||||
|
|
||||||
describe('position DTO validation', () => {
|
|
||||||
const validateDto = async <T extends object>(cls: new () => T, payload: Record<string, unknown>) =>
|
|
||||||
validate(plainToInstance(cls, payload));
|
|
||||||
|
|
||||||
it('rejects zero quantity when adding a position', async () => {
|
|
||||||
const errors = await validateDto(AddPositionDto, { secid: 'SBER', quantity: 0 });
|
|
||||||
|
|
||||||
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects zero quantity when updating a position', async () => {
|
|
||||||
const errors = await validateDto(UpdatePositionDto, { quantity: 0 });
|
|
||||||
|
|
||||||
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid buyDate values', async () => {
|
|
||||||
const addErrors = await validateDto(AddPositionDto, {
|
|
||||||
secid: 'SBER',
|
|
||||||
quantity: 1,
|
|
||||||
buyDate: 'not-a-date',
|
|
||||||
});
|
|
||||||
const updateErrors = await validateDto(UpdatePositionDto, { buyDate: 'not-a-date' });
|
|
||||||
|
|
||||||
expect(addErrors.some((error) => error.property === 'buyDate')).toBe(true);
|
|
||||||
expect(updateErrors.some((error) => error.property === 'buyDate')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts valid position payloads', async () => {
|
|
||||||
await expect(
|
|
||||||
validateDto(AddPositionDto, { secid: 'SBER', quantity: 1, buyDate: '2026-06-01' }),
|
|
||||||
).resolves.toHaveLength(0);
|
|
||||||
await expect(
|
|
||||||
validateDto(UpdatePositionDto, { quantity: 2, buyDate: '2026-06-15' }),
|
|
||||||
).resolves.toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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