Initial commit

This commit is contained in:
2025-11-14 11:05:31 +09:00
commit b147498c31
124 changed files with 17983 additions and 0 deletions

45
.env.example Normal file
View File

@@ -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

72
.github/workflows/ci-cd.yml vendored Normal file
View File

@@ -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

62
.gitignore vendored Normal file
View File

@@ -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/

247
.venv/bin/Activate.ps1 Normal file
View File

@@ -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"

76
.venv/bin/activate Normal file
View File

@@ -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

27
.venv/bin/activate.csh Normal file
View File

@@ -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 <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
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

69
.venv/bin/activate.fish Normal file
View File

@@ -0,0 +1,69 @@
# This file must be used with "source <venv>/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

7
.venv/bin/alembic Executable file
View File

@@ -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())

7
.venv/bin/celery Executable file
View File

@@ -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())

7
.venv/bin/distro Executable file
View File

@@ -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())

7
.venv/bin/dotenv Executable file
View File

@@ -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())

7
.venv/bin/f2py Executable file
View File

@@ -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())

7
.venv/bin/fastapi Executable file
View File

@@ -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())

7
.venv/bin/hf Executable file
View File

@@ -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())

7
.venv/bin/httpx Executable file
View File

@@ -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())

7
.venv/bin/mako-render Executable file
View File

@@ -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())

7
.venv/bin/numpy-config Executable file
View File

@@ -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())

7
.venv/bin/pip Executable file
View File

@@ -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())

7
.venv/bin/pip3 Executable file
View File

@@ -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())

7
.venv/bin/pip3.12 Executable file
View File

@@ -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())

7
.venv/bin/py.test Executable file
View File

@@ -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())

7
.venv/bin/pyrsa-decrypt Executable file
View File

@@ -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())

7
.venv/bin/pyrsa-encrypt Executable file
View File

@@ -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())

7
.venv/bin/pyrsa-keygen Executable file
View File

@@ -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())

7
.venv/bin/pyrsa-priv2pub Executable file
View File

@@ -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())

7
.venv/bin/pyrsa-sign Executable file
View File

@@ -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())

7
.venv/bin/pyrsa-verify Executable file
View File

@@ -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())

7
.venv/bin/pytest Executable file
View File

@@ -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())

1
.venv/bin/python Symbolic link
View File

@@ -0,0 +1 @@
python3.12

1
.venv/bin/python3 Symbolic link
View File

@@ -0,0 +1 @@
python3.12

1
.venv/bin/python3.12 Symbolic link
View File

@@ -0,0 +1 @@
/opt/homebrew/opt/python@3.12/bin/python3.12

7
.venv/bin/tiny-agents Executable file
View File

@@ -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())

7
.venv/bin/tqdm Executable file
View File

@@ -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())

7
.venv/bin/uvicorn Executable file
View File

@@ -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())

7
.venv/bin/watchfiles Executable file
View File

@@ -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())

View File

@@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#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 */

5
.venv/pyvenv.cfg Normal file
View File

@@ -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

308
API_DOCS.md Normal file
View File

@@ -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

285
API_SAFE_OPERATION.md Normal file
View File

@@ -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 통계를 함께 확인하세요.

101
BATCH_GUIDE.md Normal file
View File

@@ -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
// 실시간 새로고침 버튼
<button onClick={() => refetch({ realtime: true })}>
🔴 실시간 분석 (30초)
</button>
// 기본은 DB 조회 (빠름)
<button onClick={() => refetch()}>
✅ 빠른 조회 (0.1초)
</button>
```
## 데이터베이스 스키마
```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` 옵션 사용

149
INSTALLATION.md Normal file
View File

@@ -0,0 +1,149 @@
# Auto Stock Trading System - 설치 가이드
## 사전 요구사항
- Docker & Docker Compose
- 한국투자증권 API 키
- Claude API 키 (Anthropic)
## 설치 단계
### 1. 저장소 클론
```bash
git clone <repository-url>
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

77
Makefile Normal file
View File

@@ -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

266
PROJECT_STRUCTURE.md Normal file
View File

@@ -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는 언제나 환영합니다!

59
README.md Normal file
View File

@@ -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

211
USER_GUIDE.md Normal file
View File

@@ -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에 문의해주세요.

57
backend/.env.example Normal file
View File

@@ -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

22
backend/Dockerfile Normal file
View File

@@ -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"]

42
backend/README.md Normal file
View File

@@ -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.

View File

@@ -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"])

View File

@@ -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]

View File

@@ -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)}")

View File

@@ -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()
}

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

124
backend/app/main.py Normal file
View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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())

View File

@@ -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")

View File

@@ -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())

81
backend/register_rules.sh Executable file
View File

@@ -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 "🎉 모든 매매 규칙이 등록되었습니다!"

24
backend/requirements.txt Normal file
View File

@@ -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

160
backend/test_api_safety.py Executable file
View File

@@ -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())

View File

@@ -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())

View File

@@ -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
}
}
]
}

50
docker-compose.dev.yml Normal file
View File

@@ -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

148
docker-compose.yml Normal file
View File

@@ -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

184
docker/init-db.sql Normal file
View File

@@ -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();

21
ecosystem.config.js Normal file
View File

@@ -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',
},
],
}

13
frontend/.env.example Normal file
View File

@@ -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

29
frontend/Dockerfile Normal file
View File

@@ -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;"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auto Stock Trading System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
frontend/nginx.conf Normal file
View File

@@ -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";
}
}

5216
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
frontend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

18
frontend/src/App.tsx Normal file
View File

@@ -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 (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/rules" element={<TradingRules />} />
<Route path="/trades" element={<TradeHistory />} />
<Route path="/mcp" element={<MCPStatus />} />
</Routes>
)
}
export default App

View File

@@ -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 (
<div style={{ width, height, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
데이터가 없습니다
</div>
)
}
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 (
<svg width={width} height={height} style={{ background: 'transparent' }}>
{/* 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 (
<g key={ratio}>
<line
x1={80}
y1={y}
x2={width - 20}
y2={y}
stroke="#222"
strokeDasharray="3 3"
/>
<text
x={75}
y={y + 4}
fill="#888"
fontSize="11"
textAnchor="end"
>
{new Intl.NumberFormat('ko-KR', { maximumFractionDigits: 0 }).format(price)}
</text>
</g>
)
})}
{/* 볼린저밴드 영역 - 그리드 뒤, 캔들 앞 */}
{indicators.bb.upper.length > 0 && (
<path
d={createBBAreaPath()}
fill="rgba(139, 92, 246, 0.08)"
stroke="none"
/>
)}
{/* 이동평균선 - 캔들 앞 */}
<path
d={createLinePath(indicators.ma60)}
stroke="#06b6d4"
strokeWidth={2}
fill="none"
opacity={0.9}
/>
<path
d={createLinePath(indicators.ma20)}
stroke="#8b5cf6"
strokeWidth={2}
fill="none"
opacity={0.9}
/>
<path
d={createLinePath(indicators.ma5)}
stroke="#f59e0b"
strokeWidth={2}
fill="none"
opacity={0.9}
/>
{/* 볼린저밴드 라인 - 캔들 앞 */}
<path
d={createLinePath(indicators.bb.upper)}
stroke="#8b5cf6"
strokeWidth={1.5}
fill="none"
strokeDasharray="4 4"
opacity={0.5}
/>
<path
d={createLinePath(indicators.bb.middle)}
stroke="#8b5cf6"
strokeWidth={1.5}
fill="none"
opacity={0.4}
/>
<path
d={createLinePath(indicators.bb.lower)}
stroke="#8b5cf6"
strokeWidth={1.5}
fill="none"
strokeDasharray="4 4"
opacity={0.5}
/>
{/* 캔들스틱 */}
{candles.map((candle, index) => (
<g key={index}>
{/* 심지 (High-Low 라인) */}
<line
x1={candle.x}
y1={candle.highY}
x2={candle.x}
y2={candle.lowY}
stroke={candle.isUp ? '#22c55e' : '#ef4444'}
strokeWidth={1}
/>
{/* 캔들 몸통 */}
<rect
x={candle.x - candle.candleWidth / 2}
y={candle.bodyTop}
width={candle.candleWidth}
height={candle.bodyHeight}
fill={candle.isUp ? '#22c55e' : '#ef4444'}
stroke={candle.isUp ? '#22c55e' : '#ef4444'}
strokeWidth={1}
/>
{/* X축 라벨 (일부만 표시) */}
{index % Math.max(1, Math.floor(candles.length / 10)) === 0 && (
<text
x={candle.x}
y={chartHeight + 25}
fill="#888"
fontSize="10"
textAnchor="middle"
>
{candle.dateLabel}
</text>
)}
{/* 거래량 바 */}
<rect
x={candle.x - candle.candleWidth / 2}
y={volumeTop + volumeScale - candle.volumeBarHeight}
width={candle.candleWidth}
height={candle.volumeBarHeight}
fill={candle.isUp ? '#22c55e' : '#ef4444'}
opacity={0.4}
/>
</g>
))}
{/* 거래량 라벨 */}
<text x={10} y={volumeTop + 15} fill="#888" fontSize="10">거래량</text>
<text x={width - 20} y={volumeTop + volumeScale} fill="#888" fontSize="10" textAnchor="end">
{new Intl.NumberFormat('ko-KR', { notation: 'compact' }).format(maxVolume)}
</text>
{/* 범례 */}
<g transform={`translate(70, 15)`}>
<line x1={0} y1={0} x2={20} y2={0} stroke="#f59e0b" strokeWidth={2} />
<text x={25} y={4} fill="#f59e0b" fontSize="11" fontWeight="600">MA5</text>
<line x1={70} y1={0} x2={90} y2={0} stroke="#8b5cf6" strokeWidth={2} />
<text x={95} y={4} fill="#8b5cf6" fontSize="11" fontWeight="600">MA20</text>
<line x1={150} y1={0} x2={170} y2={0} stroke="#06b6d4" strokeWidth={2} />
<text x={175} y={4} fill="#06b6d4" fontSize="11" fontWeight="600">MA60</text>
<line x1={240} y1={0} x2={260} y2={0} stroke="#94a3b8" strokeWidth={1} strokeDasharray="3 3" />
<text x={265} y={4} fill="#94a3b8" fontSize="11" fontWeight="600">BB</text>
</g>
</svg>
)
}
export default CandlestickChart

View File

@@ -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;
}

View File

@@ -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 (
<div className="hts-header">
<div className="header-left">
<h1>📊 Auto Stock Trading Dashboard</h1>
</div>
<div className="header-center">
<nav className="hts-nav">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`nav-item ${location.pathname === item.path ? 'active' : ''}`}
>
{item.label}
</Link>
))}
</nav>
</div>
<div className="header-right">
{/* MCP 상태 표시 */}
<span className={`mcp-indicator ${mcpStatus?.status === 'connected' ? 'live' : 'offline'}`}>
{mcpStatus?.status === 'connected' ? '🟢 MCP LIVE' : '⚫ MCP'}
</span>
<span className="live-indicator" style={{ marginLeft: '8px' }}>🔴 LIVE</span>
<button
onClick={handleRiskCheck}
style={{
marginLeft: '16px',
padding: '6px 12px',
background: '#f59e0b',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600'
}}
onMouseOver={(e) => e.currentTarget.style.background = '#d97706'}
onMouseOut={(e) => e.currentTarget.style.background = '#f59e0b'}
>
⚠️ 리스크 체크
</button>
<span className="time" style={{ marginLeft: '16px' }}>{currentTime.toLocaleTimeString('ko-KR')}</span>
</div>
</div>
)
}
export default HtsHeader

View File

@@ -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;
}

View File

@@ -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 (
<div className={clsx('hts-page', className)}>
<HtsHeader />
<div className={clsx('hts-content', contentClassName)}>
{children}
</div>
</div>
)
}
export default HtsPage

View File

@@ -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 (
<div className="min-h-screen bg-slate-900">
{/* Header */}
<header className="bg-slate-800 border-b border-slate-700">
<div className="container mx-auto px-4 py-4">
<h1 className="text-2xl font-bold text-white">
Auto Stock Trading System
</h1>
<p className="text-slate-400 text-sm">
Claude AI & MCP 기반 자동 매매 시스템
</p>
</div>
</header>
<div className="flex">
{/* Sidebar */}
<aside className="w-64 bg-slate-800 min-h-[calc(100vh-73px)] border-r border-slate-700">
<nav className="p-4 space-y-2">
{navItems.map((item) => {
const Icon = item.icon
const isActive = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${isActive
? 'bg-primary text-white'
: 'text-slate-400 hover:bg-slate-700 hover:text-white'
}`}
>
<Icon size={20} />
<span>{item.label}</span>
</Link>
)
})}
</nav>
</aside>
{/* Main Content */}
<main className="flex-1 p-6">
{children}
</main>
</div>
</div>
)
}
export default Layout

8
frontend/src/config.ts Normal file
View File

@@ -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`,
}

View File

@@ -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<WebSocket | null>(null)
const [messages, setMessages] = useState<WebSocketMessage[]>([])
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 }
}

63
frontend/src/index.css Normal file
View File

@@ -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;
}
}

25
frontend/src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -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;
}

View File

@@ -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<string>('')
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<Record<string, number>>({
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<string, number>, 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 (
<HtsPage contentClassName="dashboard-content">
<div className="hts-container">
<div className="hts-main-layout">
<div className="left-panel">
<div className="panel-header">계좌 정보</div>
<div className="balance-info">
<div className="balance-row">
<span>예수금</span>
<span style={{ color: '#22c55e' }}>10,000,000원</span>
</div>
<div className="balance-row">
<span>총 자산</span>
<span>{formatCurrency((portfolioSummary?.total_value || 0) + 10000000)}</span>
</div>
<div className="balance-row">
<span>총 손익</span>
<span className={(portfolioSummary?.total_profit_loss || 0) >= 0 ? 'up' : 'down'}>
{formatCurrency(portfolioSummary?.total_profit_loss || 0)}
</span>
</div>
<div className="balance-row">
<span>수익률</span>
<span className={(portfolioSummary?.total_profit_loss_rate || 0) >= 0 ? 'up' : 'down'}>
{formatPercent(portfolioSummary?.total_profit_loss_rate || 0)}
</span>
</div>
<div className="balance-row">
<span>보유 종목</span>
<span>{portfolioSummary?.total_stocks || 0}개</span>
</div>
</div>
<div className="panel-header" style={{ borderTop: '1px solid #333', marginTop: '12px' }}>보유 종목</div>
<div className="portfolio-list" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{portfolio && portfolio.length > 0 ? (
portfolio.map((item: any) => (
<div
key={item.id}
className="portfolio-item"
onClick={() => handleStockSelect(item.stock_code)}
style={{ cursor: 'pointer' }}
>
<div className="stock-name">{item.stock_name} ({item.stock_code})</div>
<div className="stock-info">
<span>{item.quantity}주</span>
<span>{formatCurrency(item.current_price)}</span>
<span className={(item.profit_loss_rate || 0) >= 0 ? 'up' : 'down'}>
{formatPercent(item.profit_loss_rate || 0)}
</span>
</div>
</div>
))
) : (
<div className="no-data" style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
보유 종목 없음
</div>
)}
</div>
</div>
<div className="center-panel">
<div className="chart-section">
<div className="panel-header">대표 종목 차트</div>
<div className="chart-toolbar">
<div className="chart-stocks">
{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 (
<button
key={item.stock_code}
className={`chart-stock ${isSelected ? 'active' : ''}`}
onClick={() => handleStockSelect(item.stock_code)}
>
<span className="name">{item.stock_name}</span>
<span className="price">{formatCurrency(displayPrice)}</span>
</button>
)
})
) : (
<div className="no-data">추천 종목 로딩 중...</div>
)}
</div>
<div className="chart-actions">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '4px' }}>
{/* MCP 상태 UI 제거됨 */}
{/* 실시간 연결 UI 제거됨 */}
</div>
<div className="chart-period">
{(['D', 'W', 'M'] as const).map((period) => (
<button
key={period}
className={chartPeriod === period ? 'active' : ''}
onClick={() => setChartPeriod(period)}
>
{period === 'D' ? '일봉' : period === 'W' ? '주봉' : '월봉'}
</button>
))}
</div>
</div>
</div>
<div className="chart-container">
{chartLoading && <div className="no-data">차트 로딩 중...</div>}
{!chartLoading && chartSeries.length === 0 && (
<div className="no-data">차트 데이터가 없습니다.</div>
)}
{!chartLoading && chartSeries.length > 0 && (
<div style={{ width: '100%', height: '100%' }}>
<CandlestickChart
data={chartSeries}
width={1100}
height={380}
/>
</div>
)}
</div>
</div>
<div className="trades-section">
<div className="panel-header">최근 거래 내역</div>
<div className="trades-table-container">
<table className="trades-table">
<thead>
<tr>
<th>시간</th>
<th>종목</th>
<th>구분</th>
<th>수량</th>
<th>가격</th>
<th>상태</th>
</tr>
</thead>
<tbody>
{recentTrades && recentTrades.length > 0 ? (
recentTrades.map((trade: any) => (
<tr key={trade.id}>
<td>{new Date(trade.created_at).toLocaleTimeString('ko-KR')}</td>
<td>{trade.stock_name}</td>
<td className={trade.order_type === 'BUY' ? 'up' : 'down'}>
{trade.order_type}
</td>
<td>{trade.order_quantity}</td>
<td>{formatCurrency(trade.order_price || 0)}</td>
<td>
<span className={`trade-status ${getTradeStatusClass(trade.status)}`}>
{trade.status}
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="no-data">거래 내역 없음</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="right-panel">
<div className="recommend-section">
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>추천 종목</span>
<button
onClick={async () => {
console.log('🤖 자동매매 버튼 클릭됨')
if (!confirm('규칙에 맞는 종목을 자동으로 매수/매도 하시겠습니까?')) {
console.log('⚠️ 사용자가 자동매매 취소함')
return
}
console.log('✅ 자동매매 확인됨, API 호출 시작...')
try {
console.log('📡 POST /api/v1/recommendations/auto-trade')
const response = await api.post('/recommendations/auto-trade')
console.log('🟢 자동매매 응답:', response.data)
const result = response.data
if (!result.trades || result.trades.length === 0) {
alert(`⚠️ ${result.message || '실행된 거래가 없습니다'}`)
return
}
alert(`✅ ${result.message}\n\n${result.trades.map((t: any) =>
`${t.action}: ${t.stock} ${t.quantity}주 @ ${t.price.toLocaleString()}원`
).join('\n')}`)
} catch (error: any) {
console.error('🔴 자동매매 에러:', error)
console.error('🔴 에러 상세:', {
message: error.message,
response: error.response?.data,
status: error.response?.status
})
const errorMsg = error.response?.data?.detail || error.message || '알 수 없는 오류'
alert(`❌ 자동 매매 실패: ${errorMsg}`)
}
}}
style={{
padding: '4px 8px',
background: '#22c55e',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: '600'
}}
>
🤖 자동매매 시작
</button>
</div>
<div className="recommend-list" style={{ maxHeight: '280px', overflowY: 'auto' }}>
{(() => {
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) => (
<div key={ruleGroup.rule_id} style={{ marginBottom: '16px' }}>
{/* 규칙 헤더 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px',
padding: '8px',
background: '#0a0a0a',
borderRadius: '4px',
borderLeft: '3px solid #22c55e'
}}>
<span style={{
fontSize: '11px',
fontWeight: '600',
color: '#22c55e',
background: 'rgba(34, 197, 94, 0.1)',
padding: '2px 8px',
borderRadius: '4px'
}}>
규칙 {ruleGroup.priority}
</span>
<span style={{ fontSize: '12px', fontWeight: '600', color: '#fff' }}>
{ruleGroup.rule_name}
</span>
<span style={{ fontSize: '10px', color: '#666' }}>
({ruleGroup.matched_count}개 종목 매칭)
</span>
</div>
{/* 종목 리스트 */}
{ruleGroup.stocks.map((item: any) => {
// stockPrices에서 최신 가격 가져오기 (숫자 변환)
const latestPrice = Number(stockPrices[item.stock_code] || item.current_price || 0)
return (
<button
key={item.stock_code}
className={`recommend-item ${selectedStock === item.stock_code ? 'active' : ''}`}
onClick={() => handleStockSelect(item.stock_code)}
style={{ width: '100%', marginBottom: '4px' }}
>
<div className="info">
<span className="name">{item.stock_name}</span>
<span className="code">{item.stock_code}</span>
</div>
<div className="figures">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<span className="price">{formatCurrency(latestPrice)}</span>
{item.price_date && (
<span style={{ fontSize: '9px', color: '#64748b' }}>
{formatDate(item.price_date)} 기준
</span>
)}
</div>
<span className={`rate ${(item.change_rate ?? 0) >= 0 ? 'up' : 'down'}`}>
{formatPercent(item.change_rate ?? 0)}
</span>
</div>
<div style={{ fontSize: '10px', color: '#64748b', marginTop: '4px' }}>
점수: {item.score} | {item.signals?.slice(0, 2).join(', ')}
</div>
</button>
)
})}
</div>
))
) : aiRecommendations && aiRecommendations.recommendations?.length > 0 ? (
/* 2. 수동 지표 분석 추천 */
aiRecommendations.recommendations.map((item: any) => {
// stockPrices에서 최신 가격 가져오기 (숫자 변환)
const latestPrice = Number(stockPrices[item.stock_code] || item.current_price || 0)
return (
<button
key={item.stock_code}
className={`recommend-item ${selectedStock === item.stock_code ? 'active' : ''}`}
onClick={() => handleStockSelect(item.stock_code)}
>
<div className="info">
<span className="name">{item.stock_name}</span>
<span className="code">{item.stock_code}</span>
</div>
<div className="figures">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<span className="price">{formatCurrency(latestPrice)}</span>
{item.price_date && (
<span style={{ fontSize: '9px', color: '#64748b' }}>
{formatDate(item.price_date)} 기준
</span>
)}
</div>
<span className={`rate ${(item.change_rate ?? 0) >= 0 ? 'up' : 'down'}`}>
{formatPercent(item.change_rate ?? 0)}
</span>
</div>
<div style={{ fontSize: '10px', color: '#64748b', marginTop: '4px' }}>
점수: {item.score} | {item.signals.slice(0, 2).join(', ')}
</div>
</button>
)
})
) : recommendedStocks.length > 0 ? (
recommendedStocks.map((item: any) => {
const displayPrice = Number(selectedStock === item.stock_code && currentStockPrice
? currentStockPrice
: item.current_price)
return (
<button
key={item.stock_code}
className={`recommend-item ${selectedStock === item.stock_code ? 'active' : ''}`}
onClick={() => handleStockSelect(item.stock_code)}
>
<div className="info">
<span className="name">{item.stock_name}</span>
<span className="code">{item.stock_code}</span>
</div>
<div className="figures">
<span className="price">{formatCurrency(displayPrice || 0)}</span>
<span className={`rate ${(item.profit_loss_rate ?? 0) >= 0 ? 'up' : 'down'}`}>
{formatPercent(item.profit_loss_rate ?? 0)}
</span>
</div>
</button>
)
})
) : (
<div className="no-data">보유 종목이 없습니다</div>
)}
</div>
</div>
{/* AI 분석 섹션 */}
<div className="ai-analysis-section">
<div className="panel-header">🤖 AI 종합 분석</div>
<div className="analysis-content">
{aiAnalysisLoading ? (
<div className="analysis-loading">분석 중...</div>
) : aiAnalysis ? (
typeof aiAnalysis === 'string' ? (
<div style={{ whiteSpace: 'pre-wrap' }}>{aiAnalysis}</div>
) : typeof aiAnalysis === 'object' && aiAnalysis !== null ? (
<div style={{ whiteSpace: 'pre-wrap' }}>
{aiAnalysis.analysis || aiAnalysis.recommendation || JSON.stringify(aiAnalysis, null, 2)}
</div>
) : (
<div className="no-data">분석 결과를 표시할 수 없습니다</div>
)
) : (
<div className="no-data">종목을 선택하세요</div>
)}
</div>
</div>
{/* 리스크 체크 섹션 */}
<div className="risk-check-section">
<div className="panel-header">⚠️ 리스크 체크</div>
<div className="risk-content">
{riskCheckLoading ? (
<div className="risk-loading">분석 중...</div>
) : riskCheck ? (
typeof riskCheck === 'string' ? (
<div style={{ whiteSpace: 'pre-wrap' }}>{riskCheck}</div>
) : typeof riskCheck === 'object' && riskCheck !== null ? (
<div style={{ whiteSpace: 'pre-wrap' }}>
{riskCheck.analysis || riskCheck.recommendation || JSON.stringify(riskCheck, null, 2)}
</div>
) : (
<div className="no-data">분석 결과를 표시할 수 없습니다</div>
)
) : (
<div className="no-data">종목을 선택하세요</div>
)}
</div>
</div>
<div className="news-section">
<div className="panel-header">시장 뉴스</div>
<div className="news-list">
{news && news.length > 0 ? (
// 선택된 종목에 해당하는 뉴스만 필터링
news.filter((item: any) => !selectedStock || item.stock_code === selectedStock)
.slice(0, 5)
.map((item: any) => (
<div key={item.id} className="news-item" onClick={() => window.open(item.url, '_blank')}>
<div className="news-title">{item.title}</div>
<div className="news-meta">
{new Date(item.published_at).toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
))
) : (
<div className="no-data">뉴스 없음</div>
)}
</div>
</div>
</div>
</div>
</div>
</HtsPage>
)
}
export default Dashboard

View File

@@ -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<any[]>([])
// 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 <CheckCircle className="text-success" size={24} />
case 'disconnected':
return <XCircle className="text-danger" size={24} />
default:
return <AlertCircle className="text-warning" size={24} />
}
}
return (
<HtsPage>
<div className="space-y-6">
<h2 className="text-3xl font-bold text-white">MCP 상태 모니터</h2>
{/* MCP 서버 상태 */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-white">MCP 서버 연결 상태</h3>
<button
onClick={() => refetch()}
className="btn-secondary text-sm"
>
새로고침
</button>
</div>
{mcpStatus && (
<div className="flex items-center space-x-4">
{getStatusIcon(mcpStatus.status)}
<div>
<p className={`text-xl font-bold ${getStatusColor(mcpStatus.status)}`}>
{mcpStatus.status === 'connected' ? '연결됨' :
mcpStatus.status === 'disconnected' ? '연결 끊김' : '알 수 없음'}
</p>
<p className="text-sm text-slate-400">
{mcpStatus.mcp_server_url || '서버 URL 없음'}
</p>
{mcpStatus.message && (
<p className="text-sm text-slate-500 mt-1">{mcpStatus.message}</p>
)}
</div>
</div>
)}
</div>
{/* 사용 가능한 도구 */}
<div className="card">
<h3 className="text-xl font-bold text-white mb-4">사용 가능한 MCP 도구</h3>
{mcpTools && mcpTools.tools && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{mcpTools.tools.map((tool: any, index: number) => (
<div
key={index}
className="bg-slate-900 rounded-lg p-4 border border-slate-700"
>
<div className="flex items-start justify-between mb-2">
<h4 className="text-lg font-semibold text-white">{tool.name}</h4>
<Activity className="text-primary" size={20} />
</div>
<p className="text-sm text-slate-400">{tool.description}</p>
{tool.input_schema && tool.input_schema.properties && (
<div className="mt-3 text-xs">
<p className="text-slate-500 font-medium mb-1">파라미터:</p>
<ul className="space-y-1">
{Object.entries(tool.input_schema.properties).map(([key, prop]: [string, any]) => (
<li key={key} className="text-slate-400">
• <span className="text-primary">{key}</span>
{tool.input_schema.required?.includes(key) && (
<span className="text-danger ml-1">*</span>
)}
: {prop.type}
</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
)}
{(!mcpTools || !mcpTools.tools || mcpTools.tools.length === 0) && (
<p className="text-slate-400 text-center py-8">
사용 가능한 도구가 없습니다.
</p>
)}
</div>
{/* MCP 메시지 로그 */}
<div className="card">
<h3 className="text-xl font-bold text-white mb-4">실시간 MCP 로그</h3>
<div className="space-y-2 max-h-96 overflow-y-auto">
{mcpMessages.map((msg, index) => (
<div
key={index}
className="bg-slate-900 rounded p-3 text-sm font-mono"
>
<div className="flex items-center justify-between mb-1">
<span className="text-slate-500">
{new Date().toLocaleTimeString('ko-KR')}
</span>
<span className="text-primary">{msg.type}</span>
</div>
<pre className="text-slate-300 whitespace-pre-wrap">
{JSON.stringify(msg.data, null, 2)}
</pre>
</div>
))}
{mcpMessages.length === 0 && (
<p className="text-slate-400 text-center py-8">
MCP 로그가 없습니다.
</p>
)}
</div>
</div>
</div>
</HtsPage>
)
}
export default MCPStatus

View File

@@ -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<Trade[]>({
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 <CheckCircle className="text-success" size={20} />
case 'FAILED':
return <XCircle className="text-danger" size={20} />
case 'PENDING':
return <Clock className="text-warning" size={20} />
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 (
<HtsPage contentClassName="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold text-white">매매 로그</h2>
{isLoading && <span className="text-sm text-slate-400">데이터 갱신 중...</span>}
</div>
{isError && (
<div className="card border border-danger/40 bg-danger/10 text-danger">
최근 거래 내역을 불러오는 중 문제가 발생했습니다.
</div>
)}
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-700">
<th className="text-left py-3 px-4 text-slate-400">시간</th>
<th className="text-left py-3 px-4 text-slate-400">상태</th>
<th className="text-left py-3 px-4 text-slate-400">종목</th>
<th className="text-center py-3 px-4 text-slate-400">구분</th>
<th className="text-right py-3 px-4 text-slate-400">주문가</th>
<th className="text-right py-3 px-4 text-slate-400">수량</th>
<th className="text-right py-3 px-4 text-slate-400">체결가</th>
<th className="text-right py-3 px-4 text-slate-400">체결수량</th>
<th className="text-left py-3 px-4 text-slate-400">메시지</th>
</tr>
</thead>
<tbody>
{trades.map((trade) => (
<tr key={trade.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="py-3 px-4 text-slate-300 text-sm whitespace-nowrap">
{format(new Date(trade.created_at), 'MM/dd HH:mm:ss', { locale: ko })}
</td>
<td className="py-3 px-4">
<div className="flex items-center space-x-2">
{getStatusIcon(trade.status)}
<span className="text-sm text-slate-300">{trade.status}</span>
</div>
</td>
<td className="py-3 px-4">
<div>
<div className="text-white font-medium">{trade.stock_code}</div>
{trade.stock_name && (
<div className="text-xs text-slate-400">{trade.stock_name}</div>
)}
</div>
</td>
<td className="py-3 px-4 text-center">
<span
className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${trade.order_type === 'BUY'
? 'bg-primary/20 text-primary'
: 'bg-danger/20 text-danger'
}`}
>
{trade.order_type === 'BUY' ? (
<>
<TrendingUp size={12} className="mr-1" />
매수
</>
) : (
<>
<TrendingDown size={12} className="mr-1" />
매도
</>
)}
</span>
</td>
<td className="py-3 px-4 text-right text-white">
{formatCurrency(trade.order_price)}
</td>
<td className="py-3 px-4 text-right text-white">
{trade.order_quantity.toLocaleString()}
</td>
<td className="py-3 px-4 text-right text-white">
{trade.executed_price != null ? formatCurrency(trade.executed_price) : '-'}
</td>
<td className="py-3 px-4 text-right text-white">
{trade.executed_quantity != null ? trade.executed_quantity.toLocaleString() : '-'}
</td>
<td className="py-3 px-4 text-slate-400 text-sm">
{trade.message || '-'}
</td>
</tr>
))}
</tbody>
</table>
{!trades.length && !isLoading && (
<div className="text-center py-12">
<p className="text-slate-400">거래 이력이 없습니다.</p>
</div>
)}
</div>
</div>
</HtsPage>
)
}
export default TradeHistory

View File

@@ -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;
}

View File

@@ -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 <div className="text-white">로딩 중...</div>
}
return (
<HtsPage>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold text-white">전략 룰 설정</h2>
<button
onClick={() => setShowForm(true)}
className="btn-primary flex items-center space-x-2"
>
<Plus size={20} />
<span>새 규칙 추가</span>
</button>
</div>
{showForm && (
<div className="card border border-primary/40 bg-slate-900/70">
<h3 className="text-xl font-bold text-white mb-4">새 규칙 추가</h3>
<form onSubmit={(e) => {
e.preventDefault()
createMutation.mutate(formData)
}}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">규칙 이름</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">설명</label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">규칙 타입</label>
<select
value={formData.rule_type}
onChange={(e) => setFormData({ ...formData, rule_type: e.target.value })}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
>
<option value="BUY">BUY</option>
<option value="SELL">SELL</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">우선순위</label>
<input
type="number"
value={formData.priority}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">조건 (JSON)</label>
<textarea
value={formData.conditions}
onChange={(e) => setFormData({ ...formData, conditions: e.target.value })}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white font-mono text-xs"
rows={6}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">실행 액션 (JSON)</label>
<textarea
value={formData.actions}
onChange={(e) => setFormData({ ...formData, actions: e.target.value })}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white font-mono text-xs"
rows={6}
required
/>
</div>
</div>
<div className="mt-4 flex justify-end space-x-2">
<button
type="button"
onClick={() => setShowForm(false)}
className="btn-secondary text-sm"
>
취소
</button>
<button
type="submit"
className="btn-primary text-sm"
disabled={createMutation.isPending}
>
{createMutation.isPending ? '검증 중...' : '🤖 AI 검증 후 추가'}
</button>
</div>
</form>
</div>
)}
<div className="grid gap-4">
{rules && rules.map((rule: any) => (
<div key={rule.id} className="card">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="text-xl font-bold text-white">{rule.name}</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${rule.is_active ? 'bg-success/20 text-success' : 'bg-slate-700 text-slate-400'
}`}>
{rule.is_active ? '활성' : '비활성'}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${rule.rule_type === 'BUY' ? 'bg-primary/20 text-primary' : 'bg-danger/20 text-danger'
}`}>
{rule.rule_type}
</span>
</div>
{rule.description && (
<p className="text-slate-400 mb-3">{rule.description}</p>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-slate-500 font-medium mb-1">조건</p>
<pre className="bg-slate-900 p-2 rounded text-slate-300 overflow-x-auto">
{JSON.stringify(rule.conditions, null, 2)}
</pre>
</div>
<div>
<p className="text-slate-500 font-medium mb-1">실행</p>
<pre className="bg-slate-900 p-2 rounded text-slate-300 overflow-x-auto">
{JSON.stringify(rule.actions, null, 2)}
</pre>
</div>
</div>
</div>
<div className="flex space-x-2 ml-4">
<button
onClick={() => toggleMutation.mutate(rule.id)}
className={`btn ${rule.is_active ? 'btn-danger' : 'btn-success'}`}
title={rule.is_active ? '비활성화' : '활성화'}
>
{rule.is_active ? <PowerOff size={18} /> : <Power size={18} />}
</button>
<button
onClick={() => deleteMutation.mutate(rule.id)}
className="btn btn-danger"
title="삭제"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
))}
{(!rules || rules.length === 0) && (
<div className="card text-center py-12">
<p className="text-slate-400">설정된 트레이딩 규칙이 없습니다.</p>
<button
onClick={() => setShowForm(true)}
className="btn-primary mt-4"
>
첫 규칙 추가하기
</button>
</div>
)}
</div>
</div>
</HtsPage>
)
}
export default TradingRules

10
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_WS_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: '#2563eb',
secondary: '#64748b',
success: '#10b981',
danger: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6',
},
},
},
plugins: [],
}

Some files were not shown because too many files have changed in this diff Show More