From b147498c311dfabe56971d5c3c502936cce9df34 Mon Sep 17 00:00:00 2001 From: shuim Date: Fri, 14 Nov 2025 11:05:31 +0900 Subject: [PATCH] Initial commit --- .env.example | 45 + .github/workflows/ci-cd.yml | 72 + .gitignore | 62 + .venv/bin/Activate.ps1 | 247 + .venv/bin/activate | 76 + .venv/bin/activate.csh | 27 + .venv/bin/activate.fish | 69 + .venv/bin/alembic | 7 + .venv/bin/celery | 7 + .venv/bin/distro | 7 + .venv/bin/dotenv | 7 + .venv/bin/f2py | 7 + .venv/bin/fastapi | 7 + .venv/bin/hf | 7 + .venv/bin/httpx | 7 + .venv/bin/mako-render | 7 + .venv/bin/numpy-config | 7 + .venv/bin/pip | 7 + .venv/bin/pip3 | 7 + .venv/bin/pip3.12 | 7 + .venv/bin/py.test | 7 + .venv/bin/pyrsa-decrypt | 7 + .venv/bin/pyrsa-encrypt | 7 + .venv/bin/pyrsa-keygen | 7 + .venv/bin/pyrsa-priv2pub | 7 + .venv/bin/pyrsa-sign | 7 + .venv/bin/pyrsa-verify | 7 + .venv/bin/pytest | 7 + .venv/bin/python | 1 + .venv/bin/python3 | 1 + .venv/bin/python3.12 | 1 + .venv/bin/tiny-agents | 7 + .venv/bin/tqdm | 7 + .venv/bin/uvicorn | 7 + .venv/bin/watchfiles | 7 + .../site/python3.12/greenlet/greenlet.h | 164 + .venv/pyvenv.cfg | 5 + API_DOCS.md | 308 + API_SAFE_OPERATION.md | 285 + BATCH_GUIDE.md | 101 + INSTALLATION.md | 149 + Makefile | 77 + PROJECT_STRUCTURE.md | 266 + README.md | 59 + USER_GUIDE.md | 211 + backend/.env.example | 57 + backend/Dockerfile | 22 + backend/README.md | 42 + backend/app/api/__init__.py | 12 + backend/app/api/endpoints/market_data.py | 272 + backend/app/api/endpoints/mcp.py | 118 + backend/app/api/endpoints/monitoring.py | 69 + backend/app/api/endpoints/portfolio.py | 228 + backend/app/api/endpoints/recommendations.py | 1378 +++++ backend/app/api/endpoints/trades.py | 118 + backend/app/api/endpoints/trading_rules.py | 143 + backend/app/core/config.py | 97 + backend/app/core/database.py | 38 + backend/app/main.py | 124 + backend/app/models/models.py | 141 + backend/app/services/korea_investment_api.py | 456 ++ backend/app/services/websocket_manager.py | 79 + backend/batch_daily_analysis.py | 238 + backend/insert_test_data.py | 57 + backend/load_trading_rules.py | 101 + backend/register_rules.sh | 81 + backend/requirements.txt | 24 + backend/test_api_safety.py | 160 + backend/test_db_connection.py | 69 + backend/trading_rules_config.json | 137 + docker-compose.dev.yml | 50 + docker-compose.yml | 148 + docker/init-db.sql | 184 + ecosystem.config.js | 21 + frontend/.env.example | 13 + frontend/Dockerfile | 29 + frontend/index.html | 13 + frontend/nginx.conf | 40 + frontend/package-lock.json | 5216 +++++++++++++++++ frontend/package.json | 40 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 18 + frontend/src/components/CandlestickChart.tsx | 434 ++ frontend/src/components/HtsHeader.css | 123 + frontend/src/components/HtsHeader.tsx | 101 + frontend/src/components/HtsPage.css | 19 + frontend/src/components/HtsPage.tsx | 23 + frontend/src/components/Layout.tsx | 66 + frontend/src/config.ts | 8 + frontend/src/hooks/useWebSocket.ts | 54 + frontend/src/index.css | 63 + frontend/src/main.tsx | 25 + frontend/src/pages/Dashboard.css | 660 +++ frontend/src/pages/Dashboard.tsx | 922 +++ frontend/src/pages/MCPStatus.tsx | 176 + frontend/src/pages/TradeHistory.tsx | 163 + frontend/src/pages/TradingRules.css | 370 ++ frontend/src/pages/TradingRules.tsx | 286 + frontend/src/vite-env.d.ts | 10 + frontend/tailwind.config.js | 20 + frontend/tsconfig.json | 35 + frontend/tsconfig.node.json | 12 + frontend/vite.config.ts | 29 + init_db.py | 44 + mcp-server/.env.example | 51 + mcp-server/Dockerfile | 16 + mcp-server/claude_client.py | 287 + mcp-server/config.py | 37 + mcp-server/main.py | 186 + mcp-server/mcp_tools.py | 295 + mcp-server/perplexity_client.py | 333 ++ mcp-server/requirements.txt | 12 + restart-all.sh | 21 + run-batch.sh | 17 + scripts/backup.sh | 26 + scripts/deploy.sh | 118 + scripts/dev.sh | 66 + scripts/logs.sh | 23 + scripts/restart.sh | 9 + scripts/stop.sh | 13 + start-all.sh | 145 + status.sh | 75 + stop-all.sh | 100 + view-logs.sh | 70 + 124 files changed, 17983 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci-cd.yml create mode 100644 .gitignore create mode 100644 .venv/bin/Activate.ps1 create mode 100644 .venv/bin/activate create mode 100644 .venv/bin/activate.csh create mode 100644 .venv/bin/activate.fish create mode 100755 .venv/bin/alembic create mode 100755 .venv/bin/celery create mode 100755 .venv/bin/distro create mode 100755 .venv/bin/dotenv create mode 100755 .venv/bin/f2py create mode 100755 .venv/bin/fastapi create mode 100755 .venv/bin/hf create mode 100755 .venv/bin/httpx create mode 100755 .venv/bin/mako-render create mode 100755 .venv/bin/numpy-config create mode 100755 .venv/bin/pip create mode 100755 .venv/bin/pip3 create mode 100755 .venv/bin/pip3.12 create mode 100755 .venv/bin/py.test create mode 100755 .venv/bin/pyrsa-decrypt create mode 100755 .venv/bin/pyrsa-encrypt create mode 100755 .venv/bin/pyrsa-keygen create mode 100755 .venv/bin/pyrsa-priv2pub create mode 100755 .venv/bin/pyrsa-sign create mode 100755 .venv/bin/pyrsa-verify create mode 100755 .venv/bin/pytest create mode 120000 .venv/bin/python create mode 120000 .venv/bin/python3 create mode 120000 .venv/bin/python3.12 create mode 100755 .venv/bin/tiny-agents create mode 100755 .venv/bin/tqdm create mode 100755 .venv/bin/uvicorn create mode 100755 .venv/bin/watchfiles create mode 100644 .venv/include/site/python3.12/greenlet/greenlet.h create mode 100644 .venv/pyvenv.cfg create mode 100644 API_DOCS.md create mode 100644 API_SAFE_OPERATION.md create mode 100644 BATCH_GUIDE.md create mode 100644 INSTALLATION.md create mode 100644 Makefile create mode 100644 PROJECT_STRUCTURE.md create mode 100644 README.md create mode 100644 USER_GUIDE.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/endpoints/market_data.py create mode 100644 backend/app/api/endpoints/mcp.py create mode 100644 backend/app/api/endpoints/monitoring.py create mode 100644 backend/app/api/endpoints/portfolio.py create mode 100644 backend/app/api/endpoints/recommendations.py create mode 100644 backend/app/api/endpoints/trades.py create mode 100644 backend/app/api/endpoints/trading_rules.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/models.py create mode 100644 backend/app/services/korea_investment_api.py create mode 100644 backend/app/services/websocket_manager.py create mode 100644 backend/batch_daily_analysis.py create mode 100644 backend/insert_test_data.py create mode 100644 backend/load_trading_rules.py create mode 100755 backend/register_rules.sh create mode 100644 backend/requirements.txt create mode 100755 backend/test_api_safety.py create mode 100644 backend/test_db_connection.py create mode 100644 backend/trading_rules_config.json create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 docker/init-db.sql create mode 100644 ecosystem.config.js create mode 100644 frontend/.env.example create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/CandlestickChart.tsx create mode 100644 frontend/src/components/HtsHeader.css create mode 100644 frontend/src/components/HtsHeader.tsx create mode 100644 frontend/src/components/HtsPage.css create mode 100644 frontend/src/components/HtsPage.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/hooks/useWebSocket.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Dashboard.css create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/MCPStatus.tsx create mode 100644 frontend/src/pages/TradeHistory.tsx create mode 100644 frontend/src/pages/TradingRules.css create mode 100644 frontend/src/pages/TradingRules.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 init_db.py create mode 100644 mcp-server/.env.example create mode 100644 mcp-server/Dockerfile create mode 100644 mcp-server/claude_client.py create mode 100644 mcp-server/config.py create mode 100644 mcp-server/main.py create mode 100644 mcp-server/mcp_tools.py create mode 100644 mcp-server/perplexity_client.py create mode 100644 mcp-server/requirements.txt create mode 100755 restart-all.sh create mode 100755 run-batch.sh create mode 100755 scripts/backup.sh create mode 100755 scripts/deploy.sh create mode 100755 scripts/dev.sh create mode 100755 scripts/logs.sh create mode 100755 scripts/restart.sh create mode 100755 scripts/stop.sh create mode 100755 start-all.sh create mode 100755 status.sh create mode 100755 stop-all.sh create mode 100755 view-logs.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..304e078 --- /dev/null +++ b/.env.example @@ -0,0 +1,45 @@ +# 한국투자증권 API +KOREA_INVESTMENT_APP_KEY=your_app_key_here +KOREA_INVESTMENT_APP_SECRET=your_app_secret_here +KOREA_INVESTMENT_ACCOUNT_NUMBER=your_account_number +KOREA_INVESTMENT_API_BASE_URL=https://openapi.koreainvestment.com:9443 + +# Claude AI +ANTHROPIC_API_KEY=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 + +# Database +POSTGRES_USER=autostock +POSTGRES_PASSWORD=autostock_password +POSTGRES_DB=autostock_db +DATABASE_URL=postgresql://autostock:autostock_password@postgres:5432/autostock_db + +# Redis +REDIS_URL=redis://redis:6379/0 + +# RabbitMQ +RABBITMQ_DEFAULT_USER=autostock +RABBITMQ_DEFAULT_PASS=autostock_password +RABBITMQ_URL=amqp://autostock:autostock_password@rabbitmq:5672/ + +# Backend +BACKEND_HOST=0.0.0.0 +BACKEND_PORT=8000 +SECRET_KEY=your_secret_key_change_this_in_production + +# Frontend +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000/ws + +# MCP Server +MCP_SERVER_HOST=0.0.0.0 +MCP_SERVER_PORT=8001 + +# News API (선택) +NEWS_API_KEY=your_news_api_key_here + +# 환경 +ENVIRONMENT=development +DEBUG=true + +# 로그 레벨 +LOG_LEVEL=INFO diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..e372f5c --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,72 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test-backend: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + cd backend + pip install -r requirements.txt + + - name: Run tests + run: | + cd backend + pytest + + test-frontend: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install dependencies + run: | + cd frontend + npm install + + - name: Build + run: | + cd frontend + npm run build + + build-and-push: + needs: [test-backend, test-frontend] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push + run: | + docker-compose build + docker-compose push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..155dfb7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Environment +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +venv/ +env/ +ENV/ + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ +*.log + +# Docker +docker-compose.override.yml + +# Test +.pytest_cache/ +.coverage +htmlcov/ diff --git a/.venv/bin/Activate.ps1 b/.venv/bin/Activate.ps1 new file mode 100644 index 0000000..eeea358 --- /dev/null +++ b/.venv/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/.venv/bin/activate b/.venv/bin/activate new file mode 100644 index 0000000..f05bf13 --- /dev/null +++ b/.venv/bin/activate @@ -0,0 +1,76 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past locations. Without forgetting + # past locations the $PATH changes we made may not be respected. + # See "man bash" for more details. hash is usually a builtin of your shell + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +case "$(uname)" in + CYGWIN*|MSYS*|MINGW*) + # transform D:\path\to\venv to /d/path/to/venv on MSYS and MINGW + # and to /cygdrive/d/path/to/venv on Cygwin + VIRTUAL_ENV=$(cygpath /Users/commylife/myproject/java/workspace/auto-stock/.venv) + export VIRTUAL_ENV + ;; + *) + # use the path as-is + export VIRTUAL_ENV=/Users/commylife/myproject/java/workspace/auto-stock/.venv + ;; +esac + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/"bin":$PATH" +export PATH + +VIRTUAL_ENV_PROMPT='(.venv) ' +export VIRTUAL_ENV_PROMPT + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1="("'(.venv) '") ${PS1:-}" + export PS1 +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/.venv/bin/activate.csh b/.venv/bin/activate.csh new file mode 100644 index 0000000..f0659bb --- /dev/null +++ b/.venv/bin/activate.csh @@ -0,0 +1,27 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. + +# Created by Davide Di Blasi . +# Ported to Python 3.3 venv by Andrew Svetlov + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV /Users/commylife/myproject/java/workspace/auto-stock/.venv + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/"bin":$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = '(.venv) '"$prompt" + setenv VIRTUAL_ENV_PROMPT '(.venv) ' +endif + +alias pydoc python -m pydoc + +rehash diff --git a/.venv/bin/activate.fish b/.venv/bin/activate.fish new file mode 100644 index 0000000..fc0d662 --- /dev/null +++ b/.venv/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /bin/activate.fish" *from fish* +# (https://fishshell.com/). You cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV /Users/commylife/myproject/java/workspace/auto-stock/.venv + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/"bin $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) '(.venv) ' (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT '(.venv) ' +end diff --git a/.venv/bin/alembic b/.venv/bin/alembic new file mode 100755 index 0000000..9b57f07 --- /dev/null +++ b/.venv/bin/alembic @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from alembic.config import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/celery b/.venv/bin/celery new file mode 100755 index 0000000..bc33c8d --- /dev/null +++ b/.venv/bin/celery @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from celery.__main__ import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/distro b/.venv/bin/distro new file mode 100755 index 0000000..5b8e7c3 --- /dev/null +++ b/.venv/bin/distro @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from distro.distro import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/dotenv b/.venv/bin/dotenv new file mode 100755 index 0000000..1b5c2fd --- /dev/null +++ b/.venv/bin/dotenv @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from dotenv.__main__ import cli +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(cli()) diff --git a/.venv/bin/f2py b/.venv/bin/f2py new file mode 100755 index 0000000..3bcebfe --- /dev/null +++ b/.venv/bin/f2py @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from numpy.f2py.f2py2e import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/fastapi b/.venv/bin/fastapi new file mode 100755 index 0000000..3e80dda --- /dev/null +++ b/.venv/bin/fastapi @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from fastapi.cli import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/hf b/.venv/bin/hf new file mode 100755 index 0000000..97a1b8c --- /dev/null +++ b/.venv/bin/hf @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from huggingface_hub.cli.hf import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/httpx b/.venv/bin/httpx new file mode 100755 index 0000000..d1c595e --- /dev/null +++ b/.venv/bin/httpx @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from httpx import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/mako-render b/.venv/bin/mako-render new file mode 100755 index 0000000..902d945 --- /dev/null +++ b/.venv/bin/mako-render @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from mako.cmd import cmdline +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(cmdline()) diff --git a/.venv/bin/numpy-config b/.venv/bin/numpy-config new file mode 100755 index 0000000..309020f --- /dev/null +++ b/.venv/bin/numpy-config @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from numpy._configtool import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/pip b/.venv/bin/pip new file mode 100755 index 0000000..db24dac --- /dev/null +++ b/.venv/bin/pip @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/pip3 b/.venv/bin/pip3 new file mode 100755 index 0000000..db24dac --- /dev/null +++ b/.venv/bin/pip3 @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/pip3.12 b/.venv/bin/pip3.12 new file mode 100755 index 0000000..db24dac --- /dev/null +++ b/.venv/bin/pip3.12 @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/py.test b/.venv/bin/py.test new file mode 100755 index 0000000..59990d3 --- /dev/null +++ b/.venv/bin/py.test @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from pytest import console_main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(console_main()) diff --git a/.venv/bin/pyrsa-decrypt b/.venv/bin/pyrsa-decrypt new file mode 100755 index 0000000..9e7938b --- /dev/null +++ b/.venv/bin/pyrsa-decrypt @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from rsa.cli import decrypt +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(decrypt()) diff --git a/.venv/bin/pyrsa-encrypt b/.venv/bin/pyrsa-encrypt new file mode 100755 index 0000000..e774cd7 --- /dev/null +++ b/.venv/bin/pyrsa-encrypt @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from rsa.cli import encrypt +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(encrypt()) diff --git a/.venv/bin/pyrsa-keygen b/.venv/bin/pyrsa-keygen new file mode 100755 index 0000000..734a024 --- /dev/null +++ b/.venv/bin/pyrsa-keygen @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from rsa.cli import keygen +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(keygen()) diff --git a/.venv/bin/pyrsa-priv2pub b/.venv/bin/pyrsa-priv2pub new file mode 100755 index 0000000..bd7f8f0 --- /dev/null +++ b/.venv/bin/pyrsa-priv2pub @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from rsa.util import private_to_public +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(private_to_public()) diff --git a/.venv/bin/pyrsa-sign b/.venv/bin/pyrsa-sign new file mode 100755 index 0000000..851f602 --- /dev/null +++ b/.venv/bin/pyrsa-sign @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from rsa.cli import sign +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(sign()) diff --git a/.venv/bin/pyrsa-verify b/.venv/bin/pyrsa-verify new file mode 100755 index 0000000..10f897b --- /dev/null +++ b/.venv/bin/pyrsa-verify @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from rsa.cli import verify +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(verify()) diff --git a/.venv/bin/pytest b/.venv/bin/pytest new file mode 100755 index 0000000..59990d3 --- /dev/null +++ b/.venv/bin/pytest @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from pytest import console_main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(console_main()) diff --git a/.venv/bin/python b/.venv/bin/python new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/.venv/bin/python @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/.venv/bin/python3 b/.venv/bin/python3 new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/.venv/bin/python3 @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/.venv/bin/python3.12 b/.venv/bin/python3.12 new file mode 120000 index 0000000..a3f0508 --- /dev/null +++ b/.venv/bin/python3.12 @@ -0,0 +1 @@ +/opt/homebrew/opt/python@3.12/bin/python3.12 \ No newline at end of file diff --git a/.venv/bin/tiny-agents b/.venv/bin/tiny-agents new file mode 100755 index 0000000..9946aac --- /dev/null +++ b/.venv/bin/tiny-agents @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from huggingface_hub.inference._mcp.cli import app +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(app()) diff --git a/.venv/bin/tqdm b/.venv/bin/tqdm new file mode 100755 index 0000000..a01cc1b --- /dev/null +++ b/.venv/bin/tqdm @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from tqdm.cli import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/uvicorn b/.venv/bin/uvicorn new file mode 100755 index 0000000..6cf8c0c --- /dev/null +++ b/.venv/bin/uvicorn @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from uvicorn.main import main +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) diff --git a/.venv/bin/watchfiles b/.venv/bin/watchfiles new file mode 100755 index 0000000..c93041f --- /dev/null +++ b/.venv/bin/watchfiles @@ -0,0 +1,7 @@ +#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 +import sys +from watchfiles.cli import cli +if __name__ == '__main__': + if sys.argv[0].endswith('.exe'): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(cli()) diff --git a/.venv/include/site/python3.12/greenlet/greenlet.h b/.venv/include/site/python3.12/greenlet/greenlet.h new file mode 100644 index 0000000..d02a16e --- /dev/null +++ b/.venv/include/site/python3.12/greenlet/greenlet.h @@ -0,0 +1,164 @@ +/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */ + +/* Greenlet object interface */ + +#ifndef Py_GREENLETOBJECT_H +#define Py_GREENLETOBJECT_H + + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* This is deprecated and undocumented. It does not change. */ +#define GREENLET_VERSION "1.0.0" + +#ifndef GREENLET_MODULE +#define implementation_ptr_t void* +#endif + +typedef struct _greenlet { + PyObject_HEAD + PyObject* weakreflist; + PyObject* dict; + implementation_ptr_t pimpl; +} PyGreenlet; + +#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type)) + + +/* C API functions */ + +/* Total number of symbols that are exported */ +#define PyGreenlet_API_pointers 12 + +#define PyGreenlet_Type_NUM 0 +#define PyExc_GreenletError_NUM 1 +#define PyExc_GreenletExit_NUM 2 + +#define PyGreenlet_New_NUM 3 +#define PyGreenlet_GetCurrent_NUM 4 +#define PyGreenlet_Throw_NUM 5 +#define PyGreenlet_Switch_NUM 6 +#define PyGreenlet_SetParent_NUM 7 + +#define PyGreenlet_MAIN_NUM 8 +#define PyGreenlet_STARTED_NUM 9 +#define PyGreenlet_ACTIVE_NUM 10 +#define PyGreenlet_GET_PARENT_NUM 11 + +#ifndef GREENLET_MODULE +/* This section is used by modules that uses the greenlet C API */ +static void** _PyGreenlet_API = NULL; + +# define PyGreenlet_Type \ + (*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM]) + +# define PyExc_GreenletError \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM]) + +# define PyExc_GreenletExit \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM]) + +/* + * PyGreenlet_New(PyObject *args) + * + * greenlet.greenlet(run, parent=None) + */ +# define PyGreenlet_New \ + (*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \ + _PyGreenlet_API[PyGreenlet_New_NUM]) + +/* + * PyGreenlet_GetCurrent(void) + * + * greenlet.getcurrent() + */ +# define PyGreenlet_GetCurrent \ + (*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM]) + +/* + * PyGreenlet_Throw( + * PyGreenlet *greenlet, + * PyObject *typ, + * PyObject *val, + * PyObject *tb) + * + * g.throw(...) + */ +# define PyGreenlet_Throw \ + (*(PyObject * (*)(PyGreenlet * self, \ + PyObject * typ, \ + PyObject * val, \ + PyObject * tb)) \ + _PyGreenlet_API[PyGreenlet_Throw_NUM]) + +/* + * PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args) + * + * g.switch(*args, **kwargs) + */ +# define PyGreenlet_Switch \ + (*(PyObject * \ + (*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \ + _PyGreenlet_API[PyGreenlet_Switch_NUM]) + +/* + * PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent) + * + * g.parent = new_parent + */ +# define PyGreenlet_SetParent \ + (*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \ + _PyGreenlet_API[PyGreenlet_SetParent_NUM]) + +/* + * PyGreenlet_GetParent(PyObject* greenlet) + * + * return greenlet.parent; + * + * This could return NULL even if there is no exception active. + * If it does not return NULL, you are responsible for decrementing the + * reference count. + */ +# define PyGreenlet_GetParent \ + (*(PyGreenlet* (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_GET_PARENT_NUM]) + +/* + * deprecated, undocumented alias. + */ +# define PyGreenlet_GET_PARENT PyGreenlet_GetParent + +# define PyGreenlet_MAIN \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_MAIN_NUM]) + +# define PyGreenlet_STARTED \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_STARTED_NUM]) + +# define PyGreenlet_ACTIVE \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_ACTIVE_NUM]) + + + + +/* Macro that imports greenlet and initializes C API */ +/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we + keep the older definition to be sure older code that might have a copy of + the header still works. */ +# define PyGreenlet_Import() \ + { \ + _PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \ + } + +#endif /* GREENLET_MODULE */ + +#ifdef __cplusplus +} +#endif +#endif /* !Py_GREENLETOBJECT_H */ diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg new file mode 100644 index 0000000..6e3b18b --- /dev/null +++ b/.venv/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /opt/homebrew/opt/python@3.12/bin +include-system-site-packages = false +version = 3.12.12 +executable = /opt/homebrew/Cellar/python@3.12/3.12.12/Frameworks/Python.framework/Versions/3.12/bin/python3.12 +command = /opt/homebrew/opt/python@3.12/bin/python3.12 -m venv /Users/commylife/myproject/java/workspace/auto-stock/.venv diff --git a/API_DOCS.md b/API_DOCS.md new file mode 100644 index 0000000..159d005 --- /dev/null +++ b/API_DOCS.md @@ -0,0 +1,308 @@ +# Auto Stock Trading System - API 문서 + +## Backend API + +### 기본 정보 + +- **Base URL**: `http://localhost:8000/api/v1` +- **인증**: Bearer Token (JWT) + +## 엔드포인트 + +### 1. Trading Rules (트레이딩 규칙) + +#### 규칙 목록 조회 +```http +GET /api/v1/rules +``` + +**Query Parameters:** +- `skip` (int): 건너뛸 개수 (기본: 0) +- `limit` (int): 조회할 개수 (기본: 100) +- `is_active` (bool): 활성화 필터 (선택) + +**Response:** +```json +[ + { + "id": 1, + "name": "기본 매수 규칙", + "description": "RSI 30 이하이고 거래량이 평균보다 높을 때 매수", + "rule_type": "BUY", + "conditions": { + "rsi_below": 30, + "volume_above_average": 1.5 + }, + "actions": { + "order_type": "BUY", + "quantity_percent": 10, + "max_amount": 1000000 + }, + "is_active": true, + "priority": 10, + "created_at": "2024-11-04T00:00:00", + "updated_at": "2024-11-04T00:00:00" + } +] +``` + +#### 규칙 생성 +```http +POST /api/v1/rules +``` + +**Request Body:** +```json +{ + "name": "새 규칙", + "description": "설명", + "rule_type": "BUY", + "conditions": {}, + "actions": {}, + "is_active": true, + "priority": 10 +} +``` + +#### 규칙 수정 +```http +PUT /api/v1/rules/{rule_id} +``` + +#### 규칙 삭제 +```http +DELETE /api/v1/rules/{rule_id} +``` + +#### 규칙 활성화/비활성화 +```http +POST /api/v1/rules/{rule_id}/toggle +``` + +### 2. Trades (거래 이력) + +#### 거래 이력 조회 +```http +GET /api/v1/trades +``` + +**Query Parameters:** +- `skip`, `limit`: 페이지네이션 +- `stock_code` (string): 종목 코드 필터 +- `status` (string): 상태 필터 (PENDING, EXECUTED, FAILED) +- `order_type` (string): 주문 유형 (BUY, SELL) +- `days` (int): 조회 기간 (기본: 30일) + +#### 거래 통계 +```http +GET /api/v1/trades/stats/summary?days=30 +``` + +**Response:** +```json +{ + "period_days": 30, + "total_trades": 150, + "buy_trades": 75, + "sell_trades": 75, + "executed_trades": 140, + "failed_trades": 10, + "success_rate": 0.933, + "total_amount": 50000000 +} +``` + +### 3. Portfolio (포트폴리오) + +#### 포트폴리오 조회 +```http +GET /api/v1/portfolio +``` + +**Response:** +```json +[ + { + "id": 1, + "stock_code": "005930", + "stock_name": "삼성전자", + "quantity": 10, + "average_price": 70000, + "current_price": 71000, + "profit_loss": 10000, + "profit_loss_rate": 1.43 + } +] +``` + +#### 포트폴리오 동기화 +```http +GET /api/v1/portfolio/sync +``` + +실제 증권 계좌와 포트폴리오를 동기화합니다. + +#### 포트폴리오 요약 +```http +GET /api/v1/portfolio/summary +``` + +**Response:** +```json +{ + "total_stocks": 5, + "total_value": 10000000, + "total_investment": 9500000, + "total_profit_loss": 500000, + "total_profit_loss_rate": 5.26 +} +``` + +### 4. Market Data (시장 데이터) + +#### 주식 현재가 조회 +```http +GET /api/v1/market/price/{stock_code} +``` + +**Response:** +```json +{ + "stock_code": "005930", + "current_price": 71000, + "change_rate": 1.5, + "volume": 1000000, + "high_price": 71500, + "low_price": 70000, + "open_price": 70500 +} +``` + +#### 차트 데이터 조회 +```http +GET /api/v1/market/chart/{stock_code}?period=D +``` + +**Query Parameters:** +- `period` (string): D (일봉), W (주봉), M (월봉) + +### 5. MCP (Model Context Protocol) + +#### MCP 서버 상태 +```http +GET /api/v1/mcp/status +``` + +**Response:** +```json +{ + "status": "connected", + "mcp_server_url": "http://mcp-server:8001", + "details": { + "status": "healthy", + "claude_configured": true + } +} +``` + +#### MCP 도구 목록 +```http +GET /api/v1/mcp/tools +``` + +#### MCP 도구 호출 +```http +POST /api/v1/mcp/tools/call +``` + +**Request Body:** +```json +{ + "tool_name": "get_market_data", + "parameters": { + "stock_code": "005930", + "period": "D" + } +} +``` + +#### Claude AI 분석 요청 +```http +POST /api/v1/mcp/analyze +``` + +**Request Body:** +```json +{ + "analysis_type": "RULE_VALIDATION", + "data": { + "rule": {}, + "market_data": {} + } +} +``` + +**Analysis Types:** +- `RULE_VALIDATION`: 규칙 검증 +- `MARKET_ANALYSIS`: 시장 분석 +- `RISK_ASSESSMENT`: 리스크 평가 + +## WebSocket + +### 연결 + +```javascript +const ws = new WebSocket('ws://localhost:8000/ws/{client_id}') +``` + +### 메시지 타입 + +#### Market Update +```json +{ + "type": "market_update", + "stock_code": "005930", + "data": { + "price": 71000, + "change_rate": 1.5 + } +} +``` + +#### Trade Notification +```json +{ + "type": "trade_notification", + "data": { + "order_id": "ORD001", + "status": "EXECUTED" + } +} +``` + +#### MCP Status +```json +{ + "type": "mcp_status", + "data": { + "status": "connected" + } +} +``` + +## 에러 응답 + +모든 에러는 다음 형식으로 반환됩니다: + +```json +{ + "detail": "에러 메시지" +} +``` + +**상태 코드:** +- `400`: Bad Request +- `401`: Unauthorized +- `404`: Not Found +- `500`: Internal Server Error +- `504`: Gateway Timeout diff --git a/API_SAFE_OPERATION.md b/API_SAFE_OPERATION.md new file mode 100644 index 0000000..8b9828c --- /dev/null +++ b/API_SAFE_OPERATION.md @@ -0,0 +1,285 @@ +# 모의투자 API 안전 운영 가이드 + +한국투자증권 모의투자 API를 안전하고 안정적으로 운영하기 위한 재시도 및 쓰로틀링 전략이 적용되어 있습니다. + +## 주요 기능 + +### 1. 안전한 재시도 전략 (Exponential Backoff) + +- **재시도 횟수 제한**: 최대 3회 (설정 가능) +- **점진적 백오프**: 재시도 간격이 지수적으로 증가 (1.5초 → 3초 → 6초) +- **오류 유형 분별**: + - 429 (Rate Limit), 5xx (서버 오류): 재시도 실행 + - 4xx (클라이언트 오류, 인증 실패): 즉시 종료 +- **타임아웃 처리**: 30초 타임아웃 + 재시도 로직 + +### 2. 쓰로틀링 전략 + +- **초당 호출 제한**: 기본 0.5초 간격 (초당 2회 권장) +- **동시 요청 제한**: 최대 2건의 동시 요청 (세마포어 사용) +- **자동 간격 조절**: 마지막 요청 시간 기반 자동 대기 + +### 3. 모니터링 & 경고 + +- **실시간 통계 수집**: 총 호출 수, 오류율, 재시도 횟수, 평균 응답 시간 +- **오류율 경고**: 30% 초과 시 자동 경고 +- **API 상태 체크 엔드포인트**: `/api/v1/monitoring/api-stats` + +--- + +## 환경 변수 설정 + +`.env` 파일에 다음 설정을 추가하여 조절할 수 있습니다: + +```bash +# API 재시도 설정 +API_MAX_RETRIES=3 # 최대 재시도 횟수 (기본: 3) +API_RETRY_DELAY=1.5 # 초기 재시도 대기 시간(초) (기본: 1.5) +API_RETRY_BACKOFF=2.0 # 지수 백오프 배율 (기본: 2.0) + +# API 쓰로틀링 설정 +API_MIN_REQUEST_INTERVAL=0.5 # 최소 요청 간격(초) - 초당 2회 (기본: 0.5) +API_MAX_CONCURRENT_REQUESTS=2 # 동시 최대 요청 수 (기본: 2) +API_TIMEOUT=30.0 # API 타임아웃(초) (기본: 30.0) + +# 모니터링 설정 +API_ERROR_THRESHOLD=0.3 # 오류율 경고 임계값 (기본: 30%) +API_MONITORING_WINDOW=100 # 모니터링 윈도우 크기 (기본: 100개 요청) +``` + +--- + +## API 사용 예시 + +### 기본 사용 (자동 재시도 + 쓰로틀링) + +```python +from app.services.korea_investment_api import korea_investment_api + +# 현재가 조회 (자동 재시도 및 쓰로틀링 적용) +result = await korea_investment_api.get_current_price("005930") + +# 일봉 데이터 조회 +chart = await korea_investment_api.get_daily_chart("005930") + +# 종목 리스트 조회 +stocks = await korea_investment_api.get_stock_list("J") # KOSPI +``` + +### 통계 조회 + +```python +# API 호출 통계 확인 +stats = korea_investment_api.get_monitor_stats() +print(f"총 호출: {stats['total_calls']}건") +print(f"오류율: {stats['error_rate']:.1%}") +print(f"평균 응답 시간: {stats['avg_duration']:.2f}초") + +# 로그에 통계 출력 +korea_investment_api.log_monitor_stats() +``` + +--- + +## 모니터링 API 엔드포인트 + +### 1. API 통계 조회 + +```bash +GET http://localhost:8000/api/v1/monitoring/api-stats +``` + +**응답 예시:** +```json +{ + "status": "healthy", + "stats": { + "total_calls": 150, + "total_errors": 5, + "total_retries": 8, + "error_rate": 0.033, + "avg_duration": 1.25, + "recent_calls": 100 + }, + "message": "오류율: 3.3%, 평균 응답: 1.25초" +} +``` + +### 2. 헬스체크 + +```bash +GET http://localhost:8000/api/v1/monitoring/health +``` + +**응답 예시:** +```json +{ + "status": "healthy", + "error_rate": 0.033, + "total_calls": 150, + "timestamp": "2025-11-10T09:30:00" +} +``` + +### 3. 통계 로깅 + +```bash +POST http://localhost:8000/api/v1/monitoring/log-stats +``` + +--- + +## 배치 스크립트 실행 + +매일 아침 9시 전체 종목 분석을 실행하는 배치 스크립트: + +### 테스트 실행 (10개 종목만) + +```bash +cd backend +python batch_daily_analysis.py --test +``` + +### 제한된 수 실행 + +```bash +python batch_daily_analysis.py --limit 50 +``` + +### 전체 실행 + +```bash +python batch_daily_analysis.py +``` + +### Cron 스케줄 설정 (매일 평일 오전 9시) + +```bash +crontab -e +``` + +다음 라인 추가: +```bash +0 9 * * 1-5 cd /path/to/auto-stock/backend && /path/to/python batch_daily_analysis.py >> /path/to/logs/batch.log 2>&1 +``` + +--- + +## 로그 확인 + +배치 스크립트 실행 시 다음과 같은 상세 로그를 확인할 수 있습니다: + +``` +2025-11-10 09:00:00 - INFO - 🚀 Starting daily batch analysis for 2025-11-10 +2025-11-10 09:00:00 - INFO - ⚙️ Rate Limiting: 0.5초 간격, 최대 재시도 3회 +2025-11-10 09:00:05 - INFO - ✅ Found 30 KOSPI stocks +2025-11-10 09:00:10 - INFO - ✅ Found 30 KOSDAQ stocks +2025-11-10 09:00:10 - INFO - 📊 Total tradable stocks: 60 +2025-11-10 09:00:15 - INFO - [1/60] Analyzing 005930 (삼성전자)... +2025-11-10 09:00:16 - INFO - ✅ Saved analysis for 005930 (삼성전자): score=5 +... +2025-11-10 09:02:30 - INFO - ============================================================ +2025-11-10 09:02:30 - INFO - ✅ Batch analysis completed! +2025-11-10 09:02:30 - INFO - 📊 Success: 58 / Total: 60 +2025-11-10 09:02:30 - INFO - ❌ Failed: 2 +2025-11-10 09:02:30 - INFO - ⏱️ Duration: 145.2 seconds (2.4 minutes) +2025-11-10 09:02:30 - INFO - 📡 API 통계: +2025-11-10 09:02:30 - INFO - - 총 호출: 122건 +2025-11-10 09:02:30 - INFO - - 오류: 3건 (2.5%) +2025-11-10 09:02:30 - INFO - - 재시도: 5건 +2025-11-10 09:02:30 - INFO - - 평균 응답 시간: 1.18초 +``` + +--- + +## 문제 해결 + +### 1. 오류율이 높을 때 (30% 이상) + +- **원인**: API 서버 불안정 또는 네트워크 문제 +- **해결책**: + - `API_RETRY_DELAY`를 2~3초로 증가 + - `API_MAX_RETRIES`를 5회로 증가 + - `API_MIN_REQUEST_INTERVAL`을 1초로 증가 (호출 빈도 낮춤) + +```bash +# .env 파일 수정 +API_RETRY_DELAY=2.5 +API_MAX_RETRIES=5 +API_MIN_REQUEST_INTERVAL=1.0 +``` + +### 2. 타임아웃 빈발 + +- **원인**: 네트워크 지연 또는 API 응답 느림 +- **해결책**: 타임아웃 시간 증가 + +```bash +API_TIMEOUT=60.0 +``` + +### 3. Rate Limit 초과 (429 에러) + +- **원인**: 요청 간격이 너무 짧음 +- **해결책**: 요청 간격 증가 + +```bash +API_MIN_REQUEST_INTERVAL=1.0 # 초당 1회로 제한 +API_MAX_CONCURRENT_REQUESTS=1 # 동시 요청 1건으로 제한 +``` + +--- + +## 성능 튜닝 팁 + +### 안정성 우선 (추천) + +```bash +API_MIN_REQUEST_INTERVAL=0.5 +API_MAX_CONCURRENT_REQUESTS=2 +API_MAX_RETRIES=3 +API_RETRY_DELAY=1.5 +``` + +### 속도 우선 (주의) + +```bash +API_MIN_REQUEST_INTERVAL=0.3 +API_MAX_CONCURRENT_REQUESTS=3 +API_MAX_RETRIES=2 +API_RETRY_DELAY=1.0 +``` + +### 대규모 배치용 (안전 중시) + +```bash +API_MIN_REQUEST_INTERVAL=1.0 +API_MAX_CONCURRENT_REQUESTS=1 +API_MAX_RETRIES=5 +API_RETRY_DELAY=2.0 +API_RETRY_BACKOFF=3.0 +``` + +--- + +## 추가 모니터링 + +### Prometheus + Grafana 연동 (선택 사항) + +API 통계를 Prometheus 형식으로 내보내려면 `/api/v1/monitoring/metrics` 엔드포인트를 추가 구현할 수 있습니다. + +### 알림 설정 (선택 사항) + +오류율이 임계값을 초과할 때 Slack/Email 알림을 보내도록 확장 가능합니다. + +--- + +## 참고 자료 + +- [한국투자증권 Open API 문서](https://apiportal.koreainvestment.com/) +- [FastAPI 공식 문서](https://fastapi.tiangolo.com/) +- [httpx 재시도 전략](https://www.python-httpx.org/) + +--- + +**문의 및 피드백**: 시스템 운영 중 문제가 발생하면 로그 파일과 API 통계를 함께 확인하세요. diff --git a/BATCH_GUIDE.md b/BATCH_GUIDE.md new file mode 100644 index 0000000..de2e233 --- /dev/null +++ b/BATCH_GUIDE.md @@ -0,0 +1,101 @@ +# 배치 분석 시스템 사용 가이드 + +## 개요 + +매일 아침 전체 종목을 분석하여 DB에 저장하는 배치 시스템입니다. +API 응답 속도가 **30초 → 0.1초**로 300배 빨라집니다! + +## 사용 방법 + +### 1. 테스트 실행 (10개 종목만) +```bash +./run-batch.sh --test +``` + +### 2. 일부 종목만 분석 (예: 50개) +```bash +./run-batch.sh --limit 50 +``` + +### 3. 전체 종목 분석 (실제 운영) +```bash +./run-batch.sh +``` + +또는 + +```bash +cd backend +python batch_daily_analysis.py +``` + +## Cron 설정 (자동 실행) + +매일 아침 9시에 자동 실행: + +```bash +# crontab 편집 +crontab -e + +# 아래 내용 추가 (경로는 실제 경로로 수정) +0 9 * * 1-5 cd /Users/commylife/myproject/java/workspace/auto-stock && ./run-batch.sh >> logs/batch.log 2>&1 +``` + +## API 사용 + +### 빠른 모드 (DB 조회 - 0.1초) +```bash +GET /api/v1/recommendations/rule-based +``` + +### 실시간 모드 (KIS API - 30초) +```bash +GET /api/v1/recommendations/rule-based?realtime=true +``` + +## 프론트엔드 버튼 추가 예시 + +```typescript +// 실시간 새로고침 버튼 + + +// 기본은 DB 조회 (빠름) + +``` + +## 데이터베이스 스키마 + +```sql +CREATE TABLE stock_analysis ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(20), + stock_name VARCHAR(100), + analysis_date DATE, + current_price INTEGER, + rsi NUMERIC(10,4), + ma5 NUMERIC(15,2), + ma20 NUMERIC(15,2), + signals JSONB, + score INTEGER, + ... +); +``` + +## 장점 + +- ✅ **속도**: 30초 → 0.1초 (300배 빠름) +- ✅ **안정성**: KIS API Rate Limit 회피 +- ✅ **편의성**: 사용자가 기다릴 필요 없음 +- ✅ **확장성**: 과거 데이터 추적 가능 +- ✅ **분석**: 백테스팅, 통계 분석 가능 + +## 주의사항 + +- 배치 실행 시간: 약 5-10분 (전체 200개 종목 기준) +- KIS API Rate Limit: 초당 1건 제한 +- 데이터는 매일 아침 9시 기준 +- 장중 실시간 가격은 `realtime=true` 옵션 사용 diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..d1e3411 --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,149 @@ +# Auto Stock Trading System - 설치 가이드 + +## 사전 요구사항 + +- Docker & Docker Compose +- 한국투자증권 API 키 +- Claude API 키 (Anthropic) + +## 설치 단계 + +### 1. 저장소 클론 + +```bash +git clone +cd auto-stock +``` + +### 2. 환경 변수 설정 + +```bash +cp .env.example .env +``` + +`.env` 파일을 열어 다음 값들을 설정하세요: + +- `KOREA_INVESTMENT_APP_KEY`: 한국투자증권 앱키 +- `KOREA_INVESTMENT_APP_SECRET`: 한국투자증권 시크릿 +- `KOREA_INVESTMENT_ACCOUNT_NUMBER`: 계좌번호 +- `ANTHROPIC_API_KEY`: Claude API 키 + +### 3. 시스템 시작 + +#### Docker로 전체 시스템 시작 + +```bash +./scripts/deploy.sh +``` + +#### 개발 모드 (로컬 개발) + +```bash +# 인프라만 Docker로 시작 +./scripts/dev.sh + +# Backend 시작 +cd backend +pip install -r requirements.txt +uvicorn app.main:app --reload + +# MCP Server 시작 (새 터미널) +cd mcp-server +pip install -r requirements.txt +uvicorn main:app --reload --port 8001 + +# Frontend 시작 (새 터미널) +cd frontend +npm install +npm run dev +``` + +## 시스템 관리 + +### 로그 확인 + +```bash +# 전체 로그 +./scripts/logs.sh + +# 특정 서비스 로그 +./scripts/logs.sh backend +./scripts/logs.sh mcp-server +./scripts/logs.sh frontend +``` + +### 시스템 중지 + +```bash +./scripts/stop.sh +``` + +### 시스템 재시작 + +```bash +./scripts/restart.sh +``` + +### 데이터베이스 백업 + +```bash +./scripts/backup.sh +``` + +## 접속 URL + +- **Frontend**: http://localhost:3000 +- **Backend API**: http://localhost:8000 +- **API 문서**: http://localhost:8000/docs +- **MCP Server**: http://localhost:8001 +- **RabbitMQ 관리**: http://localhost:15672 (guest/guest) + +## 트러블슈팅 + +### 포트 충돌 + +다른 애플리케이션이 사용 중인 포트가 있다면 `.env` 파일에서 포트를 변경하세요. + +### 데이터베이스 연결 오류 + +```bash +# 데이터베이스 재시작 +docker-compose restart postgres +``` + +### 전체 시스템 재빌드 + +```bash +docker-compose down -v +docker-compose build --no-cache +docker-compose up -d +``` + +## 개발 가이드 + +### Backend API 엔드포인트 추가 + +1. `backend/app/api/endpoints/` 에 새 엔드포인트 파일 생성 +2. `backend/app/api/__init__.py` 에 라우터 등록 + +### Frontend 페이지 추가 + +1. `frontend/src/pages/` 에 새 페이지 컴포넌트 생성 +2. `frontend/src/App.tsx` 에 라우트 추가 + +### MCP 도구 추가 + +1. `mcp-server/mcp_tools.py` 에 새 도구 메서드 추가 +2. `get_tool_definitions()` 에 도구 정의 추가 + +## 보안 주의사항 + +- 프로덕션 환경에서는 반드시 강력한 `SECRET_KEY` 사용 +- API 키를 절대 Git에 커밋하지 마세요 +- 프로덕션에서는 HTTPS 사용 +- 데이터베이스 비밀번호 변경 +- CORS 설정을 프로덕션 도메인으로 제한 + +## 라이센스 + +MIT License diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c0861ac --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +# Makefile for Auto Stock Trading System + +.PHONY: help install dev deploy stop restart logs backup clean + +help: ## 도움말 표시 + @echo "Auto Stock Trading System - 사용 가능한 명령어:" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + +install: ## 초기 설정 + @echo "환경 설정 중..." + @cp -n .env.example .env || true + @chmod +x scripts/*.sh + @echo "✓ 설정 완료. .env 파일을 수정한 후 'make deploy'를 실행하세요." + +dev: ## 개발 모드 시작 + @./scripts/dev.sh + +deploy: ## 프로덕션 배포 + @./scripts/deploy.sh + +stop: ## 시스템 중지 + @./scripts/stop.sh + +restart: ## 시스템 재시작 + @./scripts/restart.sh + +logs: ## 로그 확인 + @./scripts/logs.sh + +logs-backend: ## Backend 로그 + @./scripts/logs.sh backend + +logs-mcp: ## MCP Server 로그 + @./scripts/logs.sh mcp-server + +logs-frontend: ## Frontend 로그 + @./scripts/logs.sh frontend + +backup: ## 데이터베이스 백업 + @./scripts/backup.sh + +clean: ## 모든 컨테이너 및 볼륨 삭제 + @echo "⚠️ 경고: 모든 데이터가 삭제됩니다!" + @read -p "계속하시겠습니까? (y/N) " confirm && [ "$$confirm" = "y" ] + @docker-compose down -v + @echo "✓ 정리 완료" + +rebuild: ## 전체 재빌드 + @docker-compose down + @docker-compose build --no-cache + @docker-compose up -d + @echo "✓ 재빌드 완료" + +status: ## 서비스 상태 확인 + @docker-compose ps + +shell-backend: ## Backend 쉘 접속 + @docker exec -it autostock-backend /bin/bash + +shell-mcp: ## MCP Server 쉘 접속 + @docker exec -it autostock-mcp-server /bin/bash + +shell-db: ## PostgreSQL 쉘 접속 + @docker exec -it autostock-postgres psql -U autostock -d autostock_db + +test-backend: ## Backend 테스트 + @cd backend && pytest + +test-frontend: ## Frontend 테스트 + @cd frontend && npm test + +lint-backend: ## Backend 린트 + @cd backend && pylint app/ + +lint-frontend: ## Frontend 린트 + @cd frontend && npm run lint diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..2b2a73f --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -0,0 +1,266 @@ +# Auto Stock Trading System 프로젝트 구조 + +## 📁 디렉토리 구조 + +``` +auto-stock/ +├── backend/ # FastAPI 백엔드 +│ ├── app/ +│ │ ├── api/ +│ │ │ ├── __init__.py # API 라우터 통합 +│ │ │ └── endpoints/ +│ │ │ ├── trading_rules.py # 트레이딩 규칙 API +│ │ │ ├── trades.py # 거래 이력 API +│ │ │ ├── portfolio.py # 포트폴리오 API +│ │ │ ├── market_data.py # 시장 데이터 API +│ │ │ └── mcp.py # MCP 연동 API +│ │ ├── core/ +│ │ │ ├── config.py # 설정 관리 +│ │ │ └── database.py # 데이터베이스 연결 +│ │ ├── models/ +│ │ │ └── models.py # SQLAlchemy 모델 +│ │ ├── services/ +│ │ │ ├── websocket_manager.py # WebSocket 관리 +│ │ │ └── korea_investment_api.py # 한국투자증권 API +│ │ └── main.py # FastAPI 앱 진입점 +│ ├── Dockerfile +│ └── requirements.txt +│ +├── mcp-server/ # MCP 서버 (Claude AI) +│ ├── main.py # MCP 서버 진입점 +│ ├── config.py # 설정 +│ ├── claude_client.py # Claude API 클라이언트 +│ ├── mcp_tools.py # MCP 도구 레지스트리 +│ ├── Dockerfile +│ └── requirements.txt +│ +├── frontend/ # React 프론트엔드 +│ ├── src/ +│ │ ├── components/ +│ │ │ └── Layout.tsx # 레이아웃 컴포넌트 +│ │ ├── pages/ +│ │ │ ├── Dashboard.tsx # 대시보드 +│ │ │ ├── TradingRules.tsx # 트레이딩 규칙 +│ │ │ ├── TradeHistory.tsx # 거래 이력 +│ │ │ └── MCPStatus.tsx # MCP 상태 +│ │ ├── hooks/ +│ │ │ └── useWebSocket.ts # WebSocket 훅 +│ │ ├── lib/ +│ │ │ └── api.ts # API 클라이언트 +│ │ ├── App.tsx # 앱 컴포넌트 +│ │ ├── main.tsx # 진입점 +│ │ ├── config.ts # 설정 +│ │ └── index.css # 스타일 +│ ├── public/ +│ ├── index.html +│ ├── package.json +│ ├── vite.config.ts +│ ├── tailwind.config.js +│ ├── tsconfig.json +│ ├── nginx.conf +│ └── Dockerfile +│ +├── docker/ # Docker 설정 +│ └── init-db.sql # DB 초기화 스크립트 +│ +├── scripts/ # 유틸리티 스크립트 +│ ├── deploy.sh # 프로덕션 배포 +│ ├── dev.sh # 개발 모드 +│ ├── stop.sh # 시스템 중지 +│ ├── restart.sh # 재시작 +│ ├── logs.sh # 로그 확인 +│ └── backup.sh # DB 백업 +│ +├── .github/ +│ └── workflows/ +│ └── ci-cd.yml # CI/CD 파이프라인 +│ +├── docker-compose.yml # 프로덕션 Docker Compose +├── docker-compose.dev.yml # 개발용 Docker Compose +├── .env.example # 환경 변수 예시 +├── .gitignore +├── Makefile # Make 명령어 +├── ecosystem.config.js # PM2 설정 +├── README.md # 프로젝트 소개 +├── INSTALLATION.md # 설치 가이드 +├── USER_GUIDE.md # 사용자 가이드 +└── API_DOCS.md # API 문서 +``` + +## 🎯 주요 기능 + +### 1. **대시보드** +- 실시간 포트폴리오 현황 +- 총 자산 및 수익률 +- 보유 종목 상세 + +### 2. **전략 룰 설정** +- 자동 매매 규칙 생성/수정/삭제 +- 조건 및 액션 설정 +- 규칙 우선순위 관리 + +### 3. **매매 로그** +- 전체 거래 내역 +- 실행 상태 모니터링 +- 거래 통계 + +### 4. **MCP 상태 모니터** +- MCP 서버 연결 상태 +- Claude AI 도구 목록 +- 실시간 로그 + +## 🔧 기술 스택 + +### Backend +- **FastAPI**: 비동기 REST API +- **SQLAlchemy**: ORM +- **PostgreSQL**: 데이터베이스 +- **Redis**: 캐싱 +- **RabbitMQ**: 메시지 큐 +- **WebSocket**: 실시간 통신 + +### MCP Server +- **FastAPI**: MCP 프로토콜 서버 +- **Anthropic Claude**: AI 분석 +- **Pandas/TA**: 기술적 지표 계산 + +### Frontend +- **React 18**: UI 프레임워크 +- **TypeScript**: 타입 안정성 +- **Vite**: 빌드 도구 +- **TailwindCSS**: 스타일링 +- **React Query**: 데이터 페칭 +- **Recharts**: 차트 + +### Infrastructure +- **Docker**: 컨테이너화 +- **Docker Compose**: 오케스트레이션 +- **Nginx**: 웹 서버 +- **GitHub Actions**: CI/CD + +## 🚀 빠른 시작 + +### 1. 환경 설정 +```bash +cp .env.example .env +# .env 파일 편집하여 API 키 설정 +``` + +### 2. Docker로 시작 +```bash +./scripts/deploy.sh +``` + +### 3. 접속 +- Frontend: http://localhost:3000 +- Backend: http://localhost:8000 +- MCP Server: http://localhost:8001 + +## 📊 시스템 아키텍처 + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Frontend │────▶│ Backend │────▶│ MCP Server │ +│ (React) │ │ (FastAPI) │ │ (Claude) │ +└─────────────┘ └──────────────┘ └─────────────┘ + │ │ + ▼ ▼ + ┌──────────────┐ ┌─────────────┐ + │ PostgreSQL │ │ Claude AI │ + │ Redis │ └─────────────┘ + │ RabbitMQ │ + └──────────────┘ + │ + ▼ + ┌──────────────┐ + │ 한국투자증권 │ + │ API │ + └──────────────┘ +``` + +## 🔐 보안 + +- API 키는 환경 변수로 관리 +- JWT 토큰 기반 인증 +- CORS 설정 +- HTTPS 지원 (프로덕션) + +## 📝 개발 가이드 + +### Backend 개발 +```bash +cd backend +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +### MCP Server 개발 +```bash +cd mcp-server +pip install -r requirements.txt +uvicorn main:app --reload --port 8001 +``` + +### Frontend 개발 +```bash +cd frontend +npm install +npm run dev +``` + +## 🧪 테스트 + +```bash +# Backend 테스트 +make test-backend + +# Frontend 테스트 +make test-frontend +``` + +## 📦 배포 + +### Docker Compose +```bash +make deploy +``` + +### PM2 (Node.js Process Manager) +```bash +pm2 start ecosystem.config.js +``` + +## 🛠️ 유용한 명령어 + +```bash +make help # 도움말 +make dev # 개발 모드 +make deploy # 배포 +make stop # 중지 +make restart # 재시작 +make logs # 로그 +make backup # DB 백업 +make clean # 정리 +``` + +## 📚 문서 + +- [README.md](README.md) - 프로젝트 소개 +- [INSTALLATION.md](INSTALLATION.md) - 설치 가이드 +- [USER_GUIDE.md](USER_GUIDE.md) - 사용자 가이드 +- [API_DOCS.md](API_DOCS.md) - API 문서 + +## ⚠️ 주의사항 + +- 실제 거래 전 충분한 테스트 필요 +- API 키 보안 관리 +- 손실 위험 인지 +- 규칙 신중하게 설정 + +## 📄 라이센스 + +MIT License + +## 🤝 기여 + +Pull Request와 Issue는 언제나 환영합니다! diff --git a/README.md b/README.md new file mode 100644 index 0000000..e946ad6 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Auto Stock Trading System with MCP + +Claude AI와 MCP(Model Context Protocol)를 활용한 한국 주식 자동매매 시스템 + +## 시스템 아키텍처 + +- **Frontend**: React + TypeScript + Vite +- **Backend**: Python FastAPI + WebSocket +- **MCP Server**: Claude AI 통합 +- **Database**: PostgreSQL +- **Cache**: Redis +- **Message Queue**: RabbitMQ +- **API**: 한국투자증권 API + +## 주요 기능 + +1. **실시간 시장 데이터 수집** +2. **Claude AI 기반 규칙 검증** +3. **자동 매매 실행** +4. **포트폴리오 관리** +5. **실시간 모니터링** + +## 프로젝트 구조 + +``` +auto-stock/ +├── frontend/ # React 프론트엔드 +├── backend/ # FastAPI 백엔드 +├── mcp-server/ # MCP 서버 +├── docker/ # Docker 설정 +└── scripts/ # 배포 스크립트 +``` + +## 빠른 시작 + +```bash +# 환경 변수 설정 +cp .env.example .env + +# Docker로 전체 시스템 시작 +./scripts/deploy.sh + +# 개발 모드 +./scripts/dev.sh +``` + +## 환경 변수 + +`.env` 파일에 다음 설정 필요: + +- `KOREA_INVESTMENT_APP_KEY`: 한국투자증권 앱키 +- `KOREA_INVESTMENT_APP_SECRET`: 한국투자증권 시크릿 +- `ANTHROPIC_API_KEY`: Claude API 키 +- `DATABASE_URL`: PostgreSQL 연결 URL +- `REDIS_URL`: Redis 연결 URL + +## 라이센스 + +MIT diff --git a/USER_GUIDE.md b/USER_GUIDE.md new file mode 100644 index 0000000..4000d03 --- /dev/null +++ b/USER_GUIDE.md @@ -0,0 +1,211 @@ +# Auto Stock Trading System - 사용 가이드 + +## 기능 소개 + +### 1. 대시보드 +- 실시간 포트폴리오 현황 +- 총 자산 및 수익률 모니터링 +- 보유 종목 상세 정보 + +### 2. 전략 룰 설정 +- 자동 매매 규칙 생성/수정/삭제 +- 규칙 활성화/비활성화 +- 조건 및 액션 설정 + +### 3. 매매 로그 +- 모든 거래 내역 조회 +- 실행 상태 확인 +- 거래 통계 + +### 4. MCP 상태 모니터 +- MCP 서버 연결 상태 +- 사용 가능한 도구 목록 +- 실시간 MCP 로그 + +## 트레이딩 규칙 생성 가이드 + +### 규칙 구조 + +```json +{ + "name": "규칙 이름", + "description": "규칙 설명", + "rule_type": "BUY" | "SELL", + "conditions": { + // 실행 조건 + }, + "actions": { + // 실행할 액션 + } +} +``` + +### 조건 예시 + +#### RSI 기반 매수 +```json +{ + "conditions": { + "rsi_below": 30, + "volume_above_average": 1.5 + } +} +``` + +#### 손절매 +```json +{ + "conditions": { + "loss_rate_below": -5 + } +} +``` + +#### 익절 +```json +{ + "conditions": { + "profit_rate_above": 15 + } +} +``` + +### 액션 예시 + +#### 매수 액션 +```json +{ + "actions": { + "order_type": "BUY", + "quantity_percent": 10, + "max_amount": 1000000 + } +} +``` + +#### 매도 액션 +```json +{ + "actions": { + "order_type": "SELL", + "quantity_percent": 100 + } +} +``` + +## Claude AI 분석 활용 + +### 1. 규칙 검증 +시장 상황에 맞게 규칙이 적절한지 Claude AI가 분석합니다. + +### 2. 시장 분석 +기술적 지표와 뉴스를 종합하여 시장 트렌드를 분석합니다. + +### 3. 리스크 평가 +포트폴리오와 거래 계획을 분석하여 리스크를 평가합니다. + +## MCP 도구 + +### get_market_data +시장 데이터를 조회합니다. + +```json +{ + "tool_name": "get_market_data", + "parameters": { + "stock_code": "005930", + "period": "D" + } +} +``` + +### calculate_indicators +기술적 지표를 계산합니다. + +```json +{ + "tool_name": "calculate_indicators", + "parameters": { + "price_data": [...], + "indicators": ["RSI", "MACD", "BB"] + } +} +``` + +### check_portfolio +현재 포트폴리오를 확인합니다. + +```json +{ + "tool_name": "check_portfolio", + "parameters": {} +} +``` + +### execute_order +주문을 실행합니다. + +```json +{ + "tool_name": "execute_order", + "parameters": { + "stock_code": "005930", + "order_type": "BUY", + "quantity": 10, + "price": 71000 + } +} +``` + +## 모범 사례 + +### 1. 리스크 관리 +- 한 종목에 총 자산의 10% 이상 투자하지 않기 +- 손절매 규칙 반드시 설정 +- 일일 최대 거래 횟수 제한 + +### 2. 규칙 우선순위 +- 손절매: 최고 우선순위 (100) +- 익절: 높은 우선순위 (90) +- 일반 매매: 중간 우선순위 (10-50) + +### 3. 백테스팅 +새로운 규칙은 과거 데이터로 먼저 테스트하세요. + +### 4. 모니터링 +- 실시간 로그 확인 +- 규칙 성과 정기 검토 +- MCP 서버 연결 상태 체크 + +## 주의사항 + +⚠️ **중요**: 이 시스템은 자동 매매를 수행합니다. +- 실제 계좌로 사용하기 전에 충분히 테스트하세요 +- 시장 상황을 항상 모니터링하세요 +- 규칙을 신중하게 설정하세요 +- 손실 위험을 항상 고려하세요 + +## 문제 해결 + +### Q: 거래가 실행되지 않아요 +A: +1. 규칙이 활성화되어 있는지 확인 +2. 조건이 충족되었는지 확인 +3. 계좌 잔액 확인 +4. API 연결 상태 확인 + +### Q: MCP 서버가 연결되지 않아요 +A: +1. MCP 서버가 실행 중인지 확인 +2. Claude API 키가 올바른지 확인 +3. 네트워크 연결 확인 + +### Q: 포트폴리오가 동기화되지 않아요 +A: +1. "포트폴리오 동기화" 버튼 클릭 +2. API 키와 계좌번호 확인 +3. 한국투자증권 API 상태 확인 + +## 지원 + +문제가 발생하거나 도움이 필요하시면 GitHub Issues에 문의해주세요. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..27cd7d5 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,57 @@ +# Database Configuration +DATABASE_URL=postgresql://user:password@localhost:5432/auto_stock +DB_POOL_SIZE=10 + +# Redis Configuration +REDIS_URL=redis://localhost:6379/0 + +# RabbitMQ Configuration +RABBITMQ_URL=amqp://guest:guest@localhost:5672/ + +# Korea Investment Securities API - Mode Selection +KIS_MODE=virtual # virtual (모의투자) 또는 real (실전투자) + +# 모의투자 (Virtual Trading) +KIS_VIRTUAL_APP_KEY=your_virtual_app_key_here +KIS_VIRTUAL_APP_SECRET=your_virtual_app_secret_here +KIS_VIRTUAL_ACCOUNT_NO=your_virtual_account_number_here +KIS_VIRTUAL_URL=https://openapivts.koreainvestment.com:29443 + +# 실전투자 (Real Trading) +KIS_REAL_APP_KEY=your_real_app_key_here +KIS_REAL_APP_SECRET=your_real_app_secret_here +KIS_REAL_ACCOUNT_NO=your_real_account_number_here +KIS_REAL_URL=https://openapi.koreainvestment.com:9443 + +# 현재 활성 설정 (KIS_MODE에 따라 자동 선택됨) +KIS_APP_KEY=your_virtual_app_key_here +KIS_APP_SECRET=your_virtual_app_secret_here +KIS_ACCOUNT_NO=your_virtual_account_number_here +KIS_BASE_URL=https://openapivts.koreainvestment.com:29443 + +# MCP Server Configuration +MCP_SERVER_URL=http://localhost:8001 +MCP_API_KEY=your_mcp_api_key_here + +# Claude AI Configuration (for MCP Server) +ANTHROPIC_API_KEY=your_anthropic_api_key_here +CLAUDE_MODEL=claude-3-5-sonnet-20241022 +CLAUDE_MAX_TOKENS=4096 + +# Server Configuration +SERVER_HOST=0.0.0.0 +SERVER_PORT=8000 +DEBUG=false +LOG_LEVEL=info + +# Security +SECRET_KEY=your_secret_key_here_change_in_production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# CORS +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# WebSocket +WS_HEARTBEAT_INTERVAL=30 +WS_MESSAGE_QUEUE_SIZE=1000 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..39f59bd --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 시스템 의존성 설치 +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Python 의존성 설치 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 애플리케이션 코드 복사 +COPY . . + +# 포트 노출 +EXPOSE 8000 + +# 애플리케이션 실행 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..dfe8d69 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,42 @@ +# Backend + +## Batch daily analysis (DB cache) + +Precompute technical indicators each morning and cache results for fast recommendations. + +### Run once (today) + +```zsh +cd /Users/commylife/myproject/java/workspace/auto-stock/backend +python3 batch_daily_analysis.py --test # quick: ~10 stocks +# or full run +python3 batch_daily_analysis.py +``` + +### Schedule (cron on macOS) + +```zsh +crontab -e +# weekdays 08:30 run (adjust path) +30 8 * * 1-5 cd /Users/commylife/myproject/java/workspace/auto-stock/backend && /usr/bin/python3 batch_daily_analysis.py >> ../logs/batch.log 2>&1 +``` + +### Use DB cache in API + +- Endpoint: `/api/v1/recommendations/rule-based` +- Default: uses DB cache; if cache empty, it automatically falls back to realtime analysis. +- Force realtime: + +```text +/api/v1/recommendations/rule-based?realtime=true +``` + +### Monitoring + +- API safety stats: `/api/v1/monitoring/api-stats` +- Health: `/api/v1/monitoring/health` + +### Troubleshooting + +- No recommendations: ensure `stock_analysis` has rows for today (run batch). +- Slow responses: rely on DB cache or reduce realtime stock count via query params. diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..4946802 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter +from app.api.endpoints import trading_rules, trades, portfolio, market_data, mcp, recommendations, monitoring + +api_router = APIRouter() + +api_router.include_router(trading_rules.router, prefix="/rules", tags=["trading_rules"]) +api_router.include_router(trades.router, prefix="/trades", tags=["trades"]) +api_router.include_router(portfolio.router, prefix="/portfolio", tags=["portfolio"]) +api_router.include_router(market_data.router, prefix="/market-data", tags=["market_data"]) +api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"]) +api_router.include_router(recommendations.router, prefix="/recommendations", tags=["recommendations"]) +api_router.include_router(monitoring.router, prefix="/monitoring", tags=["monitoring"]) diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py new file mode 100644 index 0000000..d58772a --- /dev/null +++ b/backend/app/api/endpoints/market_data.py @@ -0,0 +1,272 @@ +from datetime import datetime, timedelta +import random +import logging + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from app.core.config import settings +from app.services.korea_investment_api import korea_investment_api + +router = APIRouter() +logger = logging.getLogger(__name__) + + +class StockPriceResponse(BaseModel): + stock_code: str + current_price: float + change_rate: float + volume: int + high_price: float + low_price: float + open_price: float + + +@router.get("/price/{stock_code}") +async def get_stock_price(stock_code: str): + """주식 현재가 조회""" + try: + data = await korea_investment_api.get_current_price(stock_code) + + if "output" not in data: + raise HTTPException(status_code=404, detail="Stock data not found") + + output = data["output"] + + return { + "stock_code": stock_code, + "current_price": float(output.get("stck_prpr", 0)), + "change_rate": float(output.get("prdy_ctrt", 0)), + "volume": int(output.get("acml_vol", 0)), + "high_price": float(output.get("stck_hgpr", 0)), + "low_price": float(output.get("stck_lwpr", 0)), + "open_price": float(output.get("stck_oprc", 0)) + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch stock price: {str(e)}") + + +def _generate_sample_chart(stock_code: str, period: str) -> dict: + """로컬 개발 환경을 위한 샘플 차트 데이터를 생성""" + if period not in {"D", "W", "M"}: + period = "D" + + if period == "D": + points = 60 + step = 1 + elif period == "W": + points = 52 + step = 7 + else: # "M" + points = 36 + step = 30 + + today = datetime.now() + start_date = today - timedelta(days=(points - 1) * step) + + rng = random.Random(f"{stock_code}:{period}") + base_price = rng.randint(30000, 90000) + drift = rng.uniform(-0.04, 0.06) + volatility = rng.uniform(0.004, 0.018) + volume_base = rng.randint(80000, 180000) + + def round_price(value: float) -> int: + return max(1000, int(round(value / 10)) * 10) + + price = float(base_price) + chart_data = [] + + for idx in range(points): + current_date = start_date + timedelta(days=idx * step) + daily_change = drift / points + rng.uniform(-volatility, volatility) + + open_price = price + close_price = max(1000.0, open_price * (1 + daily_change)) + + high_price = max(open_price, close_price) * (1 + rng.uniform(0, volatility * 1.8)) + low_price = min(open_price, close_price) * (1 - rng.uniform(0, volatility * 1.8)) + + if low_price <= 0: + low_price = min(open_price, close_price) * 0.98 + + volume = int(volume_base * (1 + rng.uniform(-0.35, 0.35))) + + open_value = round_price(open_price) + close_value = round_price(close_price) + high_value = max(round_price(high_price), open_value, close_value) + low_value = min(round_price(low_price), open_value, close_value) + + chart_data.append({ + "date": current_date.strftime("%Y%m%d"), + "open": float(open_value), + "high": float(high_value), + "low": float(low_value), + "close": float(close_value), + "volume": max(1000, volume), + }) + + price = close_price + + return { + "stock_code": stock_code, + "period": period, + "data": chart_data, + "is_sample": True, + } + + +@router.get("/chart/{stock_code}") +async def get_stock_chart( + stock_code: str, + period: str = "D", + realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환") +): + """주식 차트 데이터 조회 + + Args: + stock_code: 종목 코드 + period: D(일봉), W(주봉), M(월봉) + """ + try: + if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET: + return _generate_sample_chart(stock_code, period) + + data = await korea_investment_api.get_daily_chart(stock_code, period) + + if "output" not in data: + raise HTTPException(status_code=404, detail="Chart data not found") + + chart_data = [] + for item in data["output"]: + # KIS API 가격 데이터 안전하게 파싱 + try: + close_str = str(item.get("stck_clpr", "0")) + open_str = str(item.get("stck_oprc", "0")) + high_str = str(item.get("stck_hgpr", "0")) + low_str = str(item.get("stck_lwpr", "0")) + + # 소수점이 포함되어 있으면 제거하고 정수로 변환 + close_val = float(close_str.replace(",", "")) + open_val = float(open_str.replace(",", "")) + high_val = float(high_str.replace(",", "")) + low_val = float(low_str.replace(",", "")) + + chart_data.append({ + "date": item.get("stck_bsop_date"), + "open": open_val, + "high": high_val, + "low": low_val, + "close": close_val, + "volume": int(item.get("acml_vol", 0)) + }) + except (ValueError, TypeError) as e: + logger.error(f"가격 파싱 오류: {item} - {e}") + continue + + # KIS API는 최신 데이터가 가장 먼저 올 수 있음 → 그대로 두고 프론트에서 reverse 처리 + + latest_candle = None + if realtime: + try: + price_data = await korea_investment_api.get_current_price(stock_code) + if price_data and price_data.get("output"): + o = price_data["output"] + # 현재가를 마지막(최신) 캔들로 보정 (지표 계산 시 영향 최소화 위해 기존 첫 캔들과 가격 다르면 prepend) + current_price = float(str(o.get("stck_prpr", "0")).replace(",", "")) + high_price = float(str(o.get("stck_hgpr", "0")).replace(",", "")) + low_price = float(str(o.get("stck_lwpr", "0")).replace(",", "")) + open_price = float(str(o.get("stck_oprc", current_price)).replace(",", "")) + volume = int(o.get("acml_vol", 0)) + today_str = o.get("stck_bsop_date") or datetime.now().strftime("%Y%m%d") + latest_candle = { + "date": today_str, + "open": open_price, + "high": high_price, + "low": low_price, + "close": current_price, + "volume": volume, + "realtime": True + } + # 기존 첫 번째 캔들의 날짜와 같으면 교체, 다르면 맨 앞에 추가 + if chart_data and chart_data[0]["date"] == latest_candle["date"]: + chart_data[0] = latest_candle + else: + chart_data.insert(0, latest_candle) + except Exception as e: + logger.warning(f"실시간 현재가 캔들 생성 실패: {e}") + + return { + "stock_code": stock_code, + "period": period, + "data": chart_data, + "is_sample": False, + "realtime": realtime, + "latest": latest_candle + } + + except Exception as e: + # 외부 API 실패 시 개발 편의를 위해 샘플 데이터 제공 (이유 포함) + sample = _generate_sample_chart(stock_code, period) + sample["error"] = str(e) + sample["is_sample"] = True + sample["realtime"] = False + sample["latest"] = None + return sample + + +@router.get("/news") +async def get_market_news(limit: int = 10): + """시장 뉴스 조회 (네이버 금융 RSS)""" + import feedparser + from datetime import datetime + + try: + # 네이버 금융 증권 뉴스 RSS + feed_url = "https://news.naver.com/main/list.naver?mode=LSD&mid=sec&sid1=101&listType=title" + rss_url = "https://news.google.com/rss/search?q=한국+증시+주식&hl=ko&gl=KR&ceid=KR:ko" + + # RSS 피드 파싱 + feed = feedparser.parse(rss_url) + + news_list = [] + for idx, entry in enumerate(feed.entries[:limit]): + news_list.append({ + "id": idx + 1, + "title": entry.get("title", "제목 없음"), + "summary": entry.get("summary", entry.get("description", ""))[:200] + "..." if entry.get("summary", entry.get("description", "")) else "", + "source": entry.get("source", {}).get("title", "Google News") if hasattr(entry.get("source", {}), "get") else "Google News", + "published_at": entry.get("published", datetime.now().isoformat()), + "url": entry.get("link", "#") + }) + + return news_list + + except Exception as e: + # 에러 발생 시 샘플 데이터 반환 + return [ + { + "id": 1, + "title": "코스피, 외국인 매수세에 상승 마감", + "summary": "외국인 투자자들의 순매수세가 이어지면서 코스피가 상승 마감했습니다.", + "source": "한국경제", + "published_at": datetime.now().isoformat(), + "url": "#" + }, + { + "id": 2, + "title": "반도체 업종 강세, 삼성전자 상승", + "summary": "반도체 업종이 강세를 보이며 삼성전자 주가가 상승했습니다.", + "source": "매일경제", + "published_at": datetime.now().isoformat(), + "url": "#" + }, + { + "id": 3, + "title": "배터리 3사 동반 상승", + "summary": "LG에너지솔루션, 삼성SDI, SK온이 동반 상승했습니다.", + "source": "이데일리", + "published_at": datetime.now().isoformat(), + "url": "#" + } + ][:limit] diff --git a/backend/app/api/endpoints/mcp.py b/backend/app/api/endpoints/mcp.py new file mode 100644 index 0000000..1c532dc --- /dev/null +++ b/backend/app/api/endpoints/mcp.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, HTTPException +from typing import Dict, Any +from pydantic import BaseModel +import httpx +from app.core.config import settings + +router = APIRouter() + + +class MCPToolRequest(BaseModel): + tool_name: str + parameters: Dict[str, Any] + + +class MCPAnalysisRequest(BaseModel): + analysis_type: str # "RULE_VALIDATION", "MARKET_ANALYSIS", "RISK_ASSESSMENT" + data: Dict[str, Any] + + +@router.get("/status") +async def get_mcp_status(): + """MCP 서버 상태 확인""" + try: + async with httpx.AsyncClient() as client: + response = await client.get(f"{settings.MCP_SERVER_URL}/health", timeout=5.0) + + if response.status_code == 200: + return { + "status": "connected", + "mcp_server_url": settings.MCP_SERVER_URL, + "details": response.json() + } + else: + return { + "status": "error", + "message": "MCP server responded with error" + } + + except Exception as e: + return { + "status": "disconnected", + "message": str(e) + } + + +@router.post("/tools/call") +async def call_mcp_tool(request: MCPToolRequest): + """MCP 도구 호출""" + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{settings.MCP_SERVER_URL}/tools/call", + json={ + "tool_name": request.tool_name, + "parameters": request.parameters + }, + timeout=30.0 + ) + + if response.status_code == 200: + return response.json() + else: + raise HTTPException( + status_code=response.status_code, + detail=f"MCP tool call failed: {response.text}" + ) + + except httpx.TimeoutException: + raise HTTPException(status_code=504, detail="MCP server timeout") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to call MCP tool: {str(e)}") + + +@router.post("/analyze") +async def analyze_with_claude(request: MCPAnalysisRequest): + """Claude AI를 통한 분석 요청""" + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{settings.MCP_SERVER_URL}/analyze", + json={ + "analysis_type": request.analysis_type, + "data": request.data + }, + timeout=60.0 + ) + + if response.status_code == 200: + return response.json() + else: + raise HTTPException( + status_code=response.status_code, + detail=f"Claude analysis failed: {response.text}" + ) + + except httpx.TimeoutException: + raise HTTPException(status_code=504, detail="Analysis timeout") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to analyze: {str(e)}") + + +@router.get("/tools") +async def list_mcp_tools(): + """사용 가능한 MCP 도구 목록""" + try: + async with httpx.AsyncClient() as client: + response = await client.get(f"{settings.MCP_SERVER_URL}/tools", timeout=10.0) + + if response.status_code == 200: + return response.json() + else: + raise HTTPException( + status_code=response.status_code, + detail="Failed to fetch tools list" + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to list tools: {str(e)}") diff --git a/backend/app/api/endpoints/monitoring.py b/backend/app/api/endpoints/monitoring.py new file mode 100644 index 0000000..5b17b3c --- /dev/null +++ b/backend/app/api/endpoints/monitoring.py @@ -0,0 +1,69 @@ +""" +API 모니터링 엔드포인트 + +KIS API 호출 통계 및 상태 확인 +""" + +from fastapi import APIRouter +from typing import Dict, Any +import logging + +from app.services.korea_investment_api import korea_investment_api + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/api-stats") +async def get_api_stats() -> Dict[str, Any]: + """ + KIS API 호출 통계 조회 + + Returns: + - total_calls: 총 API 호출 수 + - total_errors: 총 오류 수 + - total_retries: 총 재시도 수 + - error_rate: 오류율 (0.0 ~ 1.0) + - avg_duration: 평균 응답 시간 (초) + - recent_calls: 최근 모니터링 윈도우 내 호출 수 + """ + stats = korea_investment_api.get_monitor_stats() + + # 상태 평가 + status = "healthy" + if stats["error_rate"] > 0.5: + status = "critical" + elif stats["error_rate"] > 0.3: + status = "warning" + + return { + "status": status, + "stats": stats, + "message": f"오류율: {stats['error_rate']:.1%}, 평균 응답: {stats['avg_duration']:.2f}초" + } + + +@router.post("/log-stats") +async def log_api_stats() -> Dict[str, str]: + """ + 현재 API 통계를 로그에 기록 + """ + korea_investment_api.log_monitor_stats() + return {"message": "통계가 로그에 기록되었습니다"} + + +@router.get("/health") +async def health_check() -> Dict[str, Any]: + """ + API 헬스체크 + """ + stats = korea_investment_api.get_monitor_stats() + + is_healthy = stats["error_rate"] < 0.5 + + return { + "status": "healthy" if is_healthy else "unhealthy", + "error_rate": stats["error_rate"], + "total_calls": stats["total_calls"], + "timestamp": __import__("datetime").datetime.now().isoformat() + } diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py new file mode 100644 index 0000000..a0f859b --- /dev/null +++ b/backend/app/api/endpoints/portfolio.py @@ -0,0 +1,228 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import List +from decimal import Decimal + +from app.core.database import get_db +from app.models.models import Portfolio +from app.services.korea_investment_api import korea_investment_api +import asyncio +import logging +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +# 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요. +class PortfolioCache: + def __init__(self): + self.holdings = [] # List[Portfolio] + self.summary = None # Dict + self.last_synced: datetime | None = None + self.change_flag = False # 외부 이벤트로 변경 감지 (매수/매도 등) + self.sync_lock = asyncio.Lock() + self.min_interval = timedelta(seconds=15) # 최소 동기화 간격 + self.last_attempt: datetime | None = None + self.last_error: str | None = None + # 실패 관리 (백오프) + self.failure_count: int = 0 + self.next_retry_at: datetime | None = None + self.last_sync_success: bool = False + + def mark_changed(self): + self.change_flag = True + + def should_sync(self) -> bool: + now = datetime.now() + # 최초 또는 변경 플래그 또는 최소간격 경과 후 오류 상태 재시도 + if self.last_synced is None: + return True + if self.change_flag: + return True + # 오류 발생 시 백오프 기간에는 재시도 금지 + if self.last_error: + if self.next_retry_at and now < self.next_retry_at: + return False + # 백오프가 지났으면 재시도 허용 (최소 간격과 무관) + return True + if (now - self.last_synced) > timedelta(minutes=5): # 주기적 신선도 보장 + return True + return False + + def _compute_backoff(self) -> timedelta: + # 30s, 60s, 120s, max 5min + base = 30 + delay = base * (2 ** max(self.failure_count - 1, 0)) + delay = min(delay, 300) + return timedelta(seconds=delay) + + def record_failure(self, err: Exception | str): + self.last_error = str(err) + self.failure_count += 1 + self.next_retry_at = datetime.now() + self._compute_backoff() + self.last_sync_success = False + + def record_success(self): + self.last_error = None + self.failure_count = 0 + self.next_retry_at = None + self.last_sync_success = True + + def cache_summary(self): + holdings = self.holdings + total_stocks = len(holdings) + total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings) + total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings) + total_profit_loss = sum(float(h.profit_loss or 0) for h in holdings) + total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0 + self.summary = { + "total_stocks": total_stocks, + "total_value": total_value, + "total_investment": total_investment, + "total_profit_loss": total_profit_loss, + "total_profit_loss_rate": total_profit_loss_rate + } + +portfolio_cache = PortfolioCache() +from pydantic import BaseModel + +router = APIRouter() + + +class PortfolioResponse(BaseModel): + id: int + stock_code: str + stock_name: str = None + quantity: int + average_price: Decimal = None + current_price: Decimal = None + profit_loss: Decimal = None + profit_loss_rate: Decimal = None + + class Config: + from_attributes = True + + +@router.get("", response_model=List[PortfolioResponse]) +async def get_portfolio(db: AsyncSession = Depends(get_db)): + """포트폴리오 조회 (캐시 활용)""" + if portfolio_cache.holdings and not portfolio_cache.should_sync(): + logger.debug("Portfolio cache hit (holdings)") + return portfolio_cache.holdings + # 필요하면 동기화 시도 + try: + await sync_portfolio(db, force=False) + except HTTPException: + # 강제 동기화가 아닌 경우에는 오류를 사용자에게 전파하지 않고 캐시/빈 목록 반환 + logger.warning("get_portfolio: sync failed, returning cached holdings") + return portfolio_cache.holdings + + +@router.get("/sync") +async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True): + """실제 계좌와 포트폴리오 동기화 (캐시 반영) + force=False이면 캐시 정책에 따라 생략될 수 있음. + """ + if not force and not portfolio_cache.should_sync(): + logger.debug("Skip sync: cache fresh & no change flag") + return {"message": "Skipped (cache fresh)", "cached": True} + async with portfolio_cache.sync_lock: + portfolio_cache.last_attempt = datetime.now() + try: + balance_data = await korea_investment_api.get_balance() + portfolio_cache.record_success() + # 기존 레코드 로드 (삭제 대신 업데이트) + result = await db.execute(select(Portfolio)) + existing_all = {p.stock_code: p for p in result.scalars().all()} + processed_codes = set() + new_holdings = [] + if "output1" in balance_data: + for item in balance_data["output1"]: + stock_code = item.get("pdno") + stock_name = item.get("prdt_name") + quantity = int(item.get("hldg_qty", 0)) + if quantity <= 0: + continue + average_price = Decimal(item.get("pchs_avg_pric", 0)) + current_price = Decimal(item.get("prpr", 0)) + profit_loss = Decimal(item.get("evlu_pfls_amt", 0)) + profit_loss_rate = Decimal(item.get("evlu_pfls_rt", 0)) + processed_codes.add(stock_code) + if stock_code in existing_all: + h = existing_all[stock_code] + h.stock_name = stock_name + h.quantity = quantity + h.average_price = average_price + h.current_price = current_price + h.profit_loss = profit_loss + h.profit_loss_rate = profit_loss_rate + new_holdings.append(h) + else: + new_holding = Portfolio( + stock_code=stock_code, + stock_name=stock_name, + quantity=quantity, + average_price=average_price, + current_price=current_price, + profit_loss=profit_loss, + profit_loss_rate=profit_loss_rate + ) + db.add(new_holding) + new_holdings.append(new_holding) + # 제거된 종목 반영 (수량 0 처리) + for code, h in existing_all.items(): + if code not in processed_codes: + h.quantity = 0 + await db.commit() + portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0] + portfolio_cache.cache_summary() + portfolio_cache.last_synced = datetime.now() + portfolio_cache.change_flag = False + logger.info(f"Portfolio synced: {len(portfolio_cache.holdings)} holdings") + return {"message": "Portfolio synchronized", "count": len(portfolio_cache.holdings), "cached": False} + except Exception as e: + portfolio_cache.record_failure(e) + logger.error(f"Portfolio sync failed: {e}") + if force: + # 명시적 강제 동기화 요청 시에는 오류 전파 + raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}") + # 비강제(백그라운드) 동기화 실패 시에는 캐시/빈 값 사용 + return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)} + + +@router.get("/summary") +async def get_portfolio_summary(db: AsyncSession = Depends(get_db)): + """포트폴리오 요약 (캐시 활용)""" + if portfolio_cache.summary and not portfolio_cache.should_sync(): + logger.debug("Portfolio cache hit (summary)") + resp = dict(portfolio_cache.summary) + resp.update({ + "sync_status": "ok" if portfolio_cache.last_error is None else "error", + "last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None, + "last_error": portfolio_cache.last_error, + }) + return resp + try: + await sync_portfolio(db, force=False) + except HTTPException: + logger.warning("get_portfolio_summary: sync failed, returning cached summary") + base = portfolio_cache.summary or { + "total_stocks": 0, + "total_value": 0, + "total_investment": 0, + "total_profit_loss": 0, + "total_profit_loss_rate": 0 + } + resp = dict(base) + resp.update({ + "sync_status": "ok" if portfolio_cache.last_error is None else "error", + "last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None, + "last_error": portfolio_cache.last_error, + }) + return resp + +@router.post("/refresh") +async def refresh_portfolio(db: AsyncSession = Depends(get_db)): + """강제 캐시 무효화 및 재동기화""" + portfolio_cache.change_flag = True + return await sync_portfolio(db, force=True) diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py new file mode 100644 index 0000000..52ce90e --- /dev/null +++ b/backend/app/api/endpoints/recommendations.py @@ -0,0 +1,1378 @@ +from fastapi import APIRouter, HTTPException, Query, Depends +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime, date +from decimal import Decimal +import asyncio +import logging + +from app.services.korea_investment_api import korea_investment_api +from app.core.database import get_db +from app.models.models import TradeHistory, Portfolio, StockAnalysis +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ + +router = APIRouter() +logger = logging.getLogger(__name__) + + +class RecommendationCriteria(BaseModel): + """추천 종목 조건""" + use_macd: bool = False + use_rsi: bool = False + use_bollinger: bool = False + use_moving_average: bool = False + use_stochastic: bool = False + use_adx: bool = False + use_obv: bool = False + + +class StockRecommendation(BaseModel): + """추천 종목 정보""" + stock_code: str + stock_name: str + current_price: float + change_rate: float + signals: List[str] + score: int + price_date: str = "" # 가격 기준일 (YYYYMMDD 형식) + # 추가 지표 (배치 저장 및 API 응답에 사용) + volume: Optional[int] = None + rsi: Optional[float] = None + ma5: Optional[float] = None + ma20: Optional[float] = None + ma60: Optional[float] = None + stochastic_k: Optional[float] = None + stochastic_d: Optional[float] = None + bollinger_upper: Optional[float] = None + bollinger_lower: Optional[float] = None + bollinger_middle: Optional[float] = None + + +# 공식 기반 신호 분석 함수 (이동평균 정배열/역배열, 눌림목, 손절/익절) +def analyze_custom_signals(chart_data): + """커스텀 매매 공식 기반 신호 분석""" + closes = [float(item.get("stck_clpr", 0)) for item in chart_data] + if len(closes) < 120: + return None + + ma20 = sum(closes[-20:]) / 20 + ma60 = sum(closes[-60:]) / 60 + ma120 = sum(closes[-120:]) / 120 + close = closes[-1] + + # 정배열 매수 + if ma20 > ma60 > ma120 and close > ma20: + return {"signal": "buy", "description": "정배열 상승 매수타점"} + + # 역배열 매도 + elif ma20 < ma60 < ma120 and close < ma20: + return {"signal": "sell", "description": "역배열 하락 매도타점"} + + # 눌림목 진입 + recent_lows = [float(item.get("stck_lwpr", 0)) for item in chart_data[-5:]] + if any(low < ma20 for low in recent_lows) and close > ma20: + return {"signal": "buy", "description": "눌림목 진입 매수타점"} + + # 손절/익절 + stop_loss_line = ma60 + take_profit_line = ma20 * 1.05 + if close < stop_loss_line: + return {"signal": "exit", "description": "손절라인 이탈"} + if close > take_profit_line: + return {"signal": "take_profit", "description": "익절라인 돌파"} + + return None + + +def analyze_macd(chart_data: list) -> dict: + """MACD 분석 (골든크로스/데드크로스)""" + if len(chart_data) < 26: + return {"signal": None, "description": "데이터 부족"} + + # 간단한 MACD 계산 (12일, 26일, 9일) + prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-26:]] + + if len(prices) < 26: + return {"signal": None, "description": "데이터 부족"} + + # EMA 계산 + def calculate_ema(prices, period): + ema = [sum(prices[:period]) / period] + multiplier = 2 / (period + 1) + for price in prices[period:]: + ema.append((price - ema[-1]) * multiplier + ema[-1]) + return ema + + ema12 = calculate_ema(prices, 12) + ema26 = calculate_ema(prices, 26) + + if len(ema12) < 2 or len(ema26) < 2: + return {"signal": None, "description": "데이터 부족"} + + macd = [ema12[i] - ema26[i] for i in range(len(ema26))] + signal = calculate_ema(macd, 9) + + if len(signal) < 2: + return {"signal": None, "description": "데이터 부족"} + + # 골든크로스: MACD가 시그널선을 상향 돌파 + if macd[-2] <= signal[-2] and macd[-1] > signal[-1]: + return {"signal": "buy", "description": "MACD 골든크로스"} + # 데드크로스: MACD가 시그널선을 하향 돌파 + elif macd[-2] >= signal[-2] and macd[-1] < signal[-1]: + return {"signal": "sell", "description": "MACD 데드크로스"} + + return {"signal": None, "description": "신호 없음"} + + +def analyze_rsi(chart_data: list) -> dict: + """RSI 분석 (과매수/과매도)""" + if len(chart_data) < 14: + return {"signal": None, "description": "데이터 부족"} + + prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-15:]] + + # RSI 계산 + gains = [] + losses = [] + for i in range(1, len(prices)): + change = prices[i] - prices[i-1] + if change > 0: + gains.append(change) + losses.append(0) + else: + gains.append(0) + losses.append(abs(change)) + + avg_gain = sum(gains[-14:]) / 14 + avg_loss = sum(losses[-14:]) / 14 + + if avg_loss == 0: + rsi = 100 + else: + rs = avg_gain / avg_loss + rsi = 100 - (100 / (1 + rs)) + + # 조건 완화: 40 이하 매수, 60 이상 매도 + if rsi < 40: + return {"signal": "buy", "description": f"RSI 과매도 ({rsi:.1f})"} + elif rsi > 60: + return {"signal": "sell", "description": f"RSI 과매수 ({rsi:.1f})"} + elif 45 <= rsi <= 55: + # 중립 구간도 약한 매수 신호로 + return {"signal": "buy", "description": f"RSI 중립-상승 ({rsi:.1f})"} + + return {"signal": None, "description": f"RSI 중립 ({rsi:.1f})"} + + +def analyze_bollinger(chart_data: list) -> dict: + """볼린저밴드 분석""" + if len(chart_data) < 20: + return {"signal": None, "description": "데이터 부족"} + + prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-20:]] + + # 20일 이동평균 + ma20 = sum(prices) / len(prices) + + # 표준편차 + variance = sum((p - ma20) ** 2 for p in prices) / len(prices) + std_dev = variance ** 0.5 + + upper_band = ma20 + (2 * std_dev) + lower_band = ma20 - (2 * std_dev) + current_price = prices[-1] + + if current_price < lower_band: + return {"signal": "buy", "description": "볼린저 하한선 터치"} + elif current_price > upper_band: + return {"signal": "sell", "description": "볼린저 상한선 돌파"} + + return {"signal": None, "description": "볼린저 중립"} + + +def analyze_moving_average(chart_data: list) -> dict: + """이동평균선 분석 (5, 20, 60일)""" + if len(chart_data) < 60: + return {"signal": None, "description": "데이터 부족"} + + prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-60:]] + + ma5 = sum(prices[-5:]) / 5 + ma20 = sum(prices[-20:]) / 20 + ma60 = sum(prices[-60:]) / 60 + current_price = prices[-1] + + # 조건 완화: 5일선이 20일선 위에만 있어도 매수 + if ma5 > ma20 and current_price > ma5: + return {"signal": "buy", "description": "5일선 > 20일선"} + # 20일선이 60일선 위에 있으면 추가 점수 + elif ma20 > ma60 and current_price > ma20: + return {"signal": "buy", "description": "20일선 > 60일선"} + # 역배열 + elif ma5 < ma20 < ma60 and current_price < ma5: + return {"signal": "sell", "description": "이동평균 역배열"} + + return {"signal": None, "description": "이동평균 중립"} + + +def analyze_stochastic(chart_data: list) -> dict: + """스토캐스틱 분석""" + if len(chart_data) < 14: + return {"signal": None, "description": "데이터 부족"} + + recent_data = chart_data[-14:] + highs = [float(item.get("stck_hgpr", 0)) for item in recent_data] + lows = [float(item.get("stck_lwpr", 0)) for item in recent_data] + closes = [float(item.get("stck_clpr", 0)) for item in recent_data] + + highest_high = max(highs) + lowest_low = min(lows) + current_close = closes[-1] + + if highest_high == lowest_low: + return {"signal": None, "description": "데이터 부족"} + + k = ((current_close - lowest_low) / (highest_high - lowest_low)) * 100 + + # 조건 완화: 30 이하 매수, 70 이상 매도 + if k < 30: + return {"signal": "buy", "description": f"스토캐스틱 과매도 (%K: {k:.1f})"} + elif k > 70: + return {"signal": "sell", "description": f"스토캐스틱 과매수 (%K: {k:.1f})"} + + return {"signal": None, "description": f"스토캐스틱 중립 (%K: {k:.1f})"} + + +def analyze_adx(chart_data: list) -> dict: + """ADX 분석 (추세 강도)""" + if len(chart_data) < 14: + return {"signal": None, "description": "데이터 부족"} + + # 간단한 ADX 추정 (변동성 기반) + recent_data = chart_data[-14:] + changes = [] + for item in recent_data: + high = float(item.get("stck_hgpr", 0)) + low = float(item.get("stck_lwpr", 0)) + changes.append(high - low) + + avg_change = sum(changes) / len(changes) + current_change = changes[-1] + + # 추세 강도 추정 + if current_change > avg_change * 1.5: + return {"signal": "buy", "description": "ADX 강한 추세"} + elif current_change < avg_change * 0.5: + return {"signal": "sell", "description": "ADX 약한 추세"} + + return {"signal": None, "description": "ADX 중립"} + + +def analyze_obv(chart_data: list) -> dict: + """거래량(OBV) 분석""" + if len(chart_data) < 2: + return {"signal": None, "description": "데이터 부족"} + + volumes = [int(item.get("acml_vol", 0)) for item in chart_data[-5:]] + closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-5:]] + + if len(volumes) < 2 or len(closes) < 2: + return {"signal": None, "description": "데이터 부족"} + + # 거래량 증가와 가격 상승 + if volumes[-1] > volumes[-2] * 1.5 and closes[-1] > closes[-2]: + return {"signal": "buy", "description": "거래량 증가와 상승"} + # 거래량 감소와 가격 하락 + elif volumes[-1] < volumes[-2] * 0.5 and closes[-1] < closes[-2]: + return {"signal": "sell", "description": "거래량 감소와 하락"} + + return {"signal": None, "description": "거래량 중립"} + + +async def analyze_stock(stock_code: str, stock_name: str, criteria: RecommendationCriteria) -> Optional[StockRecommendation]: + """개별 종목 분석""" + try: + logger.info(f"Analyzing stock: {stock_code}") + + # 차트 데이터 조회 (현재가 포함) + chart_response = await korea_investment_api.get_daily_chart(stock_code, "D") + if "output" not in chart_response or not chart_response["output"]: + logger.warning(f"No chart data for {stock_code}") + return None + + chart_data = chart_response["output"] + logger.info(f"Got {len(chart_data)} chart data points for {stock_code}") + + # 최신 데이터에서 현재가 및 종목명 추출 + latest = chart_data[0] if chart_data else None + if not latest: + return None + + # 종목명이 없으면 차트 데이터에서 추출 시도 + if not stock_name: + stock_name = latest.get("hts_kor_isnm", stock_code) + + current_price = float(latest.get("stck_clpr", 0)) + price_date = latest.get("stck_bsop_date", "") # 가격 기준일 + volume = int(latest.get("acml_vol", 0) or 0) + if current_price == 0: + logger.warning(f"Zero price for {stock_code}") + return None + + # 등락률 계산 + if len(chart_data) >= 2: + prev_price = float(chart_data[1].get("stck_clpr", current_price)) + change_rate = ((current_price - prev_price) / prev_price) * 100 if prev_price > 0 else 0 + else: + change_rate = 0 + + # 기본 수치형 지표 계산 (배치 저장용) + closes = [float(item.get("stck_clpr", 0)) for item in chart_data] + ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else None + ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else None + ma60 = sum(closes[-60:]) / 60 if len(closes) >= 60 else None + + # RSI 계산 (단순 방식) + rsi = None + if len(closes) >= 15: + prices_for_rsi = closes[-15:] + gains = [] + losses = [] + for i in range(1, len(prices_for_rsi)): + diff = prices_for_rsi[i] - prices_for_rsi[i-1] + gains.append(max(diff, 0)) + losses.append(max(-diff, 0)) + avg_gain = sum(gains[-14:]) / 14 + avg_loss = sum(losses[-14:]) / 14 + if avg_loss == 0: + rsi = 100.0 + else: + rs = avg_gain / avg_loss + rsi = 100 - (100 / (1 + rs)) + + # Stochastic %K, %D 간단 계산 + stochastic_k = None + stochastic_d = None + if len(chart_data) >= 14: + recent = chart_data[-14:] + highs = [float(item.get("stck_hgpr", 0)) for item in recent] + lows = [float(item.get("stck_lwpr", 0)) for item in recent] + closes_for_stoch = [float(item.get("stck_clpr", 0)) for item in recent] + highest_high = max(highs) + lowest_low = min(lows) + if highest_high != lowest_low: + k = ((closes_for_stoch[-1] - lowest_low) / (highest_high - lowest_low)) * 100 + stochastic_k = k + # %D는 간단히 최근 3일 %K 평균 + stochastic_d_vals = [] + for j in range(len(recent)-3, len(recent)): + slice_high = max([float(item.get("stck_hgpr", 0)) for item in recent[j-3 if j-3>=0 else 0:j+1]]) + slice_low = min([float(item.get("stck_lwpr", 0)) for item in recent[j-3 if j-3>=0 else 0:j+1]]) + if slice_high != slice_low: + slice_k = ((float(recent[j].get("stck_clpr", 0)) - slice_low) / (slice_high - slice_low)) * 100 + stochastic_d_vals.append(slice_k) + if stochastic_d_vals: + stochastic_d = sum(stochastic_d_vals) / len(stochastic_d_vals) + + # Bollinger bands + bollinger_upper = bollinger_lower = bollinger_middle = None + if len(closes) >= 20: + window = closes[-20:] + middle = sum(window) / len(window) + variance = sum((p - middle) ** 2 for p in window) / len(window) + stddev = variance ** 0.5 + bollinger_middle = middle + bollinger_upper = middle + 2 * stddev + bollinger_lower = middle - 2 * stddev + + # 지표 분석 + signals = [] + score = 0 + + # 커스텀 공식 기반 신호 분석 적용 (정배열, 역배열, 눌림목, 손절/익절) + custom_result = analyze_custom_signals(chart_data) + if custom_result: + if custom_result["signal"] == "buy": + signals.append(custom_result["description"]) + score += 3 # 높은 가중치 + elif custom_result["signal"] == "sell": + # 매도 신호는 추천에서 제외하거나 음수 점수 + signals.append(custom_result["description"]) + score -= 2 + elif custom_result["signal"] == "exit": + # 손절 신호는 추천에서 제외 + signals.append(custom_result["description"]) + score -= 5 + elif custom_result["signal"] == "take_profit": + # 익절 신호는 양수 점수 + signals.append(custom_result["description"]) + score += 2 + + if criteria.use_macd: + result = analyze_macd(chart_data) + if result["signal"] == "buy": + signals.append(result["description"]) + score += 2 + + if criteria.use_rsi: + result = analyze_rsi(chart_data) + if result["signal"] == "buy": + signals.append(result["description"]) + score += 2 + + if criteria.use_bollinger: + result = analyze_bollinger(chart_data) + if result["signal"] == "buy": + signals.append(result["description"]) + score += 1 + + if criteria.use_moving_average: + result = analyze_moving_average(chart_data) + if result["signal"] == "buy": + signals.append(result["description"]) + score += 2 + + if criteria.use_stochastic: + result = analyze_stochastic(chart_data) + if result["signal"] == "buy": + signals.append(result["description"]) + score += 1 + + if criteria.use_adx: + result = analyze_adx(chart_data) + if result["signal"] == "buy": + signals.append(result["description"]) + score += 1 + + if criteria.use_obv: + result = analyze_obv(chart_data) + if result["signal"] == "buy": + signals.append(result["description"]) + score += 1 + + logger.info(f"{stock_code} ({stock_name}): score={score}, signals={signals}") + + # 신호가 있거나 점수가 0 이상이면 추천 (규칙 기반에서는 매칭된 종목 모두 표시) + if len(signals) > 0 or score > 0: + # 신호가 있으면 최소 1점 보장 + final_score = max(1, score) if len(signals) > 0 else score + + return StockRecommendation( + stock_code=stock_code, + stock_name=stock_name, + current_price=current_price, + change_rate=change_rate, + signals=signals if signals else ["조건 분석 중"], + score=final_score, + price_date=price_date, + volume=volume, + rsi=rsi, + ma5=ma5, + ma20=ma20, + ma60=ma60, + stochastic_k=stochastic_k, + stochastic_d=stochastic_d, + bollinger_upper=bollinger_upper, + bollinger_lower=bollinger_lower, + bollinger_middle=bollinger_middle + ) + + logger.debug(f"{stock_code}: No recommendation (score={score})") + return None + except Exception as e: + logger.error(f"Error analyzing {stock_code}: {str(e)}") + return None + + +async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Optional[StockRecommendation]: + """호출 편의성: 모든 지표를 켜서 분석한 결과(Pydantic 모델)를 반환합니다. + + 이 함수는 배치 스크립트에서 사용됩니다. + """ + criteria = RecommendationCriteria( + use_macd=True, + use_rsi=True, + use_bollinger=True, + use_moving_average=True, + use_stochastic=True, + use_adx=True, + use_obv=True + ) + return await analyze_stock(stock_code, stock_name, criteria) + + +async def get_all_tradable_stocks() -> List[tuple]: + """KIS API로 전체 거래 가능 종목 조회""" + try: + logger.info("Fetching all tradable stocks from KIS API...") + + # 이전 API 호출과의 충돌을 피하기 위해 대기 + await asyncio.sleep(3.0) + + # KOSPI 거래대금 상위 종목 조회 + kospi_result = await korea_investment_api.get_stock_list("J") + logger.info(f"KOSPI result: {kospi_result}") + + # Rate limit을 위한 대기 (초당 1건 제한) - 안전하게 3초 대기 + await asyncio.sleep(3.0) + + # KOSDAQ 거래대금 상위 종목 조회 + kosdaq_result = await korea_investment_api.get_stock_list("Q") + logger.info(f"KOSDAQ result: {kosdaq_result}") + + stocks = [] + + # KOSPI 종목 추출 + if "output" in kospi_result: + for item in kospi_result["output"]: + stock_code = item.get("mksc_shrn_iscd", "") + stock_name = item.get("hts_kor_isnm", "") + if stock_code and stock_name: + stocks.append((stock_code, stock_name)) + + # KOSDAQ 종목 추가 + if "output" in kosdaq_result: + for item in kosdaq_result["output"]: + stock_code = item.get("mksc_shrn_iscd", "") + stock_name = item.get("hts_kor_isnm", "") + if stock_code and stock_name: + stocks.append((stock_code, stock_name)) + + logger.info(f"Found {len(stocks)} tradable stocks from KIS API") + + # API에서 종목을 못 가져온 경우 기본 종목 리스트 사용 + if not stocks: + logger.warning("No stocks from KIS API, using default list") + stocks = [ + ("005930", "삼성전자"), + ("000660", "SK하이닉스"), + ("035420", "네이버"), + ("051910", "LG화학"), + ("005380", "현대차"), + ("006400", "삼성SDI"), + ("035720", "카카오"), + ("000270", "기아"), + ("068270", "셀트리온"), + ("207940", "삼성바이오로직스"), + ] + + return stocks + + except Exception as e: + logger.error(f"Error fetching stock list from KIS API: {e}", exc_info=True) + # API 실패 시 기본 종목 리스트 반환 + logger.warning("Using default stock list due to API error") + return [ + ("005930", "삼성전자"), + ("000660", "SK하이닉스"), + ("035420", "네이버"), + ("051910", "LG화학"), + ("005380", "현대차"), + ("006400", "삼성SDI"), + ("035720", "카카오"), + ("000270", "기아"), + ("068270", "셀트리온"), + ("207940", "삼성바이오로직스"), + ] + + +@router.get("/recommendations") +async def get_recommendations( + use_macd: bool = Query(False, description="MACD 지표 사용"), + use_rsi: bool = Query(False, description="RSI 지표 사용"), + use_bollinger: bool = Query(False, description="볼린저밴드 사용"), + use_moving_average: bool = Query(False, description="이동평균선 사용"), + use_stochastic: bool = Query(False, description="스토캐스틱 사용"), + use_adx: bool = Query(False, description="ADX 사용"), + use_obv: bool = Query(False, description="거래량(OBV) 사용"), + max_stocks: int = Query(100, description="분석할 최대 종목 수 (기본 100개)"), +): + """기술적 지표 기반 추천 종목 조회 + + 선택한 기술적 지표 조건에 부합하는 종목을 KOSPI 시가총액 상위 종목 중에서 찾아 추천합니다. + """ + + criteria = RecommendationCriteria( + use_macd=use_macd, + use_rsi=use_rsi, + use_bollinger=use_bollinger, + use_moving_average=use_moving_average, + use_stochastic=use_stochastic, + use_adx=use_adx, + use_obv=use_obv + ) + + # 모든 지표가 꺼져있으면 기본값 사용 + if not any([use_macd, use_rsi, use_bollinger, use_moving_average, use_stochastic, use_adx, use_obv]): + criteria.use_rsi = True + criteria.use_moving_average = True + + # 분석할 종목 리스트 가져오기 (KIS API에서 실시간 조회) + all_stocks = await get_all_tradable_stocks() + + if not all_stocks: + logger.warning("No stocks found from KIS API") + return { + "total": 0, + "analyzed_stocks": 0, + "criteria": criteria.dict(), + "recommendations": [], + "message": "거래 가능한 종목을 조회할 수 없습니다" + } + + stocks_to_analyze = all_stocks[:max_stocks] + + logger.info(f"Analyzing {len(stocks_to_analyze)} stocks with criteria: {criteria.dict()}") + + # Rate Limit 방지: 배치 처리 (초당 최대 5개) + batch_size = 5 + delay_between_batches = 1.2 # 1.2초 대기 + + all_results = [] + + for i in range(0, len(stocks_to_analyze), batch_size): + batch = stocks_to_analyze[i:i + batch_size] + batch_num = i // batch_size + 1 + total_batches = (len(stocks_to_analyze) + batch_size - 1) // batch_size + logger.info(f"Processing batch {batch_num}/{total_batches} (stocks {i+1}-{min(i+batch_size, len(stocks_to_analyze))})") + + # 배치 단위로 병렬 처리 + tasks = [analyze_stock(code, name, criteria) for code, name in batch] + batch_results = await asyncio.gather(*tasks, return_exceptions=True) + all_results.extend(batch_results) + + # 다음 배치 전 대기 (마지막 배치는 제외) + if i + batch_size < len(stocks_to_analyze): + await asyncio.sleep(delay_between_batches) + + # 예외 로깅 + errors = [r for r in all_results if isinstance(r, Exception)] + if errors: + logger.error(f"Encountered {len(errors)} errors during analysis") + + # None 및 예외 제거 후 점수순 정렬 + recommendations = [r for r in all_results if r is not None and not isinstance(r, Exception)] + recommendations.sort(key=lambda x: x.score, reverse=True) + + logger.info(f"Found {len(recommendations)} recommendations out of {len(stocks_to_analyze)} analyzed stocks") + + if not recommendations: + logger.warning("No recommendations found! Criteria might be too strict or API failed") + + return { + "total": len(recommendations), + "analyzed_stocks": len(stocks_to_analyze), + "criteria": criteria.dict(), + "recommendations": recommendations[:20] # 상위 20개만 반환 + } + + +@router.get("/rule-based") +async def get_rule_based_recommendations( + realtime: bool = Query(False, description="실시간 분석 여부 (느림, 30초)"), + db: AsyncSession = Depends(get_db) +): + """ + 매매 규칙 기반 자동 추천 + + - realtime=false (기본): DB에서 조회 (빠름, 0.1초) - 매일 아침 9시 데이터 + - realtime=true: KIS API 실시간 분석 (느림, 30초) + + 활성화된 모든 매매 규칙을 확인하고, 조건에 맞는 종목을 자동으로 추천합니다. + """ + + # 실시간 모드: 기존 방식 (KIS API 직접 호출) + if realtime: + logger.info("🔴 Real-time mode: Fetching live data from KIS API (slow)") + return await get_rule_based_recommendations_realtime() + + # DB 모드: 빠른 조회 + logger.info("🟢 DB mode: Fetching from cached analysis (fast)") + return await get_rule_based_recommendations_from_db(db) + + +async def get_rule_based_recommendations_from_db(db: AsyncSession): + """DB에서 사전 분석된 데이터 조회 (빠름 - 0.1초)""" + + logger.info("Rule-based recommendations from DB") + + # 샘플 규칙 생성 (실제로는 DB에서 조회) + from collections import namedtuple + Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active']) + + active_rules = [ + Rule( + id=1, + name="RSI 과매도 반등", + description="RSI 40 이하 과매도 종목", + rule_type="BUY", + priority=1, + conditions={"rsi_threshold": 40}, + actions={"take_profit": 5.0, "stop_loss": 3.0, "position_sizing": {"target_ratio": 0.15}}, + is_active=True + ), + Rule( + id=2, + name="볼린저밴드 하단", + description="볼린저밴드 하단 근처 반등", + rule_type="BUY", + priority=3, + conditions={"bb_threshold": 0.2}, + actions={"take_profit": 7.0, "stop_loss": 4.0, "position_sizing": {"target_ratio": 0.15}}, + is_active=True + ) + ] + + logger.info(f"Found {len(active_rules)} active trading rules") + + # 오늘 날짜의 분석 데이터 조회 + today = date.today() + result = await db.execute( + select(StockAnalysis) + .where(StockAnalysis.analysis_date >= today) + .order_by(StockAnalysis.score.desc()) + ) + all_analyzed_stocks = result.scalars().all() + + if not all_analyzed_stocks: + logger.warning(f"No analysis data found for {today}. Run batch_daily_analysis.py first!") + # ✅ 안전장치: DB에 데이터가 없으면 실시간 분석으로 자동 폴백 + try: + logger.info("Falling back to realtime analysis due to empty DB cache...") + fallback = await get_rule_based_recommendations_realtime() + # 메시지 보강 + base_msg = fallback.get("message") or "" + fallback["message"] = ( + f"⚠️ 배치 캐시 없음 → 실시간 분석으로 대체 ({today}). " + + base_msg + ) + return fallback + except Exception as e: + logger.error(f"Realtime fallback failed: {e}") + return { + "total": 0, + "active_rules": len(active_rules), + "analyzed_stocks": 0, + "recommendations": [], + "recommendations_by_rule": [], + "rules_applied": [], + "message": f"배치 분석 데이터가 없습니다 ({today}). 실시간 분석 폴백도 실패: {e}" + } + + logger.info(f"Found {len(all_analyzed_stocks)} stocks in DB for {today}") + + # 규칙별로 매칭되는 종목 찾기 + rule_matches = [] + recommendations_by_rule = [] + + for rule in active_rules: + logger.info(f"Applying rule: {rule.name}") + + conditions = rule.conditions + actions = rule.actions + + # DB에서 조회한 종목 중 규칙에 맞는 종목 필터링 + rule_matched_stocks = [] + + for stock in all_analyzed_stocks: + matches = True + + # RSI 조건 체크 + if "rsi_threshold" in conditions: + rsi_threshold = conditions["rsi_threshold"] + if not stock.rsi or stock.rsi > rsi_threshold: + matches = False + + # 볼린저밴드 하단 체크 + if "bb_threshold" in conditions: + if not stock.bollinger_lower or not stock.current_price: + matches = False + elif stock.current_price > stock.bollinger_lower * 1.05: # 하단 5% 이내 + matches = False + + if matches: + rule_matched_stocks.append(stock) + + # 점수순 정렬 + rule_matched_stocks.sort(key=lambda x: x.score, reverse=True) + + # 규칙별로 종목 저장 + rule_stocks = [] + for stock in rule_matched_stocks[:10]: # 규칙당 상위 10개 + stock_dict = { + "stock_code": stock.stock_code, + "stock_name": stock.stock_name, + "current_price": stock.current_price, + "change_rate": float(stock.change_rate) if stock.change_rate else 0, + "volume": stock.volume, + "rsi": float(stock.rsi) if stock.rsi else None, + "ma5": float(stock.ma5) if stock.ma5 else None, + "ma20": float(stock.ma20) if stock.ma20 else None, + "stochastic_k": float(stock.stochastic_k) if stock.stochastic_k else None, + "signals": stock.signals or [], + "score": stock.score, + "price_date": stock.analysis_date.strftime("%Y%m%d"), + "rule_id": rule.id, + "rule_name": rule.name, + "rule_description": rule.description, + "rule_priority": rule.priority, + "position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.15), + "take_profit": actions.get("take_profit", 5.0), + "stop_loss": actions.get("stop_loss", 3.0) + } + rule_stocks.append(stock_dict) + rule_matches.append(stock_dict) + + # 규칙별 그룹 추가 + if rule_stocks: + recommendations_by_rule.append({ + "rule_id": rule.id, + "rule_name": rule.name, + "rule_description": rule.description, + "rule_type": rule.rule_type, + "priority": rule.priority, + "matched_count": len(rule_matched_stocks), + "stocks": rule_stocks + }) + + logger.info(f"Rule '{rule.name}' matched {len(rule_matched_stocks)} stocks, showing top {len(rule_stocks)}") + + # 우선순위와 점수로 정렬 + rule_matches.sort(key=lambda x: (x["rule_priority"], -x["score"])) + + return { + "total": len(rule_matches), + "active_rules": len(active_rules), + "analyzed_stocks": len(all_analyzed_stocks), + "recommendations": rule_matches[:20], + "recommendations_by_rule": recommendations_by_rule, + "rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules], + "message": f"✅ DB 캐시 데이터 사용 ({today} 기준)" + } + + +async def get_rule_based_recommendations_realtime(): + """ + 실시간 KIS API 분석 (느림 - 30초) + + 기존 방식 유지 + """ + # TODO: 데이터베이스 연결 후 실제 규칙 조회 + # 현재는 샘플 규칙으로 동작 + + logger.info("Rule-based recommendations requested (realtime mode)") + + # 샘플 규칙 생성 (실제로는 DB에서 조회) + from collections import namedtuple + Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active']) + + active_rules = [ + Rule( + id=1, + name="RSI 과매도 반등", + description="RSI 40 이하 과매도 종목", + rule_type="BUY", + priority=1, + conditions={"rsi_threshold": 40}, + actions={"take_profit": 5.0, "stop_loss": 3.0, "position_sizing": {"target_ratio": 0.15}}, + is_active=True + ), + Rule( + id=2, + name="골든크로스 추세", + description="5일선이 20일선 돌파", + rule_type="BUY", + priority=2, + conditions={"sma_short": 5, "sma_long": 20}, + actions={"take_profit": 10.0, "stop_loss": 5.0, "position_sizing": {"target_ratio": 0.2}}, + is_active=True + ), + Rule( + id=3, + name="볼린저밴드 하단", + description="볼린저밴드 하단 근처 반등", + rule_type="BUY", + priority=3, + conditions={"bb_threshold": 0.2}, + actions={"take_profit": 7.0, "stop_loss": 4.0, "position_sizing": {"target_ratio": 0.15}}, + is_active=True + ), + ] + + # active_rules가 비어있으면 기본 지표로 분석 + if not active_rules: + logger.info("No rules in database, using default indicators") + + # 규칙이 없으면 기본 RSI + 이동평균으로 분석 + if not active_rules: + logger.info("Using default criteria: RSI + Moving Average") + criteria = RecommendationCriteria( + use_rsi=True, + use_moving_average=True + ) + + # 분석할 종목 리스트 + all_stocks = await get_all_tradable_stocks() + stocks_to_analyze = all_stocks[:50] # 50개 종목만 + + # 배치 처리 + batch_size = 5 + delay_between_batches = 1.2 + batch_results = [] + + for i in range(0, len(stocks_to_analyze), batch_size): + batch = stocks_to_analyze[i:i + batch_size] + tasks = [analyze_stock(code, name, criteria) for code, name in batch] + results = await asyncio.gather(*tasks, return_exceptions=True) + batch_results.extend(results) + + if i + batch_size < len(stocks_to_analyze): + await asyncio.sleep(delay_between_batches) + + # 유효한 추천만 필터링 + valid_recommendations = [ + r for r in batch_results + if r is not None and not isinstance(r, Exception) + ] + valid_recommendations.sort(key=lambda x: x.score, reverse=True) + + # 기본 규칙으로 그룹핑 + default_stocks = [] + for rec in valid_recommendations[:10]: + stock_dict = { + "stock_code": rec.stock_code, + "stock_name": rec.stock_name, + "current_price": rec.current_price, + "change_rate": rec.change_rate, + "signals": rec.signals, + "score": rec.score, + "price_date": rec.price_date, + "rule_id": 0, + "rule_name": "기본 추천", + "rule_description": "RSI + 이동평균 기반 추천", + "rule_priority": 1, + "position_ratio": 0.15, + "take_profit": 5.0, + "stop_loss": 3.0 + } + default_stocks.append(stock_dict) + + return { + "total": len(default_stocks), + "active_rules": 0, + "analyzed_stocks": len(stocks_to_analyze), + "recommendations": default_stocks, + "recommendations_by_rule": [{ + "rule_id": 0, + "rule_name": "기본 추천 (RSI + 이동평균)", + "rule_description": "RSI 40 이하 과매도 + 이동평균 정배열 종목", + "rule_type": "BUY", + "priority": 1, + "matched_count": len(valid_recommendations), + "stocks": default_stocks + }] if default_stocks else [], + "rules_applied": [], + "message": "기본 지표로 분석되었습니다 (규칙 미설정)" + } + + logger.info(f"Found {len(active_rules)} active trading rules") + + # 분석할 종목 리스트 (타임아웃 방지를 위해 20개로 제한) + all_stocks = await get_all_tradable_stocks() + stocks_to_analyze = all_stocks[:20] # 상위 20개 종목으로 제한 + + # ✅ 최적화: 모든 종목을 한 번만 분석 (모든 지표 포함) + logger.info(f"Analyzing {len(stocks_to_analyze)} stocks once with all indicators...") + criteria = RecommendationCriteria( + use_rsi=True, + use_moving_average=True, + use_macd=True, + use_bollinger=True, + use_stochastic=True + ) + + # 배치 처리로 종목 분석 (Rate Limiting) + batch_size = 1 # KIS API rate limit: 초당 1건 + delay_between_batches = 1.5 # 배치 간 1.5초 대기 + all_analyzed_stocks = [] + + for i in range(0, len(stocks_to_analyze), batch_size): + batch = stocks_to_analyze[i:i + batch_size] + tasks = [analyze_stock(code, name, criteria) for code, name in batch] + results = await asyncio.gather(*tasks, return_exceptions=True) + all_analyzed_stocks.extend(results) + + # 다음 배치 전 대기 (Rate Limiting) + if i + batch_size < len(stocks_to_analyze): + await asyncio.sleep(delay_between_batches) + + # 유효한 분석 결과만 필터링 + valid_analyzed_stocks = [ + r for r in all_analyzed_stocks + if r is not None and not isinstance(r, Exception) + ] + + logger.info(f"Successfully analyzed {len(valid_analyzed_stocks)} stocks") + + # 규칙별로 매칭되는 종목 찾기 (이미 분석된 데이터 재사용) + rule_matches = [] + recommendations_by_rule = [] + + for rule in active_rules: + logger.info(f"Applying rule: {rule.name}") + + # 규칙 조건 파싱 + conditions = rule.conditions + actions = rule.actions + + # 이미 분석된 종목들 중에서 규칙에 맞는 종목 필터링 + rule_matched_stocks = [] + + for stock in valid_analyzed_stocks: + # 규칙 조건 체크 + matches = True + + # RSI 조건 체크 + if "rsi_threshold" in conditions: + rsi_threshold = conditions["rsi_threshold"] + if not any(f"RSI" in signal for signal in stock.signals): + matches = False + + if matches: + rule_matched_stocks.append(stock) + + # 점수순 정렬 + rule_matched_stocks.sort(key=lambda x: x.score, reverse=True) + + # 규칙별로 종목 저장 + rule_stocks = [] + for rec in rule_matched_stocks[:10]: # 규칙당 상위 10개 + stock_with_rule = { + **rec.model_dump(), + "rule_id": rule.id, + "rule_name": rule.name, + "rule_description": rule.description, + "rule_priority": rule.priority, + "position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.15), + "take_profit": actions.get("take_profit", 5.0), + "stop_loss": actions.get("stop_loss", 3.0) + } + rule_stocks.append(stock_with_rule) + rule_matches.append(stock_with_rule) + + # 규칙별 그룹 추가 + if rule_stocks: + recommendations_by_rule.append({ + "rule_id": rule.id, + "rule_name": rule.name, + "rule_description": rule.description, + "rule_type": rule.rule_type, + "priority": rule.priority, + "matched_count": len(rule_matched_stocks), + "stocks": rule_stocks + }) + + logger.info(f"Rule '{rule.name}' matched {len(rule_matched_stocks)} stocks, showing top {len(rule_stocks)}") + + # 우선순위와 점수로 정렬 + rule_matches.sort(key=lambda x: (x["rule_priority"], -x["score"])) + + # 실시간 현재가 업데이트 (상위 20개만) + # 토큰 재요청을 줄이기 위해 공유 인스턴스를 사용합니다. + from app.services.korea_investment_api import korea_investment_api as kis_api + + top_recommendations = rule_matches[:20] + for rec in top_recommendations: + try: + await asyncio.sleep(0.2) # Rate limit 고려 + price_data = await kis_api.get_current_price(rec["stock_code"]) + if price_data and price_data.get("output"): + output = price_data["output"] + rec["current_price"] = int(output.get("stck_prpr", rec["current_price"])) + rec["change_rate"] = float(output.get("prdy_ctrt", rec.get("change_rate", 0))) + logger.info(f"Updated real-time price for {rec['stock_code']}: {rec['current_price']}원") + except Exception as e: + logger.warning(f"Failed to get real-time price for {rec['stock_code']}: {e}") + + # recommendations_by_rule의 종목들도 실시간 가격 업데이트 + for rule_group in recommendations_by_rule: + for stock in rule_group["stocks"]: + # top_recommendations에서 업데이트된 가격 찾기 + updated = next((r for r in top_recommendations if r["stock_code"] == stock["stock_code"]), None) + if updated: + stock["current_price"] = updated["current_price"] + stock["change_rate"] = updated["change_rate"] + + return { + "total": len(rule_matches), + "active_rules": len(active_rules), + "analyzed_stocks": len(stocks_to_analyze), + "recommendations": top_recommendations, # 전체 통합 리스트 (실시간 가격 포함) + "recommendations_by_rule": recommendations_by_rule, # 규칙별 그룹핑 (실시간 가격 포함) + "rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules] + } + + +# 자동 매매 실행 함수 +async def execute_auto_trade(stock: dict, db: AsyncSession): + """ + 추천 종목에 대해 자동 매매 실행 + - 매수 신호: 정배열, RSI < 30 등 + - 매도 신호: 역배열, RSI > 70, 손익률 기준 + """ + try: + stock_code = stock.get("stock_code") + stock_name = stock.get("stock_name") + current_price = float(stock.get("current_price", 0)) + signals = stock.get("signals", []) + + if not stock_code or current_price <= 0: + logger.debug(f"Invalid stock data for auto trade: {stock}") + return {"action": "SKIP", "reason": "invalid_data"} + + # 이미 보유 중인지 확인 + result = await db.execute( + select(Portfolio).where( + Portfolio.stock_code == stock_code, + Portfolio.quantity > 0 + ) + ) + holding = result.scalar_one_or_none() + + # 신호 문자열 기반 매수/매도 판단 (실제 분석 함수의 문구와 일치시킴) + text_signals = " ".join(signals) + # 점수 숫자형 보정 + try: + score_val = float(stock.get("score", 0) or 0) + except Exception: + score_val = 0.0 + + buy_keywords = [ + "정배열 상승 매수타점", + "눌림목", # 눌림목 진입 매수타점 + "RSI 과매도", + "RSI 중립-상승", + "스토캐스틱 과매도", + "5일선 > 20일선", + "20일선 > 60일선", + "골든크로스", + "볼린저 하한선 터치", + "ADX 강한 추세", + "거래량 증가와 상승", + ] + should_buy = any(k in text_signals for k in buy_keywords) + + sell_keywords = [ + "역배열 하락 매도타점", + "RSI 과매수", + "데드크로스", + "이동평균 역배열", + "볼린저 상한선 돌파", + "손절라인 이탈", + ] + should_sell = any(k in text_signals for k in sell_keywords) + + # 매수 로직 (신호 또는 점수 기반) + # 조건 완화: 점수 기준 1점 이상으로 매수 허용 (신호가 약해도 소액 진입) + if (should_buy or (score_val >= 1)) and not holding: + # 투자 가능 금액의 10% 매수 (예수금 1000만원 기준 100만원) + available_cash = 10000000 # 예수금 (실제로는 API에서 조회해야 함) + invest_amount = available_cash * 0.10 # 10% 투자 + quantity = int(invest_amount / current_price) + + if quantity > 0: + logger.info(f"🟢 매수 신호 감지: {stock_name}({stock_code}) - {signals}") + + # KIS API로 매수 주문 + try: + order_result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="BUY", + quantity=quantity, + price=None # 시장가 주문 + ) + + # 거래 이력 저장 + trade = TradeHistory( + rule_id=stock.get("rule_id"), + stock_code=stock_code, + stock_name=stock_name, + order_type="BUY", + order_price=Decimal(str(current_price)), + order_quantity=quantity, + executed_price=Decimal(str(current_price)), + executed_quantity=quantity, + status="EXECUTED" if order_result.get("rt_cd") == "0" else "FAILED", + order_id=order_result.get("odno"), + message=f"자동매수: {' | '.join(signals[:2])}", + created_at=datetime.now() + ) + db.add(trade) + await db.commit() + + success = order_result.get("rt_cd") == "0" + logger.info(f"✅ 매수 주문 완료: {stock_name} {quantity}주 @ {current_price}원 (success={success})") + # Optimistic local 포트폴리오 업데이트 + if success: + if holding: + # 가중 평균 재계산 + new_total_qty = holding.quantity + quantity + prev_cost = float(holding.average_price or 0) * holding.quantity + new_cost = prev_cost + current_price * quantity + holding.quantity = new_total_qty + holding.average_price = Decimal(str(new_cost / new_total_qty)) + holding.current_price = Decimal(str(current_price)) + else: + new_holding = Portfolio( + stock_code=stock_code, + stock_name=stock_name, + quantity=quantity, + average_price=Decimal(str(current_price)), + current_price=Decimal(str(current_price)), + profit_loss=Decimal("0"), + profit_loss_rate=Decimal("0") + ) + db.add(new_holding) + await db.commit() + try: + from app.api.endpoints.portfolio import portfolio_cache + portfolio_cache.mark_changed() + except Exception: + logger.debug("Portfolio cache not available for mark_changed") + return {"action": "BUY", "stock": stock_name, "quantity": quantity, "price": current_price, "success": success} + + except Exception as e: + logger.error(f"❌ 매수 주문 실패: {stock_name} - {e}") + return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} + + # 매도 로직 + elif should_sell and holding: + quantity = holding.quantity + profit_loss_rate = float(holding.profit_loss_rate or 0) + + # 익절/손절 조건 확인 + take_profit = stock.get("take_profit", 5.0) # 5% 익절 + stop_loss = stock.get("stop_loss", -3.0) # 3% 손절 + + should_execute_sell = ( + profit_loss_rate >= take_profit or # 익절 + profit_loss_rate <= stop_loss or # 손절 + should_sell # 신호 기반 매도 + ) + + if should_execute_sell: + logger.info(f"🔴 매도 신호 감지: {stock_name}({stock_code}) - 손익률: {profit_loss_rate:.2f}% - {signals}") + + try: + order_result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="SELL", + quantity=quantity, + price=None # 시장가 주문 + ) + + # 거래 이력 저장 + trade = TradeHistory( + rule_id=stock.get("rule_id"), + stock_code=stock_code, + stock_name=stock_name, + order_type="SELL", + order_price=Decimal(str(current_price)), + order_quantity=quantity, + executed_price=Decimal(str(current_price)), + executed_quantity=quantity, + status="EXECUTED" if order_result.get("rt_cd") == "0" else "FAILED", + order_id=order_result.get("odno"), + message=f"자동매도: 손익률 {profit_loss_rate:.2f}% | {' | '.join(signals[:2])}", + created_at=datetime.now() + ) + db.add(trade) + await db.commit() + + success = order_result.get("rt_cd") == "0" + logger.info(f"✅ 매도 주문 완료: {stock_name} {quantity}주 @ {current_price}원 (success={success})") + if success: + # 포트폴리오에서 수량 0 처리 + holding.current_price = Decimal(str(current_price)) + holding.quantity = 0 + # 손익 계산 (단순: (현재가 - 평균단가)*원래수량) + if holding.average_price: + raw_pl = (current_price - float(holding.average_price)) * quantity + holding.profit_loss = Decimal(str(raw_pl)) + rate = (raw_pl / (float(holding.average_price) * quantity) * 100) if holding.average_price and quantity > 0 else 0 + holding.profit_loss_rate = Decimal(str(rate)) + await db.commit() + try: + from app.api.endpoints.portfolio import portfolio_cache + portfolio_cache.mark_changed() + except Exception: + logger.debug("Portfolio cache not available for mark_changed") + return {"action": "SELL", "stock": stock_name, "quantity": quantity, "price": current_price, "profit_loss": profit_loss_rate, "success": success} + + except Exception as e: + logger.error(f"❌ 매도 주문 실패: {stock_name} - {e}") + return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} + + # 아무 것도 실행 안됨 + logger.debug( + f"HOLD decision for {stock_code}: buy={should_buy}, sell={should_sell}, holding={bool(holding)}, score={score_val}, signals={signals}" + ) + return {"action": "HOLD", "stock": stock_name} + + except Exception as e: + logger.error(f"❌ 자동 매매 오류: {e}") + return {"action": "ERROR", "error": str(e)} + + +@router.post("/auto-trade") +async def execute_auto_trading(db: AsyncSession = Depends(get_db)): + """ + 규칙 기반 추천 종목에 대해 자동 매매 실행 + """ + try: + # 규칙 기반 추천 조회 (실시간 기준으로 매매 판단 일치화) + # DB 캐시의 신호 문자열 불일치로 매수가 안 걸리는 문제를 방지하기 위해 + # 실시간 분석 결과를 우선 사용합니다. + recommendations_response = await get_rule_based_recommendations(realtime=True) + + if not recommendations_response.get("recommendations_by_rule"): + return {"message": "추천 종목이 없습니다", "trades": []} + + # 각 규칙별 추천 종목에 대해 자동 매매 실행 + trade_results = [] + total_processed = 0 + max_trades = 5 # 한 번에 최대 5개만 처리 + + for rule_group in recommendations_response["recommendations_by_rule"]: + if total_processed >= max_trades: + break + + for stock in rule_group["stocks"][:2]: # 규칙당 상위 2개만 + if total_processed >= max_trades: + break + + try: + result = await execute_auto_trade(stock, db) + total_processed += 1 + + if result["action"] in ["BUY", "SELL"]: + trade_results.append(result) + + # Rate Limiting: KIS API 호출 간 0.5초 대기 + await asyncio.sleep(0.5) + + except Exception as e: + logger.error(f"Error executing auto-trade for {stock.get('stock_code')}: {e}") + continue + + return { + "message": f"자동 매매 완료: {len(trade_results)}건 실행 / {total_processed}개 분석", + "trades": trade_results, + "total_analyzed": total_processed + } + + except Exception as e: + logger.error(f"❌ 자동 매매 실행 오류: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/endpoints/trades.py b/backend/app/api/endpoints/trades.py new file mode 100644 index 0000000..f6fec31 --- /dev/null +++ b/backend/app/api/endpoints/trades.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import List, Optional +from datetime import datetime, timedelta +from decimal import Decimal + +from app.core.database import get_db +from app.models.models import TradeHistory +from pydantic import BaseModel + +router = APIRouter() + + +class TradeHistoryResponse(BaseModel): + id: int + rule_id: Optional[int] + stock_code: str + stock_name: Optional[str] + order_type: str + order_price: Optional[Decimal] + order_quantity: int + executed_price: Optional[Decimal] + executed_quantity: Optional[int] + status: str + order_id: Optional[str] + message: Optional[str] + created_at: datetime + executed_at: Optional[datetime] + + class Config: + from_attributes = True + + +@router.get("", response_model=List[TradeHistoryResponse]) +async def get_trade_history( + skip: int = 0, + limit: int = 100, + stock_code: Optional[str] = None, + status: Optional[str] = None, + order_type: Optional[str] = None, + days: int = 30, + db: AsyncSession = Depends(get_db) +): + """거래 이력 조회""" + query = select(TradeHistory) + + # 날짜 필터 + since_date = datetime.utcnow() - timedelta(days=days) + query = query.where(TradeHistory.created_at >= since_date) + + # 종목 코드 필터 + if stock_code: + query = query.where(TradeHistory.stock_code == stock_code) + + # 상태 필터 + if status: + query = query.where(TradeHistory.status == status) + + # 주문 유형 필터 + if order_type: + query = query.where(TradeHistory.order_type == order_type) + + query = query.order_by(TradeHistory.created_at.desc()) + query = query.offset(skip).limit(limit) + + result = await db.execute(query) + trades = result.scalars().all() + return trades + + +@router.get("/{trade_id}", response_model=TradeHistoryResponse) +async def get_trade(trade_id: int, db: AsyncSession = Depends(get_db)): + """특정 거래 조회""" + result = await db.execute(select(TradeHistory).where(TradeHistory.id == trade_id)) + trade = result.scalar_one_or_none() + + if not trade: + raise HTTPException(status_code=404, detail="Trade not found") + + return trade + + +@router.get("/stats/summary") +async def get_trade_stats( + days: int = 30, + db: AsyncSession = Depends(get_db) +): + """거래 통계 조회""" + since_date = datetime.utcnow() - timedelta(days=days) + + result = await db.execute( + select(TradeHistory).where(TradeHistory.created_at >= since_date) + ) + trades = result.scalars().all() + + total_trades = len(trades) + buy_trades = len([t for t in trades if t.order_type == "BUY"]) + sell_trades = len([t for t in trades if t.order_type == "SELL"]) + executed_trades = len([t for t in trades if t.status == "EXECUTED"]) + failed_trades = len([t for t in trades if t.status == "FAILED"]) + + # 총 거래 금액 계산 + total_amount = sum( + float(t.executed_price or 0) * (t.executed_quantity or 0) + for t in trades if t.status == "EXECUTED" + ) + + return { + "period_days": days, + "total_trades": total_trades, + "buy_trades": buy_trades, + "sell_trades": sell_trades, + "executed_trades": executed_trades, + "failed_trades": failed_trades, + "success_rate": executed_trades / total_trades if total_trades > 0 else 0, + "total_amount": total_amount + } diff --git a/backend/app/api/endpoints/trading_rules.py b/backend/app/api/endpoints/trading_rules.py new file mode 100644 index 0000000..f0f2088 --- /dev/null +++ b/backend/app/api/endpoints/trading_rules.py @@ -0,0 +1,143 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, delete +from typing import List +from datetime import datetime + +from app.core.database import get_db +from app.models.models import TradingRule +from pydantic import BaseModel + +router = APIRouter() + + +class TradingRuleCreate(BaseModel): + name: str + description: str = None + rule_type: str + conditions: dict + actions: dict + is_active: bool = True + priority: int = 0 + + +class TradingRuleUpdate(BaseModel): + name: str = None + description: str = None + rule_type: str = None + conditions: dict = None + actions: dict = None + is_active: bool = None + priority: int = None + + +class TradingRuleResponse(BaseModel): + id: int + name: str + description: str = None + rule_type: str + conditions: dict + actions: dict + is_active: bool + priority: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +@router.get("", response_model=List[TradingRuleResponse]) +async def get_trading_rules( + skip: int = 0, + limit: int = 100, + is_active: bool = None, + db: AsyncSession = Depends(get_db) +): + """트레이딩 규칙 목록 조회""" + query = select(TradingRule) + + if is_active is not None: + query = query.where(TradingRule.is_active == is_active) + + query = query.order_by(TradingRule.priority.desc(), TradingRule.created_at.desc()) + query = query.offset(skip).limit(limit) + + result = await db.execute(query) + rules = result.scalars().all() + return rules + + +@router.get("/{rule_id}", response_model=TradingRuleResponse) +async def get_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)): + """특정 트레이딩 규칙 조회""" + result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id)) + rule = result.scalar_one_or_none() + + if not rule: + raise HTTPException(status_code=404, detail="Trading rule not found") + + return rule + + +@router.post("", response_model=TradingRuleResponse) +async def create_trading_rule(rule: TradingRuleCreate, db: AsyncSession = Depends(get_db)): + """새 트레이딩 규칙 생성""" + db_rule = TradingRule(**rule.model_dump()) + db.add(db_rule) + await db.commit() + await db.refresh(db_rule) + return db_rule + + +@router.put("/{rule_id}", response_model=TradingRuleResponse) +async def update_trading_rule( + rule_id: int, + rule: TradingRuleUpdate, + db: AsyncSession = Depends(get_db) +): + """트레이딩 규칙 수정""" + result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id)) + db_rule = result.scalar_one_or_none() + + if not db_rule: + raise HTTPException(status_code=404, detail="Trading rule not found") + + update_data = rule.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_rule, field, value) + + db_rule.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(db_rule) + return db_rule + + +@router.delete("/{rule_id}") +async def delete_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)): + """트레이딩 규칙 삭제""" + result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id)) + db_rule = result.scalar_one_or_none() + + if not db_rule: + raise HTTPException(status_code=404, detail="Trading rule not found") + + await db.delete(db_rule) + await db.commit() + return {"message": "Trading rule deleted successfully"} + + +@router.post("/{rule_id}/toggle") +async def toggle_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)): + """트레이딩 규칙 활성화/비활성화 토글""" + result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id)) + db_rule = result.scalar_one_or_none() + + if not db_rule: + raise HTTPException(status_code=404, detail="Trading rule not found") + + db_rule.is_active = not db_rule.is_active + db_rule.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(db_rule) + return db_rule diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..c8db21d --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,97 @@ +import os +from pathlib import Path +from pydantic_settings import BaseSettings +from functools import lru_cache + +# 프로젝트 루트 디렉토리 (backend의 상위 디렉토리) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +BACKEND_ENV_FILE = BASE_DIR / ".env" + + +class Settings(BaseSettings): + # Application + APP_NAME: str = "Auto Stock Trading System" + ENVIRONMENT: str = "development" + DEBUG: bool = True + SECRET_KEY: str = "change-this-secret-key" + + # Server + BACKEND_HOST: str = "0.0.0.0" + BACKEND_PORT: int = 8000 + + # Database + DATABASE_URL: str = "postgresql://autostock:autostock_password@localhost:5432/autostock_db" + + # Redis + REDIS_URL: str = "redis://localhost:6379/0" + + # RabbitMQ + RABBITMQ_URL: str = "amqp://autostock:autostock_password@localhost:5672/" + + # Korea Investment API + KOREA_INVESTMENT_APP_KEY: str = "" + KOREA_INVESTMENT_APP_SECRET: str = "" + KOREA_INVESTMENT_ACCOUNT_NUMBER: str = "" + KOREA_INVESTMENT_API_BASE_URL: str = "https://openapi.koreainvestment.com:9443" + + # Alias for backward compatibility with .env file + KIS_APP_KEY: str = "" + KIS_APP_SECRET: str = "" + KIS_ACCOUNT_NO: str = "" + KIS_BASE_URL: str = "" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # Use KIS_* values if KOREA_INVESTMENT_* are empty + if not self.KOREA_INVESTMENT_APP_KEY and self.KIS_APP_KEY: + self.KOREA_INVESTMENT_APP_KEY = self.KIS_APP_KEY + if not self.KOREA_INVESTMENT_APP_SECRET and self.KIS_APP_SECRET: + self.KOREA_INVESTMENT_APP_SECRET = self.KIS_APP_SECRET + if not self.KOREA_INVESTMENT_ACCOUNT_NUMBER and self.KIS_ACCOUNT_NO: + self.KOREA_INVESTMENT_ACCOUNT_NUMBER = self.KIS_ACCOUNT_NO + if self.KIS_BASE_URL: + self.KOREA_INVESTMENT_API_BASE_URL = self.KIS_BASE_URL + + # Claude AI + ANTHROPIC_API_KEY: str = "" + + # MCP Server + MCP_SERVER_URL: str = "http://localhost:8001" + + # News API + NEWS_API_KEY: str = "" + + # Logging + LOG_LEVEL: str = "INFO" + + # Trading Settings + MAX_POSITION_SIZE: float = 0.1 # 10% of portfolio + MAX_DAILY_TRADES: int = 10 + STOP_LOSS_PERCENT: float = -5.0 + TAKE_PROFIT_PERCENT: float = 15.0 + + # API Rate Limiting & Retry Settings (모의투자 API 안전 운영) + API_MAX_RETRIES: int = 3 # 최대 재시도 횟수 + API_RETRY_DELAY: float = 1.5 # 초기 재시도 대기 시간 (초) + API_RETRY_BACKOFF: float = 2.0 # 지수 백오프 배율 + API_MIN_REQUEST_INTERVAL: float = 0.5 # 최소 요청 간격 (초) - 초당 2회 권장 + API_MAX_CONCURRENT_REQUESTS: int = 2 # 동시 최대 요청 수 + API_TIMEOUT: float = 30.0 # API 타임아웃 (초) + + # Monitoring & Alerting + API_ERROR_THRESHOLD: float = 0.3 # 오류율 임계값 (30%) + API_MONITORING_WINDOW: int = 100 # 모니터링 윈도우 (최근 N개 요청) + + class Config: + env_file = str(BACKEND_ENV_FILE) if BACKEND_ENV_FILE.exists() else ".env" + case_sensitive = True + extra = "ignore" # .env 파일의 추가 필드 무시 + env_file_encoding = "utf-8" + + +@lru_cache() +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..c446c1c --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,38 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +# PostgreSQL 연결 URL 변환 (asyncpg 사용) +DATABASE_URL = settings.DATABASE_URL +if DATABASE_URL.startswith("postgresql://"): + DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://") + +engine = create_async_engine( + DATABASE_URL, + echo=settings.DEBUG, + future=True, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, + connect_args={ + "timeout": 60, + "command_timeout": 60, + } +) + +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + +Base = declarative_base() + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..d77abe1 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,124 @@ +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +import logging +import time + +from app.core.config import settings +from app.api import api_router +from app.services.websocket_manager import websocket_manager +from app.core.database import engine, Base +from app.models import models # 모델 임포트 + +# 로깅 설정 +logging.basicConfig( + level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """애플리케이션 시작/종료 시 실행될 코드""" + logger.info("Starting Auto Stock Trading System...") + + # DB 테이블 자동 생성 + try: + logger.info(f"Connecting to database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'unknown'}") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("✅ Database tables created successfully") + except ConnectionRefusedError as e: + logger.error(f"❌ Database connection refused: {e}") + logger.error("Please check if PostgreSQL server is running and accessible") + raise + except Exception as e: + logger.error(f"❌ Failed to create database tables: {e}") + import traceback + logger.error(traceback.format_exc()) + raise + + yield + + # 종료 시 정리 작업 + logger.info("Shutting down Auto Stock Trading System...") + await engine.dispose() + + +app = FastAPI( + title=settings.APP_NAME, + description="Claude AI와 MCP를 활용한 자동 주식 매매 시스템", + version="1.0.0", + lifespan=lifespan, + debug=settings.DEBUG, +) + +# CORS 설정 (반드시 라우터 등록 전에) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["*"], +) + +logger.info("CORS middleware configured") + +# 요청 로깅 미들웨어 +@app.middleware("http") +async def log_requests(request: Request, call_next): + start_time = time.time() + logger.info(f"🔵 Request: {request.method} {request.url}") + + response = await call_next(request) + + process_time = time.time() - start_time + logger.info(f"🟢 Response: {request.method} {request.url} - Status: {response.status_code} - Time: {process_time:.3f}s") + + return response + +# API 라우터 등록 +app.include_router(api_router, prefix="/api/v1") + + +@app.get("/") +async def root(): + return { + "message": "Auto Stock Trading System API", + "version": "1.0.0", + "status": "running" + } + + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + + +@app.websocket("/ws/{client_id}") +async def websocket_endpoint(websocket: WebSocket, client_id: str): + """WebSocket 연결 엔드포인트""" + await websocket_manager.connect(websocket, client_id) + try: + while True: + data = await websocket.receive_text() + # 메시지 처리 + await websocket_manager.send_personal_message( + f"Echo: {data}", client_id + ) + except WebSocketDisconnect: + websocket_manager.disconnect(client_id) + logger.info(f"Client {client_id} disconnected") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host=settings.BACKEND_HOST, + port=settings.BACKEND_PORT, + reload=settings.DEBUG, + ) diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..dcc4f38 --- /dev/null +++ b/backend/app/models/models.py @@ -0,0 +1,141 @@ +from datetime import datetime +from typing import Optional, List +from sqlalchemy import Column, Integer, String, Numeric, Boolean, DateTime, Text, ARRAY +from sqlalchemy.dialects.postgresql import JSONB +from app.core.database import Base + + +class TradingRule(Base): + __tablename__ = "trading_rules" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + description = Column(Text) + rule_type = Column(String(50), nullable=False) + conditions = Column(JSONB, nullable=False) + actions = Column(JSONB, nullable=False) + is_active = Column(Boolean, default=True) + priority = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class TradeHistory(Base): + __tablename__ = "trade_history" + + id = Column(Integer, primary_key=True, index=True) + rule_id = Column(Integer) + stock_code = Column(String(20), nullable=False, index=True) + stock_name = Column(String(100)) + order_type = Column(String(20), nullable=False) + order_price = Column(Numeric(15, 2)) + order_quantity = Column(Integer, nullable=False) + executed_price = Column(Numeric(15, 2)) + executed_quantity = Column(Integer) + status = Column(String(50), nullable=False) + order_id = Column(String(100)) + message = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow, index=True) + executed_at = Column(DateTime) + + +class Portfolio(Base): + __tablename__ = "portfolio" + + id = Column(Integer, primary_key=True, index=True) + stock_code = Column(String(20), nullable=False, unique=True) + stock_name = Column(String(100)) + quantity = Column(Integer, nullable=False, default=0) + average_price = Column(Numeric(15, 2)) + current_price = Column(Numeric(15, 2)) + profit_loss = Column(Numeric(15, 2)) + profit_loss_rate = Column(Numeric(10, 4)) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class MarketData(Base): + __tablename__ = "market_data" + + id = Column(Integer, primary_key=True, index=True) + stock_code = Column(String(20), nullable=False, index=True) + price = Column(Numeric(15, 2), nullable=False) + change_rate = Column(Numeric(10, 4)) + volume = Column(Integer) + high_price = Column(Numeric(15, 2)) + low_price = Column(Numeric(15, 2)) + open_price = Column(Numeric(15, 2)) + timestamp = Column(DateTime, default=datetime.utcnow, index=True) + + +class News(Base): + __tablename__ = "news" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(500), nullable=False) + content = Column(Text) + source = Column(String(100)) + url = Column(Text) + stock_codes = Column(ARRAY(String)) + sentiment = Column(String(20)) + published_at = Column(DateTime, index=True) + created_at = Column(DateTime, default=datetime.utcnow) + + +class ClaudeAnalysisLog(Base): + __tablename__ = "claude_analysis_log" + + id = Column(Integer, primary_key=True, index=True) + rule_id = Column(Integer) + stock_code = Column(String(20)) + analysis_type = Column(String(50)) + input_data = Column(JSONB) + analysis_result = Column(JSONB) + decision = Column(String(50)) + confidence = Column(Numeric(5, 4)) + created_at = Column(DateTime, default=datetime.utcnow, index=True) + + +class MCPToolCall(Base): + __tablename__ = "mcp_tool_calls" + + id = Column(Integer, primary_key=True, index=True) + tool_name = Column(String(100), nullable=False) + parameters = Column(JSONB) + result = Column(JSONB) + status = Column(String(50)) + execution_time_ms = Column(Integer) + created_at = Column(DateTime, default=datetime.utcnow, index=True) + + +class StockAnalysis(Base): + """매일 배치로 저장되는 종목 분석 데이터""" + __tablename__ = "stock_analysis" + + id = Column(Integer, primary_key=True, index=True) + stock_code = Column(String(20), nullable=False, index=True) + stock_name = Column(String(100), nullable=False) + analysis_date = Column(DateTime, nullable=False, index=True) + + # 가격 정보 + current_price = Column(Integer, nullable=False) + change_rate = Column(Numeric(10, 4)) + volume = Column(Integer) + + # 기술적 지표 + rsi = Column(Numeric(10, 4)) + ma5 = Column(Numeric(15, 2)) + ma20 = Column(Numeric(15, 2)) + ma60 = Column(Numeric(15, 2)) + stochastic_k = Column(Numeric(10, 4)) + stochastic_d = Column(Numeric(10, 4)) + bollinger_upper = Column(Numeric(15, 2)) + bollinger_lower = Column(Numeric(15, 2)) + bollinger_middle = Column(Numeric(15, 2)) + + # 분석 결과 + signals = Column(JSONB) # ["RSI 과매도", "골든크로스" 등] + score = Column(Integer, default=0) + + # 메타데이터 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py new file mode 100644 index 0000000..5bedd02 --- /dev/null +++ b/backend/app/services/korea_investment_api.py @@ -0,0 +1,456 @@ +import httpx +from typing import Dict, Any, Optional, Deque +from datetime import datetime +import logging +import asyncio +import time +from collections import deque +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class APICallMonitor: + """API 호출 모니터링 및 통계""" + + def __init__(self, window_size: int = 100): + self.window_size = window_size + self.call_history: Deque[Dict[str, Any]] = deque(maxlen=window_size) + self.total_calls = 0 + self.total_errors = 0 + self.total_retries = 0 + + def record_call(self, success: bool, duration: float, retries: int = 0): + """API 호출 기록""" + self.total_calls += 1 + if not success: + self.total_errors += 1 + self.total_retries += retries + + self.call_history.append({ + "timestamp": time.time(), + "success": success, + "duration": duration, + "retries": retries + }) + + def get_error_rate(self) -> float: + """최근 윈도우 내 오류율 계산""" + if not self.call_history: + return 0.0 + errors = sum(1 for call in self.call_history if not call["success"]) + return errors / len(self.call_history) + + def get_avg_duration(self) -> float: + """평균 응답 시간""" + if not self.call_history: + return 0.0 + return sum(call["duration"] for call in self.call_history) / len(self.call_history) + + def get_stats(self) -> Dict[str, Any]: + """통계 정보 반환""" + return { + "total_calls": self.total_calls, + "total_errors": self.total_errors, + "total_retries": self.total_retries, + "error_rate": self.get_error_rate(), + "avg_duration": self.get_avg_duration(), + "recent_calls": len(self.call_history) + } + + def should_alert(self, threshold: float = 0.3) -> bool: + """오류율 임계값 초과 여부""" + return self.get_error_rate() > threshold + + +class KoreaInvestmentAPI: + """한국투자증권 API 클라이언트 (안전한 재시도 및 쓰로틀링 적용)""" + + def __init__(self): + self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL + self.app_key = settings.KOREA_INVESTMENT_APP_KEY + self.app_secret = settings.KOREA_INVESTMENT_APP_SECRET + self.account_number = settings.KOREA_INVESTMENT_ACCOUNT_NUMBER + self.access_token: Optional[str] = None + self.token_expires_at: Optional[datetime] = None + self.last_request_time: Optional[float] = None + + # 설정 기반 쓰로틀링 파라미터 + self.min_request_interval = settings.API_MIN_REQUEST_INTERVAL # 기본 0.5초 (초당 2회) + self.max_retries = settings.API_MAX_RETRIES # 기본 3회 + self.retry_delay = settings.API_RETRY_DELAY # 기본 1.5초 + self.retry_backoff = settings.API_RETRY_BACKOFF # 기본 2.0배 + self.timeout = settings.API_TIMEOUT # 기본 30초 + + # 동시 요청 제한 (세마포어) + self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS) + + # 모니터링 + self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW) + + async def _get_access_token(self) -> str: + """접근 토큰 발급""" + if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at: + return self.access_token + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/oauth2/tokenP", + json={ + "grant_type": "client_credentials", + "appkey": self.app_key, + "appsecret": self.app_secret + } + ) + + if response.status_code == 200: + data = response.json() + self.access_token = data["access_token"] + # 토큰 만료 시간 설정 (일반적으로 24시간) + from datetime import timedelta + self.token_expires_at = datetime.now() + timedelta(hours=23) + return self.access_token + else: + raise Exception(f"Failed to get access token: {response.text}") + + async def _make_request(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]: + """API 요청 실행 (안전한 재시도 + 쓰로틀링 + 모니터링)""" + + # 동시 요청 수 제한 (세마포어) + async with self.semaphore: + return await self._make_request_with_retry(method, endpoint, tr_id, **kwargs) + + async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]: + """재시도 로직을 포함한 실제 요청 실행""" + start_time = time.time() + retry_count = 0 + last_exception = None + + for attempt in range(self.max_retries + 1): + try: + # 쓰로틀링: 최소 요청 간격 보장 + if self.last_request_time: + elapsed = time.time() - self.last_request_time + if elapsed < self.min_request_interval: + await asyncio.sleep(self.min_request_interval - elapsed) + + self.last_request_time = time.time() + + # 토큰 갱신 + token = await self._get_access_token() + + headers = { + "content-type": "application/json; charset=utf-8", + "authorization": f"Bearer {token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + } + + # 타임아웃 설정 + timeout = httpx.Timeout(self.timeout, connect=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.request( + method, + f"{self.base_url}{endpoint}", + headers=headers, + **kwargs + ) + + # 성공 + if response.status_code == 200: + duration = time.time() - start_time + self.monitor.record_call(success=True, duration=duration, retries=retry_count) + + # 오류율 경고 + if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD): + logger.warning( + f"⚠️ API 오류율 높음: {self.monitor.get_error_rate():.1%} " + f"(임계값: {settings.API_ERROR_THRESHOLD:.1%})" + ) + + return response.json() + + # API 호출 제한 초과 (429) 또는 서버 오류 (5xx) + elif response.status_code in [429, 500, 502, 503, 504]: + retry_count += 1 + last_exception = Exception( + f"API rate limit or server error: {response.status_code} - {response.text}" + ) + + if attempt < self.max_retries: + # 지수 백오프 적용 + delay = self.retry_delay * (self.retry_backoff ** attempt) + logger.warning( + f"⏳ API 재시도 {attempt + 1}/{self.max_retries}: " + f"상태코드 {response.status_code}, {delay:.1f}초 후 재시도" + ) + await asyncio.sleep(delay) + continue + else: + logger.error(f"❌ API 최대 재시도 횟수 초과: {response.status_code}") + raise last_exception + + # 인증 오류 또는 잘못된 요청 (4xx) - 재시도 불필요 + elif 400 <= response.status_code < 500: + duration = time.time() - start_time + self.monitor.record_call(success=False, duration=duration, retries=retry_count) + + error_msg = f"API 클라이언트 오류 (재시도 안함): {response.status_code} - {response.text}" + logger.error(f"❌ {error_msg}") + raise Exception(error_msg) + + # 기타 오류 + else: + duration = time.time() - start_time + self.monitor.record_call(success=False, duration=duration, retries=retry_count) + + error_msg = f"API 요청 실패: {response.status_code} - {response.text}" + logger.error(f"❌ {error_msg}") + raise Exception(error_msg) + + except httpx.TimeoutException as e: + retry_count += 1 + last_exception = e + + if attempt < self.max_retries: + delay = self.retry_delay * (self.retry_backoff ** attempt) + logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{self.max_retries})") + await asyncio.sleep(delay) + continue + else: + duration = time.time() - start_time + self.monitor.record_call(success=False, duration=duration, retries=retry_count) + logger.error(f"❌ API 타임아웃 최대 재시도 초과") + raise + + except Exception as e: + # 예상치 못한 오류 + duration = time.time() - start_time + self.monitor.record_call(success=False, duration=duration, retries=retry_count) + logger.error(f"❌ API 요청 중 예외 발생: {str(e)}") + raise + + # 여기까지 왔다면 모든 재시도 실패 + duration = time.time() - start_time + self.monitor.record_call(success=False, duration=duration, retries=retry_count) + if last_exception: + raise last_exception + raise Exception("API 요청 실패 (알 수 없는 오류)") + + async def get_current_price(self, stock_code: str) -> Dict[str, Any]: + """현재가 조회""" + try: + result = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/quotations/inquire-price", + "FHKST01010100", + params={ + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code + } + ) + return result + except Exception as e: + logger.error(f"Error fetching price for {stock_code}: {e}") + raise + + async def get_daily_chart(self, stock_code: str, period: str = "D") -> Dict[str, Any]: + """일봉/주봉/월봉 조회 + + KIS API는 기본적으로 최근 30~100개 데이터를 반환 + 더 많은 데이터가 필요하면 반복 호출 필요 (페이지네이션 없음) + """ + try: + result = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/quotations/inquire-daily-price", + "FHKST01010400", + params={ + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + "FID_PERIOD_DIV_CODE": period, + "FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가 + } + ) + return result + except Exception as e: + logger.error(f"Error fetching chart for {stock_code}: {e}") + raise + + async def get_balance(self) -> Dict[str, Any]: + """계좌 잔고 조회""" + try: + account_number_prefix = self.account_number[:8] + account_number_suffix = self.account_number[8:] + + result = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/trading/inquire-balance", + "TTTC8434R", + params={ + "CANO": account_number_prefix, + "ACNT_PRDT_CD": account_number_suffix, + "AFHR_FLPR_YN": "N", + "OFL_YN": "", + "INQR_DVSN": "02", + "UNPR_DVSN": "01", + "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN": "01", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "" + } + ) + return result + except Exception as e: + logger.error(f"Error fetching balance: {e}") + raise + + async def place_order( + self, + stock_code: str, + order_type: str, # "BUY" or "SELL" + quantity: int, + price: Optional[float] = None # None for market order + ) -> Dict[str, Any]: + """주문 실행""" + try: + account_number_prefix = self.account_number[:8] + account_number_suffix = self.account_number[8:] + + # 매수/매도 구분 + if order_type == "BUY": + tr_id = "TTTC0802U" # 현금 매수 + else: + tr_id = "TTTC0801U" # 현금 매도 + + # 주문 유형 (00: 지정가, 01: 시장가) + order_dvsn = "01" if price is None else "00" + order_price = "0" if price is None else str(int(price)) + + result = await self._make_request( + "POST", + "/uapi/domestic-stock/v1/trading/order-cash", + tr_id, + json={ + "CANO": account_number_prefix, + "ACNT_PRDT_CD": account_number_suffix, + "PDNO": stock_code, + "ORD_DVSN": order_dvsn, + "ORD_QTY": str(quantity), + "ORD_UNPR": order_price + } + ) + return result + except Exception as e: + logger.error(f"Error placing order: {e}") + raise + + async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]: + """주문 취소""" + try: + account_number_prefix = self.account_number[:8] + account_number_suffix = self.account_number[8:] + + result = await self._make_request( + "POST", + "/uapi/domestic-stock/v1/trading/order-rvsecncl", + "TTTC0803U", + json={ + "CANO": account_number_prefix, + "ACNT_PRDT_CD": account_number_suffix, + "KRX_FWDG_ORD_ORGNO": "", + "ORGN_ODNO": order_id, + "ORD_DVSN": "00", + "RVSE_CNCL_DVSN_CD": "02", # 취소 + "ORD_QTY": "0", + "ORD_UNPR": "0", + "QTY_ALL_ORD_YN": "Y" + } + ) + return result + except Exception as e: + logger.error(f"Error canceling order: {e}") + raise + + async def search_stocks_by_condition( + self, + market: str = "J", # J: 코스피, Q: 코스닥 + condition_name: str = "", + max_results: int = 100 + ) -> Dict[str, Any]: + """조건 검색으로 종목 조회""" + try: + result = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/quotations/inquire-search", + "HHKST03900300", + params={ + "FID_COND_MRKT_DIV_CODE": market, + "FID_COND_SCR_DIV_CODE": "20171", # 조건검색 + "FID_INPUT_ISCD": "0000", + "FID_DIV_CLS_CODE": "0", + "FID_BLNG_CLS_CODE": "0", + "FID_TRGT_CLS_CODE": "111111111", + "FID_TRGT_EXLS_CLS_CODE": "000000", + "FID_INPUT_PRICE_1": "", + "FID_INPUT_PRICE_2": "", + "FID_VOL_CNT": "", + "FID_INPUT_DATE_1": "" + } + ) + return result + except Exception as e: + logger.error(f"Error searching stocks: {e}") + raise + + async def get_stock_list(self, market: str = "J") -> Dict[str, Any]: + """전체 종목 리스트 조회""" + try: + # 거래대금 상위 종목 조회 + result = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/quotations/volume-rank", + "FHPST01710000", + params={ + "FID_COND_MRKT_DIV_CODE": market, + "FID_COND_SCR_DIV_CODE": "20171", + "FID_INPUT_ISCD": "0000", + "FID_DIV_CLS_CODE": "0", + "FID_BLNG_CLS_CODE": "0", + "FID_TRGT_CLS_CODE": "111111111", + "FID_TRGT_EXLS_CLS_CODE": "000000", + "FID_INPUT_PRICE_1": "", + "FID_INPUT_PRICE_2": "", + "FID_VOL_CNT": "", + "FID_INPUT_DATE_1": "" + } + ) + return result + except Exception as e: + logger.error(f"Error getting stock list: {e}") + raise + + async def get_volume_rank(self, market: str = "J") -> Dict[str, Any]: + """거래량 순위 조회 (get_stock_list와 동일하지만 명시적 이름)""" + return await self.get_stock_list(market) + + def get_monitor_stats(self) -> Dict[str, Any]: + """API 호출 모니터링 통계 반환""" + return self.monitor.get_stats() + + def log_monitor_stats(self): + """모니터링 통계 로깅""" + stats = self.get_monitor_stats() + logger.info( + f"📊 API 통계: 총 {stats['total_calls']}건, " + f"오류 {stats['total_errors']}건 ({stats['error_rate']:.1%}), " + f"재시도 {stats['total_retries']}건, " + f"평균 응답 {stats['avg_duration']:.2f}초" + ) + + +# 싱글톤 인스턴스 +korea_investment_api = KoreaInvestmentAPI() diff --git a/backend/app/services/websocket_manager.py b/backend/app/services/websocket_manager.py new file mode 100644 index 0000000..5a57a60 --- /dev/null +++ b/backend/app/services/websocket_manager.py @@ -0,0 +1,79 @@ +from typing import Dict, List +from fastapi import WebSocket +import json +import logging + +logger = logging.getLogger(__name__) + + +class WebSocketManager: + def __init__(self): + self.active_connections: Dict[str, WebSocket] = {} + + async def connect(self, websocket: WebSocket, client_id: str): + """클라이언트 연결""" + await websocket.accept() + self.active_connections[client_id] = websocket + logger.info(f"Client {client_id} connected. Total connections: {len(self.active_connections)}") + + def disconnect(self, client_id: str): + """클라이언트 연결 해제""" + if client_id in self.active_connections: + del self.active_connections[client_id] + logger.info(f"Client {client_id} disconnected. Total connections: {len(self.active_connections)}") + + async def send_personal_message(self, message: str, client_id: str): + """특정 클라이언트에게 메시지 전송""" + if client_id in self.active_connections: + try: + await self.active_connections[client_id].send_text(message) + except Exception as e: + logger.error(f"Error sending message to {client_id}: {e}") + self.disconnect(client_id) + + async def broadcast(self, message: str): + """모든 연결된 클라이언트에게 메시지 브로드캐스트""" + disconnected_clients = [] + for client_id, connection in self.active_connections.items(): + try: + await connection.send_text(message) + except Exception as e: + logger.error(f"Error broadcasting to {client_id}: {e}") + disconnected_clients.append(client_id) + + # 연결 실패한 클라이언트 제거 + for client_id in disconnected_clients: + self.disconnect(client_id) + + async def broadcast_json(self, data: dict): + """JSON 데이터를 모든 클라이언트에게 브로드캐스트""" + message = json.dumps(data) + await self.broadcast(message) + + async def send_market_update(self, stock_code: str, data: dict): + """시장 데이터 업데이트 전송""" + message = { + "type": "market_update", + "stock_code": stock_code, + "data": data + } + await self.broadcast_json(message) + + async def send_trade_notification(self, trade_data: dict): + """거래 알림 전송""" + message = { + "type": "trade_notification", + "data": trade_data + } + await self.broadcast_json(message) + + async def send_mcp_status(self, status: dict): + """MCP 상태 업데이트 전송""" + message = { + "type": "mcp_status", + "data": status + } + await self.broadcast_json(message) + + +websocket_manager = WebSocketManager() diff --git a/backend/batch_daily_analysis.py b/backend/batch_daily_analysis.py new file mode 100644 index 0000000..ddf7929 --- /dev/null +++ b/backend/batch_daily_analysis.py @@ -0,0 +1,238 @@ +""" +매일 아침 장 시작 전(9시) 실행되는 배치 분석 스크립트 + +전체 거래 종목을 분석하여 DB에 저장 +- 차트 데이터 조회 +- 기술적 지표 계산 +- 매매 신호 생성 +- DB 저장 + +실행: python batch_daily_analysis.py +Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py +""" + +import asyncio +import sys +from datetime import datetime, date +from pathlib import Path + +# 프로젝트 루트를 Python 경로에 추가 +sys.path.insert(0, str(Path(__file__).parent)) + +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings +from app.models.models import StockAnalysis +from app.services.korea_investment_api import KoreaInvestmentAPI +from app.api.endpoints.recommendations import analyze_stock_with_indicators +import logging + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +# 데이터베이스 연결 +engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + pool_pre_ping=True, + pool_size=10, + max_overflow=20 +) + +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) + + +async def get_all_tradable_stocks() -> list[tuple[str, str]]: + """거래 가능한 전체 종목 조회 (안전한 재시도 적용)""" + kis_api = KoreaInvestmentAPI() + + logger.info("Fetching all tradable stocks from KIS API...") + + stocks = [] + + # KOSPI 상위 종목 조회 + try: + # 재시도 로직이 내장되어 있으므로 sleep 제거 (쓰로틀링은 자동 적용) + kospi_result = await kis_api.get_volume_rank("J") + if kospi_result and kospi_result.get("output"): + for item in kospi_result["output"]: + stocks.append(( + item["mksc_shrn_iscd"], + item["hts_kor_isnm"] + )) + logger.info(f"✅ Found {len(stocks)} KOSPI stocks") + except Exception as e: + logger.error(f"❌ Failed to fetch KOSPI stocks: {e}") + + # KOSDAQ 상위 종목 조회 + try: + kosdaq_result = await kis_api.get_volume_rank("Q") + if kosdaq_result and kosdaq_result.get("output"): + kospi_count = len(stocks) + for item in kosdaq_result["output"]: + stocks.append(( + item["mksc_shrn_iscd"], + item["hts_kor_isnm"] + )) + logger.info(f"✅ Found {len(stocks) - kospi_count} KOSDAQ stocks") + except Exception as e: + logger.error(f"❌ Failed to fetch KOSDAQ stocks: {e}") + + logger.info(f"📊 Total tradable stocks: {len(stocks)}") + return stocks + + +async def analyze_and_save_stock( + session: AsyncSession, + stock_code: str, + stock_name: str, + analysis_date: datetime +) -> bool: + """종목 분석 후 DB 저장""" + try: + # 종목 분석 (기존 함수 재사용) + result = await analyze_stock_with_indicators(stock_code, stock_name) + + if not result: + logger.warning(f"No analysis result for {stock_code}") + return False + + # DB에 저장 + analysis = StockAnalysis( + stock_code=stock_code, + stock_name=stock_name, + analysis_date=analysis_date, + current_price=result.current_price, + change_rate=result.change_rate, + volume=result.volume, + rsi=result.rsi, + ma5=result.ma5, + ma20=result.ma20, + ma60=result.ma60, + stochastic_k=result.stochastic_k, + stochastic_d=result.stochastic_d, + bollinger_upper=result.bollinger_upper, + bollinger_lower=result.bollinger_lower, + bollinger_middle=result.bollinger_middle, + signals=result.signals, + score=result.score + ) + + session.add(analysis) + await session.commit() + + logger.info(f"✅ Saved analysis for {stock_code} ({stock_name}): score={result.score}") + return True + + except Exception as e: + logger.error(f"❌ Failed to analyze {stock_code}: {e}") + await session.rollback() + return False + + +async def batch_analyze_all_stocks(limit: int = None): + """전체 종목 배치 분석 (안전한 재시도 및 쓰로틀링 적용)""" + start_time = datetime.now() + analysis_date = date.today() + + logger.info(f"🚀 Starting daily batch analysis for {analysis_date}") + logger.info(f"⚙️ Rate Limiting: {settings.API_MIN_REQUEST_INTERVAL}초 간격, 최대 재시도 {settings.API_MAX_RETRIES}회") + + # KIS API 인스턴스 + kis_api = KoreaInvestmentAPI() + + async with AsyncSessionLocal() as session: + # 1. 오늘 날짜 기존 데이터 삭제 + logger.info("🗑️ Deleting old analysis data...") + await session.execute( + delete(StockAnalysis).where( + StockAnalysis.analysis_date >= analysis_date + ) + ) + await session.commit() + logger.info("✅ Old data deleted") + + # 2. 전체 종목 조회 + all_stocks = await get_all_tradable_stocks() + + if limit: + all_stocks = all_stocks[:limit] + logger.info(f"🧪 Limited to {limit} stocks for testing") + + # 3. 배치 처리 (쓰로틀링은 KIS API 클래스에서 자동 처리) + success_count = 0 + fail_count = 0 + + for i, (stock_code, stock_name) in enumerate(all_stocks, 1): + logger.info(f"[{i}/{len(all_stocks)}] Analyzing {stock_code} ({stock_name})...") + + success = await analyze_and_save_stock( + session, + stock_code, + stock_name, + analysis_date + ) + + if success: + success_count += 1 + else: + fail_count += 1 + + # 30개마다 통계 출력 + if i % 30 == 0: + kis_api.log_monitor_stats() + + # 4. 결과 요약 및 최종 통계 + end_time = datetime.now() + duration = (end_time - start_time).total_seconds() + + logger.info("=" * 60) + logger.info(f"✅ Batch analysis completed!") + logger.info(f"📊 Success: {success_count} / Total: {len(all_stocks)}") + logger.info(f"❌ Failed: {fail_count}") + logger.info(f"⏱️ Duration: {duration:.1f} seconds ({duration/60:.1f} minutes)") + logger.info(f"📅 Analysis Date: {analysis_date}") + + # API 통계 출력 + stats = kis_api.get_monitor_stats() + logger.info(f"📡 API 통계:") + logger.info(f" - 총 호출: {stats['total_calls']}건") + logger.info(f" - 오류: {stats['total_errors']}건 ({stats['error_rate']:.1%})") + logger.info(f" - 재시도: {stats['total_retries']}건") + logger.info(f" - 평균 응답 시간: {stats['avg_duration']:.2f}초") + logger.info("=" * 60) + + +async def main(): + """메인 실행 함수""" + import argparse + + parser = argparse.ArgumentParser(description='Daily Stock Analysis Batch') + parser.add_argument('--limit', type=int, help='Limit number of stocks (for testing)') + parser.add_argument('--test', action='store_true', help='Test mode (analyze 10 stocks)') + + args = parser.parse_args() + + if args.test: + logger.info("🧪 TEST MODE: Analyzing 10 stocks only") + await batch_analyze_all_stocks(limit=10) + elif args.limit: + logger.info(f"📊 Custom limit: {args.limit} stocks") + await batch_analyze_all_stocks(limit=args.limit) + else: + logger.info("📊 Full analysis: All stocks") + await batch_analyze_all_stocks() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/insert_test_data.py b/backend/insert_test_data.py new file mode 100644 index 0000000..96e28d6 --- /dev/null +++ b/backend/insert_test_data.py @@ -0,0 +1,57 @@ +""" +테스트용 샘플 분석 데이터 생성 스크립트 + +실제 배치 대신 테스트용 데이터를 DB에 넣습니다. +""" + +import psycopg2 +from datetime import date + +# DB 연결 +conn = psycopg2.connect( + host="shium.net", + port=55432, + database="stock", + user="stock_admin", + password="stock_pass" +) + +cur = conn.cursor() + +# 테스트 데이터 +test_data = [ + ('005930', '삼성전자', 58100, -1.11, 20000000, 22.5, 61200, 65400, 68200, 14.5, 18.2, 72000, 58000, 64500, ['RSI 과매도', '스토캐스틱 과매도'], 3), + ('122630', 'KODEX 레버리지', 41140, -3.52, 40000000, 25.9, 43200, 45100, 47800, 21.9, 24.1, 49000, 39000, 44000, ['RSI 과매도', '스토캐스틱 과매도'], 3), + ('462330', 'KODEX 2차전지산업레버리지', 1690, -6.63, 50000000, 20.5, 1850, 1920, 2100, 29.4, 32.1, 2200, 1600, 1900, ['RSI 과매도', '볼린저밴드 하단'], 3), + ('000660', 'SK하이닉스', 179000, -2.15, 15000000, 28.3, 185000, 192000, 198000, 18.5, 22.3, 205000, 172000, 188500, ['RSI 과매도'], 2), + ('035720', '카카오', 42500, 1.43, 8000000, 52.1, 41800, 40200, 38500, 65.2, 68.1, 46000, 38000, 42000, ['RSI 중립-상승', '골든크로스'], 2), +] + +today = date.today() + +print(f"🚀 Inserting test data for {today}...") + +for stock in test_data: + cur.execute(""" + INSERT INTO stock_analysis ( + stock_code, stock_name, analysis_date, current_price, change_rate, volume, + rsi, ma5, ma20, ma60, stochastic_k, stochastic_d, + bollinger_upper, bollinger_lower, bollinger_middle, + signals, score + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """, ( + stock[0], stock[1], today, stock[2], stock[3], stock[4], + stock[5], stock[6], stock[7], stock[8], stock[9], stock[10], + stock[11], stock[12], stock[13], stock[14], stock[15] + )) + print(f" ✅ {stock[0]} ({stock[1]})") + +conn.commit() +cur.close() +conn.close() + +print(f"\n✅ Test data inserted successfully!") +print(f"📊 Total: {len(test_data)} stocks") +print(f"\nNow test the API:") +print(f" GET http://localhost:8000/api/v1/recommendations/rule-based") diff --git a/backend/load_trading_rules.py b/backend/load_trading_rules.py new file mode 100644 index 0000000..aae0e5f --- /dev/null +++ b/backend/load_trading_rules.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +매매 규칙 설정을 데이터베이스에 로드하는 스크립트 + +Usage: + python load_trading_rules.py +""" + +import asyncio +import json +from pathlib import Path +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy import select + +from app.core.config import settings +from app.models.models import TradingRule + + +async def load_trading_rules(): + """trading_rules_config.json 파일을 읽어서 데이터베이스에 저장""" + + # 설정 파일 읽기 + config_path = Path(__file__).parent / "trading_rules_config.json" + + if not config_path.exists(): + print(f"❌ 설정 파일을 찾을 수 없습니다: {config_path}") + return + + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + # 데이터베이스 연결 + engine = create_async_engine(settings.DATABASE_URL, echo=True) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session() as session: + print("\n📋 매매 규칙 로드 중...\n") + + # 기존 규칙 삭제 (선택적) + result = await session.execute(select(TradingRule)) + existing_rules = result.scalars().all() + + if existing_rules: + print(f"⚠️ 기존 규칙 {len(existing_rules)}개 발견") + response = input("기존 규칙을 모두 삭제하고 새로 로드하시겠습니까? (y/N): ") + if response.lower() == 'y': + for rule in existing_rules: + await session.delete(rule) + await session.commit() + print("✅ 기존 규칙 삭제 완료\n") + + # 새 규칙 추가 + rules = config.get("rules", []) + + for idx, rule_data in enumerate(rules, 1): + trading_rule = TradingRule( + name=rule_data["name"], + description=rule_data.get("description", ""), + rule_type=rule_data["rule_type"], + conditions=rule_data["conditions"], + actions=rule_data["actions"], + is_active=rule_data.get("is_active", True), + priority=rule_data.get("priority", idx) + ) + + session.add(trading_rule) + print(f"✅ [{idx}/{len(rules)}] {rule_data['name']} - {'활성화' if rule_data.get('is_active') else '비활성화'}") + + await session.commit() + + print(f"\n🎉 총 {len(rules)}개의 매매 규칙이 로드되었습니다!") + + # 포트폴리오 제약 조건 및 리스크 관리 설정 출력 + print("\n" + "="*60) + print("📊 포트폴리오 제약 조건:") + print("="*60) + for key, value in config.get("portfolio_constraints", {}).items(): + if isinstance(value, float): + print(f" • {key}: {value*100:.1f}%" if value < 1 else f" • {key}: {value:,.0f}") + else: + print(f" • {key}: {value}") + + print("\n" + "="*60) + print("⚠️ 리스크 관리:") + print("="*60) + for key, value in config.get("risk_management", {}).items(): + if isinstance(value, float) and value < 1: + print(f" • {key}: {value*100:.1f}%") + else: + print(f" • {key}: {value}") + + print("\n" + "="*60) + print("✅ 설정 로드 완료!") + print("="*60) + + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(load_trading_rules()) diff --git a/backend/register_rules.sh b/backend/register_rules.sh new file mode 100755 index 0000000..4bee7d1 --- /dev/null +++ b/backend/register_rules.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# 매매 규칙을 API로 등록하는 스크립트 + +API_URL="http://localhost:8000/api/v1/rules" + +# 1. RSI 과매도 반등 전략 +curl -X POST "$API_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "RSI 과매도 반등 전략", + "description": "RSI가 40 이하로 과매도 상태이고, 거래량이 평균의 150% 이상일 때 매수", + "rule_type": "BUY", + "is_active": true, + "priority": 1, + "conditions": { + "rsi_threshold": 40, + "volume_multiplier": 1.5, + "ma_proximity": 0.03 + }, + "actions": { + "action_type": "buy", + "position_ratio": 0.15, + "min_amount": 500000, + "take_profit": 5.0, + "stop_loss": 3.0 + } + }' + +echo -e "\n✅ RSI 전략 등록 완료\n" + +# 2. 골든크로스 추세 추종 +curl -X POST "$API_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "골든크로스 추세 추종", + "description": "5일선이 20일선을 상향 돌파하고 상승 추세가 확인될 때 매수", + "rule_type": "BUY", + "is_active": true, + "priority": 2, + "conditions": { + "sma_short": 5, + "sma_long": 20, + "trend": "up" + }, + "actions": { + "action_type": "buy", + "position_ratio": 0.2, + "min_amount": 1000000, + "take_profit": 10.0, + "stop_loss": 5.0 + } + }' + +echo -e "\n✅ 골든크로스 전략 등록 완료\n" + +# 3. 이동평균 정배열 +curl -X POST "$API_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "이동평균 정배열 전략", + "description": "5일 > 20일 > 60일 이동평균 정배열 상태에서 매수", + "rule_type": "BUY", + "is_active": true, + "priority": 3, + "conditions": { + "ma5_above_ma20": true, + "ma20_above_ma60": true, + "price_above_ma5": true + }, + "actions": { + "action_type": "buy", + "position_ratio": 0.15, + "min_amount": 500000, + "take_profit": 7.0, + "stop_loss": 4.0 + } + }' + +echo -e "\n✅ 이동평균 전략 등록 완료\n" + +echo "🎉 모든 매매 규칙이 등록되었습니다!" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9449ebb --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,24 @@ +fastapi>=0.104.1 +uvicorn[standard]>=0.24.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +sqlalchemy>=2.0.36 +asyncpg>=0.30.0 +alembic>=1.14.0 +greenlet>=3.0.0 +redis==5.0.1 +aio-pika==9.3.1 +websockets==12.0 +httpx==0.25.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +anthropic==0.7.8 +pandas>=2.2.0 +numpy>=1.26.0 +ta==0.11.0 +python-dotenv==1.0.0 +celery==5.3.4 +pytest==7.4.3 +pytest-asyncio==0.21.1 +feedparser==6.0.11 diff --git a/backend/test_api_safety.py b/backend/test_api_safety.py new file mode 100755 index 0000000..07308de --- /dev/null +++ b/backend/test_api_safety.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +API 모니터링 테스트 스크립트 + +KIS API의 재시도 및 쓰로틀링 기능을 테스트합니다. +""" + +import asyncio +import sys +from pathlib import Path + +# 프로젝트 루트 추가 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.services.korea_investment_api import KoreaInvestmentAPI +import logging + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +async def test_throttling(): + """쓰로틀링 테스트: 빠른 연속 호출""" + logger.info("=" * 60) + logger.info("쓰로틀링 테스트 시작: 5개 종목 연속 조회") + logger.info("=" * 60) + + kis_api = KoreaInvestmentAPI() + + test_stocks = [ + "005930", # 삼성전자 + "000660", # SK하이닉스 + "035420", # 네이버 + "051910", # LG화학 + "005380", # 현대차 + ] + + import time + start = time.time() + + for stock_code in test_stocks: + logger.info(f"📊 Fetching {stock_code}...") + try: + result = await kis_api.get_current_price(stock_code) + if result and result.get("output"): + price = result["output"].get("stck_prpr", "N/A") + logger.info(f"✅ {stock_code}: {price}원") + except Exception as e: + logger.error(f"❌ {stock_code}: {e}") + + elapsed = time.time() - start + logger.info(f"⏱️ Total time: {elapsed:.2f}초") + logger.info(f"📈 Expected: ~{len(test_stocks) * kis_api.min_request_interval:.1f}초 (쓰로틀링 적용)") + + # 통계 출력 + stats = kis_api.get_monitor_stats() + logger.info("=" * 60) + logger.info("📡 API 통계:") + logger.info(f" - 총 호출: {stats['total_calls']}건") + logger.info(f" - 오류: {stats['total_errors']}건") + logger.info(f" - 재시도: {stats['total_retries']}건") + logger.info(f" - 오류율: {stats['error_rate']:.1%}") + logger.info(f" - 평균 응답: {stats['avg_duration']:.2f}초") + logger.info("=" * 60) + + +async def test_retry(): + """재시도 테스트: 잘못된 종목 코드로 테스트""" + logger.info("=" * 60) + logger.info("재시도 테스트 시작: 오류 케이스") + logger.info("=" * 60) + + kis_api = KoreaInvestmentAPI() + + # 잘못된 종목 코드 + invalid_code = "000000" + + try: + logger.info(f"📊 Fetching invalid stock: {invalid_code}") + result = await kis_api.get_current_price(invalid_code) + logger.info(f"✅ Result: {result}") + except Exception as e: + logger.error(f"❌ Expected error: {e}") + + # 통계 출력 + stats = kis_api.get_monitor_stats() + logger.info("=" * 60) + logger.info("📡 API 통계:") + logger.info(f" - 총 호출: {stats['total_calls']}건") + logger.info(f" - 오류: {stats['total_errors']}건") + logger.info(f" - 재시도: {stats['total_retries']}건") + logger.info(f" - 오류율: {stats['error_rate']:.1%}") + logger.info("=" * 60) + + +async def test_concurrent(): + """동시 요청 테스트: 세마포어 확인""" + logger.info("=" * 60) + logger.info(f"동시 요청 테스트 시작 (최대 동시 요청: 2건)") + logger.info("=" * 60) + + kis_api = KoreaInvestmentAPI() + + test_stocks = [ + "005930", # 삼성전자 + "000660", # SK하이닉스 + "035420", # 네이버 + "051910", # LG화학 + ] + + import time + start = time.time() + + # 병렬 실행 + tasks = [kis_api.get_current_price(code) for code in test_stocks] + results = await asyncio.gather(*tasks, return_exceptions=True) + + elapsed = time.time() - start + + success = sum(1 for r in results if not isinstance(r, Exception)) + logger.info(f"✅ 성공: {success}/{len(test_stocks)}") + logger.info(f"⏱️ Total time: {elapsed:.2f}초") + + # 통계 출력 + stats = kis_api.get_monitor_stats() + logger.info("=" * 60) + logger.info("📡 API 통계:") + logger.info(f" - 총 호출: {stats['total_calls']}건") + logger.info(f" - 오류: {stats['total_errors']}건") + logger.info(f" - 재시도: {stats['total_retries']}건") + logger.info(f" - 오류율: {stats['error_rate']:.1%}") + logger.info(f" - 평균 응답: {stats['avg_duration']:.2f}초") + logger.info("=" * 60) + + +async def main(): + """메인 테스트 실행""" + logger.info("🚀 KIS API 안전 운영 테스트 시작") + logger.info("") + + # 1. 쓰로틀링 테스트 + await test_throttling() + await asyncio.sleep(2) + + # 2. 재시도 테스트 + await test_retry() + await asyncio.sleep(2) + + # 3. 동시 요청 테스트 + await test_concurrent() + + logger.info("") + logger.info("✅ 모든 테스트 완료") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/test_db_connection.py b/backend/test_db_connection.py new file mode 100644 index 0000000..9efbd5d --- /dev/null +++ b/backend/test_db_connection.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +DB 연결 테스트 스크립트 +실행: python test_db_connection.py +""" +import asyncio +import asyncpg + + +async def test_connection(): + print("🔵 Testing PostgreSQL connection...") + print("Host: shium.net") + print("Port: 55432") + print("Database: stock") + print("User: stock") + print() + + try: + # 직접 asyncpg로 연결 시도 + conn = await asyncpg.connect( + host='shium.net', + port=55432, + database='stock', + user='stock', + password='Tjduddn!!12', + timeout=10, + ssl=False, + ) + + print("✅ Connection successful!") + + # 간단한 쿼리 테스트 + version = await conn.fetchval('SELECT version()') + print(f"PostgreSQL version: {version}") + + # 테이블 목록 조회 + tables = await conn.fetch(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + """) + + print(f"\nTables in database:") + for table in tables: + print(f" - {table['table_name']}") + + await conn.close() + print("\n✅ Connection test completed successfully!") + + except asyncpg.PostgresConnectionError as e: + print(f"❌ PostgreSQL connection error: {e}") + except ConnectionRefusedError as e: + print(f"❌ Connection refused: {e}") + print("Possible causes:") + print(" 1. PostgreSQL server is not running") + print(" 2. Firewall blocking port 55432") + print(" 3. PostgreSQL not configured to accept external connections") + print(" 4. Need SSH tunnel or VPN to access the server") + except TimeoutError as e: + print(f"❌ Connection timeout: {e}") + print("The server is not responding. Check network connectivity.") + except Exception as e: + print(f"❌ Unexpected error: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(test_connection()) diff --git a/backend/trading_rules_config.json b/backend/trading_rules_config.json new file mode 100644 index 0000000..2330c85 --- /dev/null +++ b/backend/trading_rules_config.json @@ -0,0 +1,137 @@ +{ + "portfolio_constraints": { + "min_cash_reserve_ratio": 0.2, + "max_cash_reserve_ratio": 0.3, + "min_cash_reserve_amount": 1000000, + "max_holdings_count": 5, + "max_position_size_ratio": 0.25, + "min_position_size_ratio": 0.05, + "max_sector_concentration": 0.4, + "rebalance_threshold": 0.1 + }, + "risk_management": { + "max_daily_loss": 0.03, + "max_daily_trades": 10, + "max_loss_per_trade": 0.05, + "trailing_stop_loss": 0.02, + "daily_profit_target": 0.05 + }, + "rules": [ + { + "name": "RSI 과매도 반등 전략", + "description": "RSI가 30 이하로 과매도 상태이고, 거래량이 평균의 150% 이상일 때 매수", + "rule_type": "buy", + "is_active": true, + "priority": 1, + "conditions": { + "rsi_threshold": 30, + "volume_multiplier": 1.5, + "ma_proximity": 0.03, + "trend": "down" + }, + "actions": { + "action_type": "buy", + "position_sizing": { + "method": "fixed_ratio", + "target_ratio": 0.15, + "min_amount": 500000 + }, + "take_profit": 5.0, + "stop_loss": 3.0 + } + }, + { + "name": "골든크로스 추세 추종", + "description": "20일선이 60일선을 상향 돌파하고 상승 추세가 확인될 때 매수", + "rule_type": "buy", + "is_active": true, + "priority": 2, + "conditions": { + "sma_short": 20, + "sma_long": 60, + "macd_signal": "positive", + "volume_trend": "increasing" + }, + "actions": { + "action_type": "buy", + "position_sizing": { + "method": "fixed_ratio", + "target_ratio": 0.2, + "min_amount": 1000000 + }, + "take_profit": 10.0, + "stop_loss": 5.0 + } + }, + { + "name": "지지선 반등 전략", + "description": "주요 지지선에서 반등하고 긍정적인 뉴스나 이벤트가 있을 때 매수", + "rule_type": "buy", + "is_active": true, + "priority": 3, + "conditions": { + "support_level": "recent_low", + "bb_position": "lower_20", + "rsi_trend": "rising", + "volume_increase": true + }, + "actions": { + "action_type": "buy", + "position_sizing": { + "method": "fixed_ratio", + "target_ratio": 0.15, + "min_amount": 500000 + }, + "take_profit": 7.0, + "stop_loss": 4.0 + } + }, + { + "name": "박스권 돌파 전략", + "description": "박스권 상단을 강한 거래량과 함께 돌파할 때 매수", + "rule_type": "buy", + "is_active": true, + "priority": 4, + "conditions": { + "breakout_period": 20, + "volume_multiplier": 2.0, + "rsi_max": 70, + "trend": "up" + }, + "actions": { + "action_type": "buy", + "position_sizing": { + "method": "fixed_ratio", + "target_ratio": 0.2, + "min_amount": 1000000 + }, + "take_profit": 15.0, + "stop_loss": 5.0 + } + }, + { + "name": "모멘텀 추세 전략", + "description": "강한 상승 모멘텀이 지속되고 있을 때 추가 매수", + "rule_type": "buy", + "is_active": false, + "priority": 5, + "conditions": { + "consecutive_up_days": 3, + "volume_trend": "increasing", + "macd_histogram": "increasing", + "rsi_min": 50, + "rsi_max": 70 + }, + "actions": { + "action_type": "buy", + "position_sizing": { + "method": "fixed_ratio", + "target_ratio": 0.15, + "min_amount": 500000 + }, + "take_profit": 12.0, + "stop_loss": 4.0 + } + } + ] +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..a87ce03 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,50 @@ +version: '3' + +services: + postgres: + image: postgres:15-alpine + container_name: autostock-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/init-db.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5432:5432" + networks: + - autostock-network + + redis: + image: redis:7-alpine + container_name: autostock-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - autostock-network + + rabbitmq: + image: rabbitmq:3-management-alpine + container_name: autostock-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - autostock-network + +volumes: + postgres_data: + redis_data: + rabbitmq_data: + +networks: + autostock-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1132e73 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,148 @@ +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + container_name: autostock-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/init-db.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5432:5432" + networks: + - autostock-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + + # Redis Cache + redis: + image: redis:7-alpine + container_name: autostock-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - autostock-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + + # RabbitMQ Message Queue + rabbitmq: + image: rabbitmq:3-management-alpine + container_name: autostock-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - autostock-network + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 10s + retries: 5 + + # MCP Server + mcp-server: + build: + context: ./mcp-server + dockerfile: Dockerfile + container_name: autostock-mcp-server + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - MCP_SERVER_HOST=${MCP_SERVER_HOST} + - MCP_SERVER_PORT=${MCP_SERVER_PORT} + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - LOG_LEVEL=${LOG_LEVEL} + ports: + - "8001:8001" + networks: + - autostock-network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + # Backend API Server + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: autostock-backend + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - RABBITMQ_URL=${RABBITMQ_URL} + - KOREA_INVESTMENT_APP_KEY=${KOREA_INVESTMENT_APP_KEY} + - KOREA_INVESTMENT_APP_SECRET=${KOREA_INVESTMENT_APP_SECRET} + - KOREA_INVESTMENT_ACCOUNT_NUMBER=${KOREA_INVESTMENT_ACCOUNT_NUMBER} + - KOREA_INVESTMENT_API_BASE_URL=${KOREA_INVESTMENT_API_BASE_URL} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - SECRET_KEY=${SECRET_KEY} + - BACKEND_HOST=${BACKEND_HOST} + - BACKEND_PORT=${BACKEND_PORT} + - MCP_SERVER_URL=http://mcp-server:8001 + - NEWS_API_KEY=${NEWS_API_KEY} + - LOG_LEVEL=${LOG_LEVEL} + ports: + - "8000:8000" + volumes: + - ./backend:/app + - /app/__pycache__ + networks: + - autostock-network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + mcp-server: + condition: service_started + restart: unless-stopped + + # Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + - VITE_API_URL=${VITE_API_URL} + - VITE_WS_URL=${VITE_WS_URL} + container_name: autostock-frontend + ports: + - "3000:80" + networks: + - autostock-network + depends_on: + - backend + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + rabbitmq_data: + +networks: + autostock-network: + driver: bridge diff --git a/docker/init-db.sql b/docker/init-db.sql new file mode 100644 index 0000000..d5712a1 --- /dev/null +++ b/docker/init-db.sql @@ -0,0 +1,184 @@ +-- 트레이딩 규칙 테이블 +CREATE TABLE IF NOT EXISTS trading_rules ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + rule_type VARCHAR(50) NOT NULL, + conditions JSONB NOT NULL, + actions JSONB NOT NULL, + is_active BOOLEAN DEFAULT true, + priority INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 거래 이력 테이블 +CREATE TABLE IF NOT EXISTS trade_history ( + id SERIAL PRIMARY KEY, + rule_id INTEGER REFERENCES trading_rules(id), + stock_code VARCHAR(20) NOT NULL, + stock_name VARCHAR(100), + order_type VARCHAR(20) NOT NULL, -- BUY, SELL + order_price DECIMAL(15, 2), + order_quantity INTEGER NOT NULL, + executed_price DECIMAL(15, 2), + executed_quantity INTEGER, + status VARCHAR(50) NOT NULL, -- PENDING, EXECUTED, FAILED, CANCELLED + order_id VARCHAR(100), + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + executed_at TIMESTAMP +); + +-- 포트폴리오 테이블 +CREATE TABLE IF NOT EXISTS portfolio ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(20) NOT NULL UNIQUE, + stock_name VARCHAR(100), + quantity INTEGER NOT NULL DEFAULT 0, + average_price DECIMAL(15, 2), + current_price DECIMAL(15, 2), + profit_loss DECIMAL(15, 2), + profit_loss_rate DECIMAL(10, 4), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 시장 데이터 캐시 테이블 +CREATE TABLE IF NOT EXISTS market_data ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(20) NOT NULL, + price DECIMAL(15, 2) NOT NULL, + change_rate DECIMAL(10, 4), + volume BIGINT, + high_price DECIMAL(15, 2), + low_price DECIMAL(15, 2), + open_price DECIMAL(15, 2), + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(stock_code, timestamp) +); + +-- 뉴스 테이블 +CREATE TABLE IF NOT EXISTS news ( + id SERIAL PRIMARY KEY, + title VARCHAR(500) NOT NULL, + content TEXT, + source VARCHAR(100), + url TEXT, + stock_codes TEXT[], + sentiment VARCHAR(20), -- POSITIVE, NEGATIVE, NEUTRAL + published_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Claude 분석 로그 테이블 +CREATE TABLE IF NOT EXISTS claude_analysis_log ( + id SERIAL PRIMARY KEY, + rule_id INTEGER REFERENCES trading_rules(id), + stock_code VARCHAR(20), + analysis_type VARCHAR(50), -- RULE_VALIDATION, MARKET_ANALYSIS, RISK_ASSESSMENT + input_data JSONB, + analysis_result JSONB, + decision VARCHAR(50), + confidence DECIMAL(5, 4), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- MCP 도구 호출 로그 테이블 +CREATE TABLE IF NOT EXISTS mcp_tool_calls ( + id SERIAL PRIMARY KEY, + tool_name VARCHAR(100) NOT NULL, + parameters JSONB, + result JSONB, + status VARCHAR(50), + execution_time_ms INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 인덱스 생성 +CREATE INDEX idx_trade_history_stock_code ON trade_history(stock_code); +CREATE INDEX idx_trade_history_created_at ON trade_history(created_at DESC); +CREATE INDEX idx_market_data_stock_code ON market_data(stock_code); +CREATE INDEX idx_market_data_timestamp ON market_data(timestamp DESC); +CREATE INDEX idx_news_published_at ON news(published_at DESC); +CREATE INDEX idx_claude_analysis_created_at ON claude_analysis_log(created_at DESC); +CREATE INDEX idx_mcp_tool_calls_created_at ON mcp_tool_calls(created_at DESC); + +-- 기본 트레이딩 규칙 샘플 삽입 +INSERT INTO trading_rules (name, description, rule_type, conditions, actions, is_active, priority) VALUES +( + '기본 매수 규칙', + 'RSI 30 이하이고 거래량이 평균보다 높을 때 매수', + 'BUY', + '{"rsi_below": 30, "volume_above_average": 1.5}'::jsonb, + '{"order_type": "BUY", "quantity_percent": 10, "max_amount": 1000000}'::jsonb, + true, + 10 +), +( + '손절매 규칙', + '손실률 -5% 이하일 때 자동 매도', + 'SELL', + '{"loss_rate_below": -5}'::jsonb, + '{"order_type": "SELL", "quantity_percent": 100}'::jsonb, + true, + 100 +), +( + '익절 규칙', + '수익률 15% 이상일 때 50% 매도', + 'SELL', + '{"profit_rate_above": 15}'::jsonb, + '{"order_type": "SELL", "quantity_percent": 50}'::jsonb, + true, + 90 +); + +-- 업데이트 트리거 함수 +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 업데이트 트리거 생성 +CREATE TRIGGER update_trading_rules_updated_at BEFORE UPDATE ON trading_rules + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_portfolio_updated_at BEFORE UPDATE ON portfolio + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ============================= +-- 배치 종목 분석 캐시 테이블 +-- 매일 아침 batch_daily_analysis.py로 채워짐 +-- ============================= +CREATE TABLE IF NOT EXISTS stock_analysis ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(20) NOT NULL, + stock_name VARCHAR(100) NOT NULL, + analysis_date DATE NOT NULL, + current_price INTEGER NOT NULL, + change_rate DECIMAL(10,4), + volume BIGINT, + rsi DECIMAL(10,4), + ma5 DECIMAL(15,2), + ma20 DECIMAL(15,2), + ma60 DECIMAL(15,2), + stochastic_k DECIMAL(10,4), + stochastic_d DECIMAL(10,4), + bollinger_upper DECIMAL(15,2), + bollinger_lower DECIMAL(15,2), + bollinger_middle DECIMAL(15,2), + signals JSONB, -- ["RSI 과매도", "골든크로스"] + score INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_stock_analysis_stock_code ON stock_analysis(stock_code); +CREATE INDEX IF NOT EXISTS idx_stock_analysis_analysis_date ON stock_analysis(analysis_date); +CREATE INDEX IF NOT EXISTS idx_stock_analysis_score_desc ON stock_analysis(score DESC); + +CREATE TRIGGER update_stock_analysis_updated_at BEFORE UPDATE ON stock_analysis + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..cd2a4de --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,21 @@ +module.exports = { + apps: [ + { + name: 'backend', + script: 'uvicorn', + args: 'app.main:app --host 0.0.0.0 --port 8000', + cwd: './backend', + interpreter: 'python3', + env: { + PYTHONPATH: '.', + }, + }, + { + name: 'mcp-server', + script: 'uvicorn', + args: 'main:app --host 0.0.0.0 --port 8001', + cwd: './mcp-server', + interpreter: 'python3', + }, + ], +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..c6fc218 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,13 @@ +# Frontend Environment Variables +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000 +VITE_MCP_URL=http://localhost:8001 + +# Feature Flags +VITE_ENABLE_WEBSOCKET=true +VITE_ENABLE_MCP=true +VITE_ENABLE_NOTIFICATIONS=true + +# Development +VITE_DEBUG=false +VITE_LOG_LEVEL=info diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3afa6da --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +# Build stage +FROM node:20-alpine as build + +WORKDIR /app + +# 의존성 설치 +COPY package*.json ./ +RUN npm install + +# 애플리케이션 빌드 +COPY . . +ARG VITE_API_URL +ARG VITE_WS_URL +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_WS_URL=$VITE_WS_URL +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Nginx 설정 복사 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# 빌드된 파일 복사 +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..67ebf83 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Auto Stock Trading System + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..98472c7 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip 압축 설정 + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # SPA 라우팅 지원 + location / { + try_files $uri $uri/ /index.html; + } + + # API 프록시 (선택사항) + location /api { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # WebSocket 프록시 + location /ws { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + # 캐시 설정 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3bee811 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5216 @@ +{ + "name": "auto-stock-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "auto-stock-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.12.0", + "axios": "^1.6.2", + "clsx": "^2.0.0", + "date-fns": "^2.30.0", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "recharts": "^2.10.3", + "tailwind-merge": "^2.1.0" + }, + "devDependencies": { + "@types/node": "^24.10.0", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.6.tgz", + "integrity": "sha512-AnZSLF26R8uX+tqb/ivdrwbVdGemdEDm1Q19qM6pry6eOZ6bEYiY7mWhzXT1YDIPTNEVcZ5kYP9nWjoxDLiIVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", + "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.23.tgz", + "integrity": "sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001753", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", + "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.244", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz", + "integrity": "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz", + "integrity": "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.294.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", + "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e2e34ce --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,40 @@ +{ + "name": "auto-stock-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.12.0", + "axios": "^1.6.2", + "clsx": "^2.0.0", + "date-fns": "^2.30.0", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "recharts": "^2.10.3", + "tailwind-merge": "^2.1.0" + }, + "devDependencies": { + "@types/node": "^24.10.0", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ff3664c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,18 @@ +import { Routes, Route } from 'react-router-dom' +import Dashboard from './pages/Dashboard' +import TradingRules from './pages/TradingRules' +import TradeHistory from './pages/TradeHistory' +import MCPStatus from './pages/MCPStatus' + +function App() { + return ( + + } /> + } /> + } /> + } /> + + ) +} + +export default App diff --git a/frontend/src/components/CandlestickChart.tsx b/frontend/src/components/CandlestickChart.tsx new file mode 100644 index 0000000..943312c --- /dev/null +++ b/frontend/src/components/CandlestickChart.tsx @@ -0,0 +1,434 @@ +import { useMemo } from 'react' + +interface CandleData { + date: string + dateLabel: string + open: number + high: number + low: number + close: number + volume: number +} + +interface CandlestickChartProps { + data: CandleData[] + width: number + height: number +} + +// 이동평균 계산 +const calculateMA = (data: CandleData[], period: number): (number | null)[] => { + return data.map((_, index) => { + if (index < period - 1) return null + const sum = data.slice(index - period + 1, index + 1).reduce((acc, d) => acc + d.close, 0) + return sum / period + }) +} + +// 볼린저밴드 계산 +const calculateBollingerBands = (data: CandleData[], period: number = 20, stdDev: number = 2) => { + const ma = calculateMA(data, period) + const upper: (number | null)[] = [] + const lower: (number | null)[] = [] + + data.forEach((_, index) => { + if (index < period - 1 || ma[index] === null) { + upper.push(null) + lower.push(null) + return + } + + const slice = data.slice(index - period + 1, index + 1) + const mean = ma[index]! + const variance = slice.reduce((acc, d) => acc + Math.pow(d.close - mean, 2), 0) / period + const std = Math.sqrt(variance) + + upper.push(mean + stdDev * std) + lower.push(mean - stdDev * std) + }) + + return { middle: ma, upper, lower } +} + +const CandlestickChart = ({ data, width, height }: CandlestickChartProps) => { + const { candles, yScale, volumeScale, maxVolume, indicators } = useMemo(() => { + if (!data || data.length === 0) { + console.log('CandlestickChart: No data') + return { + candles: [], + yScale: { min: 0, max: 0, range: 0 }, + volumeScale: 0, + maxVolume: 0, + indicators: { ma5: [], ma20: [], ma60: [], bb: { middle: [], upper: [], lower: [] } } + } + } + + console.log('CandlestickChart: Rendering', data.length, 'candles') + console.log('First candle:', data[0]) + console.log('Last candle:', data[data.length - 1]) + + // 지표 계산 + const ma5 = calculateMA(data, 5) + const ma20 = calculateMA(data, 20) + const ma60 = calculateMA(data, 60) + const bb = calculateBollingerBands(data, 20, 2) + + // 가격 범위 계산 (지표 포함) + const prices = data.flatMap(d => [d.open, d.high, d.low, d.close]).filter(p => p > 0) + const indicatorValues = [ + ...ma5.filter((v): v is number => v !== null && v > 0), + ...ma20.filter((v): v is number => v !== null && v > 0), + ...ma60.filter((v): v is number => v !== null && v > 0), + ...bb.upper.filter((v): v is number => v !== null && v > 0), + ...bb.lower.filter((v): v is number => v !== null && v > 0), + ] + const allValues = [...prices, ...indicatorValues] + + if (allValues.length === 0) { + console.error('❌ No valid price data') + return { + candles: [], + yScale: { min: 0, max: 0, range: 0 }, + volumeScale: 0, + maxVolume: 0, + indicators: { ma5: [], ma20: [], ma60: [], bb: { middle: [], upper: [], lower: [] } } + } + } + + const min = Math.min(...allValues) + const max = Math.max(...allValues) + + // 이상치 검증: max가 min의 10배를 넘으면 지표 제외 + const priceRange = Math.max(...prices) - Math.min(...prices) + const totalRange = max - min + + if (totalRange > priceRange * 3) { + console.warn('⚠️ Detected outlier in indicators, using price range only') + const priceMin = Math.min(...prices) + const priceMax = Math.max(...prices) + const range = priceMax - priceMin + const padding = range * 0.1 // 패딩: 10% + + const chartHeight = height - 80 + const volumeHeight = 60 + const yMin = priceMin - padding + const yMax = priceMax + padding + const yRange = yMax - yMin + const leftMargin = 80 // 왼쪽 여백 증가: 60 → 80 + const rightMargin = 20 // 오른쪽 여백 추가 + const chartWidth = width - leftMargin - rightMargin + const candleWidth = Math.max(5, Math.min(15, chartWidth / data.length * 0.7)) // 캔들 너비 증가 + const spacing = chartWidth / data.length + const maxVol = Math.max(...data.map(d => d.volume)) + + const candles = data.map((item, index) => { + const x = leftMargin + index * spacing + spacing / 2 + const isUp = item.close >= item.open + const openY = chartHeight - ((item.open - yMin) / yRange) * chartHeight + 10 + const closeY = chartHeight - ((item.close - yMin) / yRange) * chartHeight + 10 + const highY = chartHeight - ((item.high - yMin) / yRange) * chartHeight + 10 + const lowY = chartHeight - ((item.low - yMin) / yRange) * chartHeight + 10 + const bodyTop = Math.min(openY, closeY) + const bodyHeight = Math.abs(closeY - openY) || 1 + const volumeBarHeight = (item.volume / maxVol) * volumeHeight + + return { + x, highY, lowY, bodyTop, bodyHeight, isUp, + dateLabel: item.dateLabel, + open: item.open, high: item.high, low: item.low, close: item.close, + volume: item.volume, volumeBarHeight, candleWidth, + } + }) + + return { + candles, + yScale: { min: yMin, max: yMax, range: yRange }, + volumeScale: volumeHeight, + maxVolume: maxVol, + indicators: { ma5, ma20, ma60, bb } + } + } + + const range = max - min + const padding = range * 0.1 // 패딩: 10% + + const chartHeight = height - 80 // 거래량 영역 제외 + const volumeHeight = 60 + + const yMin = min - padding + const yMax = max + padding + const yRange = yMax - yMin + + const leftMargin = 80 // 왼쪽 여백 증가: 60 → 80 + const rightMargin = 20 // 오른쪽 여백 추가 + const chartWidth = width - leftMargin - rightMargin + const candleWidth = Math.max(5, Math.min(15, chartWidth / data.length * 0.7)) // 캔들 너비 증가 + const spacing = chartWidth / data.length + + const maxVol = Math.max(...data.map(d => d.volume)) + + const candles = data.map((item, index) => { + const x = leftMargin + index * spacing + spacing / 2 + const isUp = item.close >= item.open + + const openY = chartHeight - ((item.open - yMin) / yRange) * chartHeight + 10 + const closeY = chartHeight - ((item.close - yMin) / yRange) * chartHeight + 10 + const highY = chartHeight - ((item.high - yMin) / yRange) * chartHeight + 10 + const lowY = chartHeight - ((item.low - yMin) / yRange) * chartHeight + 10 + + const bodyTop = Math.min(openY, closeY) + const bodyHeight = Math.abs(closeY - openY) || 1 + + const volumeBarHeight = (item.volume / maxVol) * volumeHeight + + return { + x, + highY, + lowY, + bodyTop, + bodyHeight, + isUp, + dateLabel: item.dateLabel, + open: item.open, + high: item.high, + low: item.low, + close: item.close, + volume: item.volume, + volumeBarHeight, + candleWidth, + } + }) + + return { + candles, + yScale: { min: yMin, max: yMax, range: yRange }, + volumeScale: volumeHeight, + maxVolume: maxVol, + indicators: { ma5, ma20, ma60, bb } + } + }, [data, width, height]) + + if (!data || data.length === 0) { + return ( +
+ 데이터가 없습니다 +
+ ) + } + + const chartHeight = height - 80 + const volumeTop = chartHeight + 20 + + // 지표 라인을 위한 좌표 계산 + const getLineY = (value: number) => { + return chartHeight - ((value - yScale.min) / yScale.range) * chartHeight + 10 + } + + const createLinePath = (values: (number | null)[]) => { + const segments: string[] = [] + let currentSegment: string[] = [] + + values.forEach((value, index) => { + if (value !== null && candles[index]) { + const x = candles[index].x + const y = getLineY(value) + if (currentSegment.length === 0) { + currentSegment.push(`M ${x} ${y}`) + } else { + currentSegment.push(`L ${x} ${y}`) + } + } else if (currentSegment.length > 0) { + // null 값을 만나면 현재 세그먼트를 종료하고 새로 시작 + segments.push(currentSegment.join(' ')) + currentSegment = [] + } + }) + + // 마지막 세그먼트 추가 + if (currentSegment.length > 0) { + segments.push(currentSegment.join(' ')) + } + + return segments.join(' ') + } + + // 볼린저밴드 영역 path 생성 + const createBBAreaPath = () => { + const upperPoints: [number, number][] = [] + const lowerPoints: [number, number][] = [] + + indicators.bb.upper.forEach((value, index) => { + if (value !== null && indicators.bb.lower[index] !== null && candles[index]) { + upperPoints.push([candles[index].x, getLineY(value)]) + lowerPoints.push([candles[index].x, getLineY(indicators.bb.lower[index]!)]) + } + }) + + if (upperPoints.length === 0) return '' + + const pathParts: string[] = [] + pathParts.push(`M ${upperPoints[0][0]} ${upperPoints[0][1]}`) + upperPoints.slice(1).forEach(([x, y]) => pathParts.push(`L ${x} ${y}`)) + lowerPoints.reverse().forEach(([x, y]) => pathParts.push(`L ${x} ${y}`)) + pathParts.push('Z') + + return pathParts.join(' ') + } + + return ( + + {/* Y축 그리드 */} + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const y = chartHeight - ratio * chartHeight + 10 + const price = yScale.min + ratio * yScale.range + return ( + + + + {new Intl.NumberFormat('ko-KR', { maximumFractionDigits: 0 }).format(price)} + + + ) + })} + + {/* 볼린저밴드 영역 - 그리드 뒤, 캔들 앞 */} + {indicators.bb.upper.length > 0 && ( + + )} + + {/* 이동평균선 - 캔들 앞 */} + + + + + {/* 볼린저밴드 라인 - 캔들 앞 */} + + + + + {/* 캔들스틱 */} + {candles.map((candle, index) => ( + + {/* 심지 (High-Low 라인) */} + + {/* 캔들 몸통 */} + + {/* X축 라벨 (일부만 표시) */} + {index % Math.max(1, Math.floor(candles.length / 10)) === 0 && ( + + {candle.dateLabel} + + )} + {/* 거래량 바 */} + + + ))} + + {/* 거래량 라벨 */} + 거래량 + + {new Intl.NumberFormat('ko-KR', { notation: 'compact' }).format(maxVolume)} + + + {/* 범례 */} + + + MA5 + + + MA20 + + + MA60 + + + BB + + + ) +} + +export default CandlestickChart diff --git a/frontend/src/components/HtsHeader.css b/frontend/src/components/HtsHeader.css new file mode 100644 index 0000000..538249b --- /dev/null +++ b/frontend/src/components/HtsHeader.css @@ -0,0 +1,123 @@ +.hts-header { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + padding: 12px 20px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 2px solid #00ff88; + box-shadow: 0 2px 10px rgba(0, 255, 136, 0.3); +} + +.header-left { + display: flex; + align-items: center; + gap: 15px; +} + +.header-left h1 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #00ff88; + text-shadow: 0 0 10px rgba(0, 255, 136, 0.5); +} + +.header-center { + flex: 1; + display: flex; + justify-content: center; +} + +.hts-nav { + display: flex; + gap: 5px; + background: rgba(0, 0, 0, 0.3); + padding: 5px; + border-radius: 8px; +} + +.nav-item { + padding: 8px 20px; + color: #8892b0; + text-decoration: none; + font-size: 14px; + font-weight: 500; + border-radius: 6px; + transition: all 0.3s ease; + cursor: pointer; +} + +.nav-item:hover { + background: rgba(0, 255, 136, 0.1); + color: #00ff88; +} + +.nav-item.active { + background: #00ff88; + color: #0a192f; + font-weight: 600; +} + +.header-right { + display: flex; + align-items: center; + gap: 20px; +} + +.live-indicator { + font-size: 11px; + padding: 3px 8px; + background: rgba(244, 67, 54, 0.2); + border: 1px solid #f44336; + border-radius: 3px; +} + +.header-right .time { + font-size: 13px; + color: #999; +} + +.mcp-status-inline { + display: flex; + align-items: center; + gap: 6px; + margin-left: 10px; +} + +.status-indicator.connected { + width: 10px; + height: 10px; + background: #00ff88; + border-radius: 50%; + border: 1px solid #00ff88; + margin-right: 2px; +} + +.status-indicator.disconnected { + width: 10px; + height: 10px; + background: #f44336; + border-radius: 50%; + border: 1px solid #f44336; + margin-right: 2px; +} + +.mcp-indicator { + font-size: 11px; + padding: 3px 8px; + background: rgba(0, 255, 136, 0.15); + border: 1px solid #00ff88; + border-radius: 3px; + margin-left: 8px; + color: #00ff88; + font-weight: 600; + letter-spacing: 1px; + display: inline-flex; + align-items: center; +} + +.mcp-indicator.offline { + background: rgba(244, 67, 54, 0.15); + border: 1px solid #f44336; + color: #f44336; +} \ No newline at end of file diff --git a/frontend/src/components/HtsHeader.tsx b/frontend/src/components/HtsHeader.tsx new file mode 100644 index 0000000..a46f51f --- /dev/null +++ b/frontend/src/components/HtsHeader.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import './HtsHeader.css' + +const navItems = [ + { path: '/', label: '대시보드' }, + { path: '/rules', label: '트레이딩 규칙' }, + { path: '/trades', label: '거래 내역' }, + { path: '/mcp', label: 'MCP 상태' }, +] + +const HtsHeader = () => { + const location = useLocation() + const [currentTime, setCurrentTime] = useState(() => new Date()) + + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()) + }, 1000) + + return () => clearInterval(timer) + }, []) + + // MCP 상태 가져오기 + const { data: mcpStatus } = useQuery({ + queryKey: ['mcp-status'], + queryFn: async () => { + const response = await fetch('/mcp/status') + return response.json() + }, + refetchInterval: 5000, + }) + + const handleRiskCheck = async () => { + try { + const response = await fetch('http://localhost:8001/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + analysis_type: 'RISK_ASSESSMENT', + data: { message: '현재 포트폴리오 리스크를 분석해주세요' } + }) + }); + const result = await response.json(); + alert(`⚠️ 포트폴리오 리스크 평가:\n\n${result.analysis || result.result}`); + } catch (error) { + console.error('리스크 평가 실패:', error); + alert('리스크 평가에 실패했습니다. MCP 서버를 확인해주세요.'); + } + } + + return ( +
+
+

📊 Auto Stock Trading Dashboard

+
+
+ +
+
+ {/* MCP 상태 표시 */} + + {mcpStatus?.status === 'connected' ? '🟢 MCP LIVE' : '⚫ MCP'} + + 🔴 LIVE + + {currentTime.toLocaleTimeString('ko-KR')} +
+
+ ) +} + +export default HtsHeader diff --git a/frontend/src/components/HtsPage.css b/frontend/src/components/HtsPage.css new file mode 100644 index 0000000..e885cea --- /dev/null +++ b/frontend/src/components/HtsPage.css @@ -0,0 +1,19 @@ +.hts-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: #000; + color: #e0e6ed; + font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; +} + +.hts-content { + flex: 1; + padding: 16px; + overflow-y: auto; +} + +.hts-content.dashboard-content { + padding: 0; + overflow: hidden; +} \ No newline at end of file diff --git a/frontend/src/components/HtsPage.tsx b/frontend/src/components/HtsPage.tsx new file mode 100644 index 0000000..7195ed1 --- /dev/null +++ b/frontend/src/components/HtsPage.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' +import clsx from 'clsx' +import HtsHeader from './HtsHeader' +import './HtsPage.css' + +interface HtsPageProps { + children: ReactNode + className?: string + contentClassName?: string +} + +const HtsPage = ({ children, className, contentClassName }: HtsPageProps) => { + return ( +
+ +
+ {children} +
+
+ ) +} + +export default HtsPage diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..56a561f --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,66 @@ +import { Link, useLocation } from 'react-router-dom' +import { LayoutDashboard, FileText, History, Cpu } from 'lucide-react' + +interface LayoutProps { + children: React.ReactNode +} + +const Layout = ({ children }: LayoutProps) => { + const location = useLocation() + + const navItems = [ + { path: '/', icon: LayoutDashboard, label: '대시보드' }, + { path: '/rules', icon: FileText, label: '전략 룰 설정' }, + { path: '/trades', icon: History, label: '매매 로그' }, + { path: '/mcp', icon: Cpu, label: 'MCP 상태' }, + ] + + return ( +
+ {/* Header */} +
+
+

+ Auto Stock Trading System +

+

+ Claude AI & MCP 기반 자동 매매 시스템 +

+
+
+ +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {children} +
+
+
+ ) +} + +export default Layout diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..da00c54 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,8 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' +const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000' + +export const config = { + apiBaseUrl: API_BASE_URL, + wsBaseUrl: WS_BASE_URL, + apiUrl: `${API_BASE_URL}/api/v1`, +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..b3cc1de --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,54 @@ +import { useEffect, useState, useCallback } from 'react' +import { config } from '../config' + +interface WebSocketMessage { + type: string + data: unknown +} + +export const useWebSocket = (clientId: string) => { + const [socket, setSocket] = useState(null) + const [messages, setMessages] = useState([]) + const [connected, setConnected] = useState(false) + + useEffect(() => { + const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`) + + ws.onopen = () => { + console.log('WebSocket connected') + setConnected(true) + } + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as WebSocketMessage + setMessages((prev: WebSocketMessage[]) => [...prev, message]) + } catch (error) { + console.error('Failed to parse WebSocket message:', error) + } + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + } + + ws.onclose = () => { + console.log('WebSocket disconnected') + setConnected(false) + } + + setSocket(ws) + + return () => { + ws.close() + } + }, [clientId]) + + const sendMessage = useCallback((message: string) => { + if (socket && connected) { + socket.send(message) + } + }, [socket, connected]) + + return { messages, connected, sendMessage } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f62ad0c --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,63 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #0f172a; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +#root { + width: 100%; + min-height: 100vh; +} + +@layer components { + .card { + @apply bg-slate-800 rounded-lg shadow-lg p-6; + } + + .btn { + @apply px-4 py-2 rounded-lg font-medium transition-colors; + } + + .btn-primary { + @apply btn bg-primary hover:bg-blue-700 text-white; + } + + .btn-success { + @apply btn bg-success hover:bg-green-700 text-white; + } + + .btn-danger { + @apply btn bg-danger hover:bg-red-700 text-white; + } + + .btn-secondary { + @apply btn bg-secondary hover:bg-slate-600 text-white; + } + + .input { + @apply w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary; + } + + .label { + @apply block text-sm font-medium text-slate-300 mb-1; + } +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a1e2d38 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css new file mode 100644 index 0000000..784b135 --- /dev/null +++ b/frontend/src/pages/Dashboard.css @@ -0,0 +1,660 @@ +/* HTS 대시보드 컨테이너 */ +.hts-container { + height: 100%; + display: flex; + flex-direction: column; + background: #000000; + color: #e0e6ed; + font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; + overflow: hidden; +} + +/* 메인 레이아웃 */ +.hts-main-layout { + display: grid; + grid-template-columns: 300px 1fr 420px; + gap: 12px; + padding: 12px; + flex: 1; + overflow: hidden; +} + +/* 왼쪽 패널: 계좌 정보 & 보유종목 */ +.left-panel { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 4px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + padding: 12px 15px; + background: #222; + border-bottom: 1px solid #333; + font-weight: 600; + font-size: 14px; +} + +.balance-info { + padding: 12px 15px; + background: #111; + border-bottom: 1px solid #333; +} + +.balance-row { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 13px; +} + +.balance-row span:first-child { + color: #999; +} + +.balance-row span.up { + color: #ff3b3b; + font-weight: 600; +} + +.balance-row span.down { + color: #2979ff; + font-weight: 600; +} + +.portfolio-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.portfolio-item { + padding: 10px; + margin-bottom: 6px; + background: #222; + border: 1px solid #333; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.portfolio-item:hover { + background: #2a2a2a; + border-color: #4fc3f7; +} + +.portfolio-item .stock-name { + font-weight: 600; + font-size: 13px; + margin-bottom: 6px; + display: block; + color: #fff; +} + +.portfolio-item .stock-info { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #999; +} + +.portfolio-item .stock-info span.up { + color: #ff3b3b; + font-weight: 600; +} + +.portfolio-item .stock-info span.down { + color: #2979ff; + font-weight: 600; +} + +/* 중앙 패널 */ +.center-panel { + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; +} + +/* 차트 섹션 */ +.chart-section { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 4px; + padding: 15px; + display: flex; + flex-direction: column; + min-height: 420px; + flex: 1; +} + +.chart-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + gap: 12px; + flex-wrap: wrap; +} + +.chart-stocks { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.chart-stock { + background: #111; + border: 1px solid #333; + border-radius: 4px; + padding: 8px 12px; + display: flex; + flex-direction: column; + min-width: 120px; + cursor: pointer; + transition: all 0.2s; +} + +.chart-stock .name { + font-size: 13px; + font-weight: 600; + color: #fff; + margin-bottom: 4px; +} + +.chart-stock .rate { + font-size: 11px; + font-weight: 600; +} + +.chart-stock.active { + border-color: #00ff88; + box-shadow: 0 0 12px rgba(0, 255, 136, 0.2); +} + +.chart-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.ws-status { + padding: 6px 12px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: rgba(136, 146, 176, 0.2); + border: 1px solid rgba(136, 146, 176, 0.4); +} + +.ws-status.online { + background: rgba(0, 255, 136, 0.15); + border-color: rgba(0, 255, 136, 0.5); + color: #00ff88; +} + +.ws-status.offline { + color: #8892b0; +} + +.chart-period { + display: flex; + gap: 6px; +} + +.chart-period button { + padding: 6px 12px; + border-radius: 4px; + border: 1px solid #333; + background: #111; + color: #8892b0; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.chart-period button.active, +.chart-period button:hover { + border-color: #00ff88; + color: #00ff88; +} + +.chart-container { + flex: 1; + min-height: 340px; + background: #0a0a0a; + border: 1px solid #222; + border-radius: 4px; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; +} + +/* 거래 이력 섹션 */ +.trades-section { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 4px; + padding: 15px; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 280px; + max-height: 320px; +} + +.trades-table-container { + flex: 1; + overflow-y: auto; +} + +.trades-table { + width: 100%; + border-collapse: collapse; +} + +.trades-table thead { + position: sticky; + top: 0; + background: #1a1a1a; + z-index: 1; +} + +.trades-table th { + padding: 8px; + font-size: 11px; + color: #999; + font-weight: 600; + text-align: left; + border-bottom: 1px solid #333; +} + +.trades-table td { + padding: 8px; + font-size: 11px; + color: #ccc; + border-bottom: 1px solid #222; +} + +.trades-table tbody tr:hover { + background: #222; +} + +.trade-status { + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; +} + +.trade-status.executed { + background: rgba(76, 175, 80, 0.2); + color: #4caf50; +} + +.trade-status.pending { + background: rgba(255, 152, 0, 0.2); + color: #ff9800; +} + +.trade-status.failed { + background: rgba(244, 67, 54, 0.2); + color: #f44336; +} + +/* 오른쪽 패널 */ +.right-panel { + display: flex; + flex-direction: column; + gap: 8px; +} + +.recommend-section, +.mcp-status-section, +.news-section, +.ai-analysis-section, +.risk-check-section { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 4px; + padding: 15px; + display: flex; + flex-direction: column; +} + +.ai-analysis-section { + min-height: 180px; + max-height: 220px; +} + +.risk-check-section { + min-height: 150px; + max-height: 180px; +} + +.analysis-content, +.risk-content { + flex: 1; + overflow-y: auto; + padding: 12px; + background: #0a0a0a; + border: 1px solid #222; + border-radius: 4px; + font-size: 12px; + line-height: 1.6; + color: #ccc; +} + +.analysis-loading, +.risk-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #666; + font-size: 12px; +} + +.risk-item { + padding: 8px; + margin-bottom: 6px; + background: #111; + border: 1px solid #333; + border-radius: 4px; + font-size: 11px; +} + +.risk-item-header { + display: flex; + justify-content: space-between; + margin-bottom: 4px; + font-weight: 600; +} + +.risk-level { + padding: 2px 8px; + border-radius: 3px; + font-size: 10px; +} + +.risk-level.low { + background: rgba(76, 175, 80, 0.2); + color: #4caf50; +} + +.risk-level.medium { + background: rgba(255, 152, 0, 0.2); + color: #ff9800; +} + +.risk-level.high { + background: rgba(244, 67, 54, 0.2); + color: #f44336; +} + +.risk-description { + color: #999; + font-size: 10px; + line-height: 1.4; +} + +.recommend-section { + min-height: 300px; + max-height: 360px; +} + +.recommend-list { + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + flex: 1; +} + +.recommend-item { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + background: #111; + border: 1px solid #333; + border-radius: 4px; + padding: 10px 12px; + cursor: pointer; + transition: all 0.2s; +} + +.recommend-item .info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.recommend-item .info .name { + font-size: 13px; + font-weight: 600; + color: #fff; +} + +.recommend-item .info .code { + font-size: 10px; + color: #666; + letter-spacing: 0.5px; +} + +.recommend-item .figures { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.recommend-item .figures .price { + font-size: 13px; + font-weight: 600; + color: #fff; +} + +.recommend-item .figures .rate { + font-size: 11px; + font-weight: 600; +} + +.recommend-item:hover, +.recommend-item.active { + border-color: #00ff88; + box-shadow: 0 0 12px rgba(0, 255, 136, 0.2); +} + +.btn-small { + background: #1a1a1a; + border: 1px solid #333; + color: #4fc3f7; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.btn-small:hover { + background: #222; + border-color: #4fc3f7; +} + +.btn-primary { + background: #4fc3f7; + color: #000; + border: none; + border-radius: 4px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary:hover { + background: #6fd4ff; +} + +.btn-primary:disabled { + background: #2a4a5a; + color: #666; + cursor: not-allowed; +} + +.mcp-status-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mcp-status { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + background: #222; + border-radius: 4px; + margin-bottom: 10px; +} + +.status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + animation: pulse 2s infinite; +} + +.status-indicator.connected { + background: #4caf50; +} + +.status-indicator.disconnected { + background: #f44336; +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +.status-text { + flex: 1; +} + +.status-label { + font-size: 11px; + color: #999; +} + +.status-value { + font-size: 13px; + font-weight: 600; + color: #fff; +} + +.mcp-tools-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} + +.mcp-tool-badge { + padding: 6px; + background: #111; + border: 1px solid #333; + border-radius: 3px; + font-size: 10px; + color: #999; + text-align: center; +} + +.news-section { + flex: 1; + min-height: 180px; + max-height: 220px; + overflow: hidden; +} + +.news-section .news-list { + max-height: 160px; + overflow-y: auto; +} + +.news-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.news-item { + padding: 10px; + background: #222; + border: 1px solid #333; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.news-item:hover { + border-color: #4fc3f7; + transform: translateX(2px); +} + +.news-title { + font-size: 12px; + font-weight: 600; + color: #fff; + margin-bottom: 4px; + line-height: 1.3; +} + +.news-meta { + font-size: 10px; + color: #666; +} + +/* 공통 */ +.no-data { + padding: 40px 20px; + text-align: center; + color: #666; + font-size: 13px; +} + +/* 색상 */ +.up { + color: #ff3b3b !important; +} + +.down { + color: #2979ff !important; +} + +/* 스크롤바 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: #1a1a1a; +} + +::-webkit-scrollbar-thumb { + background: #333; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #444; +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..56b7206 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,922 @@ +import { useQuery } from '@tanstack/react-query' +import api from '../lib/api' +import { useWebSocket } from '../hooks/useWebSocket' +import { useEffect, useMemo, useState } from 'react' +import HtsPage from '../components/HtsPage' +import CandlestickChart from '../components/CandlestickChart' +import './Dashboard.css' + +const Dashboard = () => { + console.log('🚀 Dashboard component mounted') + + const { connected: wsConnected } = useWebSocket('dashboard') + const [selectedStock, setSelectedStock] = useState('') + const [chartPeriod, setChartPeriod] = useState<'D' | 'W' | 'M'>('D') + const [showIndicators, setShowIndicators] = useState(false) + const [indicators, setIndicators] = useState({ + use_macd: false, + use_rsi: true, + use_bollinger: false, + use_moving_average: true, + use_stochastic: false, + use_adx: false, + use_obv: false, + }) + + console.log('🔧 Setting up queries...') + + const { data: portfolioSummary, error: portfolioSummaryError } = useQuery({ + queryKey: ['portfolio-summary'], + queryFn: async () => { + console.log('🔵 Fetching portfolio summary...') + const response = await api.get('/portfolio/summary') + console.log('🟢 Portfolio summary received:', response.data) + return response.data + }, + refetchInterval: 5000, + }) + + const { data: portfolio, error: portfolioError } = useQuery({ + queryKey: ['portfolio'], + queryFn: async () => { + console.log('🔵 Fetching portfolio...') + try { + const response = await api.get('/portfolio') + console.log('🟢 Portfolio received:', response.data) + return response.data + } catch (error) { + console.error('❌ Portfolio fetch error:', error) + throw error + } + }, + refetchInterval: 5000, + retry: 1, + }) + + // 에러 로깅 + useEffect(() => { + if (portfolioSummaryError) { + console.error('❌ Portfolio Summary Error:', portfolioSummaryError) + } + if (portfolioError) { + console.error('❌ Portfolio Error:', portfolioError) + } + }, [portfolioSummaryError, portfolioError]) + + const { data: recentTrades } = useQuery({ + queryKey: ['recent-trades'], + queryFn: async () => { + const response = await api.get('/trades', { params: { limit: 20 } }) + return response.data + }, + refetchInterval: 5000, + }) + + const { data: mcpStatus } = useQuery({ + queryKey: ['mcp-status'], + queryFn: async () => { + const response = await api.get('/mcp/status') + return response.data + }, + refetchInterval: 5000, + }) + + const { data: news } = useQuery({ + queryKey: ['market-news'], + queryFn: async () => { + const response = await api.get('/market-data/news', { params: { limit: 5 } }) + return response.data + }, + refetchInterval: 30000, + }) + + // 규칙 기반 자동 추천 (자동 로드) + const { data: ruleBasedRecommendations } = useQuery({ + queryKey: ['rule-based-recommendations'], + queryFn: async () => { + console.log('🔵 Fetching rule-based recommendations...') + try { + const response = await api.get('/recommendations/rule-based') + console.log('🟢 Rule-based recommendations received:', response.data) + return response.data + } catch (error) { + console.error('🔴 Rule-based recommendations error:', error) + throw error + } + }, + refetchInterval: 300000, // 5분마다 자동 갱신 + }) + + // 수동 지표 분석 (AI 분석 버튼용) + const { data: aiRecommendations, refetch: refetchRecommendations, isLoading: recommendationsLoading, error: recommendationsError } = useQuery({ + queryKey: ['ai-recommendations', indicators], + queryFn: async () => { + console.log('🔵 Fetching recommendations with indicators:', indicators) + try { + const response = await api.get('/recommendations', { params: indicators }) + console.log('🟢 Recommendations received:', response.data) + return response.data + } catch (error) { + console.error('🔴 Recommendations fetch error:', error) + throw error + } + }, + enabled: false, // 수동으로만 호출 + }) + + // 추천 종목 에러 로깅 + if (recommendationsError) { + console.error('❌ Recommendations query error:', recommendationsError) + } + + // 규칙 기반 추천 로깅 + if (ruleBasedRecommendations) { + console.log('✅ Rule-based Recommendations:', { + total: ruleBasedRecommendations.total, + activeRules: ruleBasedRecommendations.active_rules, + found: ruleBasedRecommendations.recommendations?.length || 0 + }) + } + + // 수동 추천 결과 로깅 + if (aiRecommendations) { + console.log('✅ AI Recommendations:', { + total: aiRecommendations.total, + analyzed: aiRecommendations.analyzed_stocks, + found: aiRecommendations.recommendations?.length || 0 + }) + } + + const { data: chartData, isLoading: chartLoading, error: chartError } = useQuery({ + queryKey: ['chart-data', selectedStock, chartPeriod], + queryFn: async () => { + if (!selectedStock) { + console.log('⚠️ No stock selected, skipping chart fetch') + return null + } + console.log('🔵 Fetching chart data for:', selectedStock, 'period:', chartPeriod) + try { + const response = await api.get(`/market-data/chart/${selectedStock}`, { + params: { period: chartPeriod }, + }) + console.log('🟢 Chart data received:', { + stock_code: response.data.stock_code, + selectedStock: selectedStock, + dataLength: response.data.data?.length, + firstPrice: response.data.data?.[0]?.close, + match: response.data.stock_code === selectedStock + }) + return response.data + } catch (error) { + console.error('🔴 Chart data fetch error:', error) + throw error + } + }, + enabled: Boolean(selectedStock), + refetchInterval: 60000, + }) + + // 차트 에러 로깅 + if (chartError) { + console.error('❌ Chart query error:', chartError) + } + + // AI 종합 분석 (선택된 종목에 대해) + const { data: aiAnalysis, isLoading: aiAnalysisLoading } = useQuery({ + queryKey: ['ai-analysis', selectedStock, chartData], + queryFn: async () => { + if (!selectedStock || !chartData?.data?.[0]) return null + console.log('🔵 Fetching AI analysis for:', selectedStock) + try { + const response = await fetch('http://localhost:8001/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + analysis_type: 'MARKET_ANALYSIS', + data: { + stock_code: selectedStock, + market_data: { + stock_name: chartData?.stock_name || selectedStock, + current_price: Number(chartData.data[0].close), + change_rate: Number(chartData.data[0].change_rate || 0), + volume: Number(chartData.data[0].volume || 0), + high: Number(chartData.data[0].high), + low: Number(chartData.data[0].low) + }, + news: [] + } + }) + }) + const result = await response.json() + console.log('🟢 AI analysis received:', result) + return result.result || result.analysis + } catch (error) { + console.error('🔴 AI analysis error:', error) + return '⚠️ AI 분석 서비스에 연결할 수 없습니다.' + } + }, + enabled: Boolean(selectedStock && chartData?.data?.[0]), + refetchInterval: 300000, // 5분마다 + }) + + // 리스크 체크 + const { data: riskCheck, isLoading: riskCheckLoading } = useQuery({ + queryKey: ['risk-check', selectedStock, portfolio, chartData], + queryFn: async () => { + if (!selectedStock || !chartData?.data?.[0]) return null + console.log('🔵 Fetching risk check for:', selectedStock) + try { + const response = await fetch('http://localhost:8001/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + analysis_type: 'RISK_ASSESSMENT', + data: { + portfolio: { + total_value: portfolioSummary?.total_value || 0, + total_stocks: portfolioSummary?.total_stocks || 0, + holdings: portfolio || [] + }, + trade_plan: { + stock_code: selectedStock, + stock_name: chartData?.stock_name || selectedStock, + current_price: Number(chartData.data[0].close), + action: 'BUY', + quantity: 1 + } + } + }) + }) + const result = await response.json() + console.log('🟢 Risk check received:', result) + return result.result || result.analysis + } catch (error) { + console.error('🔴 Risk check error:', error) + return '⚠️ 리스크 분석 서비스에 연결할 수 없습니다.' + } + }, + enabled: Boolean(selectedStock && chartData?.data?.[0]), + refetchInterval: 300000, // 5분마다 + }) + + // 차트 상단에 표시할 종목 리스트 생성 + const displayStocks = useMemo(() => { + // 1. 규칙 기반 추천이 있으면 우선 사용 + if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) { + const stocks: any[] = [] + ruleBasedRecommendations.recommendations_by_rule.forEach((ruleGroup: any) => { + if (ruleGroup.stocks && stocks.length < 4) { + ruleGroup.stocks.slice(0, 4 - stocks.length).forEach((stock: any) => { + stocks.push(stock) + }) + } + }) + return stocks.slice(0, 4) + } + + // 2. 포트폴리오가 있으면 사용 + if (portfolio && portfolio.length > 0) { + return [...portfolio] + .sort((a: any, b: any) => (b.profit_loss_rate ?? 0) - (a.profit_loss_rate ?? 0)) + .slice(0, 4) + } + + // 3. 둘 다 없으면 빈 배열 (테스트 데이터 제거) + return [] + }, [ruleBasedRecommendations, portfolio]) + + // 표시할 종목들의 최신 가격 가져오기 + const stockCodesToFetch = displayStocks.map((item: any) => item.stock_code) + + const { data: stockPrices = {} } = useQuery>({ + queryKey: ['all-stock-prices', stockCodesToFetch.join(',')], + queryFn: async () => { + if (stockCodesToFetch.length === 0) { + return {} + } + const promises = stockCodesToFetch.map(async (code: string) => { + try { + const response = await api.get(`/market-data/chart/${code}`, { + params: { period: 'D' }, + }) + // KIS API는 최신 데이터가 첫번째 (index 0) + const latestPrice = response.data?.data?.[0]?.close || 0 + return { code, price: Number(latestPrice) } + } catch (error) { + console.error(`Failed to fetch price for ${code}:`, error) + return { code, price: 0 } + } + }) + const results = await Promise.all(promises) + return results.reduce((acc: Record, item) => { + acc[item.code] = item.price + return acc + }, {}) + }, + enabled: stockCodesToFetch.length > 0, + refetchInterval: 60000, + }) + + const recommendedStocks = useMemo(() => { + console.log('💡 Building recommendedStocks:', { + displayStocksCount: displayStocks.length, + stockPricesCount: Object.keys(stockPrices).length, + stockPrices + }) + return displayStocks.map((item: any) => { + const priceFromQuery = stockPrices[item.stock_code] + const finalPrice = priceFromQuery || item.current_price || 0 + console.log(` - ${item.stock_code}: query=${priceFromQuery}, original=${item.current_price}, final=${finalPrice}`) + return { + ...item, + current_price: finalPrice, + } + }) + }, [displayStocks, stockPrices]) + + const currentStockPrice = useMemo(() => { + if (!chartData?.data || chartData.data.length === 0) { + console.log('❌ currentStockPrice: No data') + return null + } + // KIS API는 최신 데이터가 첫번째 (index 0) + const price = Number(chartData.data[0].close) + console.log('✅ currentStockPrice:', price, 'from', chartData.data[0].date) + return price + }, [chartData]) + + // 초기 종목 선택 (한 번만 실행) + useEffect(() => { + console.log('📊 Initial stock selection check') + + // 이미 종목이 선택되어 있으면 변경하지 않음 + if (selectedStock && selectedStock !== '') { + console.log('✅ Stock already selected:', selectedStock) + return + } + + // 규칙 기반 추천이 있으면 첫 번째 종목 선택 + if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) { + const firstRule = ruleBasedRecommendations.recommendations_by_rule[0] + if (firstRule.stocks?.length > 0) { + const firstStock = firstRule.stocks[0] + console.log('🎯 Auto-selecting first rule-based recommendation:', firstStock.stock_code) + setSelectedStock(firstStock.stock_code) + return + } + } + + // 포트폴리오가 있으면 첫 번째 종목 선택 + if (portfolio && portfolio.length > 0) { + console.log('🎯 Auto-selecting first portfolio stock:', portfolio[0].stock_code) + setSelectedStock(portfolio[0].stock_code) + return + } + + // 아무것도 없으면 종목 선택 없음 (테스트 데이터 제거) + console.log('⚠️ No stocks available') + }, [ruleBasedRecommendations, portfolio]) // selectedStock은 의존성에서 제거 + + const chartSeries = useMemo(() => { + if (!selectedStock) { + console.log('⚠️ chartSeries: No stock selected') + return [] + } + + if (!chartData?.data || chartData.data.length === 0) { + console.log('❌ chartSeries: No data', chartData) + return [] + } + + // 차트 데이터가 선택된 종목과 일치하는지 확인 + if (chartData.stock_code !== selectedStock) { + console.error('🔴 CRITICAL: Chart data mismatch!', { + chartStock: chartData.stock_code, + selectedStock: selectedStock, + chartName: chartData.stock_name, + dataLength: chartData.data.length + }) + // 데이터가 일치하지 않으면 빈 배열 반환 (새 데이터 로딩 대기) + return [] + } + + console.log('✅ Chart data matches selected stock:', selectedStock) + + console.log('✅ Raw chartData:', { + stock: chartData.stock_code, + dataLength: chartData.data.length, + first: chartData.data[0], + last: chartData.data[chartData.data.length - 1] + }) + + // KIS API는 최신 데이터가 첫번째 (20251104, 20251103, ...) + // 차트는 오래된 것부터 최신 순서로 표시해야 하므로 reverse 필요 + const sortedData = [...chartData.data] + .reverse() // 오래된 날짜부터 정렬 + // KIS API가 반환하는 모든 데이터 사용 (slice 제거) + .filter((item: any) => { + // 유효한 데이터만 필터링 (0이 아닌 가격) + return item.open > 0 && item.high > 0 && item.low > 0 && item.close > 0 + }) + .map((item: any) => ({ + date: item.date, + dateLabel: item.date ? `${item.date.slice(4, 6)}/${item.date.slice(6, 8)}` : '', + open: Number(item.open), + high: Number(item.high), + low: Number(item.low), + close: Number(item.close), + volume: Number(item.volume), + })) + + console.log('✅ Chart series processed:', { + length: sortedData.length, + first: sortedData[0], + last: sortedData[sortedData.length - 1] + }) + + if (sortedData.length === 0) { + console.log('❌ All data filtered out (invalid prices)') + } + + return sortedData + }, [chartData, selectedStock]) + + const formatCurrency = (value: number) => { + if (!value) { + return '0원' + } + return new Intl.NumberFormat('ko-KR').format(value) + '원' + } + + const formatPercent = (value: number) => { + if (!value) { + return '+0.00%' + } + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%` + } + + const formatDate = (dateStr: string) => { + if (!dateStr || dateStr.length !== 8) return '' + // YYYYMMDD → MM/DD + return `${dateStr.slice(4, 6)}/${dateStr.slice(6, 8)}` + } + + const getTradeStatusClass = (status: string) => { + switch (status) { + case 'EXECUTED': + return 'executed' + case 'PENDING': + return 'pending' + case 'FAILED': + return 'failed' + default: + return '' + } + } + + const handleStockSelect = (stockCode: string) => { + setSelectedStock(stockCode) + } + + return ( + +
+
+
+
계좌 정보
+
+
+ 예수금 + 10,000,000원 +
+
+ 총 자산 + {formatCurrency((portfolioSummary?.total_value || 0) + 10000000)} +
+
+ 총 손익 + = 0 ? 'up' : 'down'}> + {formatCurrency(portfolioSummary?.total_profit_loss || 0)} + +
+
+ 수익률 + = 0 ? 'up' : 'down'}> + {formatPercent(portfolioSummary?.total_profit_loss_rate || 0)} + +
+
+ 보유 종목 + {portfolioSummary?.total_stocks || 0}개 +
+
+ +
보유 종목
+
+ {portfolio && portfolio.length > 0 ? ( + portfolio.map((item: any) => ( +
handleStockSelect(item.stock_code)} + style={{ cursor: 'pointer' }} + > +
{item.stock_name} ({item.stock_code})
+
+ {item.quantity}주 + {formatCurrency(item.current_price)} + = 0 ? 'up' : 'down'}> + {formatPercent(item.profit_loss_rate || 0)} + +
+
+ )) + ) : ( +
+ 보유 종목 없음 +
+ )} +
+
+ +
+
+
대표 종목 차트
+
+
+ {recommendedStocks.length > 0 ? ( + recommendedStocks.map((item: any) => { + const isSelected = selectedStock === item.stock_code + // stockPrices에서 최신 가격 가져오기 (차트 상단과 추천 종목 동일한 가격) + const displayPrice = stockPrices[item.stock_code] || item.current_price || 0 + return ( + + ) + }) + ) : ( +
추천 종목 로딩 중...
+ )} +
+
+
+ {/* MCP 상태 UI 제거됨 */} + {/* 실시간 연결 UI 제거됨 */} +
+
+ {(['D', 'W', 'M'] as const).map((period) => ( + + ))} +
+
+
+
+ {chartLoading &&
차트 로딩 중...
} + {!chartLoading && chartSeries.length === 0 && ( +
차트 데이터가 없습니다.
+ )} + {!chartLoading && chartSeries.length > 0 && ( +
+ +
+ )} +
+
+ +
+
최근 거래 내역
+
+ + + + + + + + + + + + + {recentTrades && recentTrades.length > 0 ? ( + recentTrades.map((trade: any) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
시간종목구분수량가격상태
{new Date(trade.created_at).toLocaleTimeString('ko-KR')}{trade.stock_name} + {trade.order_type} + {trade.order_quantity}{formatCurrency(trade.order_price || 0)} + + {trade.status} + +
거래 내역 없음
+
+
+
+ +
+
+
+ 추천 종목 + +
+ +
+ {(() => { + console.log('🎯 Rendering recommend-list:', { + hasRule: !!ruleBasedRecommendations, + ruleLength: ruleBasedRecommendations?.recommendations?.length || 0, + hasAI: !!aiRecommendations, + aiLength: aiRecommendations?.recommendations?.length || 0, + portfolioLength: recommendedStocks.length + }) + return null + })()} + {/* 1. 규칙 기반 추천 (규칙별 그룹핑) */} + {ruleBasedRecommendations && ruleBasedRecommendations.recommendations_by_rule?.length > 0 ? ( + ruleBasedRecommendations.recommendations_by_rule.map((ruleGroup: any) => ( +
+ {/* 규칙 헤더 */} +
+ + 규칙 {ruleGroup.priority} + + + {ruleGroup.rule_name} + + + ({ruleGroup.matched_count}개 종목 매칭) + +
+ + {/* 종목 리스트 */} + {ruleGroup.stocks.map((item: any) => { + // stockPrices에서 최신 가격 가져오기 (숫자 변환) + const latestPrice = Number(stockPrices[item.stock_code] || item.current_price || 0) + return ( + + ) + })} +
+ )) + ) : aiRecommendations && aiRecommendations.recommendations?.length > 0 ? ( + /* 2. 수동 지표 분석 추천 */ + aiRecommendations.recommendations.map((item: any) => { + // stockPrices에서 최신 가격 가져오기 (숫자 변환) + const latestPrice = Number(stockPrices[item.stock_code] || item.current_price || 0) + return ( + + ) + }) + ) : recommendedStocks.length > 0 ? ( + recommendedStocks.map((item: any) => { + const displayPrice = Number(selectedStock === item.stock_code && currentStockPrice + ? currentStockPrice + : item.current_price) + return ( + + ) + }) + ) : ( +
보유 종목이 없습니다
+ )} +
+
+ + {/* AI 분석 섹션 */} +
+
🤖 AI 종합 분석
+
+ {aiAnalysisLoading ? ( +
분석 중...
+ ) : aiAnalysis ? ( + typeof aiAnalysis === 'string' ? ( +
{aiAnalysis}
+ ) : typeof aiAnalysis === 'object' && aiAnalysis !== null ? ( +
+ {aiAnalysis.analysis || aiAnalysis.recommendation || JSON.stringify(aiAnalysis, null, 2)} +
+ ) : ( +
분석 결과를 표시할 수 없습니다
+ ) + ) : ( +
종목을 선택하세요
+ )} +
+
+ + {/* 리스크 체크 섹션 */} +
+
⚠️ 리스크 체크
+
+ {riskCheckLoading ? ( +
분석 중...
+ ) : riskCheck ? ( + typeof riskCheck === 'string' ? ( +
{riskCheck}
+ ) : typeof riskCheck === 'object' && riskCheck !== null ? ( +
+ {riskCheck.analysis || riskCheck.recommendation || JSON.stringify(riskCheck, null, 2)} +
+ ) : ( +
분석 결과를 표시할 수 없습니다
+ ) + ) : ( +
종목을 선택하세요
+ )} +
+
+ +
+
시장 뉴스
+
+ {news && news.length > 0 ? ( + // 선택된 종목에 해당하는 뉴스만 필터링 + news.filter((item: any) => !selectedStock || item.stock_code === selectedStock) + .slice(0, 5) + .map((item: any) => ( +
window.open(item.url, '_blank')}> +
{item.title}
+
+ {new Date(item.published_at).toLocaleString('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} +
+
+ )) + ) : ( +
뉴스 없음
+ )} +
+
+
+
+
+
+ ) +} + +export default Dashboard diff --git a/frontend/src/pages/MCPStatus.tsx b/frontend/src/pages/MCPStatus.tsx new file mode 100644 index 0000000..35783a7 --- /dev/null +++ b/frontend/src/pages/MCPStatus.tsx @@ -0,0 +1,176 @@ +import { useQuery } from '@tanstack/react-query' +import api from '../lib/api' +import { Activity, CheckCircle, XCircle, AlertCircle } from 'lucide-react' +import { useWebSocket } from '../hooks/useWebSocket' +import { useEffect, useState } from 'react' +import HtsPage from '../components/HtsPage' + +const MCPStatus = () => { + const { messages } = useWebSocket('mcp-monitor') + const [mcpMessages, setMcpMessages] = useState([]) + + // MCP 서버 상태 조회 + const { data: mcpStatus, refetch } = useQuery({ + queryKey: ['mcp-status'], + queryFn: async () => { + const response = await api.get('/mcp/status') + return response.data + }, + refetchInterval: 5000, + }) + + // 사용 가능한 MCP 도구 목록 + const { data: mcpTools } = useQuery({ + queryKey: ['mcp-tools'], + queryFn: async () => { + const response = await api.get('/mcp/tools') + return response.data + }, + }) + + useEffect(() => { + // MCP 상태 업데이트 메시지 수신 + const statusMessages = messages.filter(msg => msg.type === 'mcp_status') + if (statusMessages.length > 0) { + setMcpMessages(prev => [...statusMessages, ...prev].slice(0, 50)) + } + }, [messages]) + + const getStatusColor = (status: string) => { + switch (status) { + case 'connected': + return 'text-success' + case 'disconnected': + return 'text-danger' + default: + return 'text-warning' + } + } + + const getStatusIcon = (status: string) => { + switch (status) { + case 'connected': + return + case 'disconnected': + return + default: + return + } + } + + return ( + +
+

MCP 상태 모니터

+ + {/* MCP 서버 상태 */} +
+
+

MCP 서버 연결 상태

+ +
+ + {mcpStatus && ( +
+ {getStatusIcon(mcpStatus.status)} +
+

+ {mcpStatus.status === 'connected' ? '연결됨' : + mcpStatus.status === 'disconnected' ? '연결 끊김' : '알 수 없음'} +

+

+ {mcpStatus.mcp_server_url || '서버 URL 없음'} +

+ {mcpStatus.message && ( +

{mcpStatus.message}

+ )} +
+
+ )} +
+ + {/* 사용 가능한 도구 */} +
+

사용 가능한 MCP 도구

+ + {mcpTools && mcpTools.tools && ( +
+ {mcpTools.tools.map((tool: any, index: number) => ( +
+
+

{tool.name}

+ +
+

{tool.description}

+ + {tool.input_schema && tool.input_schema.properties && ( +
+

파라미터:

+
    + {Object.entries(tool.input_schema.properties).map(([key, prop]: [string, any]) => ( +
  • + • {key} + {tool.input_schema.required?.includes(key) && ( + * + )} + : {prop.type} +
  • + ))} +
+
+ )} +
+ ))} +
+ )} + + {(!mcpTools || !mcpTools.tools || mcpTools.tools.length === 0) && ( +

+ 사용 가능한 도구가 없습니다. +

+ )} +
+ + {/* MCP 메시지 로그 */} +
+

실시간 MCP 로그

+ +
+ {mcpMessages.map((msg, index) => ( +
+
+ + {new Date().toLocaleTimeString('ko-KR')} + + {msg.type} +
+
+                                    {JSON.stringify(msg.data, null, 2)}
+                                
+
+ ))} + + {mcpMessages.length === 0 && ( +

+ MCP 로그가 없습니다. +

+ )} +
+
+
+
+ ) +} + +export default MCPStatus diff --git a/frontend/src/pages/TradeHistory.tsx b/frontend/src/pages/TradeHistory.tsx new file mode 100644 index 0000000..d3154b1 --- /dev/null +++ b/frontend/src/pages/TradeHistory.tsx @@ -0,0 +1,163 @@ +import { useQuery } from '@tanstack/react-query' +import { format } from 'date-fns' +import { ko } from 'date-fns/locale' +import { TrendingUp, TrendingDown, Clock, CheckCircle, XCircle } from 'lucide-react' +import HtsPage from '../components/HtsPage' +import api from '../lib/api' + +interface Trade { + id: string + created_at: string + status: 'EXECUTED' | 'FAILED' | 'PENDING' | string + stock_code: string + stock_name?: string + order_type: 'BUY' | 'SELL' + order_price: number | null + order_quantity: number + executed_price: number | null + executed_quantity: number | null + message?: string | null +} + +const TradeHistory = () => { + const { + data: trades = [], + isLoading, + isError, + } = useQuery({ + queryKey: ['trade-history'], + queryFn: async () => { + const response = await api.get('/trades', { + params: { limit: 100, days: 30 }, + }) + return response.data + }, + refetchInterval: 10000, + }) + + const getStatusIcon = (status: Trade['status']) => { + switch (status) { + case 'EXECUTED': + return + case 'FAILED': + return + case 'PENDING': + return + default: + return null + } + } + + const formatCurrency = (value: number | null) => { + if (value == null) { + return '시장가' + } + + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW', + maximumFractionDigits: 0, + }).format(value) + } + + return ( + +
+

매매 로그

+ {isLoading && 데이터 갱신 중...} +
+ + {isError && ( +
+ 최근 거래 내역을 불러오는 중 문제가 발생했습니다. +
+ )} + +
+
+ + + + + + + + + + + + + + + + {trades.map((trade) => ( + + + + + + + + + + + + ))} + +
시간상태종목구분주문가수량체결가체결수량메시지
+ {format(new Date(trade.created_at), 'MM/dd HH:mm:ss', { locale: ko })} + +
+ {getStatusIcon(trade.status)} + {trade.status} +
+
+
+
{trade.stock_code}
+ {trade.stock_name && ( +
{trade.stock_name}
+ )} +
+
+ + {trade.order_type === 'BUY' ? ( + <> + + 매수 + + ) : ( + <> + + 매도 + + )} + + + {formatCurrency(trade.order_price)} + + {trade.order_quantity.toLocaleString()} + + {trade.executed_price != null ? formatCurrency(trade.executed_price) : '-'} + + {trade.executed_quantity != null ? trade.executed_quantity.toLocaleString() : '-'} + + {trade.message || '-'} +
+ + {!trades.length && !isLoading && ( +
+

거래 이력이 없습니다.

+
+ )} +
+
+
+ ) +} + +export default TradeHistory diff --git a/frontend/src/pages/TradingRules.css b/frontend/src/pages/TradingRules.css new file mode 100644 index 0000000..8995919 --- /dev/null +++ b/frontend/src/pages/TradingRules.css @@ -0,0 +1,370 @@ +.trading-rules-container { + padding: 20px; + background: #0a192f; + min-height: 100vh; + color: #ccd6f6; +} + +.rules-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 15px; + border-bottom: 2px solid #00ff88; +} + +.rules-header h1 { + font-size: 28px; + font-weight: 700; + color: #00ff88; + text-shadow: 0 0 10px rgba(0, 255, 136, 0.5); +} + +.btn-create { + padding: 10px 20px; + background: #00ff88; + color: #0a192f; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-create:hover { + background: #00cc6a; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 255, 136, 0.4); +} + +/* 규칙 목록 */ +.rules-list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.rule-card { + background: linear-gradient(135deg, #16213e 0%, #1a1a2e 100%); + border-radius: 12px; + padding: 20px; + border: 1px solid rgba(0, 255, 136, 0.2); + transition: all 0.3s ease; +} + +.rule-card:hover { + border-color: #00ff88; + box-shadow: 0 4px 20px rgba(0, 255, 136, 0.3); + transform: translateY(-2px); +} + +.rule-card.active { + border-color: #00ff88; +} + +.rule-card.inactive { + opacity: 0.6; + border-color: #64748b; +} + +.rule-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; +} + +.rule-title { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.rule-title h3 { + font-size: 20px; + font-weight: 600; + color: #00ff88; + margin: 0; +} + +.rule-badge { + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.rule-badge.buy { + background: rgba(0, 255, 136, 0.2); + color: #00ff88; +} + +.rule-badge.sell { + background: rgba(255, 99, 71, 0.2); + color: #ff6347; +} + +.rule-badge.stop_loss { + background: rgba(255, 215, 0, 0.2); + color: #ffd700; +} + +.rule-badge.take_profit { + background: rgba(64, 224, 208, 0.2); + color: #40e0d0; +} + +.rule-priority { + font-size: 12px; + color: #8892b0; + padding: 4px 8px; + background: rgba(136, 146, 176, 0.1); + border-radius: 6px; +} + +.rule-actions { + display: flex; + gap: 8px; +} + +.btn-toggle { + padding: 6px 12px; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-toggle.active { + background: rgba(0, 255, 136, 0.2); + color: #00ff88; +} + +.btn-toggle.inactive { + background: rgba(255, 99, 71, 0.2); + color: #ff6347; +} + +.btn-edit, +.btn-delete { + padding: 6px 12px; + border: none; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-edit { + background: rgba(100, 116, 139, 0.2); + color: #cbd5e1; +} + +.btn-edit:hover { + background: rgba(100, 116, 139, 0.4); +} + +.btn-delete { + background: rgba(255, 99, 71, 0.2); + color: #ff6347; +} + +.btn-delete:hover { + background: rgba(255, 99, 71, 0.4); +} + +.rule-description { + color: #8892b0; + margin-bottom: 15px; + line-height: 1.6; +} + +.rule-conditions { + background: rgba(10, 25, 47, 0.6); + padding: 15px; + border-radius: 8px; + margin-bottom: 15px; + border: 1px solid rgba(0, 255, 136, 0.1); +} + +.rule-conditions strong { + color: #00ff88; + display: block; + margin-bottom: 8px; +} + +.rule-conditions pre { + color: #cbd5e1; + font-size: 13px; + line-height: 1.5; + overflow-x: auto; +} + +.rule-meta { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #64748b; + padding-top: 15px; + border-top: 1px solid rgba(100, 116, 139, 0.2); +} + +/* 규칙 추가/수정 모달 */ +.rule-form-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.rule-form-content { + background: linear-gradient(135deg, #16213e 0%, #1a1a2e 100%); + border-radius: 12px; + padding: 30px; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + border: 2px solid #00ff88; + box-shadow: 0 10px 40px rgba(0, 255, 136, 0.3); +} + +.rule-form-content h2 { + color: #00ff88; + margin-bottom: 25px; + font-size: 24px; + text-shadow: 0 0 10px rgba(0, 255, 136, 0.5); +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + color: #00ff88; + font-weight: 600; + margin-bottom: 8px; + font-size: 14px; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 10px; + background: rgba(10, 25, 47, 0.8); + border: 1px solid rgba(0, 255, 136, 0.3); + border-radius: 6px; + color: #ccd6f6; + font-size: 14px; + font-family: inherit; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: #00ff88; + box-shadow: 0 0 10px rgba(0, 255, 136, 0.3); +} + +.form-group textarea { + resize: vertical; + font-family: 'Monaco', 'Courier New', monospace; + line-height: 1.5; +} + +.checkbox-group { + display: flex; + align-items: center; +} + +.checkbox-group label { + display: flex; + align-items: center; + cursor: pointer; + margin: 0; +} + +.checkbox-group input[type="checkbox"] { + width: auto; + margin-right: 8px; + cursor: pointer; +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 30px; +} + +.btn-save, +.btn-cancel { + flex: 1; + padding: 12px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-save { + background: #00ff88; + color: #0a192f; +} + +.btn-save:hover { + background: #00cc6a; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 255, 136, 0.4); +} + +.btn-cancel { + background: rgba(100, 116, 139, 0.2); + color: #cbd5e1; +} + +.btn-cancel:hover { + background: rgba(100, 116, 139, 0.4); +} + +/* 빈 상태 */ +.no-rules { + text-align: center; + padding: 60px 20px; + color: #8892b0; +} + +.no-rules p:first-child { + font-size: 18px; + margin-bottom: 10px; +} + +.no-rules p:last-child { + font-size: 14px; + color: #64748b; +} + +/* 로딩 */ +.loading { + text-align: center; + padding: 60px 20px; + font-size: 18px; + color: #00ff88; +} \ No newline at end of file diff --git a/frontend/src/pages/TradingRules.tsx b/frontend/src/pages/TradingRules.tsx new file mode 100644 index 0000000..91a216d --- /dev/null +++ b/frontend/src/pages/TradingRules.tsx @@ -0,0 +1,286 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '../lib/api' +import { Plus, Trash2, Power, PowerOff } from 'lucide-react' +import { useState } from 'react' +import HtsPage from '../components/HtsPage' + +const TradingRules = () => { + const queryClient = useQueryClient() + const [showForm, setShowForm] = useState(false) + const [formData, setFormData] = useState({ + name: '', + description: '', + rule_type: 'BUY', + priority: 1, + conditions: '{\n "rsi_threshold": 40,\n "sma_short": 5,\n "sma_long": 20\n}', + actions: '{\n "take_profit": 5.0,\n "stop_loss": 3.0,\n "position_sizing": {\n "target_ratio": 0.15\n }\n}' + }) + + // 트레이딩 규칙 조회 + const { data: rules, isLoading } = useQuery({ + queryKey: ['trading-rules'], + queryFn: async () => { + const response = await api.get('/rules') + return response.data + }, + }) + + // 규칙 생성 + const createMutation = useMutation({ + mutationFn: async (data: any) => { + // MCP로 규칙 검증 + try { + const validateResponse = await fetch('http://localhost:8001/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + analysis_type: 'RULE_VALIDATION', + data: { + name: data.name, + conditions: JSON.parse(data.conditions), + actions: JSON.parse(data.actions) + } + }) + }); + const validation = await validateResponse.json(); + + if (!confirm(`🤖 AI 규칙 검증 결과:\n\n${validation.analysis || validation.result}\n\n계속 진행하시겠습니까?`)) { + throw new Error('사용자가 취소했습니다.'); + } + } catch (error: any) { + if (error.message === '사용자가 취소했습니다.') { + throw error; + } + console.warn('MCP 검증 실패, 그대로 진행:', error); + } + + return api.post('/rules', { + ...data, + conditions: JSON.parse(data.conditions), + actions: JSON.parse(data.actions) + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trading-rules'] }) + setShowForm(false) + setFormData({ + name: '', + description: '', + rule_type: 'BUY', + priority: 1, + conditions: '{\n "rsi_threshold": 40,\n "sma_short": 5,\n "sma_long": 20\n}', + actions: '{\n "take_profit": 5.0,\n "stop_loss": 3.0,\n "position_sizing": {\n "target_ratio": 0.15\n }\n}' + }) + alert('✅ 규칙이 성공적으로 추가되었습니다!') + }, + onError: (error: any) => { + if (error.message !== '사용자가 취소했습니다.') { + alert(`❌ 규칙 추가 실패: ${error.response?.data?.detail || error.message}`) + } + }, + }) + + // 규칙 활성화/비활성화 + const toggleMutation = useMutation({ + mutationFn: async (ruleId: number) => { + return api.post(`/rules/${ruleId}/toggle`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trading-rules'] }) + }, + }) + + // 규칙 삭제 + const deleteMutation = useMutation({ + mutationFn: async (ruleId: number) => { + return api.delete(`/rules/${ruleId}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trading-rules'] }) + }, + }) + + if (isLoading) { + return
로딩 중...
+ } + + return ( + +
+
+

전략 룰 설정

+ +
+ + {showForm && ( +
+

새 규칙 추가

+
{ + e.preventDefault() + createMutation.mutate(formData) + }}> +
+
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white" + required + /> +
+
+ + setFormData({ ...formData, description: e.target.value })} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white" + /> +
+
+
+ + +
+
+ + setFormData({ ...formData, priority: parseInt(e.target.value) })} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white" + min="1" + /> +
+
+
+ +