Initial commit
This commit is contained in:
45
.env.example
Normal file
45
.env.example
Normal 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
72
.github/workflows/ci-cd.yml
vendored
Normal 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
62
.gitignore
vendored
Normal 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
247
.venv/bin/Activate.ps1
Normal 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
76
.venv/bin/activate
Normal 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
27
.venv/bin/activate.csh
Normal 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
69
.venv/bin/activate.fish
Normal 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
7
.venv/bin/alembic
Executable 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
7
.venv/bin/celery
Executable 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
7
.venv/bin/distro
Executable 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
7
.venv/bin/dotenv
Executable 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
7
.venv/bin/f2py
Executable 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
7
.venv/bin/fastapi
Executable 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
7
.venv/bin/hf
Executable 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
7
.venv/bin/httpx
Executable 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
7
.venv/bin/mako-render
Executable 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
7
.venv/bin/numpy-config
Executable 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
7
.venv/bin/pip
Executable 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
7
.venv/bin/pip3
Executable 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
7
.venv/bin/pip3.12
Executable 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
7
.venv/bin/py.test
Executable 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
7
.venv/bin/pyrsa-decrypt
Executable 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
7
.venv/bin/pyrsa-encrypt
Executable 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
7
.venv/bin/pyrsa-keygen
Executable 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
7
.venv/bin/pyrsa-priv2pub
Executable 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
7
.venv/bin/pyrsa-sign
Executable 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
7
.venv/bin/pyrsa-verify
Executable 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
7
.venv/bin/pytest
Executable 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
1
.venv/bin/python
Symbolic link
@@ -0,0 +1 @@
|
||||
python3.12
|
||||
1
.venv/bin/python3
Symbolic link
1
.venv/bin/python3
Symbolic link
@@ -0,0 +1 @@
|
||||
python3.12
|
||||
1
.venv/bin/python3.12
Symbolic link
1
.venv/bin/python3.12
Symbolic link
@@ -0,0 +1 @@
|
||||
/opt/homebrew/opt/python@3.12/bin/python3.12
|
||||
7
.venv/bin/tiny-agents
Executable file
7
.venv/bin/tiny-agents
Executable 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
7
.venv/bin/tqdm
Executable 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
7
.venv/bin/uvicorn
Executable 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
7
.venv/bin/watchfiles
Executable 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())
|
||||
164
.venv/include/site/python3.12/greenlet/greenlet.h
Normal file
164
.venv/include/site/python3.12/greenlet/greenlet.h
Normal 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
5
.venv/pyvenv.cfg
Normal 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
308
API_DOCS.md
Normal 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
285
API_SAFE_OPERATION.md
Normal 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
101
BATCH_GUIDE.md
Normal 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
149
INSTALLATION.md
Normal 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
77
Makefile
Normal 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
266
PROJECT_STRUCTURE.md
Normal 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
59
README.md
Normal 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
211
USER_GUIDE.md
Normal 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
57
backend/.env.example
Normal 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
22
backend/Dockerfile
Normal 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
42
backend/README.md
Normal 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.
|
||||
12
backend/app/api/__init__.py
Normal file
12
backend/app/api/__init__.py
Normal 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"])
|
||||
272
backend/app/api/endpoints/market_data.py
Normal file
272
backend/app/api/endpoints/market_data.py
Normal 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]
|
||||
118
backend/app/api/endpoints/mcp.py
Normal file
118
backend/app/api/endpoints/mcp.py
Normal 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)}")
|
||||
69
backend/app/api/endpoints/monitoring.py
Normal file
69
backend/app/api/endpoints/monitoring.py
Normal 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()
|
||||
}
|
||||
228
backend/app/api/endpoints/portfolio.py
Normal file
228
backend/app/api/endpoints/portfolio.py
Normal 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)
|
||||
1378
backend/app/api/endpoints/recommendations.py
Normal file
1378
backend/app/api/endpoints/recommendations.py
Normal file
File diff suppressed because it is too large
Load Diff
118
backend/app/api/endpoints/trades.py
Normal file
118
backend/app/api/endpoints/trades.py
Normal 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
|
||||
}
|
||||
143
backend/app/api/endpoints/trading_rules.py
Normal file
143
backend/app/api/endpoints/trading_rules.py
Normal 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
|
||||
97
backend/app/core/config.py
Normal file
97
backend/app/core/config.py
Normal 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()
|
||||
38
backend/app/core/database.py
Normal file
38
backend/app/core/database.py
Normal 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
124
backend/app/main.py
Normal 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,
|
||||
)
|
||||
141
backend/app/models/models.py
Normal file
141
backend/app/models/models.py
Normal 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)
|
||||
456
backend/app/services/korea_investment_api.py
Normal file
456
backend/app/services/korea_investment_api.py
Normal 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()
|
||||
79
backend/app/services/websocket_manager.py
Normal file
79
backend/app/services/websocket_manager.py
Normal 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()
|
||||
238
backend/batch_daily_analysis.py
Normal file
238
backend/batch_daily_analysis.py
Normal 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())
|
||||
57
backend/insert_test_data.py
Normal file
57
backend/insert_test_data.py
Normal 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")
|
||||
101
backend/load_trading_rules.py
Normal file
101
backend/load_trading_rules.py
Normal 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
81
backend/register_rules.sh
Executable 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
24
backend/requirements.txt
Normal 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
160
backend/test_api_safety.py
Executable 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())
|
||||
69
backend/test_db_connection.py
Normal file
69
backend/test_db_connection.py
Normal 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())
|
||||
137
backend/trading_rules_config.json
Normal file
137
backend/trading_rules_config.json
Normal 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
50
docker-compose.dev.yml
Normal 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
148
docker-compose.yml
Normal 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
184
docker/init-db.sql
Normal 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
21
ecosystem.config.js
Normal 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
13
frontend/.env.example
Normal 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
29
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
40
frontend/nginx.conf
Normal 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
5216
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
18
frontend/src/App.tsx
Normal file
18
frontend/src/App.tsx
Normal 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
|
||||
434
frontend/src/components/CandlestickChart.tsx
Normal file
434
frontend/src/components/CandlestickChart.tsx
Normal 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
|
||||
123
frontend/src/components/HtsHeader.css
Normal file
123
frontend/src/components/HtsHeader.css
Normal 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;
|
||||
}
|
||||
101
frontend/src/components/HtsHeader.tsx
Normal file
101
frontend/src/components/HtsHeader.tsx
Normal 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
|
||||
19
frontend/src/components/HtsPage.css
Normal file
19
frontend/src/components/HtsPage.css
Normal 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;
|
||||
}
|
||||
23
frontend/src/components/HtsPage.tsx
Normal file
23
frontend/src/components/HtsPage.tsx
Normal 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
|
||||
66
frontend/src/components/Layout.tsx
Normal file
66
frontend/src/components/Layout.tsx
Normal 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
8
frontend/src/config.ts
Normal 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`,
|
||||
}
|
||||
54
frontend/src/hooks/useWebSocket.ts
Normal file
54
frontend/src/hooks/useWebSocket.ts
Normal 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
63
frontend/src/index.css
Normal 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
25
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
660
frontend/src/pages/Dashboard.css
Normal file
660
frontend/src/pages/Dashboard.css
Normal 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;
|
||||
}
|
||||
922
frontend/src/pages/Dashboard.tsx
Normal file
922
frontend/src/pages/Dashboard.tsx
Normal 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
|
||||
176
frontend/src/pages/MCPStatus.tsx
Normal file
176
frontend/src/pages/MCPStatus.tsx
Normal 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
|
||||
163
frontend/src/pages/TradeHistory.tsx
Normal file
163
frontend/src/pages/TradeHistory.tsx
Normal 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
|
||||
370
frontend/src/pages/TradingRules.css
Normal file
370
frontend/src/pages/TradingRules.css
Normal 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;
|
||||
}
|
||||
286
frontend/src/pages/TradingRules.tsx
Normal file
286
frontend/src/pages/TradingRules.tsx
Normal 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
10
frontend/src/vite-env.d.ts
vendored
Normal 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
|
||||
}
|
||||
20
frontend/tailwind.config.js
Normal file
20
frontend/tailwind.config.js
Normal 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
Reference in New Issue
Block a user