AssetPilot OrangePi 5 Pluse Server-First Commit
This commit is contained in:
59
asset_pilot_docker/.gitignore
vendored
Normal file
59
asset_pilot_docker/.gitignore
vendored
Normal 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
|
||||
247
asset_pilot_docker/.venv/bin/Activate.ps1
Normal file
247
asset_pilot_docker/.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"
|
||||
70
asset_pilot_docker/.venv/bin/activate
Normal file
70
asset_pilot_docker/.venv/bin/activate
Normal 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
|
||||
27
asset_pilot_docker/.venv/bin/activate.csh
Normal file
27
asset_pilot_docker/.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 /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
|
||||
69
asset_pilot_docker/.venv/bin/activate.fish
Normal file
69
asset_pilot_docker/.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 /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
|
||||
8
asset_pilot_docker/.venv/bin/dotenv
Executable file
8
asset_pilot_docker/.venv/bin/dotenv
Executable 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())
|
||||
8
asset_pilot_docker/.venv/bin/normalizer
Executable file
8
asset_pilot_docker/.venv/bin/normalizer
Executable 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())
|
||||
8
asset_pilot_docker/.venv/bin/pip
Executable file
8
asset_pilot_docker/.venv/bin/pip
Executable 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())
|
||||
8
asset_pilot_docker/.venv/bin/pip3
Executable file
8
asset_pilot_docker/.venv/bin/pip3
Executable 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())
|
||||
8
asset_pilot_docker/.venv/bin/pip3.12
Executable file
8
asset_pilot_docker/.venv/bin/pip3.12
Executable 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())
|
||||
1
asset_pilot_docker/.venv/bin/python
Symbolic link
1
asset_pilot_docker/.venv/bin/python
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
1
asset_pilot_docker/.venv/bin/python3
Symbolic link
1
asset_pilot_docker/.venv/bin/python3
Symbolic link
@@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
1
asset_pilot_docker/.venv/bin/python3.12
Symbolic link
1
asset_pilot_docker/.venv/bin/python3.12
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
8
asset_pilot_docker/.venv/bin/uvicorn
Executable file
8
asset_pilot_docker/.venv/bin/uvicorn
Executable 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())
|
||||
8
asset_pilot_docker/.venv/bin/watchfiles
Executable file
8
asset_pilot_docker/.venv/bin/watchfiles
Executable 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())
|
||||
8
asset_pilot_docker/.venv/bin/websockets
Executable file
8
asset_pilot_docker/.venv/bin/websockets
Executable 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())
|
||||
@@ -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 */
|
||||
1
asset_pilot_docker/.venv/lib64
Symbolic link
1
asset_pilot_docker/.venv/lib64
Symbolic link
@@ -0,0 +1 @@
|
||||
lib
|
||||
5
asset_pilot_docker/.venv/pyvenv.cfg
Normal file
5
asset_pilot_docker/.venv/pyvenv.cfg
Normal 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
|
||||
420
asset_pilot_docker/DOCKER_GUIDE.md
Normal file
420
asset_pilot_docker/DOCKER_GUIDE.md
Normal 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
|
||||
```
|
||||
34
asset_pilot_docker/Dockerfile
Normal file
34
asset_pilot_docker/Dockerfile
Normal 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"]
|
||||
159
asset_pilot_docker/README.md
Normal file
159
asset_pilot_docker/README.md
Normal 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**
|
||||
59
asset_pilot_docker/app/calculator.py
Normal file
59
asset_pilot_docker/app/calculator.py
Normal 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
|
||||
29
asset_pilot_docker/app/database.py
Normal file
29
asset_pilot_docker/app/database.py
Normal 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()
|
||||
113
asset_pilot_docker/app/fetcher.py
Normal file
113
asset_pilot_docker/app/fetcher.py
Normal 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()
|
||||
142
asset_pilot_docker/app/fetcher.py.claude
Normal file
142
asset_pilot_docker/app/fetcher.py.claude
Normal 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()
|
||||
55
asset_pilot_docker/app/models.py
Normal file
55
asset_pilot_docker/app/models.py
Normal 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)
|
||||
67
asset_pilot_docker/docker-compose.yml
Normal file
67
asset_pilot_docker/docker-compose.yml
Normal 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
|
||||
74
asset_pilot_docker/import_csv.py
Executable file
74
asset_pilot_docker/import_csv.py
Executable 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
128
asset_pilot_docker/init_db.py
Executable 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
268
asset_pilot_docker/main.py
Normal 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"
|
||||
)
|
||||
12
asset_pilot_docker/requirements.txt
Normal file
12
asset_pilot_docker/requirements.txt
Normal 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
117
asset_pilot_docker/start.sh
Executable 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 ""
|
||||
353
asset_pilot_docker/static/css/style.css
Normal file
353
asset_pilot_docker/static/css/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
309
asset_pilot_docker/static/js/app.js
Normal file
309
asset_pilot_docker/static/js/app.js
Normal 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();
|
||||
}
|
||||
});
|
||||
136
asset_pilot_docker/templates/index.html
Normal file
136
asset_pilot_docker/templates/index.html
Normal 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">×</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>
|
||||
Reference in New Issue
Block a user