AssetPilot OrangePi 5 Pluse Server-First Commit

This commit is contained in:
Wind
2026-02-10 12:23:22 +09:00
commit bf87175f51
45 changed files with 4060 additions and 0 deletions

59
asset_pilot_docker/.gitignore vendored Normal file
View File

@@ -0,0 +1,59 @@
# 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/
# 환경 변수
.env
.env.local
# 데이터베이스
*.db
*.sqlite
*.sqlite3
# 로그
*.log
logs/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# CSV 데이터
*.csv
!example.csv
# 백업 파일
*.sql
backup/
# Docker
.dockerignore

View File

@@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

View File

@@ -0,0 +1,70 @@
# 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 commands. Without forgetting
# past commands the $PATH changes we made may not be respected
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:
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath /home/ubuntu/AssetPilot/asset_pilot_docker/.venv)
else
# use the path as-is
export VIRTUAL_ENV=/home/ubuntu/AssetPilot/asset_pilot_docker/.venv
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH"
export PATH
# 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
VIRTUAL_ENV_PROMPT='(.venv) '
export VIRTUAL_ENV_PROMPT
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

View File

@@ -0,0 +1,27 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV /home/ubuntu/AssetPilot/asset_pilot_docker/.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

View File

@@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV /home/ubuntu/AssetPilot/asset_pilot_docker/.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

View File

@@ -0,0 +1,8 @@
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from dotenv.__main__ import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

View File

@@ -0,0 +1,8 @@
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from charset_normalizer.cli import cli_detect
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli_detect())

View File

@@ -0,0 +1,8 @@
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1 @@
python3

View File

@@ -0,0 +1 @@
/usr/bin/python3

View File

@@ -0,0 +1 @@
python3

View File

@@ -0,0 +1,8 @@
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from uvicorn.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from watchfiles.cli import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

View File

@@ -0,0 +1,8 @@
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from websockets.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

View File

@@ -0,0 +1 @@
lib

View File

@@ -0,0 +1,5 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/ubuntu/AssetPilot/asset_pilot_docker/.venv

View File

@@ -0,0 +1,420 @@
# Asset Pilot - Docker 설치 가이드
## 🐳 Docker 방식의 장점
- ✅ 독립된 컨테이너로 깔끔한 환경 관리
- ✅ PostgreSQL과 애플리케이션 분리
- ✅ 한 번의 명령으로 전체 시스템 실행
- ✅ 쉬운 백업 및 복구
- ✅ 포트 충돌 없음
- ✅ 업데이트 및 롤백 간편
---
## 📋 사전 준비
### 1. Docker 설치
#### Orange Pi (Ubuntu/Debian)
```bash
# Docker 설치 스크립트
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# 현재 사용자를 docker 그룹에 추가
sudo usermod -aG docker $USER
# 로그아웃 후 재로그인 또는
newgrp docker
# Docker 서비스 시작
sudo systemctl start docker
sudo systemctl enable docker
```
#### Docker Compose 설치 (이미 포함되어 있을 수 있음)
```bash
# Docker Compose 버전 확인
docker compose version
# 없다면 설치
sudo apt-get update
sudo apt-get install docker-compose-plugin
```
### 2. 설치 확인
```bash
docker --version
docker compose version
```
---
## 🚀 설치 및 실행
### 1단계: 파일 업로드
Orange Pi에 `asset_pilot_docker.tar.gz` 파일을 전송:
```bash
# Windows에서 (PowerShell)
scp asset_pilot_docker.tar.gz orangepi@192.168.1.100:~/
# Linux/Mac에서
scp asset_pilot_docker.tar.gz orangepi@192.168.1.100:~/
```
### 2단계: 압축 해제
```bash
# SSH 접속
ssh orangepi@192.168.1.100
# 압축 해제
tar -xzf asset_pilot_docker.tar.gz
cd asset_pilot_docker
```
### 3단계: 환경 설정
```bash
# .env 파일 편집 (비밀번호 변경)
nano .env
```
`.env` 파일 내용:
```env
DB_PASSWORD=your_secure_password_here # 여기를 변경하세요!
```
저장: `Ctrl + X``Y``Enter`
### 4단계: Docker 컨테이너 실행
```bash
# 백그라운드에서 실행
docker compose up -d
# 실행 상태 확인
docker compose ps
```
출력 예시:
```
NAME IMAGE STATUS PORTS
asset_pilot_app asset_pilot_docker-app Up 30 seconds 0.0.0.0:8000->8000/tcp
asset_pilot_db postgres:16-alpine Up 30 seconds 0.0.0.0:5432->5432/tcp
```
### 5단계: 데이터베이스 초기화
```bash
# 앱 컨테이너 내부에서 초기화 스크립트 실행
docker compose exec app python init_db.py
```
### 6단계: 접속 확인
웹 브라우저에서:
```
http://[Orange_Pi_IP]:8000
```
예: `http://192.168.1.100:8000`
---
## 🔧 Docker 관리 명령어
### 컨테이너 관리
```bash
# 전체 시작
docker compose up -d
# 전체 중지
docker compose down
# 전체 재시작
docker compose restart
# 특정 서비스만 재시작
docker compose restart app # 앱만
docker compose restart postgres # DB만
# 상태 확인
docker compose ps
# 로그 확인 (실시간)
docker compose logs -f
# 특정 서비스 로그만
docker compose logs -f app
docker compose logs -f postgres
```
### 데이터베이스 관리
```bash
# PostgreSQL 컨테이너 접속
docker compose exec postgres psql -U asset_user -d asset_pilot
# SQL 쿼리 실행 예시
# \dt # 테이블 목록
# \d assets # assets 테이블 구조
# SELECT * FROM assets;
# \q # 종료
```
### 애플리케이션 관리
```bash
# 앱 컨테이너 내부 접속
docker compose exec app /bin/bash
# 컨테이너 내부에서 Python 스크립트 실행
docker compose exec app python init_db.py
```
---
## 📊 데이터 관리
### 백업
#### 데이터베이스 백업
```bash
# 백업 생성
docker compose exec postgres pg_dump -U asset_user asset_pilot > backup_$(date +%Y%m%d).sql
# 또는
docker compose exec -T postgres pg_dump -U asset_user asset_pilot > backup.sql
```
#### 전체 볼륨 백업
```bash
# 볼륨 백업 (고급)
docker run --rm -v asset_pilot_docker_postgres_data:/data \
-v $(pwd):/backup alpine tar czf /backup/postgres_backup.tar.gz /data
```
### 복원
```bash
# 백업 파일 복원
cat backup.sql | docker compose exec -T postgres psql -U asset_user -d asset_pilot
```
### CSV 데이터 가져오기 (Windows 앱에서)
```bash
# 1. CSV 파일을 컨테이너로 복사
docker cp user_assets.csv asset_pilot_app:/app/
# 2. import_csv.py 생성 (아래 스크립트 참고)
docker compose exec app python import_csv.py user_assets.csv
```
---
## 🔄 업데이트
### 애플리케이션 업데이트
```bash
# 1. 새 코드 받기 (파일 업로드 또는 git pull)
# 2. 이미지 재빌드
docker compose build app
# 3. 재시작
docker compose up -d app
```
### PostgreSQL 업데이트
```bash
# 주의: 데이터 백업 필수!
# 1. 백업 생성
docker compose exec -T postgres pg_dump -U asset_user asset_pilot > backup.sql
# 2. docker-compose.yml에서 버전 변경 (예: postgres:17-alpine)
# 3. 컨테이너 재생성
docker compose down
docker compose up -d
```
---
## 🗑️ 완전 삭제
```bash
# 컨테이너 중지 및 삭제
docker compose down
# 볼륨까지 삭제 (데이터 완전 삭제!)
docker compose down -v
# 이미지도 삭제
docker rmi asset_pilot_docker-app postgres:16-alpine
```
---
## 🛠️ 문제 해결
### 컨테이너가 시작되지 않음
```bash
# 로그 확인
docker compose logs
# 특정 서비스 로그
docker compose logs app
docker compose logs postgres
# 컨테이너 상태 확인
docker compose ps -a
```
### 데이터베이스 연결 오류
```bash
# PostgreSQL 컨테이너 헬스체크
docker compose exec postgres pg_isready -U asset_user -d asset_pilot
# 연결 테스트
docker compose exec postgres psql -U asset_user -d asset_pilot -c "SELECT 1;"
```
### 포트 충돌
```bash
# 8000번 포트 사용 확인
sudo lsof -i :8000
# docker-compose.yml에서 포트 변경 (예: 8001:8000)
```
### 디스크 공간 부족
```bash
# 사용하지 않는 Docker 리소스 정리
docker system prune -a
# 볼륨 확인
docker volume ls
```
---
## 📱 원격 접근 설정
### Nginx 리버스 프록시 (선택적)
```bash
# Nginx 설치
sudo apt install nginx
# 설정 파일 생성
sudo nano /etc/nginx/sites-available/asset_pilot
```
설정 내용:
```nginx
server {
listen 80;
server_name your_domain.com; # 또는 IP 주소
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/stream {
proxy_pass http://localhost:8000/api/stream;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
}
}
```
활성화:
```bash
sudo ln -s /etc/nginx/sites-available/asset_pilot /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
---
## 🔐 보안 권장사항
### 1. .env 파일 보호
```bash
chmod 600 .env
```
### 2. 방화벽 설정
```bash
# 8000번 포트만 허용 (외부 접근 시)
sudo ufw allow 8000/tcp
# PostgreSQL 포트는 외부 차단 (기본값)
sudo ufw deny 5432/tcp
```
### 3. 정기 백업
```bash
# cron으로 매일 자동 백업
crontab -e
# 추가 (매일 새벽 3시)
0 3 * * * cd /home/orangepi/asset_pilot_docker && docker compose exec -T postgres pg_dump -U asset_user asset_pilot > backup_$(date +\%Y\%m\%d).sql
```
---
## 📊 시스템 리소스 모니터링
```bash
# 컨테이너 리소스 사용량
docker stats
# 특정 컨테이너만
docker stats asset_pilot_app asset_pilot_db
```
---
## ✅ 설치 체크리스트
- [ ] Docker 설치 완료
- [ ] Docker Compose 설치 완료
- [ ] 프로젝트 파일 압축 해제
- [ ] .env 파일 비밀번호 설정
- [ ] `docker compose up -d` 실행
- [ ] 컨테이너 상태 확인 (`docker compose ps`)
- [ ] 데이터베이스 초기화 (`docker compose exec app python init_db.py`)
- [ ] 웹 브라우저 접속 확인 (`http://[IP]:8000`)
- [ ] 데이터 수집 동작 확인
---
## 🎉 완료!
모든 과정이 완료되면 다음 URL로 접속하세요:
```
http://[Orange_Pi_IP]:8000
```
문제가 발생하면 로그를 확인하세요:
```bash
docker compose logs -f
```

View File

@@ -0,0 +1,34 @@
FROM python:3.11-slim
# 작업 디렉토리 설정
WORKDIR /app
# 시스템 패키지 업데이트 및 필수 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 파일 복사 및 설치
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# 애플리케이션 파일 복사 (구조를 유지하며 복사)
COPY . .
# 로그 디렉토리 생성
RUN mkdir -p /app/logs
# 비루트 사용자 생성 및 권한 설정
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
# 헬스체크 엔드포인트 노출
EXPOSE 8000
# 컨테이너 시작 시 실행할 명령어 (uvicorn으로 직접 실행 권장)
CMD ["python", "main.py"]

View File

@@ -0,0 +1,159 @@
# Asset Pilot - Docker Edition
🐳 Docker 컨테이너 기반 자산 모니터링 시스템
## 📦 구성
이 프로젝트는 2개의 독립된 Docker 컨테이너로 구성됩니다:
1. **PostgreSQL 컨테이너** (`asset_pilot_db`)
- 데이터베이스 서버
- 포트: 5432
- 볼륨: `postgres_data`
2. **Asset Pilot 앱 컨테이너** (`asset_pilot_app`)
- FastAPI 웹 애플리케이션
- 포트: 8000
- 실시간 데이터 수집 및 제공
## 🚀 빠른 시작
### 자동 설치 (권장)
```bash
# 압축 해제
tar -xzf asset_pilot_docker.tar.gz
cd asset_pilot_docker
# 자동 설치 스크립트 실행
bash start.sh
```
### 수동 설치
```bash
# 1. 환경 변수 설정
nano .env
# DB_PASSWORD를 원하는 비밀번호로 변경
# 2. Docker 컨테이너 시작
docker compose up -d
# 3. 데이터베이스 초기화
docker compose exec app python init_db.py
# 4. 브라우저에서 접속
# http://[IP주소]:8000
```
## 📁 디렉토리 구조
```
asset_pilot_docker/
├── app/ # 애플리케이션 코드
│ ├── calculator.py
│ ├── database.py
│ ├── fetcher.py
│ └── models.py
├── static/ # 정적 파일
│ ├── css/
│ └── js/
├── templates/ # HTML 템플릿
│ └── index.html
├── docker-compose.yml # Docker Compose 설정
├── Dockerfile # 앱 컨테이너 이미지
├── .env # 환경 변수
├── main.py # FastAPI 메인
├── init_db.py # DB 초기화
├── import_csv.py # CSV 가져오기
├── start.sh # 자동 설치 스크립트
└── DOCKER_GUIDE.md # 상세 가이드
```
## 🔧 주요 명령어
### 컨테이너 관리
```bash
# 시작
docker compose up -d
# 중지
docker compose down
# 재시작
docker compose restart
# 상태 확인
docker compose ps
# 로그 보기
docker compose logs -f
```
### 데이터 관리
```bash
# DB 백업
docker compose exec postgres pg_dump -U asset_user asset_pilot > backup.sql
# DB 복원
cat backup.sql | docker compose exec -T postgres psql -U asset_user -d asset_pilot
# CSV 가져오기
docker cp user_assets.csv asset_pilot_app:/app/
docker compose exec app python import_csv.py user_assets.csv
```
## 🌐 접속
```
http://localhost:8000 # 로컬
http://[IP주소]:8000 # 네트워크
```
## 📚 문서
- **DOCKER_GUIDE.md** - Docker 상세 설치 및 관리 가이드
- 문제 해결, 백업, 업데이트 방법 포함
## 🔐 보안
- `.env` 파일에 비밀번호 저장 (권한: 600)
- PostgreSQL은 내부 네트워크만 접근 가능
- 방화벽에서 필요한 포트만 개방
## 🆘 문제 해결
### 컨테이너가 시작되지 않음
```bash
docker compose logs
```
### 데이터베이스 연결 오류
```bash
docker compose exec postgres pg_isready -U asset_user -d asset_pilot
```
### 포트 충돌
```bash
# docker-compose.yml에서 포트 변경
# "8001:8000" 으로 수정
```
## 📊 시스템 요구사항
- Docker 20.10+
- Docker Compose 2.0+
- 최소 2GB RAM
- 최소 5GB 디스크 공간
## 🎯 특징
✅ 독립된 컨테이너로 시스템 격리
✅ 한 번의 명령으로 전체 시스템 실행
✅ 쉬운 백업 및 복구
✅ 업데이트 및 롤백 간편
✅ 개발/프로덕션 환경 일관성
---
**Asset Pilot Docker Edition v1.0**

View File

@@ -0,0 +1,59 @@
from typing import Dict, Optional
class Calculator:
"""손익 계산 클래스"""
@staticmethod
def calc_pnl(
gold_buy_price: float,
gold_quantity: float,
btc_buy_price: float,
btc_quantity: float,
current_gold: Optional[float],
current_btc: Optional[float]
) -> Dict:
"""
금과 BTC의 손익 계산
Returns:
{
"금손익": float,
"금손익%": float,
"BTC손익": float,
"BTC손익%": float,
"총손익": float,
"총손익%": float
}
"""
result = {
"금손익": 0.0,
"금손익%": 0.0,
"BTC손익": 0.0,
"BTC손익%": 0.0,
"총손익": 0.0,
"총손익%": 0.0
}
# 금 손익 계산
if current_gold:
cost_gold = gold_buy_price * gold_quantity
pnl_gold = gold_quantity * (float(current_gold) - gold_buy_price)
result["금손익"] = round(pnl_gold, 0)
if cost_gold > 0:
result["금손익%"] = round((pnl_gold / cost_gold * 100), 2)
# BTC 손익 계산
if current_btc:
cost_btc = btc_buy_price * btc_quantity
pnl_btc = btc_quantity * (float(current_btc) - btc_buy_price)
result["BTC손익"] = round(pnl_btc, 0)
if cost_btc > 0:
result["BTC손익%"] = round((pnl_btc / cost_btc * 100), 2)
# 총 손익 계산
result["총손익"] = result["금손익"] + result["BTC손익"]
total_cost = (gold_buy_price * gold_quantity) + (btc_buy_price * btc_quantity)
if total_cost > 0:
result["총손익%"] = round((result["총손익"] / total_cost * 100), 2)
return result

View File

@@ -0,0 +1,29 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from dotenv import load_dotenv
load_dotenv()
# 데이터베이스 URL 가져오기
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://asset_user:password@localhost/asset_pilot")
# SQLAlchemy 엔진 생성
engine = create_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_pre_ping=True, # 연결 유효성 자동 확인
echo=False # SQL 쿼리 로그 (디버깅 시 True로 변경)
)
# 세션 팩토리 생성
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""데이터베이스 세션 의존성"""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,113 @@
import requests
import re
from typing import Dict, Optional
import time
class DataFetcher:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
})
def fetch_investing_com(self, asset_code: str) -> Optional[float]:
"""인베스팅닷컴 (윈도우 앱 방식 정규식 적용)"""
try:
url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}"
if asset_code == "USD/DXY":
url = "https://www.investing.com/indices/usdollar"
# allow_redirects를 True로 하여 주소 변경에 대응
response = self.session.get(url, timeout=10, allow_redirects=True)
html = response.text
# 윈도우에서 가장 잘 되던 패턴 순서대로 시도
patterns = [
r'data-test="instrument-price-last">([\d,.]+)<',
r'last_last">([\d,.]+)<',
r'instrument-price-last">([\d,.]+)<'
]
for pattern in patterns:
p = re.search(pattern, html)
if p:
return float(p.group(1).replace(',', ''))
except Exception as e:
print(f"⚠️ Investing 수집 실패 ({asset_code}): {e}")
return None
def fetch_binance(self) -> Optional[float]:
"""바이낸스 BTC/USDT (보내주신 윈도우 코드 로직)"""
url = "https://api.binance.com/api/v3/ticker/price"
try:
response = requests.get(url, params={"symbol": "BTCUSDT"}, timeout=5)
response.raise_for_status()
return float(response.json()["price"])
except Exception as e:
print(f"❌ Binance API 실패: {e}")
return None
def fetch_upbit(self) -> Optional[float]:
"""업비트 BTC/KRW (보내주신 윈도우 코드 로직)"""
url = "https://api.upbit.com/v1/ticker"
try:
response = requests.get(url, params={"markets": "KRW-BTC"}, timeout=5)
response.raise_for_status()
data = response.json()
return float(data[0]["trade_price"]) if data else None
except Exception as e:
print(f"❌ Upbit API 실패: {e}")
return None
def fetch_usd_krw(self) -> Optional[float]:
"""USD/KRW 환율 (DNS 에러 방지 이중화)"""
# 방법 1: 두나무 CDN (원래 주소)
try:
url = "https://quotation-api-cdn.dunamu.com/v1/forex/recent?codes=FRX.KRWUSD"
res = requests.get(url, timeout=3)
if res.status_code == 200:
return float(res.json()[0]["basePrice"])
except:
pass # 실패하면 바로 인베스팅닷컴으로 전환
# 방법 2: 인베스팅닷컴에서 환율 가져오기 (가장 확실한 백업)
return self.fetch_investing_com("USD/KRW")
def fetch_krx_gold(self) -> Optional[float]:
"""금 시세 (네이버 금융 모바일)"""
try:
url = "https://m.stock.naver.com/marketindex/metals/M04020000"
res = requests.get(url, timeout=5)
m = re.search(r'\"closePrice\":\"([\d,]+)\"', res.text)
return float(m.group(1).replace(",", "")) if m else None
except:
return None
def fetch_all(self) -> Dict[str, Dict]:
print(f"📊 [{time.strftime('%H:%M:%S')}] 수집 시작...")
# 1. 환율 먼저 수집 (계산의 핵심)
usd_krw = self.fetch_usd_krw()
# 2. 나머지 자산 수집
results = {
"XAU/USD": {"가격": self.fetch_investing_com("XAU/USD"), "단위": "USD/oz"},
"XAU/CNY": {"가격": self.fetch_investing_com("XAU/CNY"), "단위": "CNY/oz"},
"XAU/GBP": {"가격": self.fetch_investing_com("XAU/GBP"), "단위": "GBP/oz"},
"USD/DXY": {"가격": self.fetch_investing_com("USD/DXY"), "단위": "Index"},
"USD/KRW": {"가격": usd_krw, "단위": "KRW"},
"BTC/USD": {"가격": self.fetch_binance(), "단위": "USDT"},
"BTC/KRW": {"가격": self.fetch_upbit(), "단위": "KRW"},
"KRX/GLD": {"가격": self.fetch_krx_gold(), "단위": "KRW/g"},
}
# 3. XAU/KRW 계산
xau_krw = None
if results["XAU/USD"]["가격"] and usd_krw:
xau_krw = round((results["XAU/USD"]["가격"] / 31.1034768) * usd_krw, 0)
results["XAU/KRW"] = {"가격": xau_krw, "단위": "KRW/g"}
success_count = sum(1 for v in results.values() if v['가격'] is not None)
print(f"✅ 수집 완료 (성공: {success_count}/9)")
return results
fetcher = DataFetcher()

View File

@@ -0,0 +1,142 @@
import requests
from typing import Dict, Optional
from bs4 import BeautifulSoup
import time
class DataFetcher:
"""모든 자산 가격 수집 클래스"""
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
self.investing_cache = {}
self.cache_time = 0
def fetch_investing_com(self, asset_code: str) -> Optional[float]:
"""Investing.com에서 가격 수집"""
# 간단한 캐싱 (5초)
if time.time() - self.cache_time < 5 and asset_code in self.investing_cache:
return self.investing_cache[asset_code]
asset_map = {
"XAU/USD": "8830",
"XAU/CNY": "2186",
"XAU/GBP": "8500",
"USD/DXY": "8827"
}
asset_id = asset_map.get(asset_code)
if not asset_id:
return None
try:
url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}"
response = self.session.get(url, timeout=5)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'lxml')
price_elem = soup.select_one('[data-test="instrument-price-last"]')
if price_elem:
price_text = price_elem.text.strip().replace(',', '')
price = float(price_text)
self.investing_cache[asset_code] = price
return price
except Exception as e:
print(f"Investing.com 수집 실패 ({asset_code}): {e}")
return None
def fetch_binance(self) -> Optional[float]:
"""바이낸스 BTC/USDT 가격"""
try:
url = "https://api.binance.com/api/v3/ticker/price"
response = self.session.get(url, params={"symbol": "BTCUSDT"}, timeout=5)
response.raise_for_status()
data = response.json()
return float(data["price"]) if "price" in data else None
except Exception as e:
print(f"Binance API 실패: {e}")
return None
def fetch_upbit(self) -> Optional[float]:
"""업비트 BTC/KRW 가격"""
try:
url = "https://api.upbit.com/v1/ticker"
response = self.session.get(url, params={"markets": "KRW-BTC"}, timeout=5)
response.raise_for_status()
data = response.json()
return data[0]["trade_price"] if data and "trade_price" in data[0] else None
except Exception as e:
print(f"Upbit API 실패: {e}")
return None
def fetch_usd_krw(self) -> Optional[float]:
"""USD/KRW 환율"""
try:
url = "https://quotation-api-cdn.dunamu.com/v1/forex/recent?codes=FRX.KRWUSD"
response = self.session.get(url, timeout=5)
response.raise_for_status()
data = response.json()
return data[0]["basePrice"] if data else None
except Exception as e:
print(f"USD/KRW 수집 실패: {e}")
return None
def fetch_krx_gold(self) -> Optional[float]:
"""한국거래소 금 현물 가격"""
try:
url = "http://www.goldpr.co.kr/gms/default.asp"
response = self.session.get(url, timeout=5)
response.encoding = 'euc-kr'
soup = BeautifulSoup(response.text, 'lxml')
# 금 현물 가격 파싱 (사이트 구조에 따라 조정 필요)
price_elem = soup.select_one('table tr:nth-of-type(2) td:nth-of-type(2)')
if price_elem:
price_text = price_elem.text.strip().replace(',', '').replace('원', '')
return float(price_text)
except Exception as e:
print(f"KRX 금 가격 수집 실패: {e}")
return None
def fetch_all(self) -> Dict[str, Dict]:
"""모든 자산 가격 수집"""
print("📊 데이터 수집 시작...")
# 개별 자산 수집
xau_usd = self.fetch_investing_com("XAU/USD")
xau_cny = self.fetch_investing_com("XAU/CNY")
xau_gbp = self.fetch_investing_com("XAU/GBP")
usd_dxy = self.fetch_investing_com("USD/DXY")
usd_krw = self.fetch_usd_krw()
btc_usd = self.fetch_binance()
btc_krw = self.fetch_upbit()
krx_gold = self.fetch_krx_gold()
# XAU/KRW 계산 (트로이온스 -> 그램당 원화)
xau_krw = None
if xau_usd and usd_krw:
xau_krw = round((xau_usd / 31.1034768) * usd_krw, 0)
results = {
"XAU/USD": {"가격": xau_usd, "단위": "USD/oz"},
"XAU/CNY": {"가격": xau_cny, "단위": "CNY/oz"},
"XAU/GBP": {"가격": xau_gbp, "단위": "GBP/oz"},
"USD/DXY": {"가격": usd_dxy, "단위": "Index"},
"USD/KRW": {"가격": usd_krw, "단위": "KRW"},
"BTC/USD": {"가격": btc_usd, "단위": "USDT"},
"BTC/KRW": {"가격": btc_krw, "단위": "KRW"},
"KRX/GLD": {"가격": krx_gold, "단위": "KRW/g"},
"XAU/KRW": {"가격": xau_krw, "단위": "KRW/g"},
}
print(f"✅ 데이터 수집 완료 (성공: {sum(1 for v in results.values() if v['가격'])}/9)")
return results
# 전역 인스턴스
fetcher = DataFetcher()

View File

@@ -0,0 +1,55 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
Base = declarative_base()
class Asset(Base):
"""자산 마스터 테이블"""
__tablename__ = "assets"
id = Column(Integer, primary_key=True, index=True)
symbol = Column(String(20), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
category = Column(String(50)) # 귀금속, 암호화폐, 환율 등
created_at = Column(DateTime, default=datetime.utcnow)
# 관계
user_assets = relationship("UserAsset", back_populates="asset")
price_history = relationship("PriceHistory", back_populates="asset")
class UserAsset(Base):
"""사용자 자산 정보"""
__tablename__ = "user_assets"
id = Column(Integer, primary_key=True, index=True)
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=False)
previous_close = Column(Float, default=0.0) # 전일종가
average_price = Column(Float, default=0.0) # 평균매입가
quantity = Column(Float, default=0.0) # 보유량
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 관계
asset = relationship("Asset", back_populates="user_assets")
class PriceHistory(Base):
"""가격 히스토리 (선택적)"""
__tablename__ = "price_history"
id = Column(Integer, primary_key=True, index=True)
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=False)
price = Column(Float, nullable=False)
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
# 관계
asset = relationship("Asset", back_populates="price_history")
class AlertSetting(Base):
"""알림 설정"""
__tablename__ = "alert_settings"
id = Column(Integer, primary_key=True, index=True)
setting_key = Column(String(100), unique=True, nullable=False)
setting_value = Column(Text) # JSON 형식으로 저장
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -0,0 +1,67 @@
services:
# 1. PostgreSQL 데이터베이스
postgres:
image: postgres:16-alpine
container_name: asset_pilot_db
restart: unless-stopped
environment:
POSTGRES_DB: asset_pilot
POSTGRES_USER: asset_user
POSTGRES_PASSWORD: ${DB_PASSWORD:-assetpilot}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
ports:
- "5432:5432"
networks:
- asset_pilot_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U asset_user -d asset_pilot"]
interval: 10s
timeout: 5s
retries: 5
# 2. Asset Pilot 웹 애플리케이션
app:
build:
context: .
dockerfile: Dockerfile
# DNS 설정 (빌드 밖으로 이동)
dns:
- 8.8.8.8
- 1.1.1.1
container_name: asset_pilot_app
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
# DB 비밀번호 기본값을 postgres 서비스와 동일하게 'assetpilot'으로 설정
DATABASE_URL: postgresql://asset_user:${DB_PASSWORD:-assetpilot}@postgres:5432/asset_pilot
APP_HOST: 0.0.0.0
APP_PORT: 8000
DEBUG: "False"
FETCH_INTERVAL: 5
ports:
- "8000:8000"
networks:
- asset_pilot_network
volumes:
- app_logs:/app/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
postgres_data:
driver: local
app_logs:
driver: local
networks:
asset_pilot_network:
driver: bridge

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
CSV 데이터 가져오기 스크립트 (Docker용)
Windows 앱의 user_assets.csv 파일을 PostgreSQL로 마이그레이션
"""
import sys
import os
import csv
from sqlalchemy.orm import sessionmaker
# app 모듈 import
from app.database import engine
from app.models import Asset, UserAsset
def import_csv(csv_file):
"""CSV 파일에서 데이터 가져오기"""
if not os.path.exists(csv_file):
print(f"❌ 파일을 찾을 수 없습니다: {csv_file}")
sys.exit(1)
print(f"📁 CSV 파일 읽기: {csv_file}")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
with open(csv_file, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
count = 0
for row in reader:
if len(row) != 4:
print(f"⚠️ 잘못된 행 형식 (무시): {row}")
continue
asset_symbol, prev_close, avg_price, quantity = row
# 자산 찾기
asset = db.query(Asset).filter(Asset.symbol == asset_symbol).first()
if not asset:
print(f"⚠️ 자산을 찾을 수 없음: {asset_symbol}")
continue
# 사용자 자산 업데이트
user_asset = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first()
if user_asset:
user_asset.previous_close = float(prev_close)
user_asset.average_price = float(avg_price)
user_asset.quantity = float(quantity)
count += 1
print(f"{asset_symbol}: 전일={prev_close}, 평단={avg_price}, 수량={quantity}")
else:
print(f"⚠️ 사용자 자산 정보 없음: {asset_symbol}")
db.commit()
print(f"\n{count}개 항목 가져오기 완료!")
except Exception as e:
print(f"❌ 오류 발생: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
if len(sys.argv) < 2:
print("사용법: python import_csv.py <csv_file_path>")
print("예제: python import_csv.py user_assets.csv")
print("\nDocker에서 사용:")
print("1. docker cp user_assets.csv asset_pilot_app:/app/")
print("2. docker compose exec app python import_csv.py user_assets.csv")
sys.exit(1)
csv_file = sys.argv[1]
import_csv(csv_file)

128
asset_pilot_docker/init_db.py Executable file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
데이터베이스 초기화 스크립트
PostgreSQL 데이터베이스에 테이블을 생성하고 기본 데이터를 삽입합니다.
"""
import sys
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# 환경 변수 로드
load_dotenv()
# app 모듈 import를 위한 경로 추가
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.models import Base, Asset, UserAsset, AlertSetting
from app.database import DATABASE_URL
import json
def init_database():
"""데이터베이스 초기화"""
print("🔧 데이터베이스 초기화 시작...")
try:
# 엔진 생성
engine = create_engine(DATABASE_URL, echo=True)
# 테이블 생성
print("\n📋 테이블 생성 중...")
Base.metadata.create_all(bind=engine)
print("✅ 테이블 생성 완료")
# 세션 생성
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
# 자산 마스터 데이터 초기화
print("\n📊 자산 마스터 데이터 초기화 중...")
assets_data = [
("XAU/USD", "금/달러", "귀금속"),
("XAU/CNY", "금/위안", "귀금속"),
("XAU/GBP", "금/파운드", "귀금속"),
("USD/DXY", "달러인덱스", "환율"),
("USD/KRW", "달러/원", "환율"),
("BTC/USD", "비트코인/달러", "암호화폐"),
("BTC/KRW", "비트코인/원", "암호화폐"),
("KRX/GLD", "금 현물", "귀금속"),
("XAU/KRW", "금/원", "귀금속"),
]
for symbol, name, category in assets_data:
existing = db.query(Asset).filter(Asset.symbol == symbol).first()
if not existing:
asset = Asset(symbol=symbol, name=name, category=category)
db.add(asset)
print(f"{symbol} ({name}) 추가")
else:
print(f" - {symbol} 이미 존재")
db.commit()
print("✅ 자산 마스터 데이터 초기화 완료")
# 사용자 자산 초기화
print("\n👤 사용자 자산 데이터 초기화 중...")
assets = db.query(Asset).all()
for asset in assets:
existing = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first()
if not existing:
user_asset = UserAsset(
asset_id=asset.id,
previous_close=0,
average_price=0,
quantity=0
)
db.add(user_asset)
print(f"{asset.symbol} 사용자 데이터 추가")
else:
print(f" - {asset.symbol} 사용자 데이터 이미 존재")
db.commit()
print("✅ 사용자 자산 데이터 초기화 완료")
# 알림 설정 초기화
print("\n🔔 알림 설정 초기화 중...")
default_settings = {
"급등락_감지": False,
"급등락_임계값": 3.0,
"목표수익률_감지": False,
"목표수익률": 10.0,
"특정가격_감지": False,
"금_목표가격": 100000,
"BTC_목표가격": 100000000,
}
for key, value in default_settings.items():
existing = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first()
if not existing:
setting = AlertSetting(setting_key=key, setting_value=json.dumps(value))
db.add(setting)
print(f"{key}: {value}")
else:
print(f" - {key} 이미 존재")
db.commit()
print("✅ 알림 설정 초기화 완료")
print("\n🎉 데이터베이스 초기화 완료!")
print("\n다음 명령으로 서버를 시작하세요:")
print(" python main.py")
finally:
db.close()
except Exception as e:
print(f"\n❌ 오류 발생: {e}")
print("\n문제 해결 방법:")
print("1. PostgreSQL이 실행 중인지 확인하세요:")
print(" sudo systemctl status postgresql")
print("\n2. 데이터베이스가 생성되었는지 확인하세요:")
print(" sudo -u postgres psql -c '\\l'")
print("\n3. .env 파일의 DATABASE_URL이 올바른지 확인하세요")
sys.exit(1)
if __name__ == "__main__":
init_database()

268
asset_pilot_docker/main.py Normal file
View File

@@ -0,0 +1,268 @@
import os
import json
import asyncio
from datetime import datetime
from typing import Dict
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from pydantic import BaseModel
from dotenv import load_dotenv
from app.database import get_db, engine
from app.models import Base, Asset, UserAsset, AlertSetting
from app.fetcher import fetcher
from app.calculator import Calculator
load_dotenv()
# 테이블 생성
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Asset Pilot - Orange Pi Edition", version="1.0.0")
# 정적 파일 및 템플릿 설정
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# 전역 변수: 현재 가격 캐시
current_prices: Dict = {}
# ==================== Pydantic 모델 ====================
class UserAssetUpdate(BaseModel):
symbol: str
previous_close: float
average_price: float
quantity: float
class AlertSettingUpdate(BaseModel):
settings: Dict
# ==================== 데이터베이스 초기화 ====================
def init_assets(db: Session):
"""자산 마스터 데이터 초기화"""
assets_data = [
("XAU/USD", "금/달러", "귀금속"),
("XAU/CNY", "금/위안", "귀금속"),
("XAU/GBP", "금/파운드", "귀금속"),
("USD/DXY", "달러인덱스", "환율"),
("USD/KRW", "달러/원", "환율"),
("BTC/USD", "비트코인/달러", "암호화폐"),
("BTC/KRW", "비트코인/원", "암호화폐"),
("KRX/GLD", "금 현물", "귀금속"),
("XAU/KRW", "금/원", "귀금속"),
]
for symbol, name, category in assets_data:
existing = db.query(Asset).filter(Asset.symbol == symbol).first()
if not existing:
asset = Asset(symbol=symbol, name=name, category=category)
db.add(asset)
db.commit()
print("✅ 자산 마스터 데이터 초기화 완료")
def init_user_assets(db: Session):
"""사용자 자산 초기화 (기본값 0)"""
assets = db.query(Asset).all()
for asset in assets:
existing = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first()
if not existing:
user_asset = UserAsset(
asset_id=asset.id,
previous_close=0,
average_price=0,
quantity=0
)
db.add(user_asset)
db.commit()
print("✅ 사용자 자산 데이터 초기화 완료")
def init_alert_settings(db: Session):
"""알림 설정 초기화"""
default_settings = {
"급등락_감지": False,
"급등락_임계값": 3.0,
"목표수익률_감지": False,
"목표수익률": 10.0,
"특정가격_감지": False,
"금_목표가격": 100000,
"BTC_목표가격": 100000000,
}
for key, value in default_settings.items():
existing = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first()
if not existing:
setting = AlertSetting(setting_key=key, setting_value=json.dumps(value))
db.add(setting)
db.commit()
print("✅ 알림 설정 초기화 완료")
# ==================== 앱 시작 이벤트 ====================
@app.on_event("startup")
async def startup_event():
"""앱 시작 시 초기화 및 백그라운드 작업 시작"""
db = next(get_db())
try:
init_assets(db)
init_user_assets(db)
init_alert_settings(db)
print("🚀 Asset Pilot 서버 시작 완료")
finally:
db.close()
# 백그라운드 데이터 수집 시작
asyncio.create_task(background_fetch())
async def background_fetch():
"""백그라운드에서 주기적으로 가격 수집"""
global current_prices
interval = int(os.getenv('FETCH_INTERVAL', 5))
while True:
try:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 데이터 수집 시작...")
current_prices = fetcher.fetch_all()
except Exception as e:
print(f"❌ 데이터 수집 오류: {e}")
await asyncio.sleep(interval)
# ==================== API 엔드포인트 ====================
@app.get("/", response_class=HTMLResponse)
async def read_root(request: Request):
"""메인 페이지"""
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/api/prices")
async def get_prices():
"""현재 가격 조회"""
return current_prices
@app.get("/api/assets")
async def get_assets(db: Session = Depends(get_db)):
"""사용자 자산 조회"""
assets = db.query(Asset, UserAsset).join(
UserAsset, Asset.id == UserAsset.asset_id
).all()
result = []
for asset, user_asset in assets:
result.append({
"symbol": asset.symbol,
"name": asset.name,
"category": asset.category,
"previous_close": float(user_asset.previous_close),
"average_price": float(user_asset.average_price),
"quantity": float(user_asset.quantity),
})
return result
@app.post("/api/assets")
async def update_asset(data: UserAssetUpdate, db: Session = Depends(get_db)):
"""자산 정보 업데이트"""
asset = db.query(Asset).filter(Asset.symbol == data.symbol).first()
if not asset:
raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다")
user_asset = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first()
if user_asset:
user_asset.previous_close = data.previous_close
user_asset.average_price = data.average_price
user_asset.quantity = data.quantity
db.commit()
return {"status": "success", "message": "업데이트 완료"}
raise HTTPException(status_code=404, detail="사용자 자산 정보를 찾을 수 없습니다")
@app.get("/api/pnl")
async def get_pnl(db: Session = Depends(get_db)):
"""손익 계산"""
# KRX/GLD 자산 정보
krx_asset = db.query(Asset).filter(Asset.symbol == "KRX/GLD").first()
krx_user = db.query(UserAsset).filter(UserAsset.asset_id == krx_asset.id).first() if krx_asset else None
# BTC/KRW 자산 정보
btc_asset = db.query(Asset).filter(Asset.symbol == "BTC/KRW").first()
btc_user = db.query(UserAsset).filter(UserAsset.asset_id == btc_asset.id).first() if btc_asset else None
gold_buy_price = float(krx_user.average_price) if krx_user else 0
gold_quantity = float(krx_user.quantity) if krx_user else 0
btc_buy_price = float(btc_user.average_price) if btc_user else 0
btc_quantity = float(btc_user.quantity) if btc_user else 0
current_gold = current_prices.get("KRX/GLD", {}).get("가격")
current_btc = current_prices.get("BTC/KRW", {}).get("가격")
pnl = Calculator.calc_pnl(
gold_buy_price, gold_quantity,
btc_buy_price, btc_quantity,
current_gold, current_btc
)
return pnl
@app.get("/api/alerts/settings")
async def get_alert_settings(db: Session = Depends(get_db)):
"""알림 설정 조회"""
settings = db.query(AlertSetting).all()
result = {}
for setting in settings:
try:
result[setting.setting_key] = json.loads(setting.setting_value)
except:
result[setting.setting_key] = setting.setting_value
return result
@app.post("/api/alerts/settings")
async def update_alert_settings(data: AlertSettingUpdate, db: Session = Depends(get_db)):
"""알림 설정 업데이트"""
for key, value in data.settings.items():
setting = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first()
if setting:
setting.setting_value = json.dumps(value)
else:
new_setting = AlertSetting(setting_key=key, setting_value=json.dumps(value))
db.add(new_setting)
db.commit()
return {"status": "success", "message": "알림 설정 업데이트 완료"}
@app.get("/api/stream")
async def stream_prices():
"""Server-Sent Events로 실시간 가격 스트리밍"""
async def event_generator():
while True:
if current_prices:
data = json.dumps(current_prices, ensure_ascii=False)
yield f"data: {data}\n\n"
await asyncio.sleep(1)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@app.get("/health")
async def health_check():
"""헬스 체크"""
return {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"prices_loaded": len(current_prices) > 0
}
# ==================== 메인 실행 ====================
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host=os.getenv("APP_HOST", "0.0.0.0"),
port=int(os.getenv("APP_PORT", 8000)),
reload=os.getenv("DEBUG", "False").lower() == "true"
)

View File

@@ -0,0 +1,12 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
pydantic==2.5.0
python-dotenv==1.0.0
jinja2==3.1.2
aiofiles==23.2.1
requests==2.31.0
beautifulsoup4==4.12.2
lxml==4.9.3
python-multipart==0.0.6

117
asset_pilot_docker/start.sh Executable file
View File

@@ -0,0 +1,117 @@
#!/bin/bash
# Asset Pilot Docker 빠른 시작 스크립트
set -e
echo "🐳 Asset Pilot Docker 설치를 시작합니다..."
echo ""
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Docker 설치 확인
if ! command -v docker &> /dev/null; then
echo -e "${YELLOW}⚠️ Docker가 설치되어 있지 않습니다.${NC}"
echo ""
echo "Docker를 설치하시겠습니까? (y/n)"
read -r INSTALL_DOCKER
if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then
echo -e "${BLUE}🐳 Docker 설치 중...${NC}"
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
rm get-docker.sh
echo -e "${GREEN}✓ Docker 설치 완료${NC}"
echo ""
echo -e "${YELLOW}⚠️ 로그아웃 후 다시 로그인하거나 다음 명령을 실행하세요:${NC}"
echo " newgrp docker"
echo ""
echo "그런 다음 이 스크립트를 다시 실행하세요."
exit 0
else
echo -e "${RED}❌ Docker가 필요합니다. 설치 후 다시 시도하세요.${NC}"
exit 1
fi
fi
echo -e "${GREEN}✓ Docker 확인 완료${NC}"
# Docker Compose 확인
if ! docker compose version &> /dev/null; then
echo -e "${YELLOW}⚠️ Docker Compose가 설치되어 있지 않습니다.${NC}"
echo -e "${BLUE}📦 Docker Compose 설치 중...${NC}"
sudo apt-get update
sudo apt-get install -y docker-compose-plugin
echo -e "${GREEN}✓ Docker Compose 설치 완료${NC}"
fi
echo -e "${GREEN}✓ Docker Compose 확인 완료${NC}"
echo ""
# .env 파일 설정
if [ ! -f ".env" ]; then
echo -e "${YELLOW}🔐 데이터베이스 비밀번호를 설정하세요${NC}"
echo -n "비밀번호 입력: "
read -s DB_PASSWORD
echo ""
cat > .env << EOF
# PostgreSQL 데이터베이스 비밀번호
DB_PASSWORD=${DB_PASSWORD}
# 선택적 설정
# FETCH_INTERVAL=5
# DEBUG=False
EOF
chmod 600 .env
echo -e "${GREEN}✓ .env 파일 생성 완료${NC}"
else
echo -e "${YELLOW}⚠️ .env 파일이 이미 존재합니다${NC}"
fi
echo ""
echo -e "${BLUE}🚀 Docker 컨테이너 빌드 및 실행 중...${NC}"
echo ""
# Docker 이미지 빌드 및 컨테이너 시작
docker compose up -d --build
echo ""
echo -e "${BLUE}⏳ 데이터베이스 준비 대기 중...${NC}"
sleep 10
# 데이터베이스 초기화
echo ""
echo -e "${BLUE}🔧 데이터베이스 초기화 중...${NC}"
docker compose exec app python init_db.py
echo ""
echo -e "${GREEN}✅ 설치 완료!${NC}"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "${GREEN}🌐 접속 정보${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# IP 주소 확인
IP_ADDR=$(hostname -I | awk '{print $1}')
echo -e "로컬: ${BLUE}http://localhost:8000${NC}"
echo -e "네트워크: ${BLUE}http://${IP_ADDR}:8000${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "유용한 명령어:"
echo " docker compose ps # 컨테이너 상태 확인"
echo " docker compose logs -f # 로그 실시간 보기"
echo " docker compose restart # 재시작"
echo " docker compose down # 중지"
echo ""
echo "CSV 데이터 가져오기:"
echo " docker cp user_assets.csv asset_pilot_app:/app/"
echo " docker compose exec app python import_csv.py user_assets.csv"
echo ""

View File

@@ -0,0 +1,353 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #2563eb;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--bg-color: #f8fafc;
--card-bg: #ffffff;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: #e2e8f0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* 헤더 */
header {
background: var(--card-bg);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
header h1 {
font-size: 32px;
margin-bottom: 8px;
}
.subtitle {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 12px;
}
.status-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-secondary);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--success-color);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* 손익 요약 */
.pnl-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.pnl-card {
background: var(--card-bg);
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.pnl-card.total {
background: linear-gradient(135deg, var(--primary-color) 0%, #1e40af 100%);
color: white;
}
.pnl-card h3 {
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
opacity: 0.9;
}
.pnl-value {
font-size: 28px;
font-weight: 700;
margin-bottom: 4px;
}
.pnl-percent {
font-size: 16px;
font-weight: 500;
}
.pnl-value.profit {
color: var(--danger-color);
}
.pnl-value.loss {
color: var(--primary-color);
}
.pnl-card.total .pnl-value,
.pnl-card.total .pnl-percent {
color: white;
}
/* 자산 섹션 */
.assets-section {
background: var(--card-bg);
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 24px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-header h2 {
font-size: 20px;
}
/* 테이블 */
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--bg-color);
font-weight: 600;
font-size: 14px;
color: var(--text-secondary);
}
td {
font-size: 14px;
}
.numeric {
text-align: right;
}
td input {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
}
td input:focus {
outline: none;
border-color: var(--primary-color);
}
.price-up {
color: var(--danger-color);
}
.price-down {
color: var(--primary-color);
}
/* 버튼 */
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #1e40af;
}
.btn-secondary {
background-color: var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background-color: #cbd5e1;
}
/* 모달 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal.active {
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background-color: var(--card-bg);
border-radius: 12px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-size: 20px;
}
.close {
font-size: 28px;
font-weight: 300;
cursor: pointer;
color: var(--text-secondary);
}
.close:hover {
color: var(--text-primary);
}
.modal-body {
padding: 24px;
}
.setting-group {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border-color);
}
.setting-group:last-child {
border-bottom: none;
}
.setting-group h3 {
font-size: 16px;
margin-bottom: 16px;
}
.setting-group label {
display: block;
margin-bottom: 12px;
}
.setting-group input[type="checkbox"] {
margin-right: 8px;
}
.setting-group input[type="number"] {
width: 120px;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
margin-left: 8px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid var(--border-color);
}
/* 푸터 */
footer {
text-align: center;
padding: 24px;
color: var(--text-secondary);
}
footer p {
margin-top: 12px;
font-size: 14px;
}
/* 반응형 */
@media (max-width: 768px) {
.container {
padding: 12px;
}
header h1 {
font-size: 24px;
}
.pnl-summary {
grid-template-columns: 1fr;
}
table {
font-size: 12px;
}
th, td {
padding: 8px;
}
}

View File

@@ -0,0 +1,309 @@
// 전역 변수
let currentPrices = {};
let userAssets = [];
let alertSettings = {};
// 초기화
document.addEventListener('DOMContentLoaded', () => {
loadAssets();
loadAlertSettings();
startPriceStream();
// 이벤트 리스너
document.getElementById('refresh-btn').addEventListener('click', refreshData);
document.getElementById('alert-settings-btn').addEventListener('click', openAlertModal);
document.querySelector('.close').addEventListener('click', closeAlertModal);
document.getElementById('save-alerts').addEventListener('click', saveAlertSettings);
document.getElementById('cancel-alerts').addEventListener('click', closeAlertModal);
// 주기적 PnL 업데이트
setInterval(updatePnL, 1000);
});
// 자산 데이터 로드
async function loadAssets() {
try {
const response = await fetch('/api/assets');
userAssets = await response.json();
renderAssetsTable();
} catch (error) {
console.error('자산 로드 실패:', error);
}
}
// 알림 설정 로드
async function loadAlertSettings() {
try {
const response = await fetch('/api/alerts/settings');
alertSettings = await response.json();
} catch (error) {
console.error('알림 설정 로드 실패:', error);
}
}
// 실시간 가격 스트리밍
function startPriceStream() {
const eventSource = new EventSource('/api/stream');
eventSource.onmessage = (event) => {
currentPrices = JSON.parse(event.data);
updatePricesInTable();
updateLastUpdateTime();
};
eventSource.onerror = () => {
console.error('SSE 연결 오류');
document.getElementById('status-indicator').style.backgroundColor = '#ef4444';
};
}
// 테이블 렌더링
function renderAssetsTable() {
const tbody = document.getElementById('assets-tbody');
tbody.innerHTML = '';
const assets = [
'XAU/USD', 'XAU/CNY', 'XAU/GBP', 'USD/DXY', 'USD/KRW',
'BTC/USD', 'BTC/KRW', 'KRX/GLD', 'XAU/KRW'
];
assets.forEach(symbol => {
const asset = userAssets.find(a => a.symbol === symbol);
if (!asset) return;
const row = document.createElement('tr');
row.dataset.symbol = symbol;
const decimalPlaces = symbol.includes('BTC') ? 8 : 2;
row.innerHTML = `
<td><strong>${symbol}</strong></td>
<td class="numeric">
<input type="number"
class="prev-close"
value="${asset.previous_close}"
step="0.01"
data-symbol="${symbol}">
</td>
<td class="numeric current-price">N/A</td>
<td class="numeric change">N/A</td>
<td class="numeric change-percent">N/A</td>
<td class="numeric">
<input type="number"
class="avg-price"
value="${asset.average_price}"
step="0.01"
data-symbol="${symbol}">
</td>
<td class="numeric">
<input type="number"
class="quantity"
value="${asset.quantity}"
step="${symbol.includes('BTC') ? '0.00000001' : '0.01'}"
data-symbol="${symbol}">
</td>
<td class="numeric buy-total">0</td>
`;
tbody.appendChild(row);
});
// 입력 필드 이벤트 리스너
document.querySelectorAll('input[type="number"]').forEach(input => {
input.addEventListener('change', handleAssetChange);
input.addEventListener('blur', handleAssetChange);
});
}
// 테이블에 가격 업데이트
function updatePricesInTable() {
const rows = document.querySelectorAll('#assets-tbody tr');
rows.forEach(row => {
const symbol = row.dataset.symbol;
const priceData = currentPrices[symbol];
if (!priceData || !priceData.가격) {
return;
}
const currentPrice = priceData.가격;
const prevClose = parseFloat(row.querySelector('.prev-close').value) || 0;
// 현재가 표시
const decimalPlaces = symbol.includes('USD') || symbol.includes('DXY') ? 2 : 0;
row.querySelector('.current-price').textContent = formatNumber(currentPrice, decimalPlaces);
// 변동 계산
const change = currentPrice - prevClose;
const changePercent = prevClose > 0 ? (change / prevClose * 100) : 0;
const changeCell = row.querySelector('.change');
const changePercentCell = row.querySelector('.change-percent');
changeCell.textContent = formatNumber(change, decimalPlaces);
changePercentCell.textContent = `${formatNumber(changePercent, 2)}%`;
// 색상 적용
const colorClass = change > 0 ? 'price-up' : change < 0 ? 'price-down' : '';
changeCell.className = `numeric ${colorClass}`;
changePercentCell.className = `numeric ${colorClass}`;
// 매입액 계산
const avgPrice = parseFloat(row.querySelector('.avg-price').value) || 0;
const quantity = parseFloat(row.querySelector('.quantity').value) || 0;
const buyTotal = avgPrice * quantity;
row.querySelector('.buy-total').textContent = formatNumber(buyTotal, 0);
});
}
// 손익 업데이트
async function updatePnL() {
try {
const response = await fetch('/api/pnl');
const pnl = await response.json();
// 금 손익
updatePnLCard('gold-pnl', 'gold-percent', pnl.금손익, pnl['금손익%']);
// BTC 손익
updatePnLCard('btc-pnl', 'btc-percent', pnl.BTC손익, pnl['BTC손익%']);
// 총 손익
updatePnLCard('total-pnl', 'total-percent', pnl.총손익, pnl['총손익%']);
} catch (error) {
console.error('PnL 업데이트 실패:', error);
}
}
// PnL 카드 업데이트
function updatePnLCard(valueId, percentId, value, percent) {
const valueElem = document.getElementById(valueId);
const percentElem = document.getElementById(percentId);
valueElem.textContent = formatNumber(value, 0) + ' 원';
percentElem.textContent = formatNumber(percent, 2) + '%';
// 총손익이 아닌 경우만 색상 적용
if (valueId !== 'total-pnl') {
valueElem.className = `pnl-value ${value > 0 ? 'profit' : value < 0 ? 'loss' : ''}`;
percentElem.className = `pnl-percent ${value > 0 ? 'profit' : value < 0 ? 'loss' : ''}`;
}
}
// 자산 변경 처리
async function handleAssetChange(event) {
const input = event.target;
const symbol = input.dataset.symbol;
const row = input.closest('tr');
const previousClose = parseFloat(row.querySelector('.prev-close').value) || 0;
const averagePrice = parseFloat(row.querySelector('.avg-price').value) || 0;
const quantity = parseFloat(row.querySelector('.quantity').value) || 0;
try {
const response = await fetch('/api/assets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
symbol,
previous_close: previousClose,
average_price: averagePrice,
quantity: quantity
})
});
if (response.ok) {
console.log(`${symbol} 업데이트 완료`);
// 매입액 즉시 업데이트
const buyTotal = averagePrice * quantity;
row.querySelector('.buy-total').textContent = formatNumber(buyTotal, 0);
}
} catch (error) {
console.error('업데이트 실패:', error);
}
}
// 알림 설정 모달 열기
function openAlertModal() {
document.getElementById('급등락_감지').checked = alertSettings.급등락_감지 || false;
document.getElementById('급등락_임계값').value = alertSettings.급등락_임계값 || 3.0;
document.getElementById('목표수익률_감지').checked = alertSettings.목표수익률_감지 || false;
document.getElementById('목표수익률').value = alertSettings.목표수익률 || 10.0;
document.getElementById('특정가격_감지').checked = alertSettings.특정가격_감지 || false;
document.getElementById('금_목표가격').value = alertSettings._목표가격 || 100000;
document.getElementById('BTC_목표가격').value = alertSettings.BTC_목표가격 || 100000000;
document.getElementById('alert-modal').classList.add('active');
}
// 알림 설정 모달 닫기
function closeAlertModal() {
document.getElementById('alert-modal').classList.remove('active');
}
// 알림 설정 저장
async function saveAlertSettings() {
const settings = {
급등락_감지: document.getElementById('급등락_감지').checked,
급등락_임계값: parseFloat(document.getElementById('급등락_임계값').value),
목표수익률_감지: document.getElementById('목표수익률_감지').checked,
목표수익률: parseFloat(document.getElementById('목표수익률').value),
특정가격_감지: document.getElementById('특정가격_감지').checked,
_목표가격: parseInt(document.getElementById('금_목표가격').value),
BTC_목표가격: parseInt(document.getElementById('BTC_목표가격').value)
};
try {
const response = await fetch('/api/alerts/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ settings })
});
if (response.ok) {
alertSettings = settings;
console.log('✅ 알림 설정 저장 완료');
closeAlertModal();
}
} catch (error) {
console.error('알림 설정 저장 실패:', error);
}
}
// 데이터 새로고침
async function refreshData() {
await loadAssets();
console.log('🔄 데이터 새로고침 완료');
}
// 마지막 업데이트 시간 표시
function updateLastUpdateTime() {
const now = new Date();
const timeString = now.toLocaleTimeString('ko-KR');
document.getElementById('last-update').textContent = `마지막 업데이트: ${timeString}`;
}
// 숫자 포맷팅
function formatNumber(value, decimals = 0) {
if (value === null || value === undefined || isNaN(value)) {
return 'N/A';
}
return value.toLocaleString('ko-KR', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
});
}
// 외부 클릭 시 모달 닫기
window.addEventListener('click', (event) => {
const modal = document.getElementById('alert-modal');
if (event.target === modal) {
closeAlertModal();
}
});

View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Asset Pilot - 자산 모니터</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="container">
<header>
<h1>💰 Asset Pilot</h1>
<p class="subtitle">실시간 자산 모니터링 시스템</p>
<div class="status-bar">
<span id="status-indicator" class="status-dot"></span>
<span id="last-update">데이터 수집 중...</span>
</div>
</header>
<main>
<!-- 손익 요약 -->
<section class="pnl-summary">
<div class="pnl-card">
<h3>금 현물</h3>
<div class="pnl-value" id="gold-pnl">N/A</div>
<div class="pnl-percent" id="gold-percent">N/A</div>
</div>
<div class="pnl-card">
<h3>비트코인</h3>
<div class="pnl-value" id="btc-pnl">N/A</div>
<div class="pnl-percent" id="btc-percent">N/A</div>
</div>
<div class="pnl-card total">
<h3>총 손익</h3>
<div class="pnl-value" id="total-pnl">N/A</div>
<div class="pnl-percent" id="total-percent">N/A</div>
</div>
</section>
<!-- 자산 테이블 -->
<section class="assets-section">
<div class="section-header">
<h2>📊 자산 현황</h2>
<button id="refresh-btn" class="btn btn-primary">새로고침</button>
</div>
<div class="table-container">
<table id="assets-table">
<thead>
<tr>
<th>항목</th>
<th class="numeric">전일종가</th>
<th class="numeric">현재가</th>
<th class="numeric">변동</th>
<th class="numeric">변동률</th>
<th class="numeric">평단가</th>
<th class="numeric">보유량</th>
<th class="numeric">매입액</th>
</tr>
</thead>
<tbody id="assets-tbody">
<!-- 동적으로 생성 -->
</tbody>
</table>
</div>
</section>
<!-- 알림 설정 모달 -->
<div id="alert-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>🔔 알림 설정</h2>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<div class="setting-group">
<h3>급등/급락 알림</h3>
<label>
<input type="checkbox" id="급등락_감지">
활성화
</label>
<label>
변동 임계값:
<input type="number" id="급등락_임계값" step="0.5" min="0.5" max="20">
%
</label>
</div>
<div class="setting-group">
<h3>목표 수익률 알림</h3>
<label>
<input type="checkbox" id="목표수익률_감지">
활성화
</label>
<label>
목표 수익률:
<input type="number" id="목표수익률" step="0.5" min="0.1" max="100">
%
</label>
</div>
<div class="setting-group">
<h3>특정 가격 도달 알림</h3>
<label>
<input type="checkbox" id="특정가격_감지">
활성화
</label>
<label>
금 목표가:
<input type="number" id="금_목표가격" step="1000" min="50000">
</label>
<label>
BTC 목표가:
<input type="number" id="BTC_목표가격" step="1000000" min="50000000">
</label>
</div>
</div>
<div class="modal-footer">
<button id="save-alerts" class="btn btn-primary">저장</button>
<button id="cancel-alerts" class="btn btn-secondary">취소</button>
</div>
</div>
</div>
</main>
<footer>
<button id="alert-settings-btn" class="btn btn-secondary">⚙️ 알림 설정</button>
<p>Asset Pilot v1.0 - Orange Pi Edition</p>
</footer>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>