diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..fa4e5ab --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,26 @@ +{ + "permissions": { + "allow": [ + "Bash(docker ps:*)", + "Bash(lsof:*)", + "Bash(./scripts/dev.sh:*)", + "Bash(curl:*)", + "Bash(ls:*)", + "Bash(docker compose:*)", + "Bash(docker-compose up:*)", + "Bash(docker-compose restart:*)", + "Bash(npm run build:*)", + "Bash(python3:*)", + "Bash(npm install:*)", + "Bash(docker exec:*)", + "Bash(docker restart:*)", + "mcp__auto-stock__get_recommendations", + "Bash(pip install:*)", + "Bash(python -c:*)" + ] + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": [ + "auto-stock" + ] +} diff --git a/.gitignore b/.gitignore index 155dfb7..2ffb878 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,14 @@ docker-compose.override.yml .pytest_cache/ .coverage htmlcov/ + +# Runtime artifacts +backend_logs.txt +mcp_logs.txt +ps_output.txt +summary_output.json +*.bak +.env.bak + +# Virtual environments +.venv/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..8a2e0ea --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "auto-stock": { + "command": "/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python", + "args": [ + "/Users/commylife/myproject/java/workspace/auto-stock/mcp-server/stdio_server.py" + ], + "env": { + "PYTHONPATH": "/Users/commylife/myproject/java/workspace/auto-stock/mcp-server" + } + } + } +} diff --git a/.venv/bin/numpy-config b/.venv/bin/numpy-config deleted file mode 100755 index 309020f..0000000 --- a/.venv/bin/numpy-config +++ /dev/null @@ -1,7 +0,0 @@ -#!/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3.12 -import sys -from numpy._configtool import main -if __name__ == '__main__': - if sys.argv[0].endswith('.exe'): - sys.argv[0] = sys.argv[0][:-4] - sys.exit(main()) diff --git a/AUTOTRADE_DIAGNOSIS.md b/AUTOTRADE_DIAGNOSIS.md new file mode 100644 index 0000000..77e72d3 --- /dev/null +++ b/AUTOTRADE_DIAGNOSIS.md @@ -0,0 +1,178 @@ +# ๐Ÿ” ์ž๋™๋งค๋งค ์ ๊ฒ€ ๋ฆฌํฌํŠธ + +## ๐Ÿ“‹ ์ฒดํฌ๋ฆฌ์ŠคํŠธ + +### 1. โฐ ์žฅ ์šด์˜ ์‹œ๊ฐ„ +**ํ˜„์žฌ ์ƒํ™ฉ:** +- ์˜ค๋Š˜: 2026๋…„ 2์›” 5์ผ (์ˆ˜์š”์ผ) +- ํ•œ๊ตญ ์ฃผ์‹์‹œ์žฅ ์ •๊ทœ ์žฅ์‹œ๊ฐ„: 09:00 ~ 15:30 +- **๋ฌธ์ œ:** ํ˜„์žฌ ์‹œ๊ฐ์ด ์žฅ ๋งˆ๊ฐ ์ดํ›„๋ผ๋ฉด ์ž๋™๋งค๋งค๊ฐ€ ์ค‘๋‹จ๋ฉ๋‹ˆ๋‹ค + +**ํ™•์ธ ๋ฐฉ๋ฒ•:** +```python +# ์ฝ”๋“œ์—์„œ ํ™•์ธ +from app.core.market_schedule import is_market_open +print(is_market_open()) # False์ด๋ฉด ์žฅ ๋งˆ๊ฐ +``` + +**ํ•ด๊ฒฐ์ฑ…:** +- ์žฅ ์‹œ๊ฐ„(09:00~15:30) ๋‚ด์— ๋‹ค์‹œ ํ™•์ธ +- ํ…Œ์ŠคํŠธ ๋ชฉ์ ์ด๋ผ๋ฉด `market_schedule.py`์—์„œ ์‹œ๊ฐ„ ์ฒดํฌ ์ž„์‹œ ๋น„ํ™œ์„ฑํ™” + +--- + +### 2. โš™๏ธ AUTO_TRADE_ENABLED ์„ค์ • +**ํ˜„์žฌ ์„ค์ •:** `config.py`์— `AUTO_TRADE_ENABLED = True`๋กœ ์„ค์ •๋˜์–ด ์žˆ์Œ + +**ํ™•์ธ ํ•„์š”:** +- `.env` ํŒŒ์ผ์—์„œ `AUTO_TRADE_ENABLED=False`๋กœ ๋ฎ์–ด์“ฐ๊ณ  ์žˆ๋Š”์ง€ ํ™•์ธ +- ํ™˜๊ฒฝ ๋ณ€์ˆ˜๊ฐ€ ์ฝ”๋“œ ๊ธฐ๋ณธ๊ฐ’์„ ์˜ค๋ฒ„๋ผ์ด๋“œํ•  ์ˆ˜ ์žˆ์Œ + +**ํ™•์ธ ๋ฐฉ๋ฒ•:** +```bash +docker exec autostock-backend env | grep AUTO_TRADE +``` + +**ํ•ด๊ฒฐ์ฑ…:** +- `.env` ํŒŒ์ผ ํ™•์ธ ๋ฐ ์ˆ˜์ • +- ๋˜๋Š” config.py์—์„œ ๋ช…์‹œ์ ์œผ๋กœ True ์„ค์ • + +--- + +### 3. ๐Ÿ”‘ KIS API ์ธ์ฆ ์ •๋ณด +**ํ•„์ˆ˜ ์„ค์ •:** +- `KIS_APP_KEY` (์•ฑ ํ‚ค) +- `KIS_APP_SECRET` (์•ฑ ์‹œํฌ๋ฆฟ) +- `KIS_ACCOUNT_NO` (๊ณ„์ขŒ๋ฒˆํ˜ธ) + +**ํ™•์ธ ๋ฐฉ๋ฒ•:** +```bash +docker exec autostock-backend env | grep KIS_ +``` + +**๋ฌธ์ œ:** +- API ํ‚ค๊ฐ€ ์—†์œผ๋ฉด ์ฃผ๋ฌธ ์‹คํ–‰ ๋ถˆ๊ฐ€ +- ๋ชจ์˜ํˆฌ์ž์™€ ์‹คํˆฌ์ž URL์ด ๋‹ค๋ฆ„ + +**ํ•ด๊ฒฐ์ฑ…:** +- `.env` ํŒŒ์ผ์— ์˜ฌ๋ฐ”๋ฅธ KIS API ์ •๋ณด ์ž…๋ ฅ +- ๋ชจ์˜ํˆฌ์ž: `https://openapivts.koreainvestment.com:29443` +- ์‹คํˆฌ์ž: `https://openapi.koreainvestment.com:9443` + +--- + +### 4. ๐Ÿ“Š ์ถ”์ฒœ ์ข…๋ชฉ ์กด์žฌ ์—ฌ๋ถ€ +**์ž๋™๋งค๋งค ์‹คํ–‰ ์กฐ๊ฑด:** +1. ์žฅ์ด ์—ด๋ ค ์žˆ์–ด์•ผ ํ•จ +2. AUTO_TRADE_ENABLED = True +3. **์ถ”์ฒœ ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ์— ๋งค์ˆ˜ ์‹ ํ˜ธ๊ฐ€ ์žˆ์–ด์•ผ ํ•จ** + +**์ฝ”๋“œ ๋กœ์ง:** +```python +rec = await get_rule_based_recommendations_realtime() +if rec and rec.get("recommendations_by_rule"): + # ์ž๋™๋งค๋งค ์‹คํ–‰ +else: + logger.info("โญ๏ธ Auto-trade skipped: no recommendations matched rules") +``` + +**ํ™•์ธ ๋ฐฉ๋ฒ•:** +```bash +curl http://localhost:8090/api/v1/recommendations/rule-based +``` + +**๋ฌธ์ œ ๊ฐ€๋Šฅ์„ฑ:** +- RSI, ์Šคํ† ์บ์Šคํ‹ฑ ๋“ฑ ๊ธฐ์ˆ ์  ์ง€ํ‘œ๊ฐ€ ๋งค์ˆ˜ ์กฐ๊ฑด์„ ๋งŒ์กฑํ•˜๋Š” ์ข…๋ชฉ์ด ์—†์Œ +- TREND_FILTER_ENABLED=True์ธ ๊ฒฝ์šฐ ์ƒ์Šน ์ถ”์„ธ ์ข…๋ชฉ๋งŒ ์„ ํƒ (ํ˜„์žฌ๋Š” False) + +--- + +### 5. ๐Ÿ’พ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ +**ํ™•์ธ ํ•ญ๋ชฉ:** +- PostgreSQL ์ปจํ…Œ์ด๋„ˆ ์‹คํ–‰ ์ค‘ +- ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์™„๋ฃŒ (portfolio, trade_history ๋“ฑ) + +**ํ™•์ธ ๋ฐฉ๋ฒ•:** +```bash +docker ps | grep postgres +docker exec autostock-postgres psql -U autostock -d autostock_db -c "SELECT COUNT(*) FROM portfolio;" +``` + +--- + +### 6. ๐Ÿ”„ ์Šค์ผ€์ค„๋Ÿฌ ๋™์ž‘ ์—ฌ๋ถ€ +**๋ฐฑ์—”๋“œ ๋กœ๊ทธ ํ™•์ธ:** +```bash +docker logs autostock-backend 2>&1 | grep "Auto-trade scheduler" +docker logs autostock-backend 2>&1 | grep "Market is closed" +docker logs autostock-backend 2>&1 | grep "Starting real-time analysis" +``` + +**๊ธฐ๋Œ€ ๋กœ๊ทธ:** +- ์žฅ ์ค‘: "โฑ๏ธ Auto-trade scheduler started (every 60s)" +- ์žฅ ์ค‘: "๐Ÿ”„ Starting real-time analysis cycle..." +- ์žฅ ๋งˆ๊ฐ: "๐Ÿ’ค Market is closed. Skipping auto-trade cycle. Next check in 30 minutes." + +--- + +## ๐ŸŽฏ ๊ฐ€์žฅ ๊ฐ€๋Šฅ์„ฑ ๋†’์€ ์›์ธ + +### 1์ˆœ์œ„: ์žฅ ๋งˆ๊ฐ ์‹œ๊ฐ„ +- **์ฆ์ƒ:** 15:30 ์ดํ›„์—๋Š” ์ž๋™๋งค๋งค๊ฐ€ ์ค‘๋‹จ๋จ +- **ํ•ด๊ฒฐ:** ๋‹ค์Œ ๊ฑฐ๋ž˜์ผ 09:00~15:30์— ๋‹ค์‹œ ํ™•์ธ + +### 2์ˆœ์œ„: ๋งค์ˆ˜ ์กฐ๊ฑด ๋ฏธ์ถฉ์กฑ +- **์ฆ์ƒ:** ์ถ”์ฒœ ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋น„์–ด์žˆ์Œ +- **์›์ธ:** + - ํ˜„์žฌ ์‹œ์žฅ ์ƒํ™ฉ์—์„œ RSI < 40 ๋˜๋Š” Stochastic < 30 ์กฐ๊ฑด์„ ๋งŒ์กฑํ•˜๋Š” ์ข…๋ชฉ ์—†์Œ + - ๋ชจ๋“  ์ข…๋ชฉ์ด ๊ณผ๋งค์ˆ˜ ์ƒํƒœ์ด๊ฑฐ๋‚˜ ์ค‘๋ฆฝ ๊ตฌ๊ฐ„ +- **ํ•ด๊ฒฐ:** + - ๋งค์ˆ˜ ์กฐ๊ฑด ์™„ํ™” (RSI < 50, Stochastic < 40 ๋“ฑ) + - ๋˜๋Š” ์‹œ์žฅ์ด ์กฐ์ • ๋ฐ›์„ ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ + +### 3์ˆœ์œ„: KIS API ์ธ์ฆ ๋ฌธ์ œ +- **์ฆ์ƒ:** API ํ˜ธ์ถœ ์‹œ 401/403 ์—๋Ÿฌ +- **ํ•ด๊ฒฐ:** .env ํŒŒ์ผ์— ์˜ฌ๋ฐ”๋ฅธ API ํ‚ค ์„ค์ • + +--- + +## โœ… ์ฆ‰์‹œ ํ™•์ธํ•  ์‚ฌํ•ญ + +1. **ํ˜„์žฌ ์‹œ๊ฐ ํ™•์ธ** + ```bash + TZ='Asia/Seoul' date '+%H:%M' + ``` + - 09:00 ~ 15:30 ๋ฒ”์œ„ ๋‚ด์ธ๊ฐ€? + +2. **๋ฐฑ์—”๋“œ ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ** + ```bash + docker ps | grep backend + ``` + - "Up" ์ƒํƒœ์ธ๊ฐ€? + +3. **์ตœ๊ทผ ๋กœ๊ทธ ํ™•์ธ** + ```bash + docker logs autostock-backend --tail 50 + ``` + - ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๊ฐ€ ์žˆ๋Š”๊ฐ€? + - "Market is closed" ๋ฉ”์‹œ์ง€๊ฐ€ ์žˆ๋Š”๊ฐ€? + +4. **์ถ”์ฒœ ์ข…๋ชฉ API ํ…Œ์ŠคํŠธ** + ```bash + curl http://localhost:8090/api/v1/recommendations/rule-based + ``` + - ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋˜๋Š”๊ฐ€? + +--- + +## ๐Ÿ”ง ์ž„์‹œ ํ…Œ์ŠคํŠธ ๋ฐฉ๋ฒ• (๊ฐœ๋ฐœ ํ™˜๊ฒฝ) + +์žฅ ์‹œ๊ฐ„ ์ฒดํฌ๋ฅผ ์ž„์‹œ๋กœ ๋น„ํ™œ์„ฑํ™”ํ•˜์—ฌ ํ…Œ์ŠคํŠธ: + +```python +# backend/app/core/market_schedule.py ์ˆ˜์ • +def is_market_open() -> bool: + return True # ์ž„์‹œ๋กœ ํ•ญ์ƒ True ๋ฐ˜ํ™˜ (ํ…Œ์ŠคํŠธ์šฉ) +``` + +**์ฃผ์˜:** ์‹ค์ œ ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” ์ ˆ๋Œ€ ์‚ฌ์šฉ ๊ธˆ์ง€! diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7f06d62 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,403 @@ +# Auto Stock Trading System - ์ „๋žต ๊ฐ€์ด๋“œ (Claude.md) + +> ์ด ํŒŒ์ผ์€ Claude AI๊ฐ€ ์ž๋™๋งค๋งค ์‹œ์Šคํ…œ์˜ ์ „๋žต๊ณผ ๊ทœ์น™์„ ์ดํ•ดํ•˜๊ธฐ ์œ„ํ•œ ์ปจํ…์ŠคํŠธ ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค. +> ์ฝ”๋“œ ์ˆ˜์ •, ์ „๋žต ๋ถ„์„, ๋ฐฑํ…Œ์ŠคํŠธ ์š”์ฒญ ์‹œ ์ด ํŒŒ์ผ์„ ๊ธฐ์ค€์œผ๋กœ ํŒ๋‹จํ•˜์„ธ์š”. + +--- + +## 1. ํ”„๋กœ์ ํŠธ ๊ฐœ์š” + +ํ•œ๊ตญ/๋ฏธ๊ตญ ์ฃผ์‹ ์ž๋™๋งค๋งค ์‹œ์Šคํ…œ (ํ’€์Šคํƒ) +- ํ•œ๊ตญํˆฌ์ž์ฆ๊ถŒ API ์—ฐ๋™ (๋ชจ์˜/์‹ค์ „ ์ „ํ™˜ ๊ฐ€๋Šฅ) +- AI(Claude/Gemini) ๊ธฐ๋ฐ˜ ์‹œ์žฅ ๋ถ„์„ ๋ฐ ๋งค๋งค ํŒ๋‹จ +- MCP(Model Context Protocol) ์„œ๋ฒ„๋กœ Claude CLI ์—ฐ๋™ +- ์‹ค์‹œ๊ฐ„ WebSocket ์‹œ์„ธ + React ๋Œ€์‹œ๋ณด๋“œ + +--- + +## 2. ๊ธฐ์ˆ  ์Šคํƒ + +| ๊ณ„์ธต | ๊ธฐ์ˆ  | +|------|------| +| Backend | Python 3.11, FastAPI, SQLAlchemy (async), PostgreSQL, Redis | +| Frontend | React + TypeScript, Vite, Recharts | +| MCP Server | Python, Anthropic SDK, MCP Protocol (stdio) | +| AI | Claude 3.5 Sonnet / Gemini 2.0 Flash (`.env`์—์„œ ์ „ํ™˜) | +| Infra | Docker Compose, Nginx | + +--- + +## 3. ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ตฌ์กฐ + +``` +auto-stock/ +โ”œโ”€โ”€ backend/ +โ”‚ โ”œโ”€โ”€ app/ +โ”‚ โ”‚ โ”œโ”€โ”€ api/endpoints/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ market_data.py # ์‹œ์„ธ ์กฐํšŒ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ portfolio.py # ํฌํŠธํด๋ฆฌ์˜ค + ATR ๊ธฐ๋ฐ˜ ์ฒญ์‚ฐ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ trades.py # ์ฃผ๋ฌธ/์ฒด๊ฒฐ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ recommendations.py # ๊ทœ์น™ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ + ์ž๋™๋งค๋งค +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ trading_rules.py # ๋งค๋งค ๊ทœ์น™ CRUD +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ risk_management.py # ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ml_ensemble.py # ML ์•™์ƒ๋ธ” ์—”๋“œํฌ์ธํŠธ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ tensorflow_predictions.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ debug.py +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ monitoring.py +โ”‚ โ”‚ โ”œโ”€โ”€ core/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ config.py # ๋ฐฑ์—”๋“œ ์„ค์ • (๋งค๋งค ๊ทœ์น™ ํŒŒ๋ผ๋ฏธํ„ฐ) +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ database.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ json_helpers.py # _dumps() ๊ณต์šฉ ํ—ฌํผ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ market_schedule.py # ์žฅ ์šด์˜์‹œ๊ฐ„ +โ”‚ โ”‚ โ”œโ”€โ”€ services/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ korea_investment_api.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ websocket_manager.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ mean_reversion_strategy.py # RSI+๋ณผ๋ฆฐ์ € ํ‰๊ท ํšŒ๊ท€ (v2) +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ kosdaq_scanner.py # ์ฝ”์Šค๋‹ฅ ์ „์ข…๋ชฉ ์Šค์บ๋„ˆ (์‹ ๊ทœ) +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ short_term_strategy.py # 15๋ถ„/60๋ถ„๋ด‰ ๋‹จ๊ธฐ ์ „๋žต (์‹ ๊ทœ) +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ mdd_manager.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ loss_tracker.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ feature_engineering.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ml_ensemble.py +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ tensorflow_predictor.py +โ”‚ โ”‚ โ”œโ”€โ”€ models/models.py +โ”‚ โ”‚ โ””โ”€โ”€ main.py +โ”‚ โ”œโ”€โ”€ batch_daily_analysis.py +โ”‚ โ”œโ”€โ”€ trading_rules_config.json +โ”‚ โ””โ”€โ”€ migrations/ +โ”œโ”€โ”€ frontend/ +โ”‚ โ””โ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ pages/ +โ”‚ โ”œโ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ hooks/ +โ”‚ โ”œโ”€โ”€ lib/ +โ”‚ โ””โ”€โ”€ utils/ +โ”œโ”€โ”€ mcp-server/ +โ”‚ โ”œโ”€โ”€ config.py +โ”‚ โ”œโ”€โ”€ claude_client.py +โ”‚ โ”œโ”€โ”€ mcp_tools.py +โ”‚ โ”œโ”€โ”€ stdio_server.py +โ”‚ โ”œโ”€โ”€ main.py +โ”‚ โ”œโ”€โ”€ utils.py +โ”‚ โ”œโ”€โ”€ auto_trade_claude.py +โ”‚ โ””โ”€โ”€ trade_monitor.py +โ”œโ”€โ”€ docker/ +โ”‚ โ””โ”€โ”€ init-db.sql +โ”œโ”€โ”€ docs/ +โ”œโ”€โ”€ tests/ +โ”œโ”€โ”€ scripts/ +โ”œโ”€โ”€ docker-compose.yml +โ”œโ”€โ”€ docker-compose.dev.yml +โ””โ”€โ”€ .env +``` + +--- + +## 4. ํ™˜๊ฒฝ ์„ค์ • (.env) + +```env +# AI Provider ์ „ํ™˜ +AI_PROVIDER=gemini # gemini | claude + +# ํ† ํฐ ์ ˆ์•ฝ ์„ค์ • +TOKEN_SAVE_MODE=true +NEWS_LIMIT=3 +CHART_DAYS=30 +JSON_COMPACT=true +CLAUDE_MAX_TOKENS=1024 +CLAUDE_TEMPERATURE=0.3 + +# ์ฆ๊ถŒ API ๋ชจ๋“œ +KIS_MODE=virtual # virtual | real + +# ์Šค์บ๋„ˆ ์„ค์ • (์‹ ๊ทœ) +SCANNER_MARKET=KOSDAQ # KOSDAQ | KOSPI | ALL +SCANNER_INTERVAL=15 # ์Šค์บ” ์ฃผ๊ธฐ (๋ถ„) +SHORT_TERM_CAPITAL_RATIO=0.25 # ๋‹จ๊ธฐ์ „๋žต ์ž๋ณธ ๋น„์œจ (์ตœ๋Œ€ 25%) +``` + +--- + +## 5. ์ฝ”๋”ฉ ๊ทœ์น™ + +- ๊ธฐ์กด ํ•จ์ˆ˜/ํด๋ž˜์Šค ์‹œ๊ทธ๋‹ˆ์ฒ˜ ์œ ์ง€, ์ž‘์€ ๋‹จ์œ„ ๋ณ€๊ฒฝ ์šฐ์„  +- ์ƒˆ ์ „๋žต์€ ๋ณ„๋„ ํŒŒ์ผ/ํด๋ž˜์Šค๋กœ ์ถ”๊ฐ€ (`_v2`, `_v3`) +- AI ํ”„๋กฌํ”„ํŠธ๋Š” ๊ฐ„๊ฒฐํ•˜๊ฒŒ (ํ•„์ˆ˜ ํ•„๋“œ๋งŒ ๋ช…์‹œ, JSON ์˜ˆ์‹œ ์ตœ์†Œํ™”) +- `json.dumps`๋Š” `_dumps()` ํ—ฌํผ ์‚ฌ์šฉ (ํ† ํฐ ์ ˆ์•ฝ ๋ชจ๋“œ ์ž๋™ ์ ์šฉ) +- `.env` ํŒŒ์ผ์€ ์ปค๋ฐ‹ํ•˜์ง€ ์•Š์Œ (API ํ‚ค ํฌํ•จ) + +--- + +## 6. ํฌ์ง€์…˜ ๋ฐ ๋ฆฌ์Šคํฌ ๊ทœ์น™ + +### 6.1 ํฌ์ง€์…˜ ๊ทœ์น™ +- 1์ข…๋ชฉ๋‹น ์ตœ๋Œ€ **3~4%** ์ž๋ณธ ์‚ฌ์šฉ +- ๋™์‹œ ๋ณด์œ  ์ข…๋ชฉ ์ˆ˜: ๊ธฐ๋ณธ ์ตœ๋Œ€ **20์ข…๋ชฉ**, ์ „๋žต์— ๋”ฐ๋ผ ์ตœ๋Œ€ 25์ข…๋ชฉ +- ๋ ˆ๋ฒ„๋ฆฌ์ง€/์ธ๋ฒ„์Šค ETF: ์ „์ฒด ์ž๋ณธ์˜ **20% ์ด๋‚ด** + +### 6.2 ์†์ ˆ ๋ฐ ์ฒญ์‚ฐ ๊ทœ์น™ +- ๊ธฐ๋ณธ ์†์ ˆ: + - ์ €๋ณ€๋™ ์ข…๋ชฉ/ETF(๋Œ€ํ˜•์ฃผ): **-6%** + - ๊ณ ๋ณ€๋™ ์ข…๋ชฉ(ํ…Œ๋งˆยท์ฝ”์Šค๋‹ฅ): **-10%** +- ATR ๊ธฐ๋ฐ˜ ์†์ ˆ (์šฐ์„  ์ ์šฉ): + - ์†์ ˆ๊ฐ€ = ์ง„์ž…๊ฐ€ - **2.5 ร— ATR** + - ๋ณ€๋™์„ฑ ํฌ๋ฉด ์†์ ˆํญ ์ž๋™ ํ™•๋Œ€, ์ค„๋ฉด ์ถ•์†Œ +- ์ต์ ˆ/ํŠธ๋ ˆ์ผ๋ง: + - 2R ์ด์ƒ ์ˆ˜์ต ์‹œ ์ ˆ๋ฐ˜ ์ต์ ˆ + ๋‚˜๋จธ์ง€ ํŠธ๋ ˆ์ผ๋ง ์Šคํƒ‘ (**1.5 ร— ATR**) + +### 6.3 ํฌํŠธํด๋ฆฌ์˜ค MDD ๊ด€๋ฆฌ +- MDD **-10%** ๋ŒํŒŒ ์‹œ: + - ์‹ ๊ทœ ์ง„์ž… ์ผ์‹œ ์ค‘๋‹จ + - ๊ธฐ์กด ํฌ์ง€์…˜ ํฌ๊ธฐ **30% ์ถ•์†Œ** +- MDD **-20%** ๋ŒํŒŒ ์‹œ: + - ์ „์ฒด ํฌ์ง€์…˜ **50% ๊ฐ•์ œ ์ถ•์†Œ** + - ๋ ˆ๋ฒ„๋ฆฌ์ง€/์ธ๋ฒ„์Šค ํฌ์ง€์…˜ ์šฐ์„  ์ฒญ์‚ฐ + +### 6.4 ๊ฐ€๊ฒฉ ํ•„ํ„ฐ (์ €๊ฐ€์ฃผ ์ œํ•œ) +- ๊ตญ๋‚ด: **5,000์› ์ดํ•˜** ๋งค์ˆ˜ ๊ธˆ์ง€ + - ๋‹จ, ETFยท๋ฆฌ์ธ ยท์šฐ๋Ÿ‰ ๋Œ€ํ˜• ๊ณต๋ชจ์ฃผ๋Š” ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ๋กœ ํ—ˆ์šฉ +- ๋ฏธ๊ตญ: **4๋‹ฌ๋Ÿฌ ์ดํ•˜** ๋งค์ˆ˜ ๊ธˆ์ง€ + - ETFยท๋ฆฌ์ธ  ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ ์˜ˆ์™ธ ๊ฐ€๋Šฅ + +### 6.5 ์‹œ๊ฐ„ ํ”„๋ ˆ์ž„ ์šด์šฉ +- ๊ธฐ๋ณธ: **์ผ๋ด‰(Daily) + 60๋ถ„๋ด‰(H1)** +- ๋‹จ๊ธฐ(์ธํŠธ๋ผ๋ฐ์ด): **15๋ถ„/60๋ถ„๋ด‰** โ†’ ์ „์ฒด ์ž๋ณธ์˜ **์ตœ๋Œ€ 20~25%** ์ด๋‚ด + - ์Šฌ๋ฆฌํ”ผ์ง€ยท์ˆ˜์ˆ˜๋ฃŒ ํฌํ•จ ๋ฐฑํ…Œ์ŠคํŠธ ํ•„์ˆ˜ + +### 6.6 ์„นํ„ฐ ์ƒ๊ด€๊ด€๊ณ„ ํ•„ํ„ฐ +- ๋™์ผ ์„นํ„ฐ ์ตœ๋Œ€ **3์ข…๋ชฉ** ์ œํ•œ +- ๊ธฐ์กด ๋ณด์œ  ์ข…๋ชฉ๊ณผ ์ƒ๊ด€๊ณ„์ˆ˜ **0.7 ์ด์ƒ** ์‹œ ์ง„์ž… ์ œํ•œ +- correlation_matrix: ์ฃผ๊ฐ„ ์—…๋ฐ์ดํŠธ โ†’ Redis ์บ์‹ฑ + +--- + +## 7. RSI + ๋ณผ๋ฆฐ์ € ํ‰๊ท ํšŒ๊ท€ ์ „๋žต v2 (๊ฐœ์„ ํ˜•) + +### 7.1 ์‹œ์žฅ Regime ํŒ๋‹จ (3๋ถ„๋ฅ˜) + +```python +slope_200 = (ma200_today - ma200_20days_ago) / 20 # ๊ธฐ์šธ๊ธฐ +adx = talib.ADX(high, low, close, 14)[-1] + +if close > ma200 and slope_200 > 0 and adx > 20: + regime = MarketRegime.BULL # ์ถ”์„ธ ์ƒ์Šน์žฅ +elif close < ma200 and slope_200 < 0 and adx > 20: + regime = MarketRegime.BEAR # ์ถ”์„ธ ํ•˜๋ฝ์žฅ +else: + regime = MarketRegime.SIDEWAYS # ๋ฐ•์Šค๊ถŒ (์‹ ๊ทœ ์ง„์ž… ๊ธˆ์ง€) +``` + +> **ํŒ๋ณ„ ๊ธฐ์ค€**: ์ฝ”์Šคํ”ผ200 ์ง€์ˆ˜ ๊ธฐ์ค€ / ๋งค์ผ ์žฅ ๋งˆ๊ฐ ํ›„ ์—…๋ฐ์ดํŠธ +> **SIDEWAYS**: ADX < 20 ๋˜๋Š” 200MA ํšก๋ณด(๊ธฐ์šธ๊ธฐ โ‰ˆ 0) โ†’ ์ง„์ž… ๊ธˆ์ง€ + +--- + +### 7.2 ๋งค์ˆ˜ ์กฐ๊ฑด + +#### ๊ณตํ†ต ์ „์ œ์กฐ๊ฑด +- ๊ฐ€๊ฒฉ: ๊ตญ๋‚ด 5,000์› ์ด์ƒ / ๋ฏธ๊ตญ 4๋‹ฌ๋Ÿฌ ์ด์ƒ +- ์œ ๋™์„ฑ: ์ตœ๊ทผ 20์ผ ํ‰๊ท  ๊ฑฐ๋ž˜๋Œ€๊ธˆ ์ƒ์œ„ 20% ์ด๋‚ด + +--- + +#### ์ƒ์Šน์žฅ(BULL) ๋งค์ˆ˜ ์กฐ๊ฑด + +```python +rsi_ok = rsi14 <= 25 +pct = (close - bb_lower) / bb_lower * 100 +bb_ok = -3.0 <= pct <= 1.5 +volume_ok = volume > volume_ma20 * 0.8 +candle_ok = close > open or close > prev_low * 1.005 + +bull_signal = rsi_ok and bb_ok and volume_ok and candle_ok +# โ†’ 2ํšŒ ๋ถ„ํ•  ๋งค์ˆ˜ (50% + 50%) +``` + +--- + +#### ํ•˜๋ฝ์žฅ(BEAR) ๋งค์ˆ˜ ์กฐ๊ฑด + +```python +rsi_ok = rsi14 <= 30 +bb_ok = close <= bb_lower * 1.01 +volume_surge = volume > volume_ma20 * 1.5 +rebound_ok = close > prev_close or close > h1_prev_high + +bear_signal = rsi_ok and bb_ok and volume_surge and rebound_ok +# โ†’ 3ํšŒ ๋ถ„ํ•  ๋งค์ˆ˜ (๋‹จ, 2์ผ ์—ฐ์† ๊ณผ๋งค๋„๋ฉด 2ํšŒ๋กœ ์ถ•์†Œ) +``` + +--- + +#### ๋ณตํ•ฉ ํƒ€์ž„ํ”„๋ ˆ์ž„ ์„ ์ทจ๋งค (์„ ํƒ) +- ์ผ๋ด‰ RSI โ‰ค 40 ๊ด€์‹ฌ ์ข…๋ชฉ ์ค‘, 60๋ถ„๋ด‰ RSI โ‰ค 30 ์‹ ํ˜ธ ๋ฐœ์ƒ ์‹œ ์„ ์ทจ๋งค +- 60๋ถ„๋ด‰ ํ•„ํ„ฐ: 5์ผ ์ด๋™ํ‰๊ท ์„  ์ƒํ–ฅ ๋ŒํŒŒ ๋™์‹œ ํ™•์ธ (Fake-out ๋ฐฉ์ง€) +- ๋น„์ค‘: ์ „์ฒด ์ž๋ณธ์˜ **10% ์ด๋‚ด** + +--- + +### 7.3 ์ฒญ์‚ฐ ์กฐ๊ฑด + +#### 1์ฐจ ์ต์ ˆ +```python +first_exit = ( + rsi14 >= 50 + or close >= bb_mid + or profit_pct >= 2 * atr_pct +) +# BULL: 40% ์ต์ ˆ / BEAR: 60% ์ต์ ˆ +``` + +#### 2์ฐจ ์ต์ ˆ (์ „๋Ÿ‰ ์ฒญ์‚ฐ) +```python +trailing_stop = max_close_after_entry - 1.5 * atr + +second_exit = ( + close <= trailing_stop # ATR ํŠธ๋ ˆ์ผ๋ง (์ตœ์šฐ์„ ) + or rsi14 >= 65 + or close >= bb_upper * 0.99 +) +``` + +--- + +### 7.4 ์†์ ˆ ๋ฐ ์ถ”๊ฐ€ ์ง„์ž… ์ œํ•œ + +```python +stop_loss_atr = entry_price - 2.5 * atr +stop_loss_pct = entry_price * 0.92 # -8% +stop_triggered = close <= min(stop_loss_atr, stop_loss_pct) + +# ์—ฐ์† ์†์‹ค ์ œํ•œ +if consecutive_losses >= 3: + block_entry_days = 30 +``` + +--- + +### 7.5 ์‹œ์žฅ ์ƒํƒœ๋ณ„ ์ž๋ณธ ๋ฐฐ๋ถ„ + +| Regime | ์ „๋žต ์šด์šฉ ๋น„์ค‘ | ํŠน์ง• | +|-----------|----------------|----------------------| +| BULL | **์ตœ๋Œ€ 70%** | ๊ณต๊ฒฉ์ , ๋ชจ๋ฉ˜ํ…€ ์ตœ๋Œ€ ํ™œ์šฉ | +| SIDEWAYS | **์ง„์ž… ๊ธˆ์ง€** | ๊ฐ€์งœ ์‹ ํ˜ธ ์ฐจ๋‹จ | +| BEAR | **์ตœ๋Œ€ 30%** | ๋ณด์ˆ˜์ , ์ž๋ณธ ๋ณดํ˜ธ ์šฐ์„  | + +--- + +## 8. ๊ณต๊ฒฉ์  ์ „๋žต (์›” 5% ๋ชฉํ‘œ) + +> **๋ชฉํ‘œ**: 1์ฒœ๋งŒ์› โ†’ 1์–ต์› (4๋…„ ์ˆœ์ˆ˜ ๋ณต๋ฆฌ / ํ˜„์‹ค 7~8๋…„) +> **ํ•ต์‹ฌ**: ๋‹จ์ผ ์ข…๋ชฉ ๋Œ€๊ธฐ โ†’ ์ฝ”์Šค๋‹ฅ ์ „์ข…๋ชฉ ์Šค์บ” + ๋‹จ๊ธฐ๋ด‰ ๋ณ‘ํ–‰์œผ๋กœ ์‹ ํ˜ธ ๋นˆ๋„ โ†‘ + +### 8.1 ์ฝ”์Šค๋‹ฅ ์ „์ข…๋ชฉ ์Šค์บ๋„ˆ (`kosdaq_scanner.py`) + +```python +# ๋งค์ผ ์žฅ ๋งˆ๊ฐ ํ›„ ์ฝ”์Šค๋‹ฅ ์ „์ข…๋ชฉ ์Šค์บ” โ†’ ์กฐ๊ฑด ์ถฉ์กฑ ์ข…๋ชฉ ์šฐ์„ ์ˆœ์œ„ ํ์— ์ €์žฅ +class KosdaqScanner: + def scan_all(self) -> list[ScanResult]: + candidates = [] + for ticker in kosdaq_all_tickers: # ~1,600์ข…๋ชฉ + if not self._price_filter(ticker): # 5,000์› ์ด์ƒ + continue + if not self._liquidity_filter(ticker): # ๊ฑฐ๋ž˜๋Œ€๊ธˆ ์ƒ์œ„ 20% + continue + score = self._score(ticker) # RSI + BB + ๊ฑฐ๋ž˜๋Ÿ‰ ๋ณตํ•ฉ ์ ์ˆ˜ + candidates.append(ScanResult(ticker, score)) + return sorted(candidates, key=lambda x: x.score, reverse=True)[:20] + + def _score(self, ticker) -> float: + rsi_score = max(0, (25 - rsi14) / 25) # RSI ๋‚ฎ์„์ˆ˜๋ก โ†‘ + bb_score = max(0, (0 - pct_vs_lower) / 3) # BB ํ•˜๋‹จ ๊ทผ์ ‘ โ†‘ + volume_score = min(volume / volume_ma20, 2.0) # ๊ฑฐ๋ž˜๋Ÿ‰ ๊ธ‰์ฆ โ†‘ + return rsi_score * 0.5 + bb_score * 0.3 + volume_score * 0.2 +``` + +> **๊ธฐ๋Œ€ ํšจ๊ณผ**: ๋‹จ์ผ ์ข…๋ชฉ ๋Œ€๊ธฐ ์—ฐ 2~4ํšŒ โ†’ ์ „์ข…๋ชฉ ์Šค์บ”์œผ๋กœ ์›” 5~10๊ฐœ ์‹ ํ˜ธ ํ™•๋ณด + +--- + +### 8.2 15๋ถ„๋ด‰ ๋‹จ๊ธฐ ์ „๋žต (`short_term_strategy.py`) + +```python +class ShortTermStrategy: + MAX_CAPITAL_RATIO = 0.25 # ์ „์ฒด ์ž๋ณธ์˜ ์ตœ๋Œ€ 25% + TIMEFRAME = "15m" + + def entry_signal(self, ticker) -> bool: + # ์ผ๋ด‰ RSI โ‰ค 40 ๊ด€์‹ฌ ์ข…๋ชฉ ์ค‘์—์„œ๋งŒ ์ ์šฉ + if daily_rsi > 40: + return False + # 15๋ถ„๋ด‰ ์กฐ๊ฑด + rsi_15m = self._rsi(ticker, "15m", 14) + bb_lower = self._bb_lower(ticker, "15m", 20) + ma5 = self._ma(ticker, "15m", 5) + return ( + rsi_15m <= 30 + and close <= bb_lower * 1.01 + and close > ma5 # 5๋ถ„๋ด‰ MA5 ์ƒํ–ฅ ๋ŒํŒŒ (Fake-out ๋ฐฉ์ง€) + and volume > volume_ma * 1.3 + ) + + def exit_signal(self, position) -> bool: + rsi_15m = self._rsi(position.ticker, "15m", 14) + return rsi_15m >= 55 or profit_pct >= 1.5 * atr_15m_pct +``` + +> **์Šฌ๋ฆฌํ”ผ์ง€ ์ฃผ์˜**: 15๋ถ„๋ด‰ ์ „๋žต์€ ๋ฐ˜๋“œ์‹œ ์ˆ˜์ˆ˜๋ฃŒ 0.3% + ์Šฌ๋ฆฌํ”ผ์ง€ 0.2% ํฌํ•จ ๋ฐฑํ…Œ์ŠคํŠธ ํ•„์ˆ˜ + +--- + +### 8.3 ๋ฉ€ํ‹ฐ ์ „๋žต ์ž๋ณธ ๋ฐฐ๋ถ„ + +| ์ „๋žต | ์ž๋ณธ ๋น„์œจ | ๊ธฐ๋Œ€ ์›” ์ˆ˜์ต | ์‹ ํ˜ธ ๋นˆ๋„ | +|------|-----------|-------------|-----------| +| RSI+๋ณผ๋ฆฐ์ € ์ผ๋ด‰ (v2) | 50% | +2~3% | ์›” 3~5ํšŒ | +| ์ฝ”์Šค๋‹ฅ ์Šค์บ๋„ˆ | 25% | +1~2% | ์›” 5~10ํšŒ | +| 15๋ถ„๋ด‰ ๋‹จ๊ธฐ ์ „๋žต | 25% | +1~2% | ์›” 10~20ํšŒ | +| **ํ•ฉ๊ณ„ ๊ธฐ๋Œ€์น˜** | **100%** | **+4~7%** | - | + +> ์„ธ ์ „๋žต ๋™์‹œ ์šด์šฉ ์‹œ ์›” 5% ๋‹ฌ์„ฑ ํ˜„์‹ค ๊ฐ€๋Šฅ +> MDD -10% ๋ŒํŒŒ ์‹œ ๋‹จ๊ธฐ ์ „๋žต ์ฆ‰์‹œ ์ค‘๋‹จ, ์ผ๋ด‰ ์ „๋žต๋งŒ ์œ ์ง€ + +--- + +### 8.4 ์ˆ˜์ต ๋ชฉํ‘œ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ (๋ณต๋ฆฌ) + +| ์ด์ •ํ‘œ | ์ˆœ์ˆ˜ ๋ณต๋ฆฌ (์›” 5%) | ํ˜„์‹ค ์˜ˆ์ƒ (MDD ํฌํ•จ) | +|--------|------------------|---------------------| +| 2,000๋งŒ | 15๊ฐœ์›” (1.2๋…„) | 20~24๊ฐœ์›” | +| 3,000๋งŒ | 23๊ฐœ์›” (1.9๋…„) | 30~36๊ฐœ์›” | +| 5,000๋งŒ | 33๊ฐœ์›” (2.8๋…„) | 45~54๊ฐœ์›” | +| **1์–ต** | **48๊ฐœ์›” (4.0๋…„)** | **7~8๋…„** | + +> 10๋…„ ๋‚ด 1์–ต ๋‹ฌ์„ฑ ํ™•๋ฅ  (๋ชฌํ…Œ์นด๋ฅผ๋กœ 1,000ํšŒ): **82.5%** + +--- + +## 9. ์ฃผ์š” ํฌํŠธ (๊ฐœ๋ฐœ ํ™˜๊ฒฝ) + +| ์„œ๋น„์Šค | ํฌํŠธ | +|--------|------| +| Backend (FastAPI) | 8000 | +| MCP Server | 8001 | +| Frontend (Vite) | 5173 | +| PostgreSQL | 5432 (Docker: 55432) | +| Redis | 6379 | + +--- + +## 10. Claude์—๊ฒŒ ์š”์ฒญ ์‹œ ์ฃผ์˜์‚ฌํ•ญ + +1. **๊ธฐ์กด ์ฝ”๋“œ ์‹œ๊ทธ๋‹ˆ์ฒ˜ ๋ณ€๊ฒฝ ๊ธˆ์ง€** โ†’ ์ƒˆ ๊ธฐ๋Šฅ์€ `_v2`, `_v3` ํŒŒ์ผ๋กœ ๋ถ„๋ฆฌ +2. **๋ฐฑํ…Œ์ŠคํŠธ ์—†์ด ์‹ค์ „ ์ฝ”๋“œ ์ฆ‰์‹œ ์ ์šฉ ๊ธˆ์ง€** โ†’ ์›Œํฌํฌ์›Œ๋“œ ํ…Œ์ŠคํŠธ ํ•„์ˆ˜ +3. **`json.dumps` ์ง์ ‘ ์‚ฌ์šฉ ๊ธˆ์ง€** โ†’ ๋ฐ˜๋“œ์‹œ `_dumps()` ํ—ฌํผ ์‚ฌ์šฉ +4. **๋ฆฌ์Šคํฌ ๊ทœ์น™(ํฌ์ง€์…˜ ํฌ๊ธฐ, ์†์ ˆ, MDD, ๊ฐ€๊ฒฉ ํ•„ํ„ฐ)** ์€ ๋ชจ๋“  ์ „๋žต์— ๊ธฐ๋ณธ ์ ์šฉ +5. **SIDEWAYS ๊ตฌ๊ฐ„** ์—์„œ๋Š” ํ‰๊ท ํšŒ๊ท€ ์ „๋žต ์‹ ํ˜ธ ๋ฐœ์ƒ ์‹œ ๋ฌด์‹œ +6. **15๋ถ„๋ด‰ ๋‹จ๊ธฐ ์ „๋žต** ์€ ์ „์ฒด ์ž๋ณธ์˜ 25% ์ดˆ๊ณผ ๋ถˆ๊ฐ€ +7. **MDD -10%** ๋ŒํŒŒ ์‹œ ๋‹จ๊ธฐ ์ „๋žต ์ฆ‰์‹œ ์ค‘๋‹จ, ์ผ๋ด‰ ์ „๋žต๋งŒ ์œ ์ง€ +8. **์ฝ”์Šค๋‹ฅ ์Šค์บ๋„ˆ** ๋Š” ๊ฐ€๊ฒฉ 5,000์› ์ด์ƒ + ๊ฑฐ๋ž˜๋Œ€๊ธˆ ์ƒ์œ„ 20% ํ•„ํ„ฐ ๋ฐ˜๋“œ์‹œ ์ ์šฉ + +--- + +*Last updated: 2026-02-22* diff --git a/Makefile b/Makefile index c0861ac..04b1aa6 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,15 @@ status: ## ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ shell-backend: ## Backend ์‰˜ ์ ‘์† @docker exec -it autostock-backend /bin/bash +batch-analysis: ## ๋ฐฐ์น˜ ๋ถ„์„ ์‹คํ–‰ (์ „์ฒด ์ข…๋ชฉ) + @docker exec autostock-backend python batch_daily_analysis.py + +batch-test: ## ๋ฐฐ์น˜ ๋ถ„์„ ํ…Œ์ŠคํŠธ (10๊ฐœ ์ข…๋ชฉ) + @docker exec autostock-backend python batch_daily_analysis.py --test + +batch-limit: ## ๋ฐฐ์น˜ ๋ถ„์„ ์ œํ•œ ์‹คํ–‰ (์‚ฌ์šฉ: make batch-limit LIMIT=50) + @docker exec autostock-backend python batch_daily_analysis.py --limit $(LIMIT) + shell-mcp: ## MCP Server ์‰˜ ์ ‘์† @docker exec -it autostock-mcp-server /bin/bash diff --git a/RISK_MANAGEMENT_API.md b/RISK_MANAGEMENT_API.md new file mode 100644 index 0000000..8229e0f --- /dev/null +++ b/RISK_MANAGEMENT_API.md @@ -0,0 +1,248 @@ +# ๐Ÿ”ฅ ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ & ์„ฑ๊ณผ ํ‰๊ฐ€ API ๊ฐ€์ด๋“œ + +## ๊ฐœ์š” +์ž๋™๋งค๋งค ์‹œ์Šคํ…œ์˜ ์•ˆ์ •์„ฑ๊ณผ ์ˆ˜์ต์„ฑ์„ ๋†’์ด๊ธฐ ์œ„ํ•œ ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค. + +## ์ฃผ์š” ๊ธฐ๋Šฅ + +### 1. Maximum Drawdown (MDD) ์ถ”์  +**์—”๋“œํฌ์ธํŠธ**: `GET /api/v1/risk/risk-metrics?days=30` + +๊ณ„์ขŒ ์ตœ๊ณ ์  ๋Œ€๋น„ ์ตœ์ €์  ๋‚™ํญ์„ ์ธก์ •ํ•ฉ๋‹ˆ๋‹ค. + +**์‘๋‹ต ์˜ˆ์‹œ**: +```json +{ + "max_drawdown": 150000, + "max_drawdown_pct": 15.5, + "current_equity": 850000, + "peak_equity": 1000000, + "trades_count": 45, + "risk_level": "์ฃผ์˜", + "equity_curve": [...], + "drawdown_curve": [...] +} +``` + +**์œ„ํ—˜ ๋ ˆ๋ฒจ**: +- `์•ˆ์ „`: MDD < 10% +- `์ฃผ์˜`: 10% โ‰ค MDD < 20% +- `๊ฒฝ๊ณ `: 20% โ‰ค MDD < 30% (ํฌ์ง€์…˜ ์ถ•์†Œ ๊ถŒ์žฅ) +- `์œ„ํ—˜`: MDD โ‰ฅ 30% (์‹œ์Šคํ…œ ์ค‘์ง€ ๊ถŒ์žฅ) + +--- + +### 2. Sharpe Ratio ๊ณ„์‚ฐ +**์—”๋“œํฌ์ธํŠธ**: `GET /api/v1/risk/sharpe-ratio?days=30` + +์ˆ˜์ต๋ฅ  ๋Œ€๋น„ ๋ณ€๋™์„ฑ(๋ฆฌ์Šคํฌ)์„ ์ธก์ •ํ•˜์—ฌ ์ „๋žต์˜ ์งˆ์„ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + +**์‘๋‹ต ์˜ˆ์‹œ**: +```json +{ + "sharpe_ratio": 1.85, + "avg_return": 2.3, + "std_return": 5.1, + "annualized_return": 579.6, + "interpretation": "Good (์šฐ์ˆ˜)", + "trades_count": 42 +} +``` + +**ํ•ด์„**: +- `> 2.0`: Excellent (๋งค์šฐ ์šฐ์ˆ˜) +- `> 1.0`: Good (์šฐ์ˆ˜) +- `> 0`: Fair (๋ณดํ†ต) +- `< 0`: Poor (๊ฐœ์„  ํ•„์š”) + +--- + +### 3. ์ผ์ผ ํŠธ๋ ˆ์ด๋”ฉ ์„ธ์…˜ ์กฐํšŒ +**์—”๋“œํฌ์ธํŠธ**: `GET /api/v1/risk/session/today` + +์˜ค๋Š˜์˜ ์—ฐ์† ์†์‹ค, ์ผ์ผ ์†์ต, ๊ฑฐ๋ž˜ ์ค‘์ง€ ์ƒํƒœ๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + +**์‘๋‹ต ์˜ˆ์‹œ**: +```json +{ + "session_date": "2026-02-11", + "consecutive_losses": 2, + "consecutive_wins": 0, + "daily_profit_loss": -45000, + "daily_trades_count": 5, + "is_paused": false, + "pause_reason": null, + "starting_balance": 10000000, + "ending_balance": 9955000, + "peak_balance": 10050000 +} +``` + +--- + +### 4. ์ฃผ๋ฌธ ์ „ ๋ฆฌ์Šคํฌ ์ œํ•œ ํ™•์ธ +**์—”๋“œํฌ์ธํŠธ**: `POST /api/v1/risk/check-risk-limits` + +**ํŒŒ๋ผ๋ฏธํ„ฐ**: +- `order_value`: ์ฃผ๋ฌธ ๊ธˆ์•ก +- `account_balance`: ๊ณ„์ขŒ ์ž”๊ณ  + +**์‘๋‹ต ์˜ˆ์‹œ (ํ†ต๊ณผ)**: +```json +{ + "allowed": true, + "risk_level": "OK", + "warnings": [], + "position_ratio": 15.5, + "daily_loss_ratio": 1.2, + "consecutive_losses": 1 +} +``` + +**์‘๋‹ต ์˜ˆ์‹œ (๊ฑฐ๋ถ€)**: +```json +{ + "allowed": false, + "reason": "์—ฐ์† 3ํšŒ ์†์‹ค ๋ฐœ์ƒ", + "risk_level": "CRITICAL", + "consecutive_losses": 3 +} +``` + +**์ž๋™ ์ฐจ๋‹จ ์กฐ๊ฑด**: +1. **์—ฐ์† 3ํšŒ ์†์‹ค**: 3์—ฐํŒจ ์‹œ ์ž๋™ ์ค‘์ง€ +2. **์ผ์ผ ์†์‹ค 3% ์ดˆ๊ณผ**: ๊ณ„์ขŒ ๋Œ€๋น„ 3% ์†์‹ค ์‹œ ์ค‘์ง€ +3. **ํฌ์ง€์…˜ ์‚ฌ์ด์ฆˆ 20% ์ดˆ๊ณผ**: ๋‹จ์ผ ํฌ์ง€์…˜์ด ๊ณ„์ขŒ์˜ 20% ์ดˆ๊ณผ ์‹œ ๊ฑฐ๋ถ€ + +--- + +### 5. ๊ฑฐ๋ž˜ ๊ฒฐ๊ณผ ์—…๋ฐ์ดํŠธ +**์—”๋“œํฌ์ธํŠธ**: `POST /api/v1/risk/update-session` + +**ํŒŒ๋ผ๋ฏธํ„ฐ**: +- `trade_result`: `win` ๋˜๋Š” `loss` +- `profit_loss`: ๊ฑฐ๋ž˜ ์†์ต + +๋งค๋„ ์ฒด๊ฒฐ ํ›„ ํ˜ธ์ถœํ•˜์—ฌ ์„ธ์…˜ ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. + +--- + +### 6. ์Šน๋ฅ  ๋ฐ ์†์ต ํ†ต๊ณ„ +**์—”๋“œํฌ์ธํŠธ**: `GET /api/v1/risk/win-rate?days=30` + +**์‘๋‹ต ์˜ˆ์‹œ**: +```json +{ + "win_rate": 62.5, + "total_trades": 40, + "wins": 25, + "losses": 15, + "total_profit": 1250000, + "total_loss": 450000, + "net_profit": 800000, + "profit_factor": 2.78, + "avg_win": 50000, + "avg_loss": 30000, + "risk_reward_ratio": 1.67 +} +``` + +**์ฃผ์š” ์ง€ํ‘œ**: +- **Profit Factor**: ์ด ์ด์ต / ์ด ์†์‹ค (1.0 ์ด์ƒ ๊ถŒ์žฅ, 2.0 ์ด์ƒ ์šฐ์ˆ˜) +- **Risk/Reward Ratio**: ํ‰๊ท  ์Šน๋ฆฌ / ํ‰๊ท  ์†์‹ค (1.5 ์ด์ƒ ๊ถŒ์žฅ) + +--- + +## ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ์„ค์ • + +### ํ˜„์žฌ ๊ธฐ๋ณธ๊ฐ’ +```python +MAX_POSITION_SIZE_RATIO = 0.20 # ๋‹จ์ผ ํฌ์ง€์…˜ 20% ์ œํ•œ +MAX_DAILY_LOSS_RATIO = 0.03 # ์ผ์ผ ์†์‹ค 3% ์ œํ•œ +MAX_CONSECUTIVE_LOSSES = 3 # ์—ฐ์† 3ํšŒ ์†์‹ค ์ œํ•œ + +# ์ˆ˜์ˆ˜๋ฃŒ ๋ฐ ์„ธ๊ธˆ +COMMISSION_RATE = 0.00015 # ์ˆ˜์ˆ˜๋ฃŒ 0.015% +TRADE_TAX_RATE = 0.0023 # ๊ฑฐ๋ž˜์„ธ 0.23% (๋งค๋„๋งŒ) +TOTAL_COST_RATE = 0.0026 # ์™•๋ณต ์•ฝ 0.26% +``` + +--- + +## ์‚ฌ์šฉ ์˜ˆ์‹œ + +### ๋งค์ˆ˜ ์ „ ์ฒดํฌ +```python +# ์ฃผ๋ฌธ ์ „ ๋ฆฌ์Šคํฌ ํ™•์ธ +response = requests.post( + "/api/v1/risk/check-risk-limits", + params={ + "order_value": 2000000, # 200๋งŒ์› ์ฃผ๋ฌธ + "account_balance": 10000000 # 1000๋งŒ์› ์ž”๊ณ  + } +) + +if response.json()["allowed"]: + # ๋งค์ˆ˜ ์‹คํ–‰ + execute_buy_order() +else: + # ์ฃผ๋ฌธ ๊ฑฐ๋ถ€ + logger.warning(f"Order rejected: {response.json()['reason']}") +``` + +### ๋งค๋„ ํ›„ ์„ธ์…˜ ์—…๋ฐ์ดํŠธ +```python +# ๋งค๋„ ์ฒด๊ฒฐ ํ›„ +sell_result = execute_sell_order() + +# ์†์ต์— ๋”ฐ๋ผ ์„ธ์…˜ ์—…๋ฐ์ดํŠธ +trade_result = "win" if sell_result["profit_loss"] > 0 else "loss" + +requests.post( + "/api/v1/risk/update-session", + params={ + "trade_result": trade_result, + "profit_loss": sell_result["profit_loss"] + } +) +``` + +--- + +## ๋Œ€์‹œ๋ณด๋“œ ํ‘œ์‹œ ๊ถŒ์žฅ ์‚ฌํ•ญ + +### ํ•„์ˆ˜ ํ‘œ์‹œ ์ง€ํ‘œ +1. **์˜ค๋Š˜์˜ ์ƒํƒœ** + - ์—ฐ์† ์†์‹ค ํšŸ์ˆ˜ + - ์ผ์ผ ์†์ต + - ๊ฑฐ๋ž˜ ์ค‘์ง€ ์ƒํƒœ + +2. **๋ฆฌ์Šคํฌ ๋ฉ”ํŠธ๋ฆญ** + - MDD % + - Sharpe Ratio + - ์Šน๋ฅ  + +3. **๊ฒฝ๊ณ  ์•Œ๋ฆผ** + - ์—ฐ์† 2ํšŒ ์†์‹ค: ๋…ธ๋ž€์ƒ‰ ๊ฒฝ๊ณ  + - ์—ฐ์† 3ํšŒ ์†์‹ค: ๋นจ๊ฐ„์ƒ‰ ์ฐจ๋‹จ + - MDD 20% ์ดˆ๊ณผ: ์ฃผํ™ฉ์ƒ‰ ๊ฒฝ๊ณ  + - MDD 30% ์ดˆ๊ณผ: ๋นจ๊ฐ„์ƒ‰ ์œ„ํ—˜ + +--- + +## ์ฃผ์˜์‚ฌํ•ญ + +1. **๋ฐฑํ…Œ์ŠคํŒ… ํ›„ ์‹ค์ „ ์ ์šฉ**: ์ƒˆ๋กœ์šด ์ „๋žต์€ ๋ฐ˜๋“œ์‹œ ๋ฐฑํ…Œ์ŠคํŒ…์œผ๋กœ ๊ฒ€์ฆ +2. **๋ฆฌ์Šคํฌ ํ•œ๋„ ์ค€์ˆ˜**: ์„ค์ •๋œ ํ•œ๋„๋ฅผ ์ ˆ๋Œ€ ์ž„์˜๋กœ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๊ธฐ +3. **์ •๊ธฐ ์ ๊ฒ€**: ๋งค์ฃผ Sharpe Ratio์™€ MDD ํ™•์ธ +4. **๊ฐ์ • ์ œ์–ด**: ์‹œ์Šคํ…œ์ด ์ค‘์ง€ํ•˜๋ฉด ํ•˜๋ฃจ ์‰ฌ๊ธฐ + +--- + +## ๋‹ค์Œ ๋‹จ๊ณ„ (์ถ”๊ฐ€ ๊ฐœ๋ฐœ ์˜ˆ์ •) + +- [ ] ๋ฐฑํ…Œ์ŠคํŒ… API +- [ ] Slack/Telegram ์•Œ๋ฆผ ์—ฐ๋™ +- [ ] ์‹ค์‹œ๊ฐ„ ๊ฒฝ๊ณ  ๋Œ€์‹œ๋ณด๋“œ +- [ ] ์›”๊ฐ„/๋ถ„๊ธฐ๋ณ„ ์„ฑ๊ณผ ๋ฆฌํฌํŠธ +- [ ] ATR ๊ธฐ๋ฐ˜ ๋™์  ์†์ ˆ ์„ค์ • diff --git a/SPEED_OPTIMIZATION.md b/SPEED_OPTIMIZATION.md new file mode 100644 index 0000000..120ffb7 --- /dev/null +++ b/SPEED_OPTIMIZATION.md @@ -0,0 +1,139 @@ +# ์ดˆ๊ธฐ ์ง„์ž… ์†๋„ ์ตœ์ ํ™” + +## ๋ณ€๊ฒฝ ์‚ฌํ•ญ + +### 1. ํ”„๋ก ํŠธ์—”๋“œ - DB ์บ์‹œ ๋ชจ๋“œ ์‚ฌ์šฉ +- **ํŒŒ์ผ**: `frontend/src/pages/Dashboard.tsx` +- **๋ณ€๊ฒฝ**: ์ถ”์ฒœ ์ข…๋ชฉ ์กฐํšŒ ์‹œ `realtime=false` ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ +- **ํšจ๊ณผ**: 30์ดˆ โ†’ 0.1์ดˆ๋กœ ๋กœ๋”ฉ ์†๋„ ๊ฐœ์„  (300๋ฐฐ ํ–ฅ์ƒ) + +```typescript +// ๊ธฐ์กด (๋А๋ฆผ - 30์ดˆ) +queryFn: async () => (await api.get('/recommendations/rule-based')).data, + +// ๋ณ€๊ฒฝ (๋น ๋ฆ„ - 0.1์ดˆ) +queryFn: async () => (await api.get('/recommendations/rule-based', { + params: { realtime: false } // DB ์บ์‹œ ์‚ฌ์šฉ +})).data, +``` + +### 2. ๋ฐฐ์น˜ ๋ถ„์„ ์Šคํฌ๋ฆฝํŠธ +- **ํŒŒ์ผ**: `backend/batch_daily_analysis.py` +- **๋ชฉ์ **: ๋งค์ผ ์žฅ ์‹œ์ž‘ ์ „์— ์ „์ฒด ์ข…๋ชฉ์„ ๋ฏธ๋ฆฌ ๋ถ„์„ํ•˜์—ฌ DB์— ์ €์žฅ +- **์‹คํ–‰ ๋ฐฉ๋ฒ•**: + ```bash + make batch-test # ํ…Œ์ŠคํŠธ (10๊ฐœ ์ข…๋ชฉ) + make batch-limit LIMIT=50 # 50๊ฐœ ์ข…๋ชฉ๋งŒ + make batch-analysis # ์ „์ฒด ์ข…๋ชฉ (์•ฝ 30-60๋ถ„ ์†Œ์š”) + ``` + +### 3. ์ž๋™ ์‹คํ–‰ ์„ค์ • +- **ํŒŒ์ผ**: `setup-cron.sh` +- **์Šค์ผ€์ค„**: ํ‰์ผ ์˜ค์ „ 8์‹œ 50๋ถ„ (์žฅ ์‹œ์ž‘ ์ „) +- **์„ค์ • ๋ฐฉ๋ฒ•**: + ```bash + ./setup-cron.sh + ``` + +## ๋™์ž‘ ์›๋ฆฌ + +### DB ์บ์‹œ ์‹œ์Šคํ…œ +1. **๋ฐฐ์น˜ ๋ถ„์„** (๋งค์ผ ์•„์นจ 8:50) + - ์ „์ฒด ๊ฑฐ๋ž˜ ๊ฐ€๋Šฅ ์ข…๋ชฉ ์กฐํšŒ + - ๊ธฐ์ˆ ์  ์ง€ํ‘œ ๊ณ„์‚ฐ (RSI, MA, ๋ณผ๋ฆฐ์ €๋ฐด๋“œ ๋“ฑ) + - `stock_analysis` ํ…Œ์ด๋ธ”์— ์ €์žฅ + +2. **์‹ค์‹œ๊ฐ„ ์กฐํšŒ** (์‚ฌ์šฉ์ž ์ง„์ž… ์‹œ) + - DB์—์„œ ์‚ฌ์ „ ๋ถ„์„๋œ ๋ฐ์ดํ„ฐ ์กฐํšŒ (0.1์ดˆ) + - ์‹ค์‹œ๊ฐ„ KIS API ํ˜ธ์ถœ ์—†์Œ + +3. **ํด๋ฐฑ ๋ฉ”์ปค๋‹ˆ์ฆ˜** + - DB์— ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์ž๋™์œผ๋กœ ์‹ค์‹œ๊ฐ„ ๋ถ„์„์œผ๋กœ ์ „ํ™˜ + - ๋ฐฐ์น˜ ์‹คํ–‰ ์ „์—๋„ ์‹œ์Šคํ…œ ์ •์ƒ ๋™์ž‘ + +## ํ…Œ์ด๋ธ” ๊ตฌ์กฐ + +```sql +CREATE TABLE stock_analysis ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(20) NOT NULL, + stock_name VARCHAR(100) NOT NULL, + analysis_date TIMESTAMP NOT NULL, + current_price INTEGER NOT NULL, + rsi NUMERIC(10, 4), + ma5 NUMERIC(15, 2), + ma20 NUMERIC(15, 2), + ma60 NUMERIC(15, 2), + bollinger_upper NUMERIC(15, 2), + bollinger_lower NUMERIC(15, 2), + signals JSONB, + score INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_stock_analysis_date ON stock_analysis(analysis_date); +CREATE INDEX idx_stock_analysis_code ON stock_analysis(stock_code); +``` + +## ์‚ฌ์šฉ ๊ฐ€์ด๋“œ + +### ๋ฐฐ์น˜ ๋ถ„์„ ์‹คํ–‰ +```bash +# 1. ํ…Œ์ŠคํŠธ ์‹คํ–‰ (10๊ฐœ ์ข…๋ชฉ) +make batch-test + +# 2. ์ œํ•œ ์‹คํ–‰ (50๊ฐœ ์ข…๋ชฉ) +make batch-limit LIMIT=50 + +# 3. ์ „์ฒด ์‹คํ–‰ (๋ชจ๋“  ์ข…๋ชฉ) +make batch-analysis + +# 4. ๋กœ๊ทธ ํ™•์ธ +tail -f logs/batch_analysis.log +``` + +### ์ž๋™ ์‹คํ–‰ ์„ค์ • +```bash +# cron ์ž‘์—… ๋“ฑ๋ก +./setup-cron.sh + +# ํ™•์ธ +crontab -l + +# ์ œ๊ฑฐ (ํ•„์š” ์‹œ) +crontab -e +``` + +## ์„ฑ๋Šฅ ๋น„๊ต + +| ๋ชจ๋“œ | ๋กœ๋”ฉ ์‹œ๊ฐ„ | API ํ˜ธ์ถœ | ๋ฐ์ดํ„ฐ ์‹ ์„ ๋„ | +|------|-----------|----------|---------------| +| **์‹ค์‹œ๊ฐ„** | 30์ดˆ | 100+ ํšŒ | ์‹ค์‹œ๊ฐ„ | +| **DB ์บ์‹œ** | 0.1์ดˆ | 0ํšŒ | ๋‹น์ผ ์•„์นจ ๊ธฐ์ค€ | + +## ์ฃผ์˜์‚ฌํ•ญ + +1. **๋ฐฐ์น˜ ์‹คํ–‰ ์‹œ๊ฐ„**: ์ „์ฒด ์ข…๋ชฉ ๋ถ„์„ ์‹œ 30-60๋ถ„ ์†Œ์š” +2. **API ์ œํ•œ**: ํ•œ๊ตญํˆฌ์ž์ฆ๊ถŒ API ํ˜ธ์ถœ ์ œํ•œ (์ดˆ๋‹น 20ํšŒ) +3. **๋ฐ์ดํ„ฐ ์‹ ์„ ๋„**: ๋ฐฐ์น˜ ์‹คํ–‰ ํ›„ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ (์žฅ์ค‘ ์‹ค์‹œ๊ฐ„ ๋ณ€๋™ ๋ฐ˜์˜ ์•ˆ๋จ) +4. **์žฅ์ค‘ ๊ฐฑ์‹ **: ํ•„์š” ์‹œ `realtime=true`๋กœ ์‹ค์‹œ๊ฐ„ ๋ถ„์„ ๊ฐ€๋Šฅ + +## ๋ฌธ์ œ ํ•ด๊ฒฐ + +### DB์— ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์„ ๋•Œ +```bash +# ์ฆ‰์‹œ ๋ฐฐ์น˜ ์‹คํ–‰ +make batch-test + +# ๋˜๋Š” ์‹ค์‹œ๊ฐ„ ๋ชจ๋“œ๋กœ ์ž„์‹œ ์‚ฌ์šฉ +# (ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์ž๋™ ํด๋ฐฑ๋จ) +``` + +### ๋ฐฐ์น˜ ์‹คํ–‰ ์‹คํŒจ +```bash +# ๋กœ๊ทธ ํ™•์ธ +docker logs autostock-backend --tail 100 + +# ์ˆ˜๋™ ์‹คํ–‰ +docker exec -it autostock-backend python batch_daily_analysis.py --test +``` diff --git a/backend/Dockerfile b/backend/Dockerfile index 39f59bd..c1c1de0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,6 +2,9 @@ FROM python:3.11-slim WORKDIR /app +# Python ์ถœ๋ ฅ ๋ฒ„ํผ๋ง ๋น„ํ™œ์„ฑํ™” (๋กœ๊ทธ ์ฆ‰์‹œ ์ถœ๋ ฅ) +ENV PYTHONUNBUFFERED=1 + # ์‹œ์Šคํ…œ ์˜์กด์„ฑ ์„ค์น˜ RUN apt-get update && apt-get install -y \ gcc \ diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 4946802..775eba9 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,5 +1,10 @@ +""" +API ๋ผ์šฐํ„ฐ ํ†ตํ•ฉ ๋ชจ๋“ˆ + +- ๊ฐ ์—”๋“œํฌ์ธํŠธ ๋ผ์šฐํ„ฐ๋ฅผ prefix/tag์™€ ํ•จ๊ป˜ ๋“ฑ๋ก +""" from fastapi import APIRouter -from app.api.endpoints import trading_rules, trades, portfolio, market_data, mcp, recommendations, monitoring +from app.api.endpoints import trading_rules, trades, portfolio, market_data, mcp, recommendations, monitoring, tensorflow_predictions, debug, risk_management, ml_ensemble, journals, day_trading api_router = APIRouter() @@ -10,3 +15,9 @@ api_router.include_router(market_data.router, prefix="/market-data", tags=["mark api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"]) api_router.include_router(recommendations.router, prefix="/recommendations", tags=["recommendations"]) api_router.include_router(monitoring.router, prefix="/monitoring", tags=["monitoring"]) +api_router.include_router(tensorflow_predictions.router, prefix="/ml", tags=["ml"]) +api_router.include_router(debug.router, prefix="/debug", tags=["debug"]) +api_router.include_router(risk_management.router, prefix="/risk", tags=["risk_management"]) +api_router.include_router(ml_ensemble.router, prefix="/ml-ensemble", tags=["ml_ensemble"]) +api_router.include_router(journals.router, prefix="/journals", tags=["journals"]) +api_router.include_router(day_trading.router, prefix="/daytrading", tags=["day_trading"]) diff --git a/backend/app/api/endpoints/day_trading.py b/backend/app/api/endpoints/day_trading.py new file mode 100644 index 0000000..0efb586 --- /dev/null +++ b/backend/app/api/endpoints/day_trading.py @@ -0,0 +1,731 @@ +""" +๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ API ์—”๋“œํฌ์ธํŠธ + +๋ผ์šฐํ„ฐ prefix: /api/daytrading + +์—”๋“œํฌ์ธํŠธ: + GET /watchlist โ€” ๊ฐ์‹œ ์ข…๋ชฉ ๋ชฉ๋ก ์กฐํšŒ + POST /watchlist โ€” ๊ฐ์‹œ ์ข…๋ชฉ ์ถ”๊ฐ€/๊ต์ฒด + GET /signals โ€” ํ˜„์žฌ ์ง„์ž… ์‹ ํ˜ธ ์กฐํšŒ + POST /execute โ€” ์‹ ํ˜ธ ๊ธฐ๋ฐ˜ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์‹คํ–‰ + GET /positions โ€” ํ™œ์„ฑ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ํฌ์ง€์…˜ ์กฐํšŒ + POST /close-all โ€” ์ „์ฒด ํฌ์ง€์…˜ ๊ฐ•์ œ ์ฒญ์‚ฐ (EOD ์ˆ˜๋™) + POST /cycle โ€” 1๋ถ„ ์ฃผ๊ธฐ ์‚ฌ์ดํด ์ˆ˜๋™ ์‹คํ–‰ (๋””๋ฒ„๊ทธ์šฉ) + +์ƒํƒœ (์ธ๋ฉ”๋ชจ๋ฆฌ, ์ผ์ผ ์ดˆ๊ธฐํ™”): + _watchlist โ€” ๊ฐ์‹œ ์ข…๋ชฉ ์ฝ”๋“œ+์ด๋ฆ„ ๋”•์…”๋„ˆ๋ฆฌ + _positions โ€” ํ™œ์„ฑ ํฌ์ง€์…˜ {stock_code: {...}} + _orb_cache โ€” ์ข…๋ชฉ๋ณ„ ORB ๋ฐ์ดํ„ฐ + _daily_pnl โ€” ์˜ค๋Š˜ ์‹คํ˜„ ์†์ต + _last_date โ€” ๋งˆ์ง€๋ง‰ ์ดˆ๊ธฐํ™” ๋‚ ์งœ (๋‚ ์งœ ๋ฐ”๋€Œ๋ฉด ์ž๋™ ๋ฆฌ์…‹) +""" +from fastapi import APIRouter, HTTPException, Depends, Query +from pydantic import BaseModel +from typing import List, Optional, Dict, Any +from datetime import datetime, date, time as dtime +import asyncio +import logging +import pytz + +from app.services.korea_investment_api import korea_investment_api +from app.services.day_trading_strategy import ( + MinuteCandle, OpeningRange, DayTradeSignal, + parse_minute_candles, aggregate_to_5min, + calculate_vwap, calculate_opening_range, + check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal, + check_day_trade_exit, + calculate_day_trade_position_size, is_daily_loss_exceeded, + ORB_END, ENTRY_CUTOFF, EOD_FLAT, MAX_POSITIONS, +) +from app.core.market_schedule import ( + is_market_open, is_opening_range_period, + is_day_trade_entry_window, is_eod_flat_time, +) +from app.core.config import settings +from app.core.json_helpers import _dumps +from app.services.websocket_manager import websocket_manager +from app.core.database import get_db, AsyncSessionLocal +from app.models.models import TradeHistory +from sqlalchemy.ext.asyncio import AsyncSession + +router = APIRouter() +logger = logging.getLogger(__name__) +KST = pytz.timezone("Asia/Seoul") + + +# โ”€โ”€ ์ธ๋ฉ”๋ชจ๋ฆฌ ์ƒํƒœ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _kst_now() -> datetime: + return datetime.now(KST).replace(tzinfo=None) + + +def _kst_time() -> dtime: + return datetime.now(KST).time() + + +def _today_kst() -> date: + return datetime.now(KST).date() + + +# ๊ฐ์‹œ ์ข…๋ชฉ (code โ†’ name) +_watchlist: Dict[str, str] = {} + +# ํ™œ์„ฑ ํฌ์ง€์…˜ +# {stock_code: { +# "stock_name", "strategy", "entry_price", "stop_loss", "target_price", +# "quantity", "highest_price", "entry_time", "reason" +# }} +_positions: Dict[str, Dict[str, Any]] = {} + +# ORB ์บ์‹œ (๋‹น์ผ, stock_code โ†’ OpeningRange) +_orb_cache: Dict[str, OpeningRange] = {} + +# ์ผ์ผ ์‹คํ˜„ ์†์ต (์›) +_daily_pnl: float = 0.0 + +# ๋งˆ์ง€๋ง‰ ์ดˆ๊ธฐํ™” ๋‚ ์งœ +_last_reset_date: Optional[date] = None + +# ๋งค๋งค ์‹คํ–‰ lock +_trade_lock = asyncio.Lock() + + +def _reset_if_new_day() -> None: + """๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ์ผ์ผ ์ƒํƒœ ์ดˆ๊ธฐํ™”""" + global _daily_pnl, _orb_cache, _last_reset_date + today = _today_kst() + if _last_reset_date != today: + _daily_pnl = 0.0 + _orb_cache = {} + _last_reset_date = today + logger.info(f"๐Ÿ“… ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ƒํƒœ ์ผ์ผ ์ดˆ๊ธฐํ™”: {today}") + + +# โ”€โ”€ Pydantic ๋ชจ๋ธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class WatchlistRequest(BaseModel): + stocks: List[Dict[str, str]] # [{"code": "005930", "name": "์‚ผ์„ฑ์ „์ž"}, ...] + + +class ExecuteRequest(BaseModel): + stock_code: str + dry_run: bool = False # True๋ฉด ์ฃผ๋ฌธ ์—†์ด ์‹ ํ˜ธ๋งŒ ๋ฐ˜ํ™˜ + + +# โ”€โ”€ ๋‚ด๋ถ€ ํ—ฌํผ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async def _fetch_candles(stock_code: str) -> List[MinuteCandle]: + """๋‹น์ผ ์ „์ฒด ๋ถ„๋ด‰ ๋ฐ์ดํ„ฐ ์กฐํšŒ ๋ฐ ํŒŒ์‹ฑ""" + raw = await korea_investment_api.get_minute_chart_full_day(stock_code) + return parse_minute_candles(raw) + + +async def _get_current_price(stock_code: str) -> float: + """ํ˜„์žฌ๊ฐ€ ์กฐํšŒ""" + try: + result = await korea_investment_api.get_current_price(stock_code) + return float(result.get("output", {}).get("stck_prpr", 0) or 0) + except Exception: + return 0.0 + + +async def _get_portfolio_summary() -> Dict[str, float]: + """ํฌํŠธํด๋ฆฌ์˜ค ์š”์•ฝ (์ด์ž์‚ฐ, ๊ฐ€์šฉ ํ˜„๊ธˆ)""" + try: + balance = await korea_investment_api.get_balance() + output2 = balance.get("output2", [{}]) + summary = output2[0] if output2 else {} + total_assets = float(summary.get("tot_evlu_amt", 0) or 0) + available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0) + return {"total_assets": total_assets, "available_cash": available_cash} + except Exception as e: + logger.error(f"ํฌํŠธํด๋ฆฌ์˜ค ์กฐํšŒ ์‹คํŒจ: {e}") + return {"total_assets": 0.0, "available_cash": 0.0} + + +async def _place_buy_order( + stock_code: str, + quantity: int, + price: int, +) -> Optional[str]: + """๋งค์ˆ˜ ์ฃผ๋ฌธ ์‹คํ–‰, ์ฃผ๋ฌธ๋ฒˆํ˜ธ ๋ฐ˜ํ™˜ (์‹คํŒจ ์‹œ None)""" + try: + result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="BUY", + quantity=quantity, + price=price, + ) + if result.get("rt_cd") == "0": + return result.get("output", {}).get("ODNO", "") + logger.warning(f"๋งค์ˆ˜ ์ฃผ๋ฌธ ์‹คํŒจ [{stock_code}]: {result.get('msg1')}") + return None + except Exception as e: + logger.error(f"๋งค์ˆ˜ ์ฃผ๋ฌธ ์˜ค๋ฅ˜ [{stock_code}]: {e}") + return None + + +async def _place_sell_order( + stock_code: str, + quantity: int, + price: int = 0, # 0 = ์‹œ์žฅ๊ฐ€ +) -> bool: + """๋งค๋„ ์ฃผ๋ฌธ ์‹คํ–‰""" + try: + result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="SELL", + quantity=quantity, + price=price, + ) + return result.get("rt_cd") == "0" + except Exception as e: + logger.error(f"๋งค๋„ ์ฃผ๋ฌธ ์˜ค๋ฅ˜ [{stock_code}]: {e}") + return False + + +async def _record_trade( + db: AsyncSession, + stock_code: str, + stock_name: str, + order_type: str, + price: float, + quantity: int, + reason: str, + profit_loss: float = 0.0, +) -> None: + """TradeHistory DB ๊ธฐ๋ก""" + try: + trade = TradeHistory( + stock_code=stock_code, + stock_name=stock_name, + order_type=order_type, + executed_price=price, + executed_quantity=quantity, + total_amount=price * quantity, + profit_loss=profit_loss, + profit_loss_rate=(profit_loss / (price * quantity) * 100) if price * quantity > 0 else 0, + status="COMPLETED", + entry_reason=reason if order_type == "BUY" else None, + exit_reason=reason if order_type == "SELL" else None, + executed_at=_kst_now(), + ) + db.add(trade) + await db.commit() + except Exception as e: + logger.error(f"TradeHistory ๊ธฐ๋ก ์‹คํŒจ: {e}") + + +# โ”€โ”€ ํ•ต์‹ฌ ์‚ฌ์ดํด ๋กœ์ง โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async def run_day_trading_cycle() -> Dict[str, Any]: + """1๋ถ„ ์ฃผ๊ธฐ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์‚ฌ์ดํด + + ํ๋ฆ„: + 1. ์žฅ ๋ฏธ๊ฐœ์žฅ โ†’ ์Šคํ‚ต + 2. ์ผ์ผ ์ƒํƒœ ์ดˆ๊ธฐํ™” ์ฒดํฌ + 3. Opening Range ์ˆ˜์ง‘ (09:00~09:30) + 4. ํ™œ์„ฑ ํฌ์ง€์…˜ ์ฒญ์‚ฐ ์ฒดํฌ + 5. ์‹ ๊ทœ ์ง„์ž… ์‹ ํ˜ธ ์ฒดํฌ (09:30~15:00) + 6. ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ + """ + _reset_if_new_day() + now_time = _kst_time() + result: Dict[str, Any] = { + "time": _kst_now().strftime("%H:%M:%S"), + "phase": "", + "exits": [], + "entries": [], + "signals": [], + } + + if not is_market_open(): + result["phase"] = "market_closed" + return result + + # โ”€โ”€ 1. EOD ๊ฐ•์ œ ์ฒญ์‚ฐ โ”€โ”€ + if is_eod_flat_time(): + result["phase"] = "eod_flat" + await _execute_eod_flat() + return result + + # โ”€โ”€ 2. ORB ์ˆ˜์ง‘ ๋‹จ๊ณ„ (09:00~09:30) โ”€โ”€ + if is_opening_range_period(): + result["phase"] = "opening_range" + await _collect_opening_range() + result["orb_count"] = len(_orb_cache) + return result + + # โ”€โ”€ 3. ๋งค๋งค ๊ฐ€๋Šฅ ๊ตฌ๊ฐ„ (09:30~15:00) โ”€โ”€ + if not is_day_trade_entry_window(): + result["phase"] = "outside_window" + return result + + result["phase"] = "active" + + # โ”€โ”€ 4. ํ™œ์„ฑ ํฌ์ง€์…˜ ์ฒญ์‚ฐ ์ฒดํฌ โ”€โ”€ + exits = await _check_exits() + result["exits"] = exits + + # โ”€โ”€ 5. ์‹ ๊ทœ ์ง„์ž… ์‹ ํ˜ธ ์ฒดํฌ โ”€โ”€ + if len(_positions) < MAX_POSITIONS and settings.AUTO_TRADE_ENABLED: + portfolio = await _get_portfolio_summary() + total_assets = portfolio["total_assets"] + available_cash = portfolio["available_cash"] + + if not is_daily_loss_exceeded(total_assets, _daily_pnl): + current_day_trade_amount = sum( + p["entry_price"] * p["quantity"] for p in _positions.values() + ) + signals = await _scan_signals( + total_assets=total_assets, + available_cash=available_cash, + current_day_trade_amount=current_day_trade_amount, + ) + result["signals"] = [ + { + "stock_code": s["signal"].stock_code, + "strategy": s["signal"].strategy, + "entry_price": s["signal"].entry_price, + "stop_loss": s["signal"].stop_loss, + "target_price": s["signal"].target_price, + "reason": s["signal"].reason, + "quantity": s["quantity"], + } + for s in signals + ] + + for s in signals: + if len(_positions) >= MAX_POSITIONS: + break + entry_result = await _execute_entry(s["signal"], s["quantity"]) + if entry_result: + result["entries"].append(entry_result) + else: + logger.warning( + f"โš ๏ธ ์ผ์ผ ์†์‹ค ํ•œ๋„ ๋„๋‹ฌ ({_daily_pnl:,.0f}์›) โ†’ ์‹ ๊ทœ ์ง„์ž… ์ค‘๋‹จ" + ) + + return result + + +async def _collect_opening_range() -> None: + """Opening Range ์ง‘๊ณ„ (09:00~09:30)""" + for code in list(_watchlist.keys()): + if code in _orb_cache: + continue + try: + candles = await _fetch_candles(code) + orb = calculate_opening_range(candles) + if orb: + _orb_cache[code] = orb + logger.info( + f"๐Ÿ“Š ORB ์ˆ˜์ง‘ [{code}]: " + f"H={orb.high:.0f}, L={orb.low:.0f}, " + f"๋ถ„๋‹น๊ฑฐ๋ž˜๋Ÿ‰={orb.avg_vol_per_min:.0f}" + ) + except Exception as e: + logger.warning(f"ORB ์ˆ˜์ง‘ ์‹คํŒจ [{code}]: {e}") + await asyncio.sleep(0.6) + + +async def _check_exits() -> List[Dict[str, Any]]: + """ํ™œ์„ฑ ํฌ์ง€์…˜ ์ฒญ์‚ฐ ์กฐ๊ฑด ์ฒดํฌ ๋ฐ ์‹คํ–‰""" + global _daily_pnl + exits = [] + now_time = _kst_time() + + for code, pos in list(_positions.items()): + try: + current_price = await _get_current_price(code) + if current_price <= 0: + continue + + # ์ตœ๊ณ ๊ฐ€ ๊ฐฑ์‹  + if current_price > pos["highest_price"]: + _positions[code]["highest_price"] = current_price + + candles = await _fetch_candles(code) + candles_5m = aggregate_to_5min(candles) + + exit_signal = check_day_trade_exit( + entry_price=pos["entry_price"], + current_price=current_price, + stop_loss=pos["stop_loss"], + target_price=pos["target_price"], + highest_since_entry=pos["highest_price"], + candles_5min=candles_5m, + current_time=now_time, + ) + + if exit_signal: + quantity = pos["quantity"] + ok = await _place_sell_order(code, quantity, price=0) # ์‹œ์žฅ๊ฐ€ + if ok: + pnl = (current_price - pos["entry_price"]) * quantity + _daily_pnl += pnl + del _positions[code] + + async with AsyncSessionLocal() as db: + await _record_trade( + db, code, pos["stock_name"], "SELL", + current_price, quantity, exit_signal.reason, pnl, + ) + + exits.append({ + "stock_code": code, + "reason": exit_signal.reason, + "pnl": round(pnl, 0), + "price": current_price, + }) + logger.info( + f"โœ… ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ฒญ์‚ฐ [{code}]: " + f"{exit_signal.reason}, PNL={pnl:+,.0f}์›" + ) + await websocket_manager.broadcast(_dumps({ + "type": "day_trade_exit", + "stock_code": code, + "reason": exit_signal.reason, + "pnl": pnl, + })) + except Exception as e: + logger.error(f"์ฒญ์‚ฐ ์ฒดํฌ ์˜ค๋ฅ˜ [{code}]: {e}") + await asyncio.sleep(0.6) + + return exits + + +async def _scan_signals( + total_assets: float, + available_cash: float, + current_day_trade_amount: float, +) -> List[Dict[str, Any]]: + """๊ฐ์‹œ ์ข…๋ชฉ์—์„œ ์ง„์ž… ์‹ ํ˜ธ ์Šค์บ”""" + results = [] + for code, name in list(_watchlist.items()): + if code in _positions: + continue + try: + candles_1m = await _fetch_candles(code) + if not candles_1m: + continue + candles_5m = aggregate_to_5min(candles_1m) + current_price = candles_1m[-1].close if candles_1m else 0 + if current_price < settings.MIN_PRICE_KRW: + continue + + vwap = calculate_vwap(candles_1m) + orb = _orb_cache.get(code) + + signal: Optional[DayTradeSignal] = None + + # ORB ์šฐ์„  + if orb: + signal = check_orb_signal(code, name, candles_1m, orb, current_price) + + # VWAP ๋ฐ˜๋“ฑ + if signal is None and vwap: + signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price) + + # RSI+BB + if signal is None: + signal = check_rsi_bb_signal(code, name, candles_5m, current_price) + + if signal: + position_size = calculate_day_trade_position_size( + total_assets, available_cash, current_day_trade_amount + ) + if position_size < signal.entry_price: + continue # ์ตœ์†Œ 1์ฃผ๋„ ์‚ด ์ˆ˜ ์—†์œผ๋ฉด ์Šคํ‚ต + + quantity = max(1, int(position_size // signal.entry_price)) + results.append({"signal": signal, "quantity": quantity}) + logger.info( + f"๐ŸŽฏ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์‹ ํ˜ธ [{code}] {signal.strategy}: " + f"{signal.reason}, ์ˆ˜๋Ÿ‰={quantity}" + ) + + except Exception as e: + logger.warning(f"์‹ ํ˜ธ ์Šค์บ” ์˜ค๋ฅ˜ [{code}]: {e}") + await asyncio.sleep(0.6) + + # confidence ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ + results.sort(key=lambda x: x["signal"].confidence, reverse=True) + return results[:MAX_POSITIONS - len(_positions)] + + +async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict]: + """์ง„์ž… ์ฃผ๋ฌธ ์‹คํ–‰ ๋ฐ ํฌ์ง€์…˜ ๋“ฑ๋ก""" + global _trade_lock + async with _trade_lock: + if len(_positions) >= MAX_POSITIONS: + return None + if signal.stock_code in _positions: + return None + + order_no = await _place_buy_order( + signal.stock_code, quantity, int(signal.entry_price) + ) + if not order_no: + return None + + _positions[signal.stock_code] = { + "stock_name": signal.stock_name, + "strategy": signal.strategy, + "entry_price": signal.entry_price, + "stop_loss": signal.stop_loss, + "target_price": signal.target_price, + "quantity": quantity, + "highest_price": signal.entry_price, + "entry_time": _kst_now().strftime("%H:%M:%S"), + "reason": signal.reason, + } + + async with AsyncSessionLocal() as db: + await _record_trade( + db, signal.stock_code, signal.stock_name, "BUY", + signal.entry_price, quantity, signal.reason, + ) + + logger.info( + f"๐Ÿ›’ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ง„์ž… [{signal.stock_code}] " + f"{signal.strategy}: {signal.entry_price:.0f}์› ร— {quantity}์ฃผ" + ) + await websocket_manager.broadcast(_dumps({ + "type": "day_trade_entry", + "stock_code": signal.stock_code, + "strategy": signal.strategy, + "entry_price": signal.entry_price, + "quantity": quantity, + })) + return { + "stock_code": signal.stock_code, + "strategy": signal.strategy, + "entry_price": signal.entry_price, + "quantity": quantity, + "reason": signal.reason, + } + + +async def _execute_eod_flat() -> None: + """EOD ์ „์ฒด ํฌ์ง€์…˜ ๊ฐ•์ œ ์ฒญ์‚ฐ""" + global _daily_pnl + for code, pos in list(_positions.items()): + try: + current_price = await _get_current_price(code) + ok = await _place_sell_order(code, pos["quantity"], price=0) + if ok: + pnl = (current_price - pos["entry_price"]) * pos["quantity"] + _daily_pnl += pnl + del _positions[code] + logger.info(f"๐Ÿ”” EOD ๊ฐ•์ œ ์ฒญ์‚ฐ [{code}]: PNL={pnl:+,.0f}์›") + except Exception as e: + logger.error(f"EOD ์ฒญ์‚ฐ ์˜ค๋ฅ˜ [{code}]: {e}") + await asyncio.sleep(0.6) + + +# โ”€โ”€ API ์—”๋“œํฌ์ธํŠธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@router.get("/watchlist") +async def get_watchlist(): + """๊ฐ์‹œ ์ข…๋ชฉ ๋ชฉ๋ก ์กฐํšŒ""" + _reset_if_new_day() + return { + "watchlist": [ + {"code": k, "name": v, "has_orb": k in _orb_cache} + for k, v in _watchlist.items() + ], + "count": len(_watchlist), + } + + +@router.post("/watchlist") +async def set_watchlist(req: WatchlistRequest): + """๊ฐ์‹œ ์ข…๋ชฉ ์„ค์ • (์ตœ๋Œ€ 20์ข…๋ชฉ) + + body: {"stocks": [{"code": "005930", "name": "์‚ผ์„ฑ์ „์ž"}, ...]} + """ + global _watchlist + if len(req.stocks) > 20: + raise HTTPException(400, "๊ฐ์‹œ ์ข…๋ชฉ์€ ์ตœ๋Œ€ 20์ข…๋ชฉ๊นŒ์ง€๋งŒ ์„ค์ • ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + + _watchlist = {s["code"]: s.get("name", s["code"]) for s in req.stocks} + logger.info(f"๐Ÿ“‹ ๊ฐ์‹œ ์ข…๋ชฉ ์„ค์ •: {list(_watchlist.keys())}") + return {"status": "ok", "watchlist": list(_watchlist.keys())} + + +@router.get("/signals") +async def get_current_signals(): + """ํ˜„์žฌ ์ง„์ž… ๊ฐ€๋Šฅ ์‹ ํ˜ธ ์กฐํšŒ (์ฃผ๋ฌธ ๋ฏธ์‹คํ–‰)""" + _reset_if_new_day() + if not is_day_trade_entry_window(): + return { + "phase": "outside_window", + "message": "๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ง„์ž… ์‹œ๊ฐ„์ด ์•„๋‹™๋‹ˆ๋‹ค (09:30~15:00).", + "signals": [], + } + + portfolio = await _get_portfolio_summary() + total_assets = portfolio["total_assets"] + available_cash = portfolio["available_cash"] + current_day_trade_amount = sum( + p["entry_price"] * p["quantity"] for p in _positions.values() + ) + + signals = await _scan_signals(total_assets, available_cash, current_day_trade_amount) + return { + "phase": "active", + "signals": [ + { + "stock_code": s["signal"].stock_code, + "stock_name": s["signal"].stock_name, + "strategy": s["signal"].strategy, + "entry_price": s["signal"].entry_price, + "stop_loss": s["signal"].stop_loss, + "target_price": s["signal"].target_price, + "reason": s["signal"].reason, + "confidence": s["signal"].confidence, + "quantity": s["quantity"], + } + for s in signals + ], + "count": len(signals), + } + + +@router.post("/execute") +async def execute_day_trade(req: ExecuteRequest): + """ํŠน์ • ์ข…๋ชฉ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์‹ ํ˜ธ ์Šค์บ” ํ›„ ์ง„์ž…""" + _reset_if_new_day() + code = req.stock_code + name = _watchlist.get(code, code) + + if code in _positions: + raise HTTPException(400, f"{code}๋Š” ์ด๋ฏธ ํฌ์ง€์…˜์ด ์žˆ์Šต๋‹ˆ๋‹ค.") + if len(_positions) >= MAX_POSITIONS: + raise HTTPException(400, f"์ตœ๋Œ€ ํฌ์ง€์…˜ ์ˆ˜({MAX_POSITIONS}์ข…๋ชฉ)์— ๋„๋‹ฌํ–ˆ์Šต๋‹ˆ๋‹ค.") + if not is_day_trade_entry_window(): + raise HTTPException(400, "๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ง„์ž… ์‹œ๊ฐ„์ด ์•„๋‹™๋‹ˆ๋‹ค (09:30~15:00).") + + portfolio = await _get_portfolio_summary() + total_assets = portfolio["total_assets"] + available_cash = portfolio["available_cash"] + + if is_daily_loss_exceeded(total_assets, _daily_pnl): + raise HTTPException(400, "์ผ์ผ ์†์‹ค ํ•œ๋„(-3%)์— ๋„๋‹ฌํ•˜์—ฌ ์‹ ๊ทœ ์ง„์ž…์ด ์ฐจ๋‹จ๋ฉ๋‹ˆ๋‹ค.") + + candles_1m = await _fetch_candles(code) + candles_5m = aggregate_to_5min(candles_1m) + current_price = candles_1m[-1].close if candles_1m else 0 + vwap = calculate_vwap(candles_1m) + orb = _orb_cache.get(code) + + signal = None + if orb: + signal = check_orb_signal(code, name, candles_1m, orb, current_price) + if signal is None and vwap: + signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price) + if signal is None: + signal = check_rsi_bb_signal(code, name, candles_5m, current_price) + + if not signal: + return {"status": "no_signal", "message": "ํ˜„์žฌ ์ง„์ž… ์กฐ๊ฑด์„ ์ถฉ์กฑํ•˜๋Š” ์‹ ํ˜ธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."} + + current_day_trade_amount = sum( + p["entry_price"] * p["quantity"] for p in _positions.values() + ) + position_size = calculate_day_trade_position_size( + total_assets, available_cash, current_day_trade_amount + ) + quantity = max(1, int(position_size // signal.entry_price)) + + if req.dry_run: + return { + "status": "dry_run", + "signal": { + "strategy": signal.strategy, + "entry_price": signal.entry_price, + "stop_loss": signal.stop_loss, + "target_price": signal.target_price, + "reason": signal.reason, + "quantity": quantity, + }, + } + + entry = await _execute_entry(signal, quantity) + if not entry: + raise HTTPException(500, "์ฃผ๋ฌธ ์‹คํ–‰์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return {"status": "ok", "entry": entry} + + +@router.get("/positions") +async def get_positions(): + """ํ™œ์„ฑ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ํฌ์ง€์…˜ ์กฐํšŒ""" + _reset_if_new_day() + result = [] + for code, pos in _positions.items(): + try: + current_price = await _get_current_price(code) + except Exception: + current_price = pos["entry_price"] + pnl = (current_price - pos["entry_price"]) * pos["quantity"] + pnl_pct = (current_price - pos["entry_price"]) / pos["entry_price"] * 100 + result.append({ + "stock_code": code, + "stock_name": pos["stock_name"], + "strategy": pos["strategy"], + "entry_price": pos["entry_price"], + "current_price": current_price, + "stop_loss": pos["stop_loss"], + "target_price": pos["target_price"], + "quantity": pos["quantity"], + "pnl": round(pnl, 0), + "pnl_pct": round(pnl_pct, 2), + "entry_time": pos["entry_time"], + }) + return { + "positions": result, + "count": len(result), + "daily_pnl": round(_daily_pnl, 0), + "is_daily_loss_exceeded": is_daily_loss_exceeded( + sum(p["entry_price"] * p["quantity"] for p in _positions.values()), + _daily_pnl, + ), + } + + +@router.post("/close-all") +async def close_all_positions(): + """์ „์ฒด ํฌ์ง€์…˜ ์ˆ˜๋™ ๊ฐ•์ œ ์ฒญ์‚ฐ (EOD ๋Œ€๋น„ ์ˆ˜๋™ ์‹คํ–‰)""" + _reset_if_new_day() + await _execute_eod_flat() + return { + "status": "ok", + "message": "์ „์ฒด ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ํฌ์ง€์…˜ ์ฒญ์‚ฐ ์™„๋ฃŒ", + "daily_pnl": round(_daily_pnl, 0), + } + + +@router.post("/cycle") +async def manual_cycle(): + """1๋ถ„ ์‚ฌ์ดํด ์ˆ˜๋™ ์‹คํ–‰ (ํ…Œ์ŠคํŠธ/๋””๋ฒ„๊ทธ์šฉ)""" + _reset_if_new_day() + result = await run_day_trading_cycle() + return result + + +@router.get("/status") +async def get_status(): + """๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์‹œ์Šคํ…œ ์ƒํƒœ ์š”์•ฝ""" + _reset_if_new_day() + return { + "watchlist_count": len(_watchlist), + "active_positions": len(_positions), + "orb_collected": len(_orb_cache), + "daily_pnl": round(_daily_pnl, 0), + "is_market_open": is_market_open(), + "is_opening_range": is_opening_range_period(), + "is_entry_window": is_day_trade_entry_window(), + "is_eod_flat": is_eod_flat_time(), + "today": _today_kst().isoformat(), + } diff --git a/backend/app/api/endpoints/debug.py b/backend/app/api/endpoints/debug.py new file mode 100644 index 0000000..3a61fa1 --- /dev/null +++ b/backend/app/api/endpoints/debug.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter +from app.core.config import settings +from app.core.market_schedule import is_market_open +from datetime import datetime +import pytz + +router = APIRouter() + +@router.get("/debug/autotrade-status") +async def get_autotrade_status(): + """์ž๋™๋งค๋งค ์ƒํƒœ ์ง„๋‹จ""" + KST = pytz.timezone('Asia/Seoul') + now = datetime.now(KST) + market_open = is_market_open() + + return { + "timestamp": now.isoformat(), + "current_time_kst": now.strftime('%Y-%m-%d %H:%M:%S %A'), + "weekday": now.weekday(), + "current_minutes": now.hour * 60 + now.minute, + "market_hours": "540-930 (09:00-15:30)", + "market_open": market_open, + "auto_trade_enabled": settings.AUTO_TRADE_ENABLED, + "can_trade": market_open and settings.AUTO_TRADE_ENABLED, + "settings": { + "stop_loss_percent": settings.STOP_LOSS_PERCENT, + "take_profit_percent": settings.TAKE_PROFIT_PERCENT, + "max_daily_trades": settings.MAX_DAILY_TRADES, + "trend_filter_enabled": settings.TREND_FILTER_ENABLED, + }, + "api_configured": bool(settings.KOREA_INVESTMENT_APP_KEY), + "diagnosis": { + "market_status": "OPEN - ์ž๋™๋งค๋งค ๊ฐ€๋Šฅ" if market_open else "CLOSED - ์žฅ ๋งˆ๊ฐ", + "config_status": "ENABLED" if settings.AUTO_TRADE_ENABLED else "DISABLED", + "api_status": "CONFIGURED" if settings.KOREA_INVESTMENT_APP_KEY else "NOT CONFIGURED", + } + } diff --git a/backend/app/api/endpoints/journals.py b/backend/app/api/endpoints/journals.py new file mode 100644 index 0000000..8187420 --- /dev/null +++ b/backend/app/api/endpoints/journals.py @@ -0,0 +1,78 @@ +""" +๋งค๋งค์ผ์ง€ API ์—”๋“œํฌ์ธํŠธ + +- GET /: ์ตœ๊ทผ AI ์ƒ์„ฑ ๋งค๋งค์ผ์ง€ ๋ชฉ๋ก +- GET /{journal_id}: ํŠน์ • ์ผ์ง€ ์ƒ์„ธ +""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc +from typing import Optional +from datetime import datetime +import logging + +from app.core.database import get_db +from app.models.models import TradeJournal + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("") +async def list_journals( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + stock_code: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + """์ตœ๊ทผ AI ์ƒ์„ฑ ๋งค๋งค์ผ์ง€ ๋ชฉ๋ก ์กฐํšŒ""" + try: + query = select(TradeJournal).order_by(desc(TradeJournal.journal_date)) + if stock_code: + query = query.where(TradeJournal.stock_code == stock_code) + query = query.offset(offset).limit(limit) + + result = await db.execute(query) + journals = result.scalars().all() + + return [ + { + "id": j.id, + "trade_id": j.trade_id, + "stock_code": j.stock_code, + "stock_name": j.stock_name, + "journal_date": j.journal_date.isoformat() if j.journal_date else None, + "content": j.content, + "strategy_name": j.strategy_name, + "mood": j.mood, + "tags": j.tags, + "created_at": j.created_at.isoformat() if j.created_at else None, + } + for j in journals + ] + except Exception as e: + logger.error(f"journals list error: {e}", exc_info=True) + return [] + + +@router.get("/{journal_id}") +async def get_journal(journal_id: int, db: AsyncSession = Depends(get_db)): + """ํŠน์ • ๋งค๋งค์ผ์ง€ ์ƒ์„ธ ์กฐํšŒ""" + result = await db.execute(select(TradeJournal).where(TradeJournal.id == journal_id)) + journal = result.scalar_one_or_none() + if not journal: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Journal not found") + return { + "id": journal.id, + "trade_id": journal.trade_id, + "stock_code": journal.stock_code, + "stock_name": journal.stock_name, + "journal_date": journal.journal_date.isoformat() if journal.journal_date else None, + "content": journal.content, + "strategy_name": journal.strategy_name, + "mood": journal.mood, + "tags": journal.tags, + "created_at": journal.created_at.isoformat() if journal.created_at else None, + } diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py index d58772a..16640b4 100644 --- a/backend/app/api/endpoints/market_data.py +++ b/backend/app/api/endpoints/market_data.py @@ -1,17 +1,34 @@ +""" +์‹œ์„ธ ๋ฐ์ดํ„ฐ API ์—”๋“œํฌ์ธํŠธ + +- GET /price/{stock_code}: ์‹ค์‹œ๊ฐ„ ํ˜„์žฌ๊ฐ€ ์กฐํšŒ +- GET /chart/{stock_code}: ์ฐจํŠธ ๋ฐ์ดํ„ฐ (์ผ๋ด‰/์ฃผ๋ด‰/์›”๋ด‰) +- GET /orderbook/{stock_code}: ํ˜ธ๊ฐ€ ์ •๋ณด ์กฐํšŒ +- GET /ai-analysis/{stock_code}: AI ๊ธฐ๋ฐ˜ ์ข…๋ชฉ ๋ถ„์„ +""" +# ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋ฐ ๋ชจ๋“ˆ ์ž„ํฌํŠธ from datetime import datetime, timedelta import random import logging -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Depends +from typing import List, Dict from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +# ๋‚ด๋ถ€ ๋ชจ๋“ˆ ์ž„ํฌํŠธ from app.core.config import settings +from app.core.database import get_db from app.services.korea_investment_api import korea_investment_api +# APIRouter ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ router = APIRouter() +# ๋กœ๊ฑฐ ์„ค์ • logger = logging.getLogger(__name__) +# ์ฃผ์‹ ๊ฐ€๊ฒฉ ์‘๋‹ต์„ ์œ„ํ•œ Pydantic ๋ชจ๋ธ ์ •์˜ class StockPriceResponse(BaseModel): stock_code: str current_price: float @@ -24,15 +41,18 @@ class StockPriceResponse(BaseModel): @router.get("/price/{stock_code}") async def get_stock_price(stock_code: str): - """์ฃผ์‹ ํ˜„์žฌ๊ฐ€ ์กฐํšŒ""" + """์ฃผ์‹ ํ˜„์žฌ๊ฐ€ ์กฐํšŒ ์—”๋“œํฌ์ธํŠธ""" try: + # ํ•œ๊ตญํˆฌ์ž์ฆ๊ถŒ API๋ฅผ ํ†ตํ•ด ํ˜„์žฌ๊ฐ€ ๋ฐ์ดํ„ฐ ์กฐํšŒ data = await korea_investment_api.get_current_price(stock_code) + # API ์‘๋‹ต์— 'output' ํ•„๋“œ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ if "output" not in data: raise HTTPException(status_code=404, detail="Stock data not found") output = data["output"] + # ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”์ถœํ•˜์—ฌ ๋”•์…”๋„ˆ๋ฆฌ ํ˜•ํƒœ๋กœ ๋ฐ˜ํ™˜ return { "stock_code": stock_code, "current_price": float(output.get("stck_prpr", 0)), @@ -44,59 +64,115 @@ async def get_stock_price(stock_code: str): } except Exception as e: + # ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ 500 ์—๋Ÿฌ์™€ ํ•จ๊ป˜ ์ƒ์„ธ ๋‚ด์šฉ ๋ฐ˜ํ™˜ raise HTTPException(status_code=500, detail=f"Failed to fetch stock price: {str(e)}") -def _generate_sample_chart(stock_code: str, period: str) -> dict: - """๋กœ์ปฌ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์„ ์œ„ํ•œ ์ƒ˜ํ”Œ ์ฐจํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑ""" +@router.get("/prices") +async def get_stock_prices(codes: str): + """์—ฌ๋Ÿฌ ์ข…๋ชฉ์˜ ํ˜„์žฌ๊ฐ€๋ฅผ ์ผ๊ด„ ์กฐํšŒํ•˜๋Š” ์—”๋“œํฌ์ธํŠธ + + Query: + - codes: ์ฝค๋งˆ๋กœ ๊ตฌ๋ถ„๋œ ์ข…๋ชฉ์ฝ”๋“œ๋“ค (์˜ˆ: 005930,000660,035420) + """ + try: + # codes ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ๋นˆ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ + if not codes: + return {"prices": {}, "count": 0, "timestamp": datetime.now().isoformat()} + + # ์ฝค๋งˆ๋กœ ๊ตฌ๋ถ„๋œ ์ข…๋ชฉ ์ฝ”๋“œ ๋ฌธ์ž์—ด์„ ๋ฆฌ์ŠคํŠธ๋กœ ๋ณ€ํ™˜ + code_list: List[str] = [c.strip() for c in codes.split(",") if c.strip()] + prices: Dict[str, float] = {} + + # ๊ฐ ์ข…๋ชฉ ์ฝ”๋“œ์— ๋Œ€ํ•ด ์ˆœ์ฐจ์ ์œผ๋กœ ํ˜„์žฌ๊ฐ€ ์กฐํšŒ + # ํด๋ž˜์Šค ๋‚ด๋ถ€์— API ํ˜ธ์ถœ ๊ฐ„๊ฒฉ์„ ์กฐ์ ˆํ•˜๋Š” ์“ฐ๋กœํ‹€๋ง ๊ธฐ๋Šฅ์ด ๋‚ด์žฅ๋˜์–ด ์žˆ์Œ + for code in code_list: + try: + data = await korea_investment_api.get_current_price(code) + if data and data.get("output"): + o = data["output"] + # ์ฝค๋งˆ๊ฐ€ ํฌํ•จ๋œ ๊ฐ€๊ฒฉ ๋ฌธ์ž์—ด์„ float์œผ๋กœ ๋ณ€ํ™˜ + current_price = float(str(o.get("stck_prpr", "0")).replace(",", "")) + prices[code] = current_price + except Exception as e: + # ํŠน์ • ์ข…๋ชฉ ์กฐํšŒ ์‹คํŒจ ์‹œ ๊ฒฝ๊ณ  ๋กœ๊ทธ ๊ธฐ๋ก + logger.warning(f"Failed to fetch price for {code}: {e}") + + # ์กฐํšŒ ๊ฒฐ๊ณผ๋ฅผ ๋”•์…”๋„ˆ๋ฆฌ ํ˜•ํƒœ๋กœ ๋ฐ˜ํ™˜ + return { + "prices": prices, + "count": len(prices), + "requested": len(code_list), + "timestamp": datetime.now().isoformat(), + } + except Exception as e: + # ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ 500 ์—๋Ÿฌ์™€ ํ•จ๊ป˜ ์ƒ์„ธ ๋‚ด์šฉ ๋ฐ˜ํ™˜ + raise HTTPException(status_code=500, detail=f"Failed to fetch prices: {str(e)}") + + +def _generate_sample_chart(stock_code: str, period: str, limit: int = 100) -> dict: + """๋กœ์ปฌ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๋ฐ API ํ‚ค๊ฐ€ ์—†์„ ๋•Œ ์‚ฌ์šฉํ•  ์ƒ˜ํ”Œ ์ฐจํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ํ•จ์ˆ˜""" + # ์œ ํšจํ•œ ์ฃผ๊ธฐ(period)์ธ์ง€ ํ™•์ธ, ์•„๋‹ˆ๋ฉด 'D'(์ผ๋ด‰)์œผ๋กœ ์„ค์ • if period not in {"D", "W", "M"}: period = "D" + # ์ฃผ๊ธฐ์— ๋”ฐ๋ผ ๋ฐ์ดํ„ฐ ํฌ์ธํŠธ ์ˆ˜์™€ ๋‚ ์งœ ๊ฐ„๊ฒฉ ์„ค์ • if period == "D": - points = 60 + points = min(limit, 240) # ์ผ๋ด‰์€ ์ตœ๋Œ€ 240๊ฐœ step = 1 elif period == "W": - points = 52 + points = min(limit, 240) # ์ฃผ๋ด‰๋„ ์ตœ๋Œ€ 240๊ฐœ step = 7 - else: # "M" - points = 36 + else: # "M" (์›”๋ด‰) + points = min(limit, 240) # ์›”๋ด‰๋„ ์ตœ๋Œ€ 240๊ฐœ step = 30 + # ์ฐจํŠธ ๋ฐ์ดํ„ฐ์˜ ์‹œ์ž‘ ๋‚ ์งœ ๊ณ„์‚ฐ today = datetime.now() start_date = today - timedelta(days=(points - 1) * step) + # ์ข…๋ชฉ์ฝ”๋“œ์™€ ์ฃผ๊ธฐ์— ๊ธฐ๋ฐ˜ํ•˜์—ฌ ์ผ๊ด€๋œ ๋žœ๋ค ๋ฐ์ดํ„ฐ ์ƒ์„ฑ์„ ์œ„ํ•œ ์‹œ๋“œ ์„ค์ • rng = random.Random(f"{stock_code}:{period}") + # ๋žœ๋ค ๊ฐ€๊ฒฉ ๋ฐ ๋ณ€๋™์„ฑ ํŒŒ๋ผ๋ฏธํ„ฐ ์ดˆ๊ธฐํ™” base_price = rng.randint(30000, 90000) drift = rng.uniform(-0.04, 0.06) volatility = rng.uniform(0.004, 0.018) volume_base = rng.randint(80000, 180000) + # ๊ฐ€๊ฒฉ์„ 10๋‹จ์œ„๋กœ ๋ฐ˜์˜ฌ๋ฆผํ•˜๋Š” ํ—ฌํผ ํ•จ์ˆ˜ def round_price(value: float) -> int: return max(1000, int(round(value / 10)) * 10) price = float(base_price) chart_data = [] + # ์„ค์ •๋œ ํฌ์ธํŠธ ์ˆ˜๋งŒํผ ๋ฐ˜๋ณตํ•˜์—ฌ ์บ”๋“ค ๋ฐ์ดํ„ฐ ์ƒ์„ฑ for idx in range(points): current_date = start_date + timedelta(days=idx * step) + # ์ผ์ผ ๊ฐ€๊ฒฉ ๋ณ€๋™๋ฅ  ๊ณ„์‚ฐ daily_change = drift / points + rng.uniform(-volatility, volatility) + # ์‹œ๊ฐ€, ์ข…๊ฐ€, ๊ณ ๊ฐ€, ์ €๊ฐ€ ์ƒ์„ฑ open_price = price close_price = max(1000.0, open_price * (1 + daily_change)) high_price = max(open_price, close_price) * (1 + rng.uniform(0, volatility * 1.8)) low_price = min(open_price, close_price) * (1 - rng.uniform(0, volatility * 1.8)) + # ์ €๊ฐ€๊ฐ€ 0 ์ดํ•˜๋กœ ๋‚ด๋ ค๊ฐ€์ง€ ์•Š๋„๋ก ๋ณด์ • if low_price <= 0: low_price = min(open_price, close_price) * 0.98 + # ๊ฑฐ๋ž˜๋Ÿ‰ ์ƒ์„ฑ volume = int(volume_base * (1 + rng.uniform(-0.35, 0.35))) + # ๊ฐ€๊ฒฉ ๋ฐ˜์˜ฌ๋ฆผ ์ฒ˜๋ฆฌ open_value = round_price(open_price) close_value = round_price(close_price) high_value = max(round_price(high_price), open_value, close_value) low_value = min(round_price(low_price), open_value, close_value) + # ์ƒ์„ฑ๋œ ์บ”๋“ค ๋ฐ์ดํ„ฐ๋ฅผ ๋ฆฌ์ŠคํŠธ์— ์ถ”๊ฐ€ chart_data.append({ "date": current_date.strftime("%Y%m%d"), "open": float(open_value), @@ -106,8 +182,10 @@ def _generate_sample_chart(stock_code: str, period: str) -> dict: "volume": max(1000, volume), }) + # ๋‹ค์Œ ์บ”๋“ค์˜ ์‹œ๊ฐ€๋ฅผ ์œ„ํ•ด ํ˜„์žฌ ์ข…๊ฐ€ ์ €์žฅ price = close_price + # ์ตœ์ข… ์ƒ˜ํ”Œ ์ฐจํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๋”•์…”๋„ˆ๋ฆฌ ํ˜•ํƒœ๋กœ ๋ฐ˜ํ™˜ return { "stock_code": stock_code, "period": period, @@ -116,63 +194,99 @@ def _generate_sample_chart(stock_code: str, period: str) -> dict: } +# ์ฐจํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ์œ„ํ•œ ๊ฐ„๋‹จํ•œ ์ธ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ ๋”•์…”๋„ˆ๋ฆฌ +chart_cache = {} + @router.get("/chart/{stock_code}") async def get_stock_chart( stock_code: str, period: str = "D", - realtime: bool = Query(False, description="์ตœ์‹  ํ˜„์žฌ๊ฐ€๋ฅผ ํฌํ•จํ•˜์—ฌ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ณด์ •๋œ ๋งˆ์ง€๋ง‰ ์บ”๋“ค ๋ฐ˜ํ™˜") + realtime: bool = Query(False, description="์ตœ์‹  ํ˜„์žฌ๊ฐ€๋ฅผ ํฌํ•จํ•˜์—ฌ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ณด์ •๋œ ๋งˆ์ง€๋ง‰ ์บ”๋“ค ๋ฐ˜ํ™˜"), + limit: int = Query(100, description="์กฐํšŒํ•  ๋ฐ์ดํ„ฐ ๊ฐœ์ˆ˜ (๊ธฐ๋ณธ: 100, ์ตœ๋Œ€: 240)") ): - """์ฃผ์‹ ์ฐจํŠธ ๋ฐ์ดํ„ฐ ์กฐํšŒ - + """์ฃผ์‹ ์ฐจํŠธ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์—”๋“œํฌ์ธํŠธ (์บ์‹ฑ ์ ์šฉ) + Args: stock_code: ์ข…๋ชฉ ์ฝ”๋“œ period: D(์ผ๋ด‰), W(์ฃผ๋ด‰), M(์›”๋ด‰) + limit: ์กฐํšŒํ•  ๋ฐ์ดํ„ฐ ๊ฐœ์ˆ˜ (์ผ๋ด‰ 100๊ฐœ๋Š” ์•ฝ 3๊ฐœ์›”์น˜ ๋ฐ์ดํ„ฐ) """ try: - if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET: - return _generate_sample_chart(stock_code, period) - - data = await korea_investment_api.get_daily_chart(stock_code, period) - - if "output" not in data: - raise HTTPException(status_code=404, detail="Chart data not found") - + # ์บ์‹œ ํ‚ค ์ƒ์„ฑ (์˜ˆ: "005930__D") + cache_key = f"{stock_code}__{period}" + current_time = datetime.now() + + # ์บ์‹œ์—์„œ ๋ฐ์ดํ„ฐ ์กฐํšŒ + cached_data = chart_cache.get(cache_key) + use_cache = False chart_data = [] - for item in data["output"]: - # KIS API ๊ฐ€๊ฒฉ ๋ฐ์ดํ„ฐ ์•ˆ์ „ํ•˜๊ฒŒ ํŒŒ์‹ฑ - try: - close_str = str(item.get("stck_clpr", "0")) - open_str = str(item.get("stck_oprc", "0")) - high_str = str(item.get("stck_hgpr", "0")) - low_str = str(item.get("stck_lwpr", "0")) - - # ์†Œ์ˆ˜์ ์ด ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉด ์ œ๊ฑฐํ•˜๊ณ  ์ •์ˆ˜๋กœ ๋ณ€ํ™˜ - close_val = float(close_str.replace(",", "")) - open_val = float(open_str.replace(",", "")) - high_val = float(high_str.replace(",", "")) - low_val = float(low_str.replace(",", "")) - - chart_data.append({ - "date": item.get("stck_bsop_date"), - "open": open_val, - "high": high_val, - "low": low_val, - "close": close_val, - "volume": int(item.get("acml_vol", 0)) - }) - except (ValueError, TypeError) as e: - logger.error(f"๊ฐ€๊ฒฉ ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {item} - {e}") - continue + + # ์บ์‹œ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๊ณ , 10๋ถ„(600์ดˆ) ์ด๋‚ด์— ์ƒ์„ฑ๋œ ๊ฒฝ์šฐ ์บ์‹œ ์‚ฌ์šฉ + if cached_data: + last_updated = cached_data.get("timestamp") + if last_updated and (current_time - last_updated).total_seconds() < 600: + chart_data = list(cached_data.get("data", [])) # ์›๋ณธ ์ˆ˜์ •์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ๋ฐ์ดํ„ฐ ๋ณต์‚ฌ + use_cache = True + # logger.debug(f"Chart cache hit for {stock_code}") - # KIS API๋Š” ์ตœ์‹  ๋ฐ์ดํ„ฐ๊ฐ€ ๊ฐ€์žฅ ๋จผ์ € ์˜ฌ ์ˆ˜ ์žˆ์Œ โ†’ ๊ทธ๋Œ€๋กœ ๋‘๊ณ  ํ”„๋ก ํŠธ์—์„œ reverse ์ฒ˜๋ฆฌ + # ์บ์‹œ๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ (์บ์‹œ๊ฐ€ ์—†๊ฑฐ๋‚˜ ๋งŒ๋ฃŒ๋จ) + if not use_cache: + # API ํ‚ค๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET: + return _generate_sample_chart(stock_code, period, limit) + # ํ•œ๊ตญํˆฌ์ž์ฆ๊ถŒ API๋ฅผ ํ†ตํ•ด ์ฐจํŠธ ๋ฐ์ดํ„ฐ ์กฐํšŒ + data = await korea_investment_api.get_daily_chart(stock_code, period, limit=limit) + + if "output2" not in data: + raise HTTPException(status_code=404, detail="Chart data not found") + + chart_data = [] # ๋ฐ์ดํ„ฐ ๋ฆฌ์ŠคํŠธ ์ดˆ๊ธฐํ™” + for item in data["output2"]: + # KIS API ์‘๋‹ต์—์„œ ๊ฐ€๊ฒฉ ๋ฐ์ดํ„ฐ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ํŒŒ์‹ฑ + try: + # ์ฝค๋งˆ๊ฐ€ ํฌํ•จ๋  ์ˆ˜ ์žˆ๋Š” ๋ฌธ์ž์—ด ํ˜•ํƒœ์˜ ๊ฐ€๊ฒฉ์„ ๊ฐ€์ ธ์˜ด + close_str = str(item.get("stck_clpr", "0")) + open_str = str(item.get("stck_oprc", "0")) + high_str = str(item.get("stck_hgpr", "0")) + low_str = str(item.get("stck_lwpr", "0")) + + # ์ฝค๋งˆ๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  float์œผ๋กœ ๋ณ€ํ™˜ + close_val = float(close_str.replace(",", "")) + open_val = float(open_str.replace(",", "")) + high_val = float(high_str.replace(",", "")) + low_val = float(low_str.replace(",", "")) + + # ํŒŒ์‹ฑ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋”•์…”๋„ˆ๋ฆฌ ํ˜•ํƒœ๋กœ ๋ฆฌ์ŠคํŠธ์— ์ถ”๊ฐ€ + chart_data.append({ + "date": item.get("stck_bsop_date"), + "open": open_val, + "high": high_val, + "low": low_val, + "close": close_val, + "volume": int(item.get("acml_vol", 0)) + }) + except (ValueError, TypeError) as e: + # ๊ฐ€๊ฒฉ ํŒŒ์‹ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ๋กœ๊ทธ ๊ธฐ๋กํ•˜๊ณ  ํ•ด๋‹น ๋ฐ์ดํ„ฐ๋Š” ๊ฑด๋„ˆ๋œ€ + logger.error(f"๊ฐ€๊ฒฉ ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {item} - {e}") + continue + + # ์กฐํšŒํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์บ์‹œ์— ์ €์žฅ + chart_cache[cache_key] = { + "timestamp": current_time, + "data": chart_data + } + + # KIS API๋Š” ์ตœ์‹  ๋ฐ์ดํ„ฐ๊ฐ€ ๊ฐ€์žฅ ๋จผ์ € ์˜ค๋Š” ๊ฒฝํ–ฅ์ด ์žˆ์œผ๋ฏ€๋กœ, ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๋‚ ์งœ์ˆœ์œผ๋กœ ์ •๋ ฌ(reverse)ํ•˜์—ฌ ์‚ฌ์šฉ + latest_candle = None + # 'realtime' ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ True์ธ ๊ฒฝ์šฐ, ํ˜„์žฌ๊ฐ€๋ฅผ ๋ฐ˜์˜ํ•œ ์‹ค์‹œ๊ฐ„ ์บ”๋“ค ์ƒ์„ฑ if realtime: try: price_data = await korea_investment_api.get_current_price(stock_code) if price_data and price_data.get("output"): o = price_data["output"] - # ํ˜„์žฌ๊ฐ€๋ฅผ ๋งˆ์ง€๋ง‰(์ตœ์‹ ) ์บ”๋“ค๋กœ ๋ณด์ • (์ง€ํ‘œ ๊ณ„์‚ฐ ์‹œ ์˜ํ–ฅ ์ตœ์†Œํ™” ์œ„ํ•ด ๊ธฐ์กด ์ฒซ ์บ”๋“ค๊ณผ ๊ฐ€๊ฒฉ ๋‹ค๋ฅด๋ฉด prepend) + # ํ˜„์žฌ๊ฐ€, ๊ณ ๊ฐ€, ์ €๊ฐ€, ์‹œ๊ฐ€ ๋“ฑ์˜ ์ •๋ณด๋ฅผ ํŒŒ์‹ฑํ•˜์—ฌ ์‹ค์‹œ๊ฐ„ ์บ”๋“ค ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ current_price = float(str(o.get("stck_prpr", "0")).replace(",", "")) high_price = float(str(o.get("stck_hgpr", "0")).replace(",", "")) low_price = float(str(o.get("stck_lwpr", "0")).replace(",", "")) @@ -188,14 +302,16 @@ async def get_stock_chart( "volume": volume, "realtime": True } - # ๊ธฐ์กด ์ฒซ ๋ฒˆ์งธ ์บ”๋“ค์˜ ๋‚ ์งœ์™€ ๊ฐ™์œผ๋ฉด ๊ต์ฒด, ๋‹ค๋ฅด๋ฉด ๋งจ ์•ž์— ์ถ”๊ฐ€ + # ๊ธฐ์กด ์ฐจํŠธ ๋ฐ์ดํ„ฐ์˜ ์ฒซ ๋ฒˆ์งธ(๊ฐ€์žฅ ์ตœ์‹ ) ์บ”๋“ค์ด ์˜ค๋Š˜ ๋‚ ์งœ์™€ ๊ฐ™์œผ๋ฉด ๊ต์ฒด, ์•„๋‹ˆ๋ฉด ๋งจ ์•ž์— ์ถ”๊ฐ€ if chart_data and chart_data[0]["date"] == latest_candle["date"]: chart_data[0] = latest_candle else: chart_data.insert(0, latest_candle) except Exception as e: + # ์‹ค์‹œ๊ฐ„ ์บ”๋“ค ์ƒ์„ฑ ์‹คํŒจ ์‹œ ๊ฒฝ๊ณ  ๋กœ๊ทธ ๊ธฐ๋ก logger.warning(f"์‹ค์‹œ๊ฐ„ ํ˜„์žฌ๊ฐ€ ์บ”๋“ค ์ƒ์„ฑ ์‹คํŒจ: {e}") + # ์ตœ์ข… ์ฐจํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๋”•์…”๋„ˆ๋ฆฌ ํ˜•ํƒœ๋กœ ๋ฐ˜ํ™˜ return { "stock_code": stock_code, "period": period, @@ -206,9 +322,9 @@ async def get_stock_chart( } except Exception as e: - # ์™ธ๋ถ€ API ์‹คํŒจ ์‹œ ๊ฐœ๋ฐœ ํŽธ์˜๋ฅผ ์œ„ํ•ด ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ ์ œ๊ณต (์ด์œ  ํฌํ•จ) + # API ํ˜ธ์ถœ ๋“ฑ์—์„œ ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ, ๊ฐœ๋ฐœ ํŽธ์˜๋ฅผ ์œ„ํ•ด ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ sample = _generate_sample_chart(stock_code, period) - sample["error"] = str(e) + sample["error"] = str(e) # ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ํฌํ•จ sample["is_sample"] = True sample["realtime"] = False sample["latest"] = None @@ -217,19 +333,19 @@ async def get_stock_chart( @router.get("/news") async def get_market_news(limit: int = 10): - """์‹œ์žฅ ๋‰ด์Šค ์กฐํšŒ (๋„ค์ด๋ฒ„ ๊ธˆ์œต RSS)""" - import feedparser + """์‹œ์žฅ ๋‰ด์Šค ์กฐํšŒ ์—”๋“œํฌ์ธํŠธ (Google News RSS ํ™œ์šฉ)""" + import feedparser # RSS ํŒŒ์‹ฑ์„ ์œ„ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ from datetime import datetime try: - # ๋„ค์ด๋ฒ„ ๊ธˆ์œต ์ฆ๊ถŒ ๋‰ด์Šค RSS - feed_url = "https://news.naver.com/main/list.naver?mode=LSD&mid=sec&sid1=101&listType=title" + # Google News์—์„œ 'ํ•œ๊ตญ ์ฆ์‹œ ์ฃผ์‹' ํ‚ค์›Œ๋“œ๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ์˜ RSS ์ฃผ์†Œ rss_url = "https://news.google.com/rss/search?q=ํ•œ๊ตญ+์ฆ์‹œ+์ฃผ์‹&hl=ko&gl=KR&ceid=KR:ko" # RSS ํ”ผ๋“œ ํŒŒ์‹ฑ feed = feedparser.parse(rss_url) news_list = [] + # ์กฐํšŒํ•  ๊ฐœ์ˆ˜(limit)๋งŒํผ ๋ฐ˜๋ณตํ•˜์—ฌ ๋‰ด์Šค ํ•ญ๋ชฉ ์ถ”์ถœ for idx, entry in enumerate(feed.entries[:limit]): news_list.append({ "id": idx + 1, @@ -243,7 +359,7 @@ async def get_market_news(limit: int = 10): return news_list except Exception as e: - # ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ + # ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ๋ฏธ๋ฆฌ ์ •์˜๋œ ์ƒ˜ํ”Œ ๋‰ด์Šค ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ return [ { "id": 1, @@ -270,3 +386,290 @@ async def get_market_news(limit: int = 10): "url": "#" } ][:limit] + + +@router.get("/orderbook/{stock_code}") +async def get_orderbook(stock_code: str): + """ํ˜ธ๊ฐ€ ์ •๋ณด ์กฐํšŒ ์—”๋“œํฌ์ธํŠธ (๋งค์ˆ˜/๋งค๋„ 10๋‹จ๊ณ„ ํ˜ธ๊ฐ€)""" + try: + logger.info(f"๐Ÿ” Fetching orderbook for stock: {stock_code}") + # ํ•œ๊ตญํˆฌ์ž์ฆ๊ถŒ API๋ฅผ ํ†ตํ•ด ํ˜ธ๊ฐ€ ๋ฐ์ดํ„ฐ ์กฐํšŒ + data = await korea_investment_api.get_orderbook(stock_code) + + # API ์‘๋‹ต ์ฝ”๋“œ๊ฐ€ '0'(์„ฑ๊ณต)์ด ์•„๋‹Œ ๊ฒฝ์šฐ ์—๋Ÿฌ ์ฒ˜๋ฆฌ + if data.get("rt_cd") != "0": + logger.error(f"โŒ Orderbook API error for {stock_code}: rt_cd={data.get('rt_cd')}, msg={data.get('msg1')}") + raise HTTPException(status_code=404, detail=data.get("msg1", "Orderbook data not found")) + + # API ์‘๋‹ต์—์„œ output1(ํ˜ธ๊ฐ€), output2(์ฒด๊ฒฐ๊ฐ€) ๋ฐ์ดํ„ฐ ์ถ”์ถœ + output1 = data.get("output1", {}) + output2 = data.get("output2", {}) + + # ํ˜„์žฌ๊ฐ€์™€ ๋ˆ„์  ๊ฑฐ๋ž˜๋Ÿ‰ ์ •๋ณด ์ถ”์ถœ + current_price = 0 + current_volume = 0 + if output2 and output2.get("stck_prpr"): + current_price = int(output2.get("stck_prpr", 0)) + current_volume = int(output2.get("acml_vol", 0) or 0) + elif output1.get("stck_prpr"): + current_price = int(output1.get("stck_prpr", 0)) + current_volume = int(output1.get("acml_vol", 0) or 0) + + # ํ˜ธ๊ฐ€ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ด์„ ๋”•์…”๋„ˆ๋ฆฌ ์ดˆ๊ธฐํ™” + orderbook = { + "stock_code": stock_code, + "stock_name": output1.get("hts_kor_isnm", ""), + "current_price": current_price, + "current_volume": current_volume, + "asks": [], # ๋งค๋„ ํ˜ธ๊ฐ€ (๋‚ฎ์€ ๊ฐ€๊ฒฉ -> ๋†’์€ ๊ฐ€๊ฒฉ ์ˆœ์œผ๋กœ ์ž„์‹œ ์ €์žฅ ํ›„ reverse) + "bids": [], # ๋งค์ˆ˜ ํ˜ธ๊ฐ€ (๋†’์€ ๊ฐ€๊ฒฉ -> ๋‚ฎ์€ ๊ฐ€๊ฒฉ ์ˆœ) + "total_ask_volume": 0, + "total_bid_volume": 0, + } + + # ๋””๋ฒ„๊น…์„ ์œ„ํ•œ ์›๋ณธ ๋งค๋„ํ˜ธ๊ฐ€ ๋ฐ์ดํ„ฐ ๋กœ๊ทธ + logger.info(f"๐Ÿ“Š [{stock_code}] ๋งค๋„ํ˜ธ๊ฐ€ ์›๋ณธ ๋ฐ์ดํ„ฐ:") + for i in range(1, 11): + price = output1.get(f"askp{i}", 0) + volume = output1.get(f"askp_rsqn{i}", 0) + logger.info(f" askp{i}: {price} ({volume}์ฃผ)") + + # ๋””๋ฒ„๊น…์„ ์œ„ํ•œ ์›๋ณธ ๋งค์ˆ˜ํ˜ธ๊ฐ€ ๋ฐ์ดํ„ฐ ๋กœ๊ทธ + logger.info(f"๐Ÿ“Š [{stock_code}] ๋งค์ˆ˜ํ˜ธ๊ฐ€ ์›๋ณธ ๋ฐ์ดํ„ฐ:") + for i in range(1, 11): + price = output1.get(f"bidp{i}", 0) + volume = output1.get(f"bidp_rsqn{i}", 0) + logger.info(f" bidp{i}: {price} ({volume}์ฃผ)") + + # ๋งค๋„ํ˜ธ๊ฐ€ 10๋‹จ๊ณ„ ํŒŒ์‹ฑ (askp1 ~ askp10) + temp_asks = [] + for i in range(1, 11): + price = int(output1.get(f"askp{i}", 0) or 0) + volume = int(output1.get(f"askp_rsqn{i}", 0) or 0) + if price > 0: + temp_asks.append({"price": price, "volume": volume}) + orderbook["total_ask_volume"] += volume + + # UI ํ‘œ๊ธฐ๋ฅผ ์œ„ํ•ด ๋งค๋„ํ˜ธ๊ฐ€๋Š” ๋†’์€ ๊ฐ€๊ฒฉ์ด ์œ„๋กœ ๊ฐ€๋„๋ก ๋ฆฌ์ŠคํŠธ๋ฅผ ๋’ค์ง‘์Œ + orderbook["asks"] = list(reversed(temp_asks)) + + # ๋งค์ˆ˜ํ˜ธ๊ฐ€ 10๋‹จ๊ณ„ ํŒŒ์‹ฑ (bidp1 ~ bidp10) + for i in range(1, 11): + price = int(output1.get(f"bidp{i}", 0) or 0) + volume = int(output1.get(f"bidp_rsqn{i}", 0) or 0) + if price > 0: + orderbook["bids"].append({"price": price, "volume": volume}) + orderbook["total_bid_volume"] += volume + + # ์ตœ์ข… ํŒŒ์‹ฑ ๊ฒฐ๊ณผ ๋กœ๊ทธ + logger.info(f"โœ… Orderbook {stock_code}: current={current_price}, asks={len(orderbook['asks'])}, bids={len(orderbook['bids'])}") + logger.info(f" ํŒŒ์‹ฑ๋œ ๋งค๋„ํ˜ธ๊ฐ€: {[ask['price'] for ask in orderbook['asks']]}") + logger.info(f" ํŒŒ์‹ฑ๋œ ๋งค์ˆ˜ํ˜ธ๊ฐ€: {[bid['price'] for bid in orderbook['bids']]}") + if orderbook['asks']: + logger.info(f" Best ask: {orderbook['asks'][-1]['price']}") # ๊ฐ€์žฅ ๋‚ฎ์€ ๋งค๋„ํ˜ธ๊ฐ€ + if orderbook['bids']: + logger.info(f" Best bid: {orderbook['bids'][0]['price']}") # ๊ฐ€์žฅ ๋†’์€ ๋งค์ˆ˜ํ˜ธ๊ฐ€ + + return orderbook + + except HTTPException: + # HTTPException์€ ๊ทธ๋Œ€๋กœ ๋‹ค์‹œ ๋ฐœ์ƒ์‹œํ‚ด + raise + except Exception as e: + # ๊ทธ ์™ธ ์˜ˆ์™ธ๋Š” 500 ์—๋Ÿฌ๋กœ ์ฒ˜๋ฆฌ + logger.error(f"Error fetching orderbook for {stock_code}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to fetch orderbook: {str(e)}") + + +@router.get("/ai-analysis/{stock_code}") +async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)): + """AI ๊ธฐ๋ฐ˜ ์ข…๋ชฉ ๋ถ„์„ ์—”๋“œํฌ์ธํŠธ (๊ธฐ์ˆ ์  ์ง€ํ‘œ + ๊ทœ์น™ ๊ธฐ๋ฐ˜ ๋ถ„์„)""" + try: + import httpx + from app.core.config import settings + + logger.info(f"๐Ÿค– AI ๋ถ„์„ ์š”์ฒญ: {stock_code}") + + # 1. ์ตœ์‹  ์‹œ์žฅ ๋ฐ์ดํ„ฐ(ํ˜„์žฌ๊ฐ€ ๋“ฑ) ๊ฐ€์ ธ์˜ค๊ธฐ + price_data = await korea_investment_api.get_current_price(stock_code) + if price_data.get("rt_cd") != "0": + raise HTTPException(status_code=404, detail="์‹œ์žฅ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + + output = price_data.get("output", {}) + current_price = int(output.get("stck_prpr", 0)) + high_price = int(output.get("stck_hgpr", 0)) + low_price = int(output.get("stck_lwpr", 0)) + volume = int(output.get("acml_vol", 0)) + + # 2. DB์—์„œ ๊ฐ€์žฅ ์ตœ๊ทผ์˜ ๊ธฐ์ˆ ์  ์ง€ํ‘œ ๋ถ„์„ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ + from app.models.models import StockAnalysis + result = await db.execute( + select(StockAnalysis) + .filter(StockAnalysis.stock_code == stock_code) + .order_by(StockAnalysis.analysis_date.desc()) + .limit(1) + ) + analysis_record = result.scalar_one_or_none() + + indicators = {} + if analysis_record: + indicators = { + "rsi": float(analysis_record.rsi) if analysis_record.rsi else None, + "ma5": float(analysis_record.ma5) if analysis_record.ma5 else None, + "ma20": float(analysis_record.ma20) if analysis_record.ma20 else None, + "ma60": float(analysis_record.ma60) if analysis_record.ma60 else None, + "stochastic_k": float(analysis_record.stochastic_k) if analysis_record.stochastic_k else None, + "stochastic_d": float(analysis_record.stochastic_d) if analysis_record.stochastic_d else None, + "bollinger_upper": float(analysis_record.bollinger_upper) if analysis_record.bollinger_upper else None, + "bollinger_middle": float(analysis_record.bollinger_middle) if analysis_record.bollinger_middle else None, + "bollinger_lower": float(analysis_record.bollinger_lower) if analysis_record.bollinger_lower else None, + } + + # 3. ๊ทœ์น™ ๊ธฐ๋ฐ˜ ๋ถ„์„ ๋กœ์ง + recommendation = "HOLD" # ๊ธฐ๋ณธ ์ถ”์ฒœ: ๋ณด์œ  + confidence = 0.0 # ์‹ ๋ขฐ๋„ + trend = "SIDEWAYS" # ์ถ”์„ธ + analysis_text = "" # ๋ถ„์„ ์š”์•ฝ ํ…์ŠคํŠธ + + # ๋งค์ˆ˜/๋งค๋„ ์‹ ํ˜ธ ์ ์ˆ˜ ๊ณ„์‚ฐ + buy_signals = 0 + sell_signals = 0 + total_signals = 0 + + # ๊ธฐ์ˆ ์  ์ง€ํ‘œ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ ๋ถ„์„ ์‹œ์ž‘ + if indicators and any(indicators.values()): + signals = [] # ๋ถ„์„๋œ ์‹ ํ˜ธ๋“ค์„ ๋‹ด์„ ๋ฆฌ์ŠคํŠธ + + # RSI ๋ถ„์„ + if indicators.get("rsi") is not None: + rsi = indicators["rsi"] + total_signals += 1 + if rsi < 30: # ๊ณผ๋งค๋„ + buy_signals += 1 + signals.append(f"RSI ๊ณผ๋งค๋„({rsi:.1f})") + elif rsi > 70: # ๊ณผ๋งค์ˆ˜ + sell_signals += 1 + signals.append(f"RSI ๊ณผ๋งค์ˆ˜({rsi:.1f})") + elif rsi < 40: # ๋งค์ˆ˜ ๊ณ ๋ ค + buy_signals += 0.5 + signals.append(f"RSI ๋งค์ˆ˜๊ถŒ์žฅ({rsi:.1f})") + elif rsi > 60: # ๋งค๋„ ๊ณ ๋ ค + sell_signals += 0.5 + signals.append(f"RSI ๋งค๋„๊ถŒ์žฅ({rsi:.1f})") + + # ์ด๋™ํ‰๊ท ์„ (MA) ๋ถ„์„ + ma5 = indicators.get("ma5") + ma20 = indicators.get("ma20") + ma60 = indicators.get("ma60") + + if ma5 and ma20: + # ํ˜„์žฌ๊ฐ€์™€ 20์ผ ์ด๋™ํ‰๊ท ์„ ์˜ ๊ดด๋ฆฌ์œจ์„ ๊ณ„์‚ฐํ•˜์—ฌ ๋ฐ์ดํ„ฐ์˜ ์‹ ์„ ๋„ ์ฒดํฌ + ma_deviation = abs(current_price - ma20) / ma20 if ma20 > 0 else 999 + + if ma_deviation < 0.5: # ๊ดด๋ฆฌ์œจ์ด 50% ๋ฏธ๋งŒ์ผ ๋•Œ๋งŒ ์œ ํšจํ•œ ๋ฐ์ดํ„ฐ๋กœ ๊ฐ„์ฃผ + total_signals += 1 + + if ma60 and ma5 > ma20 > ma60: # ์ •๋ฐฐ์—ด (๊ฐ•ํ•œ ์ƒ์Šน ์ถ”์„ธ) + buy_signals += 1 + trend = "UP" + signals.append("์ด๋™ํ‰๊ท  ์ •๋ฐฐ์—ด(์ƒ์Šน)") + elif ma60 and ma5 < ma20 < ma60: # ์—ญ๋ฐฐ์—ด (๊ฐ•ํ•œ ํ•˜๋ฝ ์ถ”์„ธ) + sell_signals += 1 + trend = "DOWN" + signals.append("์ด๋™ํ‰๊ท  ์—ญ๋ฐฐ์—ด(ํ•˜๋ฝ)") + elif ma5 < ma20 and current_price < ma20: # ๋ฐ๋“œํฌ๋กœ์Šค (ํ•˜๋ฝ ์‹ ํ˜ธ) + sell_signals += 0.5 + trend = "DOWN" + signals.append("๋ฐ๋“œํฌ๋กœ์Šค ์‹ ํ˜ธ") + else: + logger.debug(f"์ด๋™ํ‰๊ท ์„  ๋ฐ์ดํ„ฐ ์˜ค๋ž˜๋จ (ํ˜„์žฌ๊ฐ€: {current_price}, MA20: {ma20}, ๊ดด๋ฆฌ์œจ: {ma_deviation*100:.1f}%)") + + # ๋ณผ๋ฆฐ์ € ๋ฐด๋“œ ๋ถ„์„ + bb_upper = indicators.get("bollinger_upper") + bb_lower = indicators.get("bollinger_lower") + bb_middle = indicators.get("bollinger_middle") + + if bb_upper and bb_lower and bb_middle and bb_upper > bb_lower: + # ํ˜„์žฌ๊ฐ€๊ฐ€ ๋ณผ๋ฆฐ์ € ๋ฐด๋“œ ๋‚ด ์–ด๋А ์œ„์น˜์— ์žˆ๋Š”์ง€ ๊ณ„์‚ฐ (0~1) + bb_position = (current_price - bb_lower) / (bb_upper - bb_lower) + + # ๋ฐด๋“œ ์œ„์น˜๊ฐ€ -0.2 ~ 1.2 ๋ฒ”์œ„ ๋‚ด์— ์žˆ์„ ๋•Œ๋งŒ ์œ ํšจํ•œ ๋ฐ์ดํ„ฐ๋กœ ๊ฐ„์ฃผ (๊ดด๋ฆฌ์œจ ์ฒดํฌ) + if -0.2 <= bb_position <= 1.2: + total_signals += 1 + if bb_position > 0.8: # ์ƒ๋‹จ ๊ทผ์ ‘ (๊ณผ๋งค์ˆ˜) + sell_signals += 1 + signals.append(f"๋ณผ๋ฆฐ์ € ๋ฐด๋“œ ์ƒ๋‹จ๊ทผ์ ‘({bb_position*100:.0f}%)") + elif bb_position < 0.2: # ํ•˜๋‹จ ๊ทผ์ ‘ (๊ณผ๋งค๋„) + buy_signals += 1 + signals.append(f"๋ณผ๋ฆฐ์ € ๋ฐด๋“œ ํ•˜๋‹จ๊ทผ์ ‘({bb_position*100:.0f}%)") + else: + logger.debug(f"๋ณผ๋ฆฐ์ € ๋ฐด๋“œ ๋ฐ์ดํ„ฐ ์˜ค๋ž˜๋จ (ํ˜„์žฌ๊ฐ€: {current_price}, ๋ฐด๋“œ: {bb_lower:.0f}~{bb_upper:.0f}, ์œ„์น˜: {bb_position*100:.0f}%)") + + # ์Šคํ† ์บ์Šคํ‹ฑ ๋ถ„์„ + stoch_k = indicators.get("stochastic_k") + stoch_d = indicators.get("stochastic_d") + + if stoch_k is not None and stoch_d is not None: + total_signals += 1 + if stoch_k < 20 and stoch_d < 20: # ๊ณผ๋งค๋„ + buy_signals += 1 + signals.append(f"์Šคํ† ์บ์Šคํ‹ฑ ๊ณผ๋งค๋„({stoch_k:.1f})") + elif stoch_k > 80 and stoch_d > 80: # ๊ณผ๋งค์ˆ˜ + sell_signals += 1 + signals.append(f"์Šคํ† ์บ์Šคํ‹ฑ ๊ณผ๋งค์ˆ˜({stoch_k:.1f})") + elif stoch_k > stoch_d and stoch_k < 50: # ์ƒ์Šน ์ „ํ™˜ ์‹ ํ˜ธ + buy_signals += 0.3 + signals.append("์Šคํ† ์บ์Šคํ‹ฑ ์ƒ์Šน์ „ํ™˜") + elif stoch_k < stoch_d and stoch_k > 50: # ํ•˜๋ฝ ์ „ํ™˜ ์‹ ํ˜ธ + sell_signals += 0.3 + signals.append("์Šคํ† ์บ์Šคํ‹ฑ ํ•˜๋ฝ์ „ํ™˜") + + # ์ตœ์ข… ์ถ”์ฒœ ๊ฒฐ์ • + if total_signals > 0: + # (๋งค์ˆ˜ ์‹ ํ˜ธ - ๋งค๋„ ์‹ ํ˜ธ) / ์ „์ฒด ์‹ ํ˜ธ ์ˆ˜๋กœ ์ ์ˆ˜ ๊ณ„์‚ฐ + signal_score = (buy_signals - sell_signals) / total_signals + confidence = min(abs(signal_score), 1.0) # ์‹ ๋ขฐ๋„๋Š” ์ ์ˆ˜์˜ ์ ˆ๋Œ€๊ฐ’ + + if signal_score > 0.3: # ์ ์ˆ˜๊ฐ€ 0.3๋ณด๋‹ค ํฌ๋ฉด '๋งค์ˆ˜' + recommendation = "BUY" + elif signal_score < -0.3: # ์ ์ˆ˜๊ฐ€ -0.3๋ณด๋‹ค ์ž‘์œผ๋ฉด '๋งค๋„' + recommendation = "SELL" + else: # ๊ทธ ์™ธ์—๋Š” '๋ณด์œ ' + recommendation = "HOLD" + + analysis_text = " | ".join(signals) if signals else "์ถฉ๋ถ„ํ•œ ์‹ ํ˜ธ ์—†์Œ" + else: + analysis_text = "๊ธฐ์ˆ ์  ์ง€ํ‘œ ๋ฐ์ดํ„ฐ ๋ถ€์กฑ" + else: + analysis_text = "๊ธฐ์ˆ ์  ์ง€ํ‘œ ๋ฐ์ดํ„ฐ ์—†์Œ" + + # ์ถ”์„ธ ์ตœ์ข… ๊ฒฐ์ • (์ด๋™ํ‰๊ท ์„  ์—†์œผ๋ฉด ๊ฐ€๊ฒฉ ๋ณ€๋™์œผ๋กœ) + if trend == "SIDEWAYS" and high_price and low_price and current_price: + daily_change_pct = ((current_price - low_price) / low_price) * 100 if low_price > 0 else 0 + if daily_change_pct > 10: + trend = "UP" + elif daily_change_pct < -10: + trend = "DOWN" + + # 4. ์ข…ํ•ฉ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ + return { + "stock_code": stock_code, + "stock_name": output.get("stck_prdt_name", ""), + "current_price": current_price, + "high_price": high_price, + "low_price": low_price, + "volume": volume, + "indicators": indicators, + "ai_analysis": analysis_text, + "recommendation": recommendation, + "confidence": confidence, + "trend": trend + } + + except HTTPException: + # HTTPException์€ ๊ทธ๋Œ€๋กœ ๋‹ค์‹œ ๋ฐœ์ƒ์‹œํ‚ด + raise + except Exception as e: + # ๊ทธ ์™ธ ์˜ˆ์™ธ๋Š” 500 ์—๋Ÿฌ๋กœ ์ฒ˜๋ฆฌ + logger.error(f"Error in AI analysis for {stock_code}: {e}") + raise HTTPException(status_code=500, detail=f"AI ๋ถ„์„ ์‹คํŒจ: {str(e)}") diff --git a/backend/app/api/endpoints/mcp.py b/backend/app/api/endpoints/mcp.py index 1c532dc..da36eb3 100644 --- a/backend/app/api/endpoints/mcp.py +++ b/backend/app/api/endpoints/mcp.py @@ -25,10 +25,13 @@ async def get_mcp_status(): response = await client.get(f"{settings.MCP_SERVER_URL}/health", timeout=5.0) if response.status_code == 200: + health_data = response.json() return { "status": "connected", "mcp_server_url": settings.MCP_SERVER_URL, - "details": response.json() + "claude_configured": health_data.get("claude_configured", False), + "ai_provider": health_data.get("ai_provider", "unknown"), + "details": health_data } else: return { diff --git a/backend/app/api/endpoints/ml_ensemble.py b/backend/app/api/endpoints/ml_ensemble.py new file mode 100644 index 0000000..c3f3dee --- /dev/null +++ b/backend/app/api/endpoints/ml_ensemble.py @@ -0,0 +1,170 @@ +""" +ML ์•™์ƒ๋ธ” API ์—”๋“œํฌ์ธํŠธ + +- POST /train: ๋ชจ๋ธ ํ•™์Šต +- GET /predict/{stock_code}: ๊ฐœ๋ณ„ ์ข…๋ชฉ ์˜ˆ์ธก +- GET /status: ๋ชจ๋ธ ์ƒํƒœ ์กฐํšŒ +- GET /backtest: ๋ฐฑํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ +""" + +from fastapi import APIRouter, HTTPException, Query, BackgroundTasks +import logging +import asyncio + +from app.services.ml_ensemble import get_ensemble +from app.services.korea_investment_api import korea_investment_api +from app.core.config import settings + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/status") +async def get_model_status(): + """์•™์ƒ๋ธ” ๋ชจ๋ธ ์ƒํƒœ ์กฐํšŒ""" + ensemble = get_ensemble() + status = ensemble.get_status() + status["config"] = { + "enabled": settings.ML_ENSEMBLE_ENABLED, + "rule_weight": settings.ML_ENSEMBLE_RULE_WEIGHT, + "ml_weight": settings.ML_ENSEMBLE_ML_WEIGHT, + "buy_threshold": settings.ML_ENSEMBLE_BUY_THRESHOLD, + "confidence_min": settings.ML_ENSEMBLE_CONFIDENCE_MIN, + } + return status + + +@router.get("/predict/{stock_code}") +async def predict_stock(stock_code: str): + """๊ฐœ๋ณ„ ์ข…๋ชฉ ML ์•™์ƒ๋ธ” ์˜ˆ์ธก""" + ensemble = get_ensemble() + + if not ensemble.is_trained: + raise HTTPException( + status_code=503, + detail="๋ชจ๋ธ์ด ํ•™์Šต๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. POST /ml-ensemble/train ์œผ๋กœ ํ•™์Šต์„ ๋จผ์ € ์‹คํ–‰ํ•˜์„ธ์š”." + ) + + try: + chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D") + chart_data = chart_resp.get("output", []) + + if not chart_data or len(chart_data) < 61: + raise HTTPException(status_code=400, detail=f"์ฐจํŠธ ๋ฐ์ดํ„ฐ ๋ถ€์กฑ: {len(chart_data) if chart_data else 0}์ผ (์ตœ์†Œ 61์ผ ํ•„์š”)") + + result = ensemble.predict(chart_data) + result["stock_code"] = stock_code + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"์˜ˆ์ธก ์‹คํŒจ ({stock_code}): {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +async def _train_task(limit: int): + """๋ฐฑ๊ทธ๋ผ์šด๋“œ ํ•™์Šต ์ž‘์—…""" + import numpy as np + from app.services.feature_engineering import ( + chart_data_to_dataframe, extract_features, prepare_training_data + ) + from app.services.korea_investment_api import KoreaInvestmentAPI + + kis_api = KoreaInvestmentAPI() + + # ์ข…๋ชฉ ์กฐํšŒ + stocks = [] + try: + kospi = await kis_api.get_volume_rank("J") + if kospi and kospi.get("output"): + for item in kospi["output"]: + name = item.get("hts_kor_isnm", "") + skip = ["ETF", "๋ ˆ๋ฒ„๋ฆฌ์ง€", "์ธ๋ฒ„์Šค", "KODEX", "์„ ๋ฌผ", "TIGER", "ARIRANG"] + if not any(k in name for k in skip): + stocks.append((item["mksc_shrn_iscd"], name)) + except Exception as e: + logger.error(f"์ข…๋ชฉ ์กฐํšŒ ์‹คํŒจ: {e}") + + await asyncio.sleep(3) + + try: + kosdaq = await kis_api.get_volume_rank("Q") + if kosdaq and kosdaq.get("output"): + for item in kosdaq["output"]: + name = item.get("hts_kor_isnm", "") + skip = ["ETF", "๋ ˆ๋ฒ„๋ฆฌ์ง€", "์ธ๋ฒ„์Šค", "KODEX", "์„ ๋ฌผ", "TIGER", "ARIRANG"] + if not any(k in name for k in skip): + stocks.append((item["mksc_shrn_iscd"], name)) + except Exception as e: + logger.error(f"์ข…๋ชฉ ์กฐํšŒ ์‹คํŒจ: {e}") + + stocks = stocks[:limit] + logger.info(f"ํ•™์Šต ๋Œ€์ƒ: {len(stocks)}๊ฐœ ์ข…๋ชฉ") + + # ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ + all_X_tab, all_X_seq, all_y = [], [], [] + for i, (code, name) in enumerate(stocks): + try: + chart_resp = await kis_api.get_daily_chart(code, "D") + chart_data = chart_resp.get("output", []) + if not chart_data or len(chart_data) < 80: + continue + + df = chart_data_to_dataframe(chart_data) + feat = extract_features(df) + X_tab, X_seq, y, _ = prepare_training_data(df, feat) + if len(X_tab) > 0: + all_X_tab.append(X_tab) + all_X_seq.append(X_seq) + all_y.append(y) + logger.info(f"[{i+1}/{len(stocks)}] {code}: {len(X_tab)}๊ฐœ ์ƒ˜ํ”Œ") + except Exception as e: + logger.warning(f"[{i+1}] {code} ์‹คํŒจ: {e}") + await asyncio.sleep(3) + + if not all_X_tab: + logger.error("ํ•™์Šต ๋ฐ์ดํ„ฐ ์—†์Œ") + return + + X_tabular = np.vstack(all_X_tab) + X_seq = np.vstack(all_X_seq) + y = np.concatenate(all_y) + + logger.info(f"์ด {len(X_tabular)}๊ฐœ ์ƒ˜ํ”Œ๋กœ ํ•™์Šต ์‹œ์ž‘") + + ensemble = get_ensemble() + metrics = ensemble.train(X_tabular, X_seq, y) + logger.info(f"ํ•™์Šต ์™„๋ฃŒ: {metrics}") + + +@router.post("/train") +async def train_model( + background_tasks: BackgroundTasks, + limit: int = Query(50, description="ํ•™์Šต ์ข…๋ชฉ ์ˆ˜"), +): + """์•™์ƒ๋ธ” ๋ชจ๋ธ ํ•™์Šต (๋ฐฑ๊ทธ๋ผ์šด๋“œ ์‹คํ–‰)""" + background_tasks.add_task(_train_task, limit) + return { + "message": f"ํ•™์Šต์ด ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค ({limit}๊ฐœ ์ข…๋ชฉ)", + "status": "training_started", + } + + +@router.get("/backtest") +async def get_backtest(): + """๋ชจ๋ธ ํ•™์Šต ๋ฉ”ํŠธ๋ฆญ ๋ฐ ๋ฐฑํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์กฐํšŒ""" + ensemble = get_ensemble() + if not ensemble.is_trained: + raise HTTPException(status_code=503, detail="๋ชจ๋ธ์ด ํ•™์Šต๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค") + + return { + "is_trained": True, + "metrics": ensemble.training_metrics, + "weights": { + "lgbm": ensemble.weights[0], + "lstm": ensemble.weights[1], + "rf": ensemble.weights[2], + }, + } diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index a0f859b..7990e84 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -1,18 +1,47 @@ +""" +ํฌํŠธํด๋ฆฌ์˜ค API ์—”๋“œํฌ์ธํŠธ + +- GET /: ๋ณด์œ  ์ข…๋ชฉ ๋ชฉ๋ก ์กฐํšŒ (์บ์‹œ ํ™œ์šฉ) +- GET /summary: ํฌํŠธํด๋ฆฌ์˜ค ์š”์•ฝ (์ด์ž์‚ฐ, ํ‰๊ฐ€์†์ต) +- GET /sync: ์‹ค์ œ ๊ณ„์ขŒ์™€ ๋™๊ธฐํ™” +- POST /sell: ์ˆ˜๋™ ๋งค๋„ (์ฒญ์‚ฐ) +""" from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List +from sqlalchemy import select, func +from typing import List, Optional from decimal import Decimal +from pydantic import BaseModel +import pytz -from app.core.database import get_db -from app.models.models import Portfolio +from app.core.database import get_db, AsyncSessionLocal +from app.models.models import Portfolio, TradeHistory, ActiveTrade, StockAnalysis, TradeJournal from app.services.korea_investment_api import korea_investment_api +from app.services.mean_reversion_strategy import ( + calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal, +) +from app.services.mdd_manager import mdd_manager +from app.services.loss_tracker import loss_tracker +from app.services.websocket_manager import websocket_manager import asyncio import logging from datetime import datetime, timedelta logger = logging.getLogger(__name__) +# ํ•œ๊ตญ ์‹œ๊ฐ„๋Œ€ +KST = pytz.timezone('Asia/Seoul') + +# Request/Response Models +class SellRequest(BaseModel): + stock_code: str + quantity: int + price: Optional[int] = None + +def _get_kst_now(): + """ํ˜„์žฌ ํ•œ๊ตญ ์‹œ๊ฐ„ ๋ฐ˜ํ™˜ (timezone-naive datetime)""" + return datetime.now(KST).replace(tzinfo=None) + # ๋‹จ์ˆœ ์ธ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ (ํ”„๋กœ์„ธ์Šค ๋‹จ์œ„). ๋‹ค์ค‘ ํ”„๋กœ์„ธ์Šค ํ™˜๊ฒฝ์ด๋ฉด Redis ๋“ฑ ํ•„์š”. class PortfolioCache: def __init__(self): @@ -28,6 +57,7 @@ class PortfolioCache: self.failure_count: int = 0 self.next_retry_at: datetime | None = None self.last_sync_success: bool = False + self.last_trade_id: int | None = None def mark_changed(self): self.change_flag = True @@ -45,7 +75,7 @@ class PortfolioCache: return False # ๋ฐฑ์˜คํ”„๊ฐ€ ์ง€๋‚ฌ์œผ๋ฉด ์žฌ์‹œ๋„ ํ—ˆ์šฉ (์ตœ์†Œ ๊ฐ„๊ฒฉ๊ณผ ๋ฌด๊ด€) return True - if (now - self.last_synced) > timedelta(minutes=5): # ์ฃผ๊ธฐ์  ์‹ ์„ ๋„ ๋ณด์žฅ + if (now - self.last_synced) > timedelta(seconds=5): # ์‹ค์‹œ๊ฐ„: 5์ดˆ๋งˆ๋‹ค ๊ฐฑ์‹  return True return False @@ -68,19 +98,102 @@ class PortfolioCache: self.next_retry_at = None self.last_sync_success = True - def cache_summary(self): + def cache_summary(self, balance_data: dict = None): holdings = self.holdings total_stocks = len(holdings) total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings) total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings) total_profit_loss = sum(float(h.profit_loss or 0) for h in holdings) total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0 + + # KIS API output2์—์„œ ์˜ˆ์ˆ˜๊ธˆ ๋ฐ ์ด์ž์‚ฐ ์ •๋ณด ์ถ”์ถœ + available_cash = 0.0 + total_assets = 0.0 + if balance_data: + logger.info(f"๐Ÿ” KIS API balance response keys: {balance_data.keys()}") + if "output2" in balance_data: + output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] + logger.info(f"๐Ÿ“Š output2 keys: {output2.keys()}") + logger.info(f"๐Ÿ’ฐ output2 data: {output2}") + else: + logger.warning("โš ๏ธ No 'output2' in balance_data!") + + if balance_data and "output2" in balance_data: + output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] + + # ๊ฐ€๊ธ‰์  ์ฆ๊ถŒ์‚ฌ ํ™”๋ฉด๊ณผ ๋™์ผ ๊ธฐ์ค€ ์‚ฌ์šฉ: + # - ํ˜„๊ธˆ: D+2 ์ •์‚ฐ ๊ธฐ์ค€ ์˜ˆ์ˆ˜๊ธˆ(d2_depo_amt) + # - ์ด์ž์‚ฐ: ์ดํ‰๊ฐ€๊ธˆ์•ก(tot_evlu_amt) ์šฐ์„ , ์—†์œผ๋ฉด ์œ ๊ฐ€ํ‰๊ฐ€+ํ˜„๊ธˆ ๊ณ„์‚ฐ + # ํ•„๋“œ๋ช…์ด ํ™˜๊ฒฝ์— ๋”ฐ๋ผ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์–ด ๋‹ค์ค‘ ํ‚ค ์ฒดํ‚น + def pick_float(d: dict, keys: list[str]) -> float: + for k in keys: + if k in d and d.get(k) not in (None, ""): + try: + return float(d.get(k)) + except Exception: + continue + return 0.0 + + # ํ˜„๊ธˆ: D+2 ์ •์‚ฐ์•ก ์šฐ์„  -> ์˜ˆ์ˆ˜๊ธˆ์ด์•ก -> ์ˆœ์ž์‚ฐ๊ธˆ์•ก ์ˆœ + available_cash = pick_float(output2, [ + "d2_depo_amt", # D+2 ์ •์‚ฐ์•ก + "d2_deposit", # ์ผ๋ถ€ ์‘๋‹ต ๋ณ€ํ˜• ํ‚ค + "dnca_tot_amt", # ์˜ˆ์ˆ˜๊ธˆ ์ด์•ก + "nass_amt" # ์ˆœ์ž์‚ฐ๊ธˆ์•ก (fallback) + ]) + + # ์ด์ž์‚ฐ: KIS API์˜ tot_evlu_amt ์‚ฌ์šฉ (๊ฐ€์žฅ ์ •ํ™•) + # tot_evlu_amt๋Š” D+2 ์ •์‚ฐ์•ก + ์œ ๊ฐ€ํ‰๊ฐ€์•ก์„ ํฌํ•จํ•œ ์‹ค์ œ ์ด์ž์‚ฐ + kis_total_assets = pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"]) + + if kis_total_assets > 0: + # KIS API ๊ฐ’ ์šฐ์„  ์‚ฌ์šฉ + total_assets = kis_total_assets + calculated_assets = total_value + available_cash + + # ์ฐจ์ด๊ฐ€ ํฌ๋ฉด ๋กœ๊ทธ (์ •์ƒ - D+2 ์ •์‚ฐ์•ก ๋•Œ๋ฌธ) + if abs(kis_total_assets - calculated_assets) > 1000: + logger.debug( + f"๐Ÿ’ฐ ์ด์ž์‚ฐ - KIS API: {kis_total_assets:,.0f}์›, " + f"๋‹จ์ˆœ๊ณ„์‚ฐ(ํ˜„๊ธˆ+์ฃผ์‹): {calculated_assets:,.0f}์›, " + f"์ฐจ์ด: {(kis_total_assets - calculated_assets):,.0f}์› (D+2์ •์‚ฐ์•ก ํฌํ•จ)" + ) + else: + # KIS API ๊ฐ’์ด ์—†์œผ๋ฉด ๊ณ„์‚ฐ (fallback) + total_assets = total_value + available_cash + + # ๋””๋ฒ„๊น… ๋กœ๊ทธ(์š”์•ฝ): ์‹ค์ œ ์‚ฌ์šฉ๋œ ๊ฐ’๊ณผ ์›๋ณธ ์ผ๋ถ€ ํ‚ค ๋…ธ์ถœ + try: + logger.debug( + "Portfolio summary calc - cash=%s, total_assets=%s, keys=%s", + f"{available_cash:,.0f}", + f"{total_assets:,.0f}", + {k: output2.get(k) for k in [ + "d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", "tot_evlu_amt" + ] if k in output2} + ) + except Exception: + pass + + # D+2 ์ •์‚ฐ์•ก ๋ฐ ๊ธฐํƒ€ ์ƒ์„ธ ์ •๋ณด ์ถ”์ถœ + d2_settlement = 0.0 + stock_value_from_api = 0.0 + + if balance_data and "output2" in balance_data: + output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] + d2_settlement = pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"]) + stock_value_from_api = pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"]) + self.summary = { "total_stocks": total_stocks, "total_value": total_value, "total_investment": total_investment, "total_profit_loss": total_profit_loss, - "total_profit_loss_rate": total_profit_loss_rate + "total_profit_loss_rate": total_profit_loss_rate, + "available_cash": available_cash, + "total_assets": total_assets, + "d2_settlement": d2_settlement, # D+2 ์ •์‚ฐ์•ก + "stock_value_from_api": stock_value_from_api # API์˜ ์œ ๊ฐ€ํ‰๊ฐ€์•ก } portfolio_cache = PortfolioCache() @@ -103,19 +216,37 @@ class PortfolioResponse(BaseModel): from_attributes = True +async def _get_latest_executed_trade_id(db: AsyncSession) -> int | None: + try: + result = await db.execute( + select(func.max(TradeHistory.id)).where(TradeHistory.status.in_(['EXECUTED', 'COMPLETED'])) + ) + return result.scalar_one_or_none() + except Exception as e: + logger.warning(f"Failed to fetch latest trade id: {e}") + return None + + @router.get("", response_model=List[PortfolioResponse]) async def get_portfolio(db: AsyncSession = Depends(get_db)): """ํฌํŠธํด๋ฆฌ์˜ค ์กฐํšŒ (์บ์‹œ ํ™œ์šฉ)""" + # ๊ฑฐ๋ž˜ ์ด๋ ฅ ๊ธฐ์ค€์œผ๋กœ ์บ์‹œ ๋ฌดํšจํ™” + latest_trade_id = await _get_latest_executed_trade_id(db) + if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id: + portfolio_cache.mark_changed() + if portfolio_cache.holdings and not portfolio_cache.should_sync(): logger.debug("Portfolio cache hit (holdings)") - return portfolio_cache.holdings + # ์ˆ˜๋Ÿ‰ 0์ธ ์ข…๋ชฉ ํ•„ํ„ฐ๋ง (์•ˆ์ „์žฅ์น˜) + return [h for h in portfolio_cache.holdings if h.quantity > 0] # ํ•„์š”ํ•˜๋ฉด ๋™๊ธฐํ™” ์‹œ๋„ try: await sync_portfolio(db, force=False) except HTTPException: # ๊ฐ•์ œ ๋™๊ธฐํ™”๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ์—๋Š” ์˜ค๋ฅ˜๋ฅผ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ „ํŒŒํ•˜์ง€ ์•Š๊ณ  ์บ์‹œ/๋นˆ ๋ชฉ๋ก ๋ฐ˜ํ™˜ logger.warning("get_portfolio: sync failed, returning cached holdings") - return portfolio_cache.holdings + # ์ˆ˜๋Ÿ‰ 0์ธ ์ข…๋ชฉ ํ•„ํ„ฐ๋ง (์•ˆ์ „์žฅ์น˜) + return [h for h in portfolio_cache.holdings if h.quantity > 0] @router.get("/sync") @@ -126,11 +257,71 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) if not force and not portfolio_cache.should_sync(): logger.debug("Skip sync: cache fresh & no change flag") return {"message": "Skipped (cache fresh)", "cached": True} - async with portfolio_cache.sync_lock: + # ๋ฝ ํš๋“์— 30์ดˆ ํƒ€์ž„์•„์›ƒ ์ ์šฉ (์ด์ „ sync hang ์‹œ ๋ฐ๋“œ๋ฝ ๋ฐฉ์ง€) + try: + await asyncio.wait_for(portfolio_cache.sync_lock.acquire(), timeout=30) + except asyncio.TimeoutError: + portfolio_cache.record_failure("Sync lock timeout (30s)") + logger.error("Portfolio sync lock timeout โ€” ์ด์ „ sync๊ฐ€ 30์ดˆ ์ด์ƒ hang ์ค‘") + if force: + raise HTTPException(status_code=503, detail="Portfolio sync timeout") + return {"message": "Sync timeout (using cache)", "cached": True} + try: portfolio_cache.last_attempt = datetime.now() try: balance_data = await korea_investment_api.get_balance() + + # ๋””๋ฒ„๊น…: KIS API ์ „์ฒด ์‘๋‹ต ๋กœ๊น… + logger.info(f"๐Ÿ” KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}") + + # Balance API ์˜ค๋ฅ˜ ์‹œ ๊ธฐ์กด DB ๋ณด์œ ์ข…๋ชฉ์„ ์‚ญ์ œํ•˜์ง€ ์•Š๊ณ  ์บ์‹œ ์œ ์ง€ + if not balance_data or balance_data.get('rt_cd') != '0': + error_msg = balance_data.get('msg1', 'Unknown error') if balance_data else 'No response' + logger.warning(f"โš ๏ธ Balance API ์˜ค๋ฅ˜ - ๊ธฐ์กด ํฌํŠธํด๋ฆฌ์˜ค ์œ ์ง€: {error_msg}") + # ์บ์‹œ๊ฐ€ ๋น„์–ด์žˆ์œผ๋ฉด DB์—์„œ ๋กœ๋“œ + if not portfolio_cache.holdings: + result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) + portfolio_cache.holdings = list(result.scalars().all()) + logger.info(f"๐Ÿ“ฆ DB์—์„œ ํฌํŠธํด๋ฆฌ์˜ค ๋กœ๋“œ: {len(portfolio_cache.holdings)}์ข…๋ชฉ") + return {"message": f"Balance API error: {error_msg}", "cached": True, "count": len(portfolio_cache.holdings)} + portfolio_cache.record_success() + + if "output1" in balance_data and balance_data["output1"]: + logger.info(f"๐Ÿ” output1 count: {len(balance_data['output1'])}") + + # 1. ์˜ค๋Š˜ ๋ฐœ์ƒํ•œ ๋ชจ๋“  ๋งค๋งค ์ด๋ ฅ ์กฐํšŒ (Ghost Stock ๋ฐฉ์ง€ - ์‹œ๊ฐ„ ๋ฒ”์œ„ ํ™•๋Œ€) + now = _get_kst_now() + # ์˜ค๋Š˜์˜ ์‹œ์ž‘ ์‹œ๊ฐ„ (00:00:00) ๊ณ„์‚ฐ + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # ์˜ค๋Š˜ ํฌํ•จ ์ตœ๊ทผ 24์‹œ๊ฐ„ (ํ˜น์‹œ ์ž์ • ๊ทผ์ฒ˜ ์ด์Šˆ ๋ฐฉ์ง€)์ด์ง€๋งŒ, ์ฃผ๋กœ '์ตœ๊ทผ ํ–‰๋™'์ด ์ค‘์š”ํ•จ. + # ์•ˆ์ „ํ•˜๊ฒŒ '์˜ค๋Š˜ 0์‹œ ์ดํ›„'์˜ ๋ชจ๋“  ๊ฑฐ๋ž˜๋ฅผ ๊ฐ€์ ธ์˜ด. + recent_point = today_start + + logger.info(f"๐Ÿ”Ž Checking recent trades since {recent_point} for Ghost detection") + + # ๋ณต์žกํ•œ SQL ๋Œ€์‹  ๋‹จ์ˆœ ์กฐํšŒ ํ›„ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ ˆ๋ฒจ์—์„œ ํ•„ํ„ฐ๋ง + trades_stmt = select(TradeHistory).where( + TradeHistory.executed_at >= recent_point, + TradeHistory.status.in_(['EXECUTED', 'COMPLETED']) + ).order_by(TradeHistory.executed_at.asc()) + + trade_result = await db.execute(trades_stmt) + recent_trades = trade_result.scalars().all() + + # ์ข…๋ชฉ๋ณ„ ์ตœ์‹  ์ƒํƒœ ์ถ”์  + stock_status_map = {} + for t in recent_trades: + stock_status_map[t.stock_code] = t.order_type # ๋ฎ์–ด์“ฐ๊ธฐ (๋งˆ์ง€๋ง‰์ด ์ตœ์‹ ) + logger.debug(f"Trade trace: {t.stock_name}({t.stock_code}) -> {t.order_type} at {t.executed_at}") + + # ์ตœ์‹  ์ƒํƒœ๊ฐ€ 'SELL'์ธ ์ข…๋ชฉ ์‹๋ณ„ + ghost_candidates = {code for code, status in stock_status_map.items() if status == "SELL"} + + if ghost_candidates: + logger.info(f"๐Ÿšซ Ghost Stock Candidates (Last action today is SELL): {ghost_candidates}") + # ๊ธฐ์กด ๋ ˆ์ฝ”๋“œ ๋กœ๋“œ (์‚ญ์ œ ๋Œ€์‹  ์—…๋ฐ์ดํŠธ) result = await db.execute(select(Portfolio)) existing_all = {p.stock_code: p for p in result.scalars().all()} @@ -139,14 +330,53 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) if "output1" in balance_data: for item in balance_data["output1"]: stock_code = item.get("pdno") + + # ์ตœ๊ทผ ์ตœ์‹  ๊ฑฐ๋ž˜๊ฐ€ '๋งค๋„'๋ผ๋ฉด KIS๊ฐ€ ๋ณด๋‚ด์ค˜๋„ ๋ฌด์‹œ (Ghost Stock ์ฒ˜๋ฆฌ) + # ๋งŒ์•ฝ ์žฌ๋งค์ˆ˜ํ–ˆ๋‹ค๋ฉด latest_trades์˜ type์ด 'BUY'์˜€์„ ๊ฒƒ์ด๋ฏ€๋กœ ghost_candidates์— ์—†์Œ. + if stock_code in ghost_candidates: + logger.info(f"๐Ÿ‘ป Skipping Ghost Stock: {stock_code} (Latest trade was SELL)") + # DB์—์„œ๋„ ๋ช…์‹œ์  ์‚ญ์ œ + if stock_code in existing_all: + await db.delete(existing_all[stock_code]) + logger.info(f"๐Ÿ—‘๏ธ Force deleted ghost stock from DB: {stock_code}") + continue + stock_name = item.get("prdt_name") quantity = int(item.get("hldg_qty", 0)) if quantity <= 0: + logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity") continue - average_price = Decimal(item.get("pchs_avg_pric", 0)) - current_price = Decimal(item.get("prpr", 0)) - profit_loss = Decimal(item.get("evlu_pfls_amt", 0)) - profit_loss_rate = Decimal(item.get("evlu_pfls_rt", 0)) + + # KIS API ํ•„๋“œ๋ช… ์ •ํ™•ํžˆ ๋งคํ•‘ (๋กœ๊น… ์ถ”๊ฐ€) + average_price = Decimal(str(item.get("pchs_avg_pric", 0))) + + # ๐Ÿ”ฅ ์‹ค์‹œ๊ฐ„ ํ˜„์žฌ๊ฐ€ ์กฐํšŒ (์ž”๊ณ  ์กฐํšŒ์˜ prpr์€ ์‹ค์‹œ๊ฐ„์ด ์•„๋‹˜) + try: + price_resp = await korea_investment_api.get_current_price(stock_code) + if price_resp and "output" in price_resp: + realtime_price = int(price_resp["output"].get("stck_prpr", 0)) + if realtime_price > 0: + current_price = Decimal(str(realtime_price)) + else: + current_price = Decimal(str(item.get("prpr", 0))) + else: + current_price = Decimal(str(item.get("prpr", 0))) + except Exception as e: + logger.warning(f"โš ๏ธ ์‹ค์‹œ๊ฐ„ ํ˜„์žฌ๊ฐ€ ์กฐํšŒ ์‹คํŒจ ({stock_code}): {e}, fallback to prpr") + current_price = Decimal(str(item.get("prpr", 0))) + + # ํ‰๊ฐ€์†์ต ๋ฐ ์ˆ˜์ต๋ฅ  - KIS API์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ฐ’ ์šฐ์„  ์‚ฌ์šฉ + profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0))) + profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0))) + + # ๋””๋ฒ„๊น…: ์‹ค์ œ KIS API ์‘๋‹ต ๋กœ๊น… + logger.info( + f"Portfolio sync - {stock_name}({stock_code}): " + f"์ˆ˜๋Ÿ‰={quantity}, ํ‰๊ท ๋‹จ๊ฐ€={average_price}, ํ˜„์žฌ๊ฐ€={current_price}, " + f"ํ‰๊ฐ€์†์ต={profit_loss}, ์ˆ˜์ต๋ฅ ={profit_loss_rate}% | " + f"์›๋ณธ ํ•„๋“œ={item}" + ) + processed_codes.add(stock_code) if stock_code in existing_all: h = existing_all[stock_code] @@ -169,15 +399,25 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) ) db.add(new_holding) new_holdings.append(new_holding) - # ์ œ๊ฑฐ๋œ ์ข…๋ชฉ ๋ฐ˜์˜ (์ˆ˜๋Ÿ‰ 0 ์ฒ˜๋ฆฌ) + # ์ œ๊ฑฐ๋œ ์ข…๋ชฉ ๋ฐ˜์˜ (DB์—์„œ ์™„์ „ ์‚ญ์ œ) for code, h in existing_all.items(): if code not in processed_codes: - h.quantity = 0 + # ๋ณดํ˜ธ ๋กœ์ง: ์ตœ๊ทผ ๋งค์ˆ˜ ์ข…๋ชฉ์ด๋ฉด API ๋ฏธ๋ฐ˜์˜ ์ƒํƒœ๋กœ ๊ฐ„์ฃผํ•˜๊ณ  ์œ ์ง€ + # (KIS API๊ฐ€ ์ฒด๊ฒฐ ์งํ›„ ์ž”๊ณ  ๋ฐ˜์˜์ด ๋А๋ฆด ์ˆ˜ ์žˆ์Œ) + last_action = stock_status_map.get(code) + if last_action == "BUY" and h.quantity > 0: + logger.info(f"๐Ÿ›ก๏ธ Preserving {h.stock_name}({code}) - API calls it missing, but recent BUY exists") + new_holdings.append(h) + continue + + logger.info(f"๐Ÿ—‘๏ธ Deleting {h.stock_name}({code}) from portfolio DB (not in KIS API response)") + await db.delete(h) await db.commit() portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0] - portfolio_cache.cache_summary() + portfolio_cache.cache_summary(balance_data) # balance_data ์ „๋‹ฌ portfolio_cache.last_synced = datetime.now() portfolio_cache.change_flag = False + portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db) logger.info(f"Portfolio synced: {len(portfolio_cache.holdings)} holdings") return {"message": "Portfolio synchronized", "count": len(portfolio_cache.holdings), "cached": False} except Exception as e: @@ -188,11 +428,18 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}") # ๋น„๊ฐ•์ œ(๋ฐฑ๊ทธ๋ผ์šด๋“œ) ๋™๊ธฐํ™” ์‹คํŒจ ์‹œ์—๋Š” ์บ์‹œ/๋นˆ ๊ฐ’ ์‚ฌ์šฉ return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)} + finally: + portfolio_cache.sync_lock.release() @router.get("/summary") async def get_portfolio_summary(db: AsyncSession = Depends(get_db)): """ํฌํŠธํด๋ฆฌ์˜ค ์š”์•ฝ (์บ์‹œ ํ™œ์šฉ)""" + # ๊ฑฐ๋ž˜ ์ด๋ ฅ ๊ธฐ์ค€์œผ๋กœ ์บ์‹œ ๋ฌดํšจํ™” + latest_trade_id = await _get_latest_executed_trade_id(db) + if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id: + portfolio_cache.mark_changed() + if portfolio_cache.summary and not portfolio_cache.should_sync(): logger.debug("Portfolio cache hit (summary)") resp = dict(portfolio_cache.summary) @@ -211,7 +458,9 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)): "total_value": 0, "total_investment": 0, "total_profit_loss": 0, - "total_profit_loss_rate": 0 + "total_profit_loss_rate": 0, + "available_cash": 0, + "total_assets": 0 } resp = dict(base) resp.update({ @@ -226,3 +475,402 @@ async def refresh_portfolio(db: AsyncSession = Depends(get_db)): """๊ฐ•์ œ ์บ์‹œ ๋ฌดํšจํ™” ๋ฐ ์žฌ๋™๊ธฐํ™”""" portfolio_cache.change_flag = True return await sync_portfolio(db, force=True) + + +async def check_and_execute_exit_trades(db: AsyncSession): + """ + ๋ณด์œ  ์ข…๋ชฉ์˜ ์†์ ˆ/์ต์ ˆ ์กฐ๊ฑด์„ ์ฒดํฌํ•˜๊ณ  ์ž๋™ ๋งค๋„ ์‹คํ–‰ + CLAUDE.md ์„น์…˜ 6.2 + 7.3 + 7.4 ๊ธฐ๋ฐ˜: + - ATR ๊ธฐ๋ฐ˜ ์†์ ˆ: min(์ตœ์ €๊ฐ€ - 2.5ร—ATR, ์ง„์ž…๊ฐ€ -8%) + - 1์ฐจ ์ต์ ˆ(50%): RSI โ‰ฅ 50 + BB ์ค‘์•™์„  ์ƒํšŒ + - 2์ฐจ ์ต์ ˆ(์ „๋Ÿ‰): RSI 60~70 + BB ์ƒ๋‹จ์„  + - ํด๋ฐฑ: ๊ณ ์ • ๋น„์œจ ์†์ ˆ (STOP_LOSS_PERCENT) + """ + from app.core.config import settings + from datetime import datetime + + try: + await sync_portfolio(db, force=True) + + # MDD ์—…๋ฐ์ดํŠธ (async โ€” thread-safe lock ๋‚ด๋ถ€) + summary = await _get_portfolio_summary(db) + total_assets = float(summary.get("total_assets", 0)) if summary else 0 + mdd_action = await mdd_manager.update(total_assets) + if mdd_action["action"] != "NONE": + logger.warning(f"MDD ์กฐ์น˜: {mdd_action}") + + result = await db.execute( + select(Portfolio).where(Portfolio.quantity > 0) + ) + holdings = result.scalars().all() + + if not holdings: + logger.debug("No holdings to check for exit conditions") + return + + executed_exits = [] + + for holding in holdings: + try: + stock_code = holding.stock_code + stock_name = holding.stock_name + quantity = holding.quantity + avg_price = float(holding.average_price or 0) + + # ์‹ค์‹œ๊ฐ„ ๊ฐ€๊ฒฉ ํ™•์ธ + price_resp = await korea_investment_api.get_current_price(stock_code) + latest_price = float(holding.current_price or 0) + if price_resp and price_resp.get("output"): + latest_price = int(price_resp["output"].get("stck_prpr", latest_price)) + + if latest_price <= 0 or avg_price <= 0: + continue + + profit_loss_rate = ((latest_price - avg_price) / avg_price) * 100 + + # ActiveTrade ์กฐํšŒ (๋ถ„ํ•  ์ฒญ์‚ฐ ์ƒํƒœ ์ถ”์ ) + at_result = await db.execute( + select(ActiveTrade).where( + ActiveTrade.stock_code == stock_code, + ActiveTrade.status == "active" + ) + ) + active_trade = at_result.scalar_one_or_none() + remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct else 1.0 + + # ATR ๊ธฐ๋ฐ˜ ์ฒญ์‚ฐ ์‹œ๋„ (์ฐจํŠธ ๋ฐ์ดํ„ฐ ํ•„์š”) + exit_signal = None + try: + chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30) + chart_data = chart_resp.get("output2", []) + if len(chart_data) >= 20: + closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)] + highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)] + lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)] + + atr = calculate_atr(highs, lows, closes) + rsi = calculate_rsi(closes) + bb = calculate_bollinger(closes) + + if atr and rsi is not None and bb: + bb_upper, bb_middle, bb_lower = bb + lowest_since = min(lows[-5:]) if lows else avg_price + exit_signal = check_exit_signal( + rsi=rsi, + close=latest_price, + bb_upper=bb_upper, + bb_middle=bb_middle, + bb_lower=bb_lower, + entry_price=avg_price, + atr=atr, + lowest_since_entry=lowest_since, + remaining_pct=remaining_pct, + ) + except Exception as chart_err: + logger.debug(f"ATR ์ฒญ์‚ฐ ๊ณ„์‚ฐ ์‹คํŒจ ({stock_code}): {chart_err}") + + should_exit = False + exit_reason = "" + sell_quantity = quantity + order_price = latest_price + + if exit_signal: + should_exit = True + exit_reason = exit_signal.reason + if exit_signal.action == "SELL_PARTIAL": + sell_quantity = max(1, int(quantity * exit_signal.sell_pct)) + logger.info(f"ATR ์ฒญ์‚ฐ: {stock_name} - {exit_reason} (๋งค๋„ {sell_quantity}์ฃผ)") + elif profit_loss_rate <= settings.STOP_LOSS_PERCENT: + # ํด๋ฐฑ: ๊ณ ์ • ๋น„์œจ ์†์ ˆ (-6%) + should_exit = True + exit_reason = f"๊ณ ์ • ์†์ ˆ ({profit_loss_rate:.2f}% <= {settings.STOP_LOSS_PERCENT}%)" + elif profit_loss_rate >= settings.TAKE_PROFIT_PERCENT: + should_exit = True + exit_reason = f"๊ณ ์ • ์ต์ ˆ ({profit_loss_rate:.2f}% >= {settings.TAKE_PROFIT_PERCENT}%)" + + if should_exit: + logger.info(f"{exit_reason} - {stock_name}({stock_code})") + try: + order_result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="SELL", + quantity=sell_quantity, + price=order_price + ) + except Exception as e: + logger.warning(f"์ง€์ •๊ฐ€ ์‹คํŒจ, ์‹œ์žฅ๊ฐ€ ๋Œ€์ฒด: {e}") + order_result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="SELL", + quantity=sell_quantity, + price=None + ) + + executed_price = Decimal(str(order_result.get("output", {}).get("ord_prc", order_price))) + total_amount = Decimal(str(executed_price)) * sell_quantity + profit_loss = (float(executed_price) - avg_price) * sell_quantity + + trade = TradeHistory( + stock_code=stock_code, + stock_name=stock_name, + order_type="SELL", + order_price=Decimal(str(order_price)), + order_quantity=sell_quantity, + executed_price=executed_price, + executed_quantity=sell_quantity, + total_amount=total_amount, + status="COMPLETED" if order_result.get("rt_cd") == "0" else "FAILED", + order_id=order_result.get("odno"), + message=f"์ž๋™ ์ฒญ์‚ฐ: {profit_loss_rate:.2f}%", + exit_reason=f"{exit_reason}. ํ‰๊ท ๋‹จ๊ฐ€: {avg_price:,.0f}์›, ๋งค๋„๊ฐ€: {latest_price:,.0f}์›, ์†์ต: {profit_loss:,.0f}์›", + created_at=_get_kst_now(), + executed_at=_get_kst_now() if order_result.get("rt_cd") == "0" else None + ) + db.add(trade) + await db.commit() + + if order_result.get("rt_cd") == "0": + # ์—ฐ์† ์†์‹ค ์ถ”์  (์„น์…˜ 7.4) โ€” ์ธ๋ฉ”๋ชจ๋ฆฌ + DB ์˜์†ํ™” + is_loss = profit_loss < 0 + loss_tracker.record_trade_result(stock_code, is_loss) + await loss_tracker.save_to_db(db, stock_code) + + # ๋ถ„ํ•  ์ฒญ์‚ฐ ์‹œ ActiveTrade ์ž”์—ฌ ๋น„์œจ ์—…๋ฐ์ดํŠธ + if exit_signal and exit_signal.action == "SELL_PARTIAL" and active_trade: + active_trade.remaining_pct = Decimal(str(remaining_pct - exit_signal.sell_pct)) + await db.commit() + else: + # ์ „๋Ÿ‰ ๋งค๋„ + holding.quantity = max(0, quantity - sell_quantity) + holding.current_price = Decimal(str(latest_price)) + if active_trade: + active_trade.status = "closed" + await db.commit() + + portfolio_cache.mark_changed() + executed_exits.append({ + "stock": stock_name, + "reason": exit_reason, + "profit_loss": profit_loss_rate, + "sell_quantity": sell_quantity, + }) + logger.info(f"์ž๋™ ๋งค๋„ ์™„๋ฃŒ: {stock_name} {sell_quantity}์ฃผ @ {latest_price:,.0f}์›") + + # ๋งค๋งค์ผ์ง€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ƒ์„ฑ + asyncio.create_task(_create_journal_background( + trade_id=trade.id, + stock_code=stock_code, + stock_name=stock_name, + order_type="SELL", + executed_price=float(executed_price), + quantity=sell_quantity, + profit_loss=profit_loss, + profit_loss_rate=profit_loss_rate, + exit_reason=exit_reason, + )) + + except Exception as e: + logger.error(f"Exit trade error for {holding.stock_code}: {e}", exc_info=True) + continue + + if executed_exits: + logger.info(f"Executed {len(executed_exits)} exit trades: {executed_exits}") + else: + logger.debug("No exit conditions met for current holdings") + + except Exception as e: + logger.error(f"check_and_execute_exit_trades error: {e}", exc_info=True) + + +async def _create_journal_background( + trade_id: int, + stock_code: str, + stock_name: str, + order_type: str, + executed_price: float, + quantity: int, + profit_loss: float, + profit_loss_rate: float, + exit_reason: str, +): + """๋งค๋„ ์ฒด๊ฒฐ ํ›„ AI ๋งค๋งค์ผ์ง€ ์ž๋™ ์ƒ์„ฑ (๋ฐฑ๊ทธ๋ผ์šด๋“œ ํƒœ์Šคํฌ)""" + try: + import httpx + from app.core.config import settings + + trade_info = { + "stock_code": stock_code, + "stock_name": stock_name, + "order_type": order_type, + "executed_price": executed_price, + "quantity": quantity, + "profit_loss": profit_loss, + "profit_loss_rate": profit_loss_rate, + "exit_reason": exit_reason, + } + + content = None + mood = None + tags = [] + + # MCP ์„œ๋ฒ„๋กœ AI ์ผ์ง€ ์ƒ์„ฑ ์š”์ฒญ (์‹คํŒจ ์‹œ ๊ทœ์น™ ๊ธฐ๋ฐ˜ ํด๋ฐฑ) + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{settings.MCP_SERVER_URL}/analyze", + json={"analysis_type": "GENERATE_JOURNAL", "data": trade_info}, + timeout=30.0, + ) + if resp.status_code == 200: + result = resp.json().get("result", {}) + content = result.get("content") or result.get("journal") or result.get("analysis") + mood = result.get("mood") + tags = result.get("tags", []) + except Exception as mcp_err: + logger.debug(f"MCP ์ผ์ง€ ์ƒ์„ฑ ์‹คํŒจ ({stock_code}): {mcp_err}") + + # ํด๋ฐฑ: ๊ทœ์น™ ๊ธฐ๋ฐ˜ ๊ฐ„๋‹จ ์ผ์ง€ + if not content: + direction = "์ต์ ˆ" if profit_loss_rate > 0 else "์†์ ˆ" + sign = "+" if profit_loss > 0 else "" + content = ( + f"{stock_name}({stock_code}) ๋งค๋„ ์ฒด๊ฒฐ.\n" + f"๋งค๋„๊ฐ€: {executed_price:,.0f}์› ร— {quantity}์ฃผ\n" + f"์†์ต: {sign}{profit_loss:,.0f}์› ({sign}{profit_loss_rate:.2f}%)\n" + f"์‚ฌ์œ : {exit_reason}" + ) + mood = "์ข‹์Œ" if profit_loss > 0 else "๋‚˜์จ" + tags = [direction, stock_code] + + # DB ์ €์žฅ (๋ณ„๋„ ์„ธ์…˜ ์‚ฌ์šฉ โ€” ํ˜ธ์ถœ ์‹œ์ ์˜ ์„ธ์…˜์€ ์ด๋ฏธ ๋‹ซํ˜”์„ ์ˆ˜ ์žˆ์Œ) + async with AsyncSessionLocal() as session: + journal = TradeJournal( + trade_id=trade_id, + stock_code=stock_code, + stock_name=stock_name, + journal_date=datetime.utcnow(), + content=content, + strategy_name="AUTO_TRADE", + mood=mood, + tags=tags if tags else None, + ) + session.add(journal) + await session.commit() + logger.info(f"๋งค๋งค์ผ์ง€ ์ƒ์„ฑ ์™„๋ฃŒ: {stock_name} (trade_id={trade_id})") + + # WebSocket์œผ๋กœ ํ”„๋ก ํŠธ์—”๋“œ์— ์ฆ‰์‹œ ์•Œ๋ฆผ + await websocket_manager.broadcast_json({ + "type": "journal_created", + "data": {"stock_code": stock_code, "stock_name": stock_name}, + }) + except Exception as e: + logger.error(f"๋งค๋งค์ผ์ง€ ์ƒ์„ฑ ์˜ค๋ฅ˜ ({stock_code}): {e}", exc_info=True) + + +async def _get_portfolio_summary(db: AsyncSession) -> dict: + """ํฌํŠธํด๋ฆฌ์˜ค ์š”์•ฝ (MDD ๊ณ„์‚ฐ์šฉ)""" + try: + result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) + holdings = result.scalars().all() + total_eval = sum(float(h.current_price or 0) * h.quantity for h in holdings) + total_invest = sum(float(h.average_price or 0) * h.quantity for h in holdings) + return {"total_assets": total_eval, "total_invest": total_invest} + except Exception: + return {"total_assets": 0, "total_invest": 0} + + +@router.post("/sell") +async def sell_stock( + request: SellRequest, + db: AsyncSession = Depends(get_db) +): + """๋ณด์œ  ์ข…๋ชฉ ์ˆ˜๋™ ๋งค๋„ (์ฒญ์‚ฐ ๋ฒ„ํŠผ)""" + try: + stock_code = request.stock_code + quantity = request.quantity + price = request.price + + logger.info(f"๐Ÿ”ต ์ˆ˜๋™ ๋งค๋„ ์š”์ฒญ: {stock_code} {quantity}์ฃผ @ {price or '์‹œ์žฅ๊ฐ€'}") + + # ๋ณด์œ  ์—ฌ๋ถ€ ํ™•์ธ + result = await db.execute( + select(Portfolio).where(Portfolio.stock_code == stock_code) + ) + holding = result.scalar_one_or_none() + + if not holding: + raise HTTPException(status_code=404, detail=f"๋ณด์œ ํ•˜์ง€ ์•Š์€ ์ข…๋ชฉ์ž…๋‹ˆ๋‹ค: {stock_code}") + + if holding.quantity < quantity: + raise HTTPException( + status_code=400, + detail=f"๋ณด์œ  ์ˆ˜๋Ÿ‰ ๋ถ€์กฑ: {holding.quantity}์ฃผ ๋ณด์œ  ์ค‘, {quantity}์ฃผ ๋งค๋„ ์š”์ฒญ" + ) + + # ๋งค๋„ ์ฃผ๋ฌธ ์‹คํ–‰ + order_result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type='SELL', + quantity=quantity, + price=price # None์ด๋ฉด ์‹œ์žฅ๊ฐ€ + ) + + if not order_result or order_result.get('rt_cd') != '0': + error_msg = order_result.get('msg1', '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜') if order_result else 'API ํ˜ธ์ถœ ์‹คํŒจ' + raise HTTPException(status_code=500, detail=f"๋งค๋„ ์ฃผ๋ฌธ ์‹คํŒจ: {error_msg}") + + # TradeHistory ๊ธฐ๋ก + executed_price = price if price else holding.current_price # ์ฒด๊ฒฐ๊ฐ€๋Š” ๋‚˜์ค‘์— ์—…๋ฐ์ดํŠธ + profit_loss = (executed_price - holding.average_price) * quantity + profit_loss_rate = ((executed_price / holding.average_price) - 1) * 100 + + trade = TradeHistory( + stock_code=stock_code, + stock_name=holding.stock_name, + order_type='SELL', + order_price=Decimal(str(price)) if price else None, + order_quantity=quantity, + executed_price=Decimal(str(executed_price)), + executed_quantity=quantity, + total_amount=Decimal(str(executed_price * quantity)), + status='EXECUTED', + order_id=order_result.get('output', {}).get('ODNO'), + message=f"์ˆ˜๋™ ์ฒญ์‚ฐ: ์†์ต {profit_loss:+,.0f}์› ({profit_loss_rate:+.2f}%)", + exit_reason=f"์‚ฌ์šฉ์ž ์ˆ˜๋™ ์ฒญ์‚ฐ (์†์ต: {profit_loss:+,.0f}์›, ์†์ต๋ฅ : {profit_loss_rate:+.2f}%)", + created_at=_get_kst_now(), + executed_at=_get_kst_now() + ) + db.add(trade) + await db.commit() + + # ํฌํŠธํด๋ฆฌ์˜ค ์บ์‹œ ๋ฌดํšจํ™” + portfolio_cache.mark_changed() + + logger.info(f"โœ… ์ˆ˜๋™ ๋งค๋„ ์™„๋ฃŒ: {holding.stock_name} {quantity}์ฃผ @ {executed_price:,.0f}์› (์†์ต: {profit_loss:+,.0f}์›)") + + # ๋งค๋งค์ผ์ง€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ƒ์„ฑ + asyncio.create_task(_create_journal_background( + trade_id=trade.id, + stock_code=stock_code, + stock_name=holding.stock_name, + order_type="SELL", + executed_price=float(executed_price), + quantity=quantity, + profit_loss=float(profit_loss), + profit_loss_rate=float(profit_loss_rate), + exit_reason=f"์ˆ˜๋™ ์ฒญ์‚ฐ (์†์ต: {profit_loss:+,.0f}์›, {profit_loss_rate:+.2f}%)", + )) + + return { + "success": True, + "message": f"{holding.stock_name} {quantity}์ฃผ ๋งค๋„ ์ฃผ๋ฌธ ์™„๋ฃŒ", + "order_id": order_result.get('output', {}).get('ODNO'), + "profit_loss": float(profit_loss), + "profit_loss_rate": float(profit_loss_rate) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Manual sell error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"๋งค๋„ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜: {str(e)}") diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 52ce90e..7967a08 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -1,20 +1,114 @@ from fastapi import APIRouter, HTTPException, Query, Depends from typing import List, Optional from pydantic import BaseModel -from datetime import datetime, date +from datetime import datetime, date, timedelta from decimal import Decimal import asyncio import logging +import httpx +import pytz from app.services.korea_investment_api import korea_investment_api -from app.core.database import get_db +from app.core.database import get_db, AsyncSessionLocal from app.models.models import TradeHistory, Portfolio, StockAnalysis from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, desc + +from app.core.config import settings +from app.core.market_schedule import is_market_open +from app.services.mean_reversion_strategy import ( + MarketRegime, detect_market_regime, check_buy_signal, + calculate_atr, calculate_adx, calculate_rsi, calculate_bollinger, is_reversal_candle, +) +from app.services.mdd_manager import mdd_manager +from app.services.loss_tracker import loss_tracker router = APIRouter() logger = logging.getLogger(__name__) +# ํ•œ๊ตญ ์‹œ๊ฐ„๋Œ€ +KST = pytz.timezone('Asia/Seoul') + +def _get_kst_now(): + """ํ˜„์žฌ ํ•œ๊ตญ ์‹œ๊ฐ„ ๋ฐ˜ํ™˜ (timezone-naive datetime)""" + return datetime.now(KST).replace(tzinfo=None) + +# Trend filter defaults (configurable via settings) +TREND_FILTER_ENABLED = getattr(settings, "TREND_FILTER_ENABLED", True) +TREND_MA_SHORT = int(getattr(settings, "TREND_MA_SHORT", 20)) +TREND_MA_LONG = int(getattr(settings, "TREND_MA_LONG", 60)) +TREND_TOLERANCE_PCT = float(getattr(settings, "TREND_TOLERANCE_PCT", 0.005)) + +# ๊ธ‰๋“ฑ ๊ณผ์—ด ํ•„ํ„ฐ defaults +OVERHEAT_FILTER_ENABLED = getattr(settings, "OVERHEAT_FILTER_ENABLED", True) +OVERHEAT_3DAY_MAX = float(getattr(settings, "OVERHEAT_3DAY_MAX", 15.0)) +OVERHEAT_5DAY_MAX = float(getattr(settings, "OVERHEAT_5DAY_MAX", 20.0)) +OVERHEAT_10DAY_MAX = float(getattr(settings, "OVERHEAT_10DAY_MAX", 30.0)) +OVERHEAT_DISPARITY_MAX = float(getattr(settings, "OVERHEAT_DISPARITY_MAX", 108.0)) + + +def _ma(values: list, window: int) -> Optional[float]: + try: + if len(values) < window: + return None + return sum(values[-window:]) / window + except Exception: + return None + + +def _passes_uptrend(closes: list, ma_short: int = TREND_MA_SHORT, ma_long: int = TREND_MA_LONG, tol: float = TREND_TOLERANCE_PCT) -> Optional[bool]: + if not closes or len(closes) < max(ma_short, ma_long): + return None + ma_s = _ma(closes, ma_short) + ma_l = _ma(closes, ma_long) + close = closes[-1] + if ma_s is None or ma_l is None: + return None + # ํ—ˆ์šฉ ์˜ค์ฐจ ์ ์šฉ: ๊ฑฐ์˜ ๊ณจ๋“ ํฌ๋กœ์Šค/์ดํƒˆ ์งํ›„๋„ ํ—ˆ์šฉ + return (ma_s >= ma_l * (1 - tol)) and (close >= ma_s * (1 - tol)) + + +def analyze_overheat(closes: list, ma20: Optional[float]) -> dict: + """๊ธ‰๋“ฑ ๊ณผ์—ด ํ•„ํ„ฐ - ๋‹จ๊ธฐ ๊ธ‰๋“ฑ ์ข…๋ชฉ ๋งค์ˆ˜ ์ฐจ๋‹จ + + 3์ผ/5์ผ/10์ผ ์ƒ์Šน๋ฅ ๊ณผ 20์ผ์„  ์ด๊ฒฉ๋„๋ฅผ ์ข…ํ•ฉ ํŒ๋‹จํ•˜์—ฌ + ๊ณ ์  ์ถ”๊ฒฉ ๋งค์ˆ˜๋ฅผ ์›์ฒœ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. + + Returns: + {"blocked": bool, "reason": str, "details": dict} + """ + details = {} + reasons = [] + + if len(closes) >= 4: + gain_3d = ((closes[-1] - closes[-4]) / closes[-4]) * 100 if closes[-4] > 0 else 0 + details["gain_3d"] = round(gain_3d, 1) + if gain_3d > OVERHEAT_3DAY_MAX: + reasons.append(f"3์ผ +{gain_3d:.1f}% (>{OVERHEAT_3DAY_MAX}%)") + + if len(closes) >= 6: + gain_5d = ((closes[-1] - closes[-6]) / closes[-6]) * 100 if closes[-6] > 0 else 0 + details["gain_5d"] = round(gain_5d, 1) + if gain_5d > OVERHEAT_5DAY_MAX: + reasons.append(f"5์ผ +{gain_5d:.1f}% (>{OVERHEAT_5DAY_MAX}%)") + + if len(closes) >= 11: + gain_10d = ((closes[-1] - closes[-11]) / closes[-11]) * 100 if closes[-11] > 0 else 0 + details["gain_10d"] = round(gain_10d, 1) + if gain_10d > OVERHEAT_10DAY_MAX: + reasons.append(f"10์ผ +{gain_10d:.1f}% (>{OVERHEAT_10DAY_MAX}%)") + + if ma20 and ma20 > 0: + disparity = (closes[-1] / ma20) * 100 + details["disparity_20"] = round(disparity, 1) + if disparity > OVERHEAT_DISPARITY_MAX: + reasons.append(f"์ด๊ฒฉ๋„ {disparity:.1f}% (>{OVERHEAT_DISPARITY_MAX}%)") + + blocked = len(reasons) > 0 + reason = "๊ธ‰๋“ฑ๊ณผ์—ด: " + ", ".join(reasons) if blocked else "" + + return {"blocked": blocked, "reason": reason, "details": details} + class RecommendationCriteria(BaseModel): """์ถ”์ฒœ ์ข…๋ชฉ ์กฐ๊ฑด""" @@ -25,6 +119,11 @@ class RecommendationCriteria(BaseModel): use_stochastic: bool = False use_adx: bool = False use_obv: bool = False + use_volatility_breakout: bool = False # ๋ณ€๋™์„ฑ ๋ŒํŒŒ ์ „๋žต + use_overheat_filter: bool = True # ๊ธ‰๋“ฑ ๊ณผ์—ด ํ•„ํ„ฐ (๊ณ ์  ์ถ”๊ฒฉ ๋ฐฉ์ง€) + min_volume_ratio: float = 1.0 # ํ‰๊ท  ๋Œ€๋น„ ๊ฑฐ๋ž˜๋Ÿ‰ ๋ฐฐ์ˆ˜ (1.0 = ํ•„ํ„ฐ ์—†์Œ) + max_disparity: float = 200.0 # ์ตœ๋Œ€ ์ด๊ฒฉ๋„ (20์ผ์„  ๋Œ€๋น„ %) - ์™„ํ™” + for_batch: bool = False # ๋ฐฐ์น˜ ๋ถ„์„ ๋ชจ๋“œ: ํ•„ํ„ฐ ์™„ํ™”, score=0๋„ ๋ฐ˜ํ™˜ class StockRecommendation(BaseModel): @@ -47,11 +146,19 @@ class StockRecommendation(BaseModel): bollinger_upper: Optional[float] = None bollinger_lower: Optional[float] = None bollinger_middle: Optional[float] = None + # ML ์•™์ƒ๋ธ” ์Šค์ฝ”์–ด + ml_score: Optional[int] = None # ์•™์ƒ๋ธ” ์Šค์ฝ”์–ด (0~100) + ml_confidence: Optional[float] = None # ์‹ ๋ขฐ๋„ (0~1) + ml_signal: Optional[str] = None # BUY/HOLD/SELL + # ATR (๋ฐฐ์น˜ ์ €์žฅ์šฉ โ€” ์ด์ค‘ API ํ˜ธ์ถœ ๋ฐฉ์ง€) + atr_14: Optional[float] = None + # ADX (์ถ”์„ธ ๊ฐ•๋„ โ€” ํ‰๊ท ํšŒ๊ท€ ํ•„ํ„ฐ์šฉ) + adx_14: Optional[float] = None # ๊ณต์‹ ๊ธฐ๋ฐ˜ ์‹ ํ˜ธ ๋ถ„์„ ํ•จ์ˆ˜ (์ด๋™ํ‰๊ท  ์ •๋ฐฐ์—ด/์—ญ๋ฐฐ์—ด, ๋ˆŒ๋ฆผ๋ชฉ, ์†์ ˆ/์ต์ ˆ) def analyze_custom_signals(chart_data): - """์ปค์Šคํ…€ ๋งค๋งค ๊ณต์‹ ๊ธฐ๋ฐ˜ ์‹ ํ˜ธ ๋ถ„์„""" + """์ปค์Šคํ…€ ๋งค๋งค ๊ณต์‹ ๊ธฐ๋ฐ˜ ์‹ ํ˜ธ ๋ถ„์„ - ์ด๊ฒฉ๋„ ํ•„ํ„ฐ ์ ์šฉ""" closes = [float(item.get("stck_clpr", 0)) for item in chart_data] if len(closes) < 120: return None @@ -61,18 +168,34 @@ def analyze_custom_signals(chart_data): ma120 = sum(closes[-120:]) / 120 close = closes[-1] - # ์ •๋ฐฐ์—ด ๋งค์ˆ˜ + # ๐Ÿ”ฅ ์ด๊ฒฉ๋„ ๊ณ„์‚ฐ (์ถ”๊ฒฉ ๋งค์ˆ˜ ๋ฐฉ์ง€) - ๋” ์—„๊ฒฉํ•˜๊ฒŒ + disparity = (close / ma20) * 100 + + # ๐Ÿšซ ๊ธ‰๋“ฑ ์ข…๋ชฉ ํ•„ํ„ฐ: ์ตœ๊ทผ 3์ผ ์ƒ์Šน๋ฅ  ์ฒดํฌ + if len(closes) >= 3: + price_3days_ago = closes[-4] + recent_gain = ((close - price_3days_ago) / price_3days_ago) * 100 if price_3days_ago > 0 else 0 + if recent_gain > 10: # 3์ผ๊ฐ„ 10% ์ด์ƒ ๊ธ‰๋“ฑ ์‹œ ๋งค์ˆ˜ ์•ˆํ•จ + return {"signal": None, "description": f"๊ธ‰๋“ฑ ์ข…๋ชฉ ์ถ”๊ฒฉ ๊ธˆ์ง€ (3์ผ +{recent_gain:.1f}%)"} + + # ์ •๋ฐฐ์—ด ๋งค์ˆ˜ - ์ด๊ฒฉ๋„ ์กฐ๊ฑด ๊ฐ•ํ™” (105% -> 102%) if ma20 > ma60 > ma120 and close > ma20: - return {"signal": "buy", "description": "์ •๋ฐฐ์—ด ์ƒ์Šน ๋งค์ˆ˜ํƒ€์ "} - + if disparity <= 102: # ๋” ์—„๊ฒฉํ•˜๊ฒŒ + return {"signal": "buy", "description": "์ •๋ฐฐ์—ด ๋ˆŒ๋ฆผ๋ชฉ ๋งค์ˆ˜ (์ด๊ฒฉ๋„ ์•ˆ์ •)"} + else: + return {"signal": None, "description": f"์ •๋ฐฐ์—ด์ด๋‚˜ ์ด๊ฒฉ ๊ณผ์—ด ({disparity:.1f}%)"} + # ์—ญ๋ฐฐ์—ด ๋งค๋„ elif ma20 < ma60 < ma120 and close < ma20: return {"signal": "sell", "description": "์—ญ๋ฐฐ์—ด ํ•˜๋ฝ ๋งค๋„ํƒ€์ "} - - # ๋ˆŒ๋ฆผ๋ชฉ ์ง„์ž… + + # ๋ˆŒ๋ฆผ๋ชฉ ์ง„์ž… - ์ด๊ฒฉ๋„ ์กฐ๊ฑด ๊ฐ•ํ™” (103% -> 101%) recent_lows = [float(item.get("stck_lwpr", 0)) for item in chart_data[-5:]] if any(low < ma20 for low in recent_lows) and close > ma20: - return {"signal": "buy", "description": "๋ˆŒ๋ฆผ๋ชฉ ์ง„์ž… ๋งค์ˆ˜ํƒ€์ "} + if disparity <= 101: # ๋” ์—„๊ฒฉํ•˜๊ฒŒ + return {"signal": "buy", "description": "๋ˆŒ๋ฆผ๋ชฉ ์ง„์ž… ๋งค์ˆ˜ํƒ€์ "} + else: + return {"signal": None, "description": f"๋ˆŒ๋ฆผ๋ชฉ์ด๋‚˜ ์ด๊ฒฉ ๊ณผ์—ด ({disparity:.1f}%)"} # ์†์ ˆ/์ต์ ˆ stop_loss_line = ma60 @@ -127,13 +250,12 @@ def analyze_macd(chart_data: list) -> dict: def analyze_rsi(chart_data: list) -> dict: - """RSI ๋ถ„์„ (๊ณผ๋งค์ˆ˜/๊ณผ๋งค๋„)""" - if len(chart_data) < 14: + """RSI ๋ถ„์„ (๊ณผ๋งค์ˆ˜/๊ณผ๋งค๋„) - ๋ฐ”๋‹ฅ ํƒˆ์ถœ ์ „๋žต""" + if len(chart_data) < 15: return {"signal": None, "description": "๋ฐ์ดํ„ฐ ๋ถ€์กฑ"} + # ํ˜„์žฌ RSI ๊ณ„์‚ฐ prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-15:]] - - # RSI ๊ณ„์‚ฐ gains = [] losses = [] for i in range(1, len(prices)): @@ -154,14 +276,42 @@ def analyze_rsi(chart_data: list) -> dict: rs = avg_gain / avg_loss rsi = 100 - (100 / (1 + rs)) - # ์กฐ๊ฑด ์™„ํ™”: 40 ์ดํ•˜ ๋งค์ˆ˜, 60 ์ด์ƒ ๋งค๋„ - if rsi < 40: - return {"signal": "buy", "description": f"RSI ๊ณผ๋งค๋„ ({rsi:.1f})"} - elif rsi > 60: + # ์ด์ „ RSI ๊ณ„์‚ฐ (์ถ”์„ธ ํ™•์ธ์šฉ) + if len(chart_data) >= 16: + prev_prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-16:-1]] + prev_gains = [] + prev_losses = [] + for i in range(1, len(prev_prices)): + change = prev_prices[i] - prev_prices[i-1] + if change > 0: + prev_gains.append(change) + prev_losses.append(0) + else: + prev_gains.append(0) + prev_losses.append(abs(change)) + + prev_avg_gain = sum(prev_gains[-14:]) / 14 + prev_avg_loss = sum(prev_losses[-14:]) / 14 + if prev_avg_loss == 0: + rsi_prev = 100 + else: + prev_rs = prev_avg_gain / prev_avg_loss + rsi_prev = 100 - (100 / (1 + prev_rs)) + else: + rsi_prev = rsi + + # ๐Ÿ”ฅ Falling Knife ๋ฐฉ์ง€: ๋ฐ”๋‹ฅ ํƒˆ์ถœ ํ™•์ธ + if rsi < 30: + # 30 ๋ฏธ๋งŒ์€ ๊ด€๋ง (ํ•˜๋ฝ์„ธ๊ฐ€ ๊ฐ•ํ•จ - ๋–จ์–ด์ง€๋Š” ์นผ๋‚ ) + return {"signal": None, "description": f"RSI ๊ณผ๋งค๋„ ์‹ฌํ™” ({rsi:.1f}) - ๋ฐ˜๋“ฑ ๋Œ€๊ธฐ"} + elif 30 <= rsi <= 45 and rsi > rsi_prev: + # ๋ฐ”๋‹ฅ์„ ์ฐ๊ณ  ์˜ฌ๋ผ์˜ค๋Š” ๊ตฌ๊ฐ„ + ์ƒ์Šน ์ค‘ + return {"signal": "buy", "description": f"RSI ๋ฐ”๋‹ฅ ํƒˆ์ถœ ๋ฐ˜๋“ฑ ({rsi:.1f})"} + elif rsi > 70: return {"signal": "sell", "description": f"RSI ๊ณผ๋งค์ˆ˜ ({rsi:.1f})"} - elif 45 <= rsi <= 55: - # ์ค‘๋ฆฝ ๊ตฌ๊ฐ„๋„ ์•ฝํ•œ ๋งค์ˆ˜ ์‹ ํ˜ธ๋กœ - return {"signal": "buy", "description": f"RSI ์ค‘๋ฆฝ-์ƒ์Šน ({rsi:.1f})"} + elif 45 <= rsi <= 60: + # ์ค‘๋ฆฝ ์ƒ์Šน ๊ตฌ๊ฐ„ + return {"signal": "buy", "description": f"RSI ์•ˆ์ • ์ƒ์Šน ({rsi:.1f})"} return {"signal": None, "description": f"RSI ์ค‘๋ฆฝ ({rsi:.1f})"} @@ -245,29 +395,158 @@ def analyze_stochastic(chart_data: list) -> dict: return {"signal": None, "description": f"์Šคํ† ์บ์Šคํ‹ฑ ์ค‘๋ฆฝ (%K: {k:.1f})"} -def analyze_adx(chart_data: list) -> dict: - """ADX ๋ถ„์„ (์ถ”์„ธ ๊ฐ•๋„)""" - if len(chart_data) < 14: +def analyze_volatility_breakout(chart_data: list, k: float = 0.5) -> dict: + """๐Ÿ”ฅ ๋ณ€๋™์„ฑ ๋ŒํŒŒ ์ „๋žต (๋ž˜๋ฆฌ ์œŒ๋ฆฌ์—„์Šค) - ์Šน๋ฅ ์ด ๋งค์šฐ ๋†’์€ ๋‹จํƒ€ ์ „๋žต + + ๊ณต์‹: ํ˜„์žฌ๊ฐ€ > ์‹œ๊ฐ€ + (์ „์ผ ๊ณ ๊ฐ€ - ์ „์ผ ์ €๊ฐ€) * K + """ + if len(chart_data) < 2: return {"signal": None, "description": "๋ฐ์ดํ„ฐ ๋ถ€์กฑ"} - # ๊ฐ„๋‹จํ•œ ADX ์ถ”์ • (๋ณ€๋™์„ฑ ๊ธฐ๋ฐ˜) - recent_data = chart_data[-14:] - changes = [] - for item in recent_data: - high = float(item.get("stck_hgpr", 0)) - low = float(item.get("stck_lwpr", 0)) - changes.append(high - low) + today = chart_data[-1] # ์ตœ์‹  ๋ฐ์ดํ„ฐ (์‹œ๊ฐ„์ˆœ ์ •๋ ฌ ๊ธฐ์ค€ ๋งˆ์ง€๋ง‰) + yesterday = chart_data[-2] # ์ „์ผ ๋ฐ์ดํ„ฐ - avg_change = sum(changes) / len(changes) - current_change = changes[-1] + # ์˜ค๋Š˜ ๋ฐ์ดํ„ฐ + current_price = float(today.get("stck_clpr", 0)) # ํ˜„์žฌ๊ฐ€ + open_price = float(today.get("stck_oprc", 0)) # ์‹œ๊ฐ€ - # ์ถ”์„ธ ๊ฐ•๋„ ์ถ”์ • - if current_change > avg_change * 1.5: - return {"signal": "buy", "description": "ADX ๊ฐ•ํ•œ ์ถ”์„ธ"} - elif current_change < avg_change * 0.5: - return {"signal": "sell", "description": "ADX ์•ฝํ•œ ์ถ”์„ธ"} + # ์ „์ผ ๋ฐ์ดํ„ฐ + prev_high = float(yesterday.get("stck_hgpr", 0)) + prev_low = float(yesterday.get("stck_lwpr", 0)) + prev_range = prev_high - prev_low - return {"signal": None, "description": "ADX ์ค‘๋ฆฝ"} + if prev_range == 0 or open_price == 0: + return {"signal": None, "description": "๋ฐ์ดํ„ฐ ๋ถ€์กฑ"} + + # ๋ชฉํ‘œ ๋ŒํŒŒ๊ฐ€ = ์‹œ๊ฐ€ + (์ „์ผ ๋ณ€๋™ํญ * K) + target_price = open_price + (prev_range * k) + + if current_price >= target_price: + return {"signal": "buy", "description": f"๋ณ€๋™์„ฑ ๋ŒํŒŒ ๋ฐœ์ƒ (๋ชฉํ‘œ๊ฐ€: {target_price:.0f})"} + + return {"signal": None, "description": "๋ณ€๋™์„ฑ ๋ŒํŒŒ ๋ฏธ๋‹ฌ"} + + +async def check_market_regime() -> dict: + """์‹œ์žฅ ๋ ˆ์ง ํŒ๋ณ„ (CLAUDE.md ์„น์…˜ 7.1: 200์ผ MA + ๊ธฐ์šธ๊ธฐ) + + ์ƒ์Šน์žฅ(Bull): ์ฝ”์Šคํ”ผ > MA200 AND MA200 ๊ธฐ์šธ๊ธฐ ์–‘(+) + ํ•˜๋ฝ์žฅ(Bear): ๊ทธ ์™ธ + + ํ•˜์œ„ ๋ถ„๋ฅ˜ (๊ธฐ์กด ํ˜ธํ™˜): + - strong_bearish: ์ฝ”์Šคํ”ผ < MA60 ๋˜๋Š” 5์ผ ์—ฐ์† ํ•˜๋ฝ + - bearish: ์ฝ”์Šคํ”ผ < MA20 + - bullish: ์ฝ”์Šคํ”ผ > MA200 + ๊ธฐ์šธ๊ธฐ ์ƒ์Šน + """ + try: + # ์žฅ์™ธ ์‹œ๊ฐ„์—๋Š” ๋ชจ์˜ํˆฌ์ž API๊ฐ€ ์ฐจํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š์Œ โ†’ neutral ๋ฐ˜ํ™˜ + if not is_market_open(): + return {"regime": "neutral", "regime_200d": "bear", "message": "์žฅ์™ธ ์‹œ๊ฐ„ - ์‹œ์žฅ ๋ ˆ์ง ํŒ๋ณ„ ์Šคํ‚ต", "position_multiplier": 0.5} + + # KODEX 200 (069500) = KOSPI 200 ์ถ”์ข… ETF ์‚ฌ์šฉ (์ง€์ˆ˜์ฝ”๋“œ 0001์€ daily-price API ๋ฏธ์ง€์›) + kospi_chart = await korea_investment_api.get_daily_chart("069500", "D", limit=240) + + if "output2" not in kospi_chart or not kospi_chart["output2"]: + logger.warning("KOSPI ์ง€์ˆ˜ ๋ฐ์ดํ„ฐ ์—†์Œ - ์‹œ์žฅ ํ•„ํ„ฐ ๋น„ํ™œ์„ฑํ™”") + return {"regime": "neutral", "message": "์‹œ์žฅ ์ง€์ˆ˜ ํ™•์ธ ๋ถˆ๊ฐ€", "position_multiplier": 0.5} + + chart_data = kospi_chart["output2"] + closes = [float(item.get("stck_clpr", 0)) for item in chart_data] + + if len(closes) < 60: + return {"regime": "neutral", "message": "๋ฐ์ดํ„ฐ ๋ถ€์กฑ", "position_multiplier": 0.5} + + current_price = closes[0] + ma20 = sum(closes[:20]) / 20 + ma60 = sum(closes[:60]) / 60 + + # 200์ผ MA ๊ธฐ๋ฐ˜ ๋ ˆ์ง ํŒ๋ณ„ (์„น์…˜ 7.1) + if len(closes) >= 205: + regime_enum = detect_market_regime(list(reversed(closes))) + is_bull_200 = regime_enum == MarketRegime.BULL + ma200 = sum(closes[:200]) / 200 + else: + is_bull_200 = current_price > ma20 # 200์ผ ๋ฐ์ดํ„ฐ ๋ถ€์กฑ ์‹œ MA20 ํด๋ฐฑ + ma200 = ma60 + + # 5์ผ ์—ฐ์† ํ•˜๋ฝ ์ฒดํฌ + consecutive_down = 0 + for i in range(len(closes) - 1): + if closes[i] < closes[i + 1]: + consecutive_down += 1 + else: + break + + drop_from_ma20 = ((current_price - ma20) / ma20) * 100 + + # ๊ฐ•ํ•œ ํ•˜๋ฝ์žฅ: ์ฝ”์Šคํ”ผ < MA60 ๋˜๋Š” 5์ผ ์—ฐ์† ํ•˜๋ฝ + if current_price < ma60 or consecutive_down >= 5: + return { + "regime": "strong_bearish", + "regime_200d": "bear", + "message": f"๊ฐ•ํ•œ ํ•˜๋ฝ์žฅ (์ฝ”์Šคํ”ผ: {current_price:.0f}, MA200: {ma200:.0f}, {consecutive_down}์ผ ์—ฐ์†ํ•˜๋ฝ)", + "position_multiplier": 0.1, + "min_score": settings.BEAR_MARKET_MIN_SCORE + 3, + "max_trades": 1, + "consecutive_down": consecutive_down, + "drop_from_ma20": round(drop_from_ma20, 1), + } + + # ์•ฝ์„ธ์žฅ: ์ฝ”์Šคํ”ผ < MA20 + if current_price < ma20: + return { + "regime": "bearish", + "regime_200d": "bear", + "message": f"ํ•˜๋ฝ์žฅ (์ฝ”์Šคํ”ผ: {current_price:.0f} < MA20: {ma20:.0f}, MA200: {ma200:.0f})", + "position_multiplier": settings.BEAR_MARKET_POSITION_RATIO, + "min_score": settings.BEAR_MARKET_MIN_SCORE, + "max_trades": settings.BEAR_MARKET_MAX_TRADES, + "consecutive_down": consecutive_down, + "drop_from_ma20": round(drop_from_ma20, 1), + } + + # ์ƒ์Šน์žฅ: 200์ผ MA ๊ธฐ๋ฐ˜ + return { + "regime": "bullish", + "regime_200d": "bull" if is_bull_200 else "bear", + "message": f"์ƒ์Šน์žฅ (์ฝ”์Šคํ”ผ: {current_price:.0f}, MA200: {ma200:.0f}, Bull={is_bull_200})", + "position_multiplier": 1.0, + "min_score": 1, + "max_trades": settings.MAX_DAILY_TRADES, + "consecutive_down": consecutive_down, + "drop_from_ma20": round(drop_from_ma20, 1), + } + except Exception as e: + logger.error(f"์‹œ์žฅ ์ง€์ˆ˜ ํ™•์ธ ์‹คํŒจ: {e}") + return {"regime": "neutral", "message": "์‹œ์žฅ ์ง€์ˆ˜ ํ™•์ธ ์‹คํŒจ", "position_multiplier": 0.5} + + +def analyze_adx(chart_data: list) -> dict: + """ADX ๋ถ„์„ โ€” Wilder's ADX(14) ๊ธฐ๋ฐ˜ ์ถ”์„ธ ๊ฐ•๋„ ์ธก์ • + + ํ‰๊ท ํšŒ๊ท€ ์ „๋žต์—์„œ์˜ ํ•ด์„: + - ADX < 20: ํšก๋ณด์žฅ โ†’ ํ‰๊ท ํšŒ๊ท€ ์œ ๋ฆฌ (BUY ์‹ ํ˜ธ) + - ADX 20~30: ์ค‘๊ฐ„ ์ถ”์„ธ โ†’ ์ค‘๋ฆฝ + - ADX > 30: ๊ฐ•ํ•œ ์ถ”์„ธ โ†’ ํ‰๊ท ํšŒ๊ท€ ์œ„ํ—˜ (SELL/SKIP ์‹ ํ˜ธ) + + chart_data๋Š” ์‹œ๊ฐ„ ์ˆœ(์˜ค๋ž˜๋œ โ†’ ์ตœ์‹ ) ์ •๋ ฌ์„ ๊ฐ€์ •. + """ + if len(chart_data) < 29: # period * 2 + 1 + return {"signal": None, "description": "๋ฐ์ดํ„ฐ ๋ถ€์กฑ", "adx": None} + + highs = [float(item.get("stck_hgpr", 0)) for item in chart_data] + lows = [float(item.get("stck_lwpr", 0)) for item in chart_data] + closes = [float(item.get("stck_clpr", 0)) for item in chart_data] + + adx = calculate_adx(highs, lows, closes) + if adx is None: + return {"signal": None, "description": "ADX ๊ณ„์‚ฐ ๋ถˆ๊ฐ€", "adx": None} + + if adx < 20: + return {"signal": "buy", "description": f"ADX({adx:.0f}<20) ํšก๋ณด์žฅ โ€” ํ‰๊ท ํšŒ๊ท€ ์œ ๋ฆฌ", "adx": adx} + if adx > 30: + return {"signal": "sell", "description": f"ADX({adx:.0f}>30) ๊ฐ•์ถ”์„ธ โ€” ํ‰๊ท ํšŒ๊ท€ ์œ„ํ—˜", "adx": adx} + return {"signal": None, "description": f"ADX({adx:.0f}) ์ค‘๋ฆฝ", "adx": adx} def analyze_obv(chart_data: list) -> dict: @@ -298,15 +577,16 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati # ์ฐจํŠธ ๋ฐ์ดํ„ฐ ์กฐํšŒ (ํ˜„์žฌ๊ฐ€ ํฌํ•จ) chart_response = await korea_investment_api.get_daily_chart(stock_code, "D") - if "output" not in chart_response or not chart_response["output"]: + if "output2" not in chart_response or not chart_response["output2"]: logger.warning(f"No chart data for {stock_code}") return None - - chart_data = chart_response["output"] + + # KIS API๋Š” ์ตœ์‹ ์ˆœ(newest-first)์œผ๋กœ ๋ฐ˜ํ™˜ โ†’ ์‹œ๊ฐ„์ˆœ(chronological)์œผ๋กœ ๋’ค์ง‘์–ด ๋ถ„์„ + chart_data = list(reversed(chart_response["output2"])) logger.info(f"Got {len(chart_data)} chart data points for {stock_code}") - - # ์ตœ์‹  ๋ฐ์ดํ„ฐ์—์„œ ํ˜„์žฌ๊ฐ€ ๋ฐ ์ข…๋ชฉ๋ช… ์ถ”์ถœ - latest = chart_data[0] if chart_data else None + + # ์ตœ์‹  ๋ฐ์ดํ„ฐ์—์„œ ํ˜„์žฌ๊ฐ€ ๋ฐ ์ข…๋ชฉ๋ช… ์ถ”์ถœ (์‹œ๊ฐ„์ˆœ ๊ธฐ์ค€ ๋งˆ์ง€๋ง‰ = ์ตœ์‹ ) + latest = chart_data[-1] if chart_data else None if not latest: return None @@ -323,17 +603,36 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati # ๋“ฑ๋ฝ๋ฅ  ๊ณ„์‚ฐ if len(chart_data) >= 2: - prev_price = float(chart_data[1].get("stck_clpr", current_price)) + prev_price = float(chart_data[-2].get("stck_clpr", current_price)) change_rate = ((current_price - prev_price) / prev_price) * 100 if prev_price > 0 else 0 else: change_rate = 0 # ๊ธฐ๋ณธ ์ˆ˜์น˜ํ˜• ์ง€ํ‘œ ๊ณ„์‚ฐ (๋ฐฐ์น˜ ์ €์žฅ์šฉ) closes = [float(item.get("stck_clpr", 0)) for item in chart_data] + volumes = [int(item.get("acml_vol", 0)) for item in chart_data] + ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else None ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else None ma60 = sum(closes[-60:]) / 60 if len(closes) >= 60 else None + # ๐Ÿ”ฅ 1. ๊ฑฐ๋ž˜๋Ÿ‰ ๋ถ„์„ (Volume Analysis) - ํ•„์ˆ˜ ์กฐ๊ฑด + avg_vol_20 = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0 + volume_ratio = (volume / avg_vol_20) if avg_vol_20 > 0 else 0 + + # ๊ฑฐ๋ž˜๋Ÿ‰์ด ๋„ˆ๋ฌด ์ ์œผ๋ฉด ์ถ”์ฒœ ์ œ์™ธ (์œ ๋™์„ฑ ๋ถ€์กฑ) - ๋ฐฐ์น˜ ๋ชจ๋“œ์—์„œ๋Š” ์ œ์™ธ ์•ˆ ํ•จ + if not criteria.for_batch and avg_vol_20 < 50000 and current_price < 50000: + logger.debug(f"{stock_code}: ๊ฑฐ๋ž˜๋Ÿ‰ ๋ถ€์กฑ์œผ๋กœ ์ œ์™ธ (ํ‰๊ท  ๊ฑฐ๋ž˜๋Ÿ‰: {avg_vol_20:.0f})") + return None + + # ๐Ÿ”ฅ 2. ์ด๊ฒฉ๋„ ๊ณ„์‚ฐ (Disparity Filter) - ์ถ”๊ฒฉ ๋งค์ˆ˜ ๋ฐฉ์ง€ + disparity = (current_price / ma20) * 100 if ma20 else 100 + + # Trend filter check (non-blocking here; used for scoring/tagging) + trend_ok = None + if TREND_FILTER_ENABLED: + trend_ok = _passes_uptrend(closes, TREND_MA_SHORT, TREND_MA_LONG) + # RSI ๊ณ„์‚ฐ (๋‹จ์ˆœ ๋ฐฉ์‹) rsi = None if len(closes) >= 15: @@ -391,6 +690,27 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati signals = [] score = 0 + # ๐Ÿ”ฅ 3. ์ด๊ฒฉ๋„ ๊ณผ์—ด ์ฒดํฌ (max_disparity ์กฐ๊ฑด) + if disparity > criteria.max_disparity: + score -= 5 + signals.append(f"์ด๊ฒฉ๋„ ๊ณผ์—ด({disparity:.1f}% > {criteria.max_disparity}%) - ๋งค์ˆ˜๊ธˆ์ง€") + logger.debug(f"{stock_code}: ์ด๊ฒฉ๋„ ๊ณผ์—ด๋กœ ์ ์ˆ˜ ๊ฐ์ ") + + # ๐Ÿ”ฅ 3.5. ๊ธ‰๋“ฑ ๊ณผ์—ด ํ•„ํ„ฐ (๊ณ ์  ์ถ”๊ฒฉ ๋งค์ˆ˜ ์ฐจ๋‹จ) + if criteria.use_overheat_filter and OVERHEAT_FILTER_ENABLED: + overheat = analyze_overheat(closes, ma20) + if overheat["blocked"]: + score -= 10 + signals.append(overheat["reason"]) + logger.info(f"{stock_code}: ๊ธ‰๋“ฑ ๊ณผ์—ด ์ฐจ๋‹จ - {overheat['reason']} {overheat['details']}") + + # ๐Ÿ”ฅ 4. ๋ณ€๋™์„ฑ ๋ŒํŒŒ ์ „๋žต (๊ณ ์Šน๋ฅ ) + if criteria.use_volatility_breakout: + vb_result = analyze_volatility_breakout(chart_data) + if vb_result and vb_result["signal"] == "buy": + signals.append(vb_result["description"]) + score += 4 # ๋งค์šฐ ๊ฐ•๋ ฅํ•œ ์‹ ํ˜ธ + # ์ปค์Šคํ…€ ๊ณต์‹ ๊ธฐ๋ฐ˜ ์‹ ํ˜ธ ๋ถ„์„ ์ ์šฉ (์ •๋ฐฐ์—ด, ์—ญ๋ฐฐ์—ด, ๋ˆŒ๋ฆผ๋ชฉ, ์†์ ˆ/์ต์ ˆ) custom_result = analyze_custom_signals(chart_data) if custom_result: @@ -419,8 +739,13 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati if criteria.use_rsi: result = analyze_rsi(chart_data) if result["signal"] == "buy": - signals.append(result["description"]) - score += 2 + # ๐Ÿ”ฅ ๊ฑฐ๋ž˜๋Ÿ‰์ด ๋™๋ฐ˜๋˜์–ด์•ผ ์ง„์งœ ๋ฐ˜๋“ฑ + if volume_ratio >= 1.2: + signals.append(result["description"] + " + ๊ฑฐ๋ž˜๋Ÿ‰์ฆ๊ฐ€") + score += 3 + else: + signals.append(result["description"] + " (๊ฑฐ๋ž˜๋Ÿ‰๋ถ€์กฑ)") + score += 1 # ์ ์ˆ˜ ๋‚ฎ๊ฒŒ ์ฑ…์ • if criteria.use_bollinger: result = analyze_bollinger(chart_data) @@ -452,13 +777,79 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati signals.append(result["description"]) score += 1 - logger.info(f"{stock_code} ({stock_name}): score={score}, signals={signals}") + # ๐Ÿ”ฅ 5. ๊ฑฐ๋ž˜๋Ÿ‰ ๊ธ‰์ฆ ๋ณด๋„ˆ์Šค (๋งค์ˆ˜ ์‹ ํ˜ธ ๊ฐ•๋„ ์ฆ๊ฐ€) + if volume_ratio >= 1.5 and change_rate > 0: + signals.append(f"๊ฑฐ๋ž˜๋Ÿ‰ ์‹ค๋ฆฐ ์ƒ์Šน ({volume_ratio:.1f}๋ฐฐ)") + score += 3 + elif volume_ratio < 0.8: + # ๊ฑฐ๋ž˜๋Ÿ‰์ด ํ‰์†Œ๋ณด๋‹ค ์ ์œผ๋ฉด ์‹ ๋ขฐ๋„ ๊ฐ์†Œ + score -= 1 + logger.debug(f"{stock_code}: ๊ฑฐ๋ž˜๋Ÿ‰ ๋ถ€์กฑ (ํ‰์†Œ์˜ {volume_ratio:.1f}๋ฐฐ)") + + # Tag trend state to signals and adjust score slightly + if TREND_FILTER_ENABLED and trend_ok is not None: + if trend_ok: + signals.append(f"์ถ”์„ธ์ƒ์Šน(>{TREND_MA_SHORT}MA & {TREND_MA_SHORT}MA>{TREND_MA_LONG}MA, tol={int(TREND_TOLERANCE_PCT*10000)/100:.2f}%)") + score += 1 + else: + signals.append(f"์ถ”์„ธ์•ฝํ•จ({TREND_MA_SHORT}MAโ‰ค{TREND_MA_LONG}MA ๋˜๋Š” ์ข…๊ฐ€โ‰ค{TREND_MA_SHORT}MA)") - # ์‹ ํ˜ธ๊ฐ€ ์žˆ๊ฑฐ๋‚˜ ์ ์ˆ˜๊ฐ€ 0 ์ด์ƒ์ด๋ฉด ์ถ”์ฒœ (๊ทœ์น™ ๊ธฐ๋ฐ˜์—์„œ๋Š” ๋งค์นญ๋œ ์ข…๋ชฉ ๋ชจ๋‘ ํ‘œ์‹œ) - if len(signals) > 0 or score > 0: + # ๐Ÿ”ฅ 6. ์ตœ์ข… ๊ฑฐ๋ž˜๋Ÿ‰ ํ•„ํ„ฐ (min_volume_ratio) + if criteria.min_volume_ratio > 0 and volume_ratio < criteria.min_volume_ratio: + logger.debug(f"{stock_code}: ๊ฑฐ๋ž˜๋Ÿ‰ ํ•„ํ„ฐ ๋ฏธ๋‹ฌ ({volume_ratio:.2f} < {criteria.min_volume_ratio})") + return None + + logger.info(f"{stock_code} ({stock_name}): score={score}, signals={signals}, volume_ratio={volume_ratio:.2f}, disparity={disparity:.1f}%") + + # ๐Ÿค– ML ์•™์ƒ๋ธ” ์Šค์ฝ”์–ด ๊ณ„์‚ฐ + ml_score_val = None + ml_confidence_val = None + ml_signal_val = None + + if settings.ML_ENSEMBLE_ENABLED: + try: + from app.services.ml_ensemble import get_ensemble + ensemble = get_ensemble() + if ensemble.is_trained: + ml_result = ensemble.predict(chart_data) + if "error" not in ml_result: + ml_score_val = ml_result["ensemble_score"] + ml_confidence_val = ml_result["confidence"] + ml_signal_val = ml_result["signal"] + + # ๋ฃฐ๋ฒ ์ด์Šค + ML ๊ฐ€์ค‘ ๊ฒฐํ•ฉ + rule_score_normalized = (score / 15.0) * 100.0 # 0~15 โ†’ 0~100 + combined_score = int( + rule_score_normalized * settings.ML_ENSEMBLE_RULE_WEIGHT + + ml_score_val * settings.ML_ENSEMBLE_ML_WEIGHT + ) + # 0~100 โ†’ 0~15 ์Šค์ผ€์ผ๋กœ ์žฌ๋ณ€ํ™˜ (๊ธฐ์กด ํ˜ธํ™˜) + score = max(0, int(combined_score / 100.0 * 15.0)) + + signals.append(f"ML์•™์ƒ๋ธ”: {ml_signal_val}({ml_score_val}์ , ์‹ ๋ขฐ๋„{ml_confidence_val:.0%})") + logger.info(f" ML ์•™์ƒ๋ธ”: score={ml_score_val}, confidence={ml_confidence_val:.2f}, signal={ml_signal_val}") + except Exception as e: + logger.debug(f"ML ์•™์ƒ๋ธ” ์Šค์ฝ”์–ด ๊ณ„์‚ฐ ์‹คํŒจ (์ •์ƒ - ๋ชจ๋ธ ๋ฏธํ•™์Šต): {e}") + + # ATR / ADX ๊ณ„์‚ฐ (์ด๋ฏธ ๊ฐ€์ ธ์˜จ ์ฐจํŠธ ๋ฐ์ดํ„ฐ ์žฌ์‚ฌ์šฉ โ€” ์ด์ค‘ API ํ˜ธ์ถœ ๋ฐฉ์ง€) + atr_14_val = None + adx_14_val = None + try: + if len(chart_data) >= 15: + _h = [float(d.get("stck_hgpr", 0)) for d in chart_data] + _l = [float(d.get("stck_lwpr", 0)) for d in chart_data] + _c = [float(d.get("stck_clpr", 0)) for d in chart_data] + atr_14_val = calculate_atr(_h, _l, _c) + if len(chart_data) >= 29: # Wilder ADX ์ตœ์†Œ ๋ฐ์ดํ„ฐ (period * 2 + 1) + adx_14_val = calculate_adx(_h, _l, _c) + except Exception: + pass + + # ์‹ ํ˜ธ๊ฐ€ ์žˆ๊ฑฐ๋‚˜ ์ ์ˆ˜๊ฐ€ 0 ์ด์ƒ์ด๋ฉด ์ถ”์ฒœ (๋ฐฐ์น˜ ๋ชจ๋“œ์—์„œ๋Š” score=0๋„ ๋ฐ˜ํ™˜) + if criteria.for_batch or len(signals) > 0 or score > 0: # ์‹ ํ˜ธ๊ฐ€ ์žˆ์œผ๋ฉด ์ตœ์†Œ 1์  ๋ณด์žฅ final_score = max(1, score) if len(signals) > 0 else score - + return StockRecommendation( stock_code=stock_code, stock_name=stock_name, @@ -476,7 +867,12 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati stochastic_d=stochastic_d, bollinger_upper=bollinger_upper, bollinger_lower=bollinger_lower, - bollinger_middle=bollinger_middle + bollinger_middle=bollinger_middle, + ml_score=ml_score_val, + ml_confidence=ml_confidence_val, + ml_signal=ml_signal_val, + atr_14=atr_14_val, + adx_14=adx_14_val, ) logger.debug(f"{stock_code}: No recommendation (score={score})") @@ -486,10 +882,11 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati return None -async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Optional[StockRecommendation]: - """ํ˜ธ์ถœ ํŽธ์˜์„ฑ: ๋ชจ๋“  ์ง€ํ‘œ๋ฅผ ์ผœ์„œ ๋ถ„์„ํ•œ ๊ฒฐ๊ณผ(Pydantic ๋ชจ๋ธ)๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. +async def analyze_stock_for_batch(stock_code: str, stock_name: str) -> Optional[StockRecommendation]: + """๋ฐฐ์น˜ DB ์ €์žฅ์šฉ: ๊ฑฐ๋ž˜๋Ÿ‰/์ ์ˆ˜ ํ•„ํ„ฐ ์—†์ด ์ง€ํ‘œ๋งŒ ๊ณ„์‚ฐํ•˜์—ฌ ์ „ ์ข…๋ชฉ ์ €์žฅ. - ์ด ํ•จ์ˆ˜๋Š” ๋ฐฐ์น˜ ์Šคํฌ๋ฆฝํŠธ์—์„œ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. + ๋ชจ๋“  ์ง€ํ‘œ๋ฅผ ๊ณ„์‚ฐํ•˜๋˜, ๋งค๋งค ํ•„ํ„ฐ(min_volume_ratio, ๊ฑฐ๋ž˜๋Ÿ‰ ๋ถ€์กฑ, score=0)๋ฅผ ์ ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + score=0์ด์–ด๋„ ๋ฐ˜ํ™˜ํ•˜์—ฌ ๋ฐฐ์น˜์—์„œ DB์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. """ criteria = RecommendationCriteria( use_macd=True, @@ -498,7 +895,36 @@ async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Opt use_moving_average=True, use_stochastic=True, use_adx=True, - use_obv=True + use_obv=True, + use_volatility_breakout=True, + min_volume_ratio=0, # ๊ฑฐ๋ž˜๋Ÿ‰ ํ•„ํ„ฐ ์—†์Œ + max_disparity=200.0, # ์ด๊ฒฉ๋„ ํ•„ํ„ฐ ์—†์Œ + for_batch=True, # score=0 ยท ์ €์œ ๋™์„ฑ ์ข…๋ชฉ๋„ ๋ฐ˜ํ™˜ + ) + return await analyze_stock(stock_code, stock_name, criteria) + + +async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Optional[StockRecommendation]: + """ํ˜ธ์ถœ ํŽธ์˜์„ฑ: ๋ชจ๋“  ์ง€ํ‘œ๋ฅผ ์ผœ์„œ ๋ถ„์„ํ•œ ๊ฒฐ๊ณผ(Pydantic ๋ชจ๋ธ)๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + + ์ด ํ•จ์ˆ˜๋Š” ๋ฐฐ์น˜ ์Šคํฌ๋ฆฝํŠธ์—์„œ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. + + ๐Ÿ”ฅ ์Šน๋ฅ  ํ–ฅ์ƒ ๊ธฐ๋Šฅ ํฌํ•จ: + - ๋ณ€๋™์„ฑ ๋ŒํŒŒ ์ „๋žต + - ๊ฑฐ๋ž˜๋Ÿ‰ ํ•„ํ„ฐ (ํ‰๊ท  ๋Œ€๋น„ 1.2๋ฐฐ) + - ์ด๊ฒฉ๋„ ํ•„ํ„ฐ (110% ์ œํ•œ) + """ + criteria = RecommendationCriteria( + use_macd=True, + use_rsi=True, + use_bollinger=True, + use_moving_average=True, + use_stochastic=True, + use_adx=True, + use_obv=True, + use_volatility_breakout=True, # ๐Ÿ”ฅ ๋ณ€๋™์„ฑ ๋ŒํŒŒ ์ถ”๊ฐ€ + min_volume_ratio=1.2, # ๐Ÿ”ฅ ๊ฑฐ๋ž˜๋Ÿ‰ ํ•„ํ„ฐ (ํ‰๊ท ์˜ 1.2๋ฐฐ ์ด์ƒ) + max_disparity=110.0 # ๐Ÿ”ฅ ์ด๊ฒฉ๋„ ํ•„ํ„ฐ (20์ผ์„  ๋Œ€๋น„ 110% ์ดํ•˜) ) return await analyze_stock(stock_code, stock_name, criteria) @@ -518,7 +944,7 @@ async def get_all_tradable_stocks() -> List[tuple]: # Rate limit์„ ์œ„ํ•œ ๋Œ€๊ธฐ (์ดˆ๋‹น 1๊ฑด ์ œํ•œ) - ์•ˆ์ „ํ•˜๊ฒŒ 3์ดˆ ๋Œ€๊ธฐ await asyncio.sleep(3.0) - # KOSDAQ ๊ฑฐ๋ž˜๋Œ€๊ธˆ ์ƒ์œ„ ์ข…๋ชฉ ์กฐํšŒ + # KOSDAQ ๊ฑฐ๋ž˜๋Œ€๊ธˆ ์ƒ์œ„ ์ข…๋ชฉ ์กฐํšŒ ("Q" = KOSDAQ, KIS API ๊ทœ๊ฒฉ) kosdaq_result = await korea_investment_api.get_stock_list("Q") logger.info(f"KOSDAQ result: {kosdaq_result}") @@ -542,40 +968,16 @@ async def get_all_tradable_stocks() -> List[tuple]: logger.info(f"Found {len(stocks)} tradable stocks from KIS API") - # API์—์„œ ์ข…๋ชฉ์„ ๋ชป ๊ฐ€์ ธ์˜จ ๊ฒฝ์šฐ ๊ธฐ๋ณธ ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ ์‚ฌ์šฉ + # API์—์„œ ์ข…๋ชฉ์„ ๋ชป ๊ฐ€์ ธ์˜จ ๊ฒฝ์šฐ ๋นˆ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ (ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ œ๊ฑฐ) if not stocks: - logger.warning("No stocks from KIS API, using default list") - stocks = [ - ("005930", "์‚ผ์„ฑ์ „์ž"), - ("000660", "SKํ•˜์ด๋‹‰์Šค"), - ("035420", "๋„ค์ด๋ฒ„"), - ("051910", "LGํ™”ํ•™"), - ("005380", "ํ˜„๋Œ€์ฐจ"), - ("006400", "์‚ผ์„ฑSDI"), - ("035720", "์นด์นด์˜ค"), - ("000270", "๊ธฐ์•„"), - ("068270", "์…€ํŠธ๋ฆฌ์˜จ"), - ("207940", "์‚ผ์„ฑ๋ฐ”์ด์˜ค๋กœ์ง์Šค"), - ] + logger.warning("No stocks from KIS API, returning empty list") return stocks except Exception as e: logger.error(f"Error fetching stock list from KIS API: {e}", exc_info=True) - # API ์‹คํŒจ ์‹œ ๊ธฐ๋ณธ ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ - logger.warning("Using default stock list due to API error") - return [ - ("005930", "์‚ผ์„ฑ์ „์ž"), - ("000660", "SKํ•˜์ด๋‹‰์Šค"), - ("035420", "๋„ค์ด๋ฒ„"), - ("051910", "LGํ™”ํ•™"), - ("005380", "ํ˜„๋Œ€์ฐจ"), - ("006400", "์‚ผ์„ฑSDI"), - ("035720", "์นด์นด์˜ค"), - ("000270", "๊ธฐ์•„"), - ("068270", "์…€ํŠธ๋ฆฌ์˜จ"), - ("207940", "์‚ผ์„ฑ๋ฐ”์ด์˜ค๋กœ์ง์Šค"), - ] + logger.warning("Returning empty list due to API error") + return [] @router.get("/recommendations") @@ -587,13 +989,26 @@ async def get_recommendations( use_stochastic: bool = Query(False, description="์Šคํ† ์บ์Šคํ‹ฑ ์‚ฌ์šฉ"), use_adx: bool = Query(False, description="ADX ์‚ฌ์šฉ"), use_obv: bool = Query(False, description="๊ฑฐ๋ž˜๋Ÿ‰(OBV) ์‚ฌ์šฉ"), + use_volatility_breakout: bool = Query(False, description="๋ณ€๋™์„ฑ ๋ŒํŒŒ ์ „๋žต ์‚ฌ์šฉ"), + min_volume_ratio: float = Query(1.0, description="์ตœ์†Œ ๊ฑฐ๋ž˜๋Ÿ‰ ๋น„์œจ (ํ‰๊ท  ๋Œ€๋น„)"), + max_disparity: float = Query(200.0, description="์ตœ๋Œ€ ์ด๊ฒฉ๋„ (20์ผ์„  ๋Œ€๋น„ %)"), max_stocks: int = Query(100, description="๋ถ„์„ํ•  ์ตœ๋Œ€ ์ข…๋ชฉ ์ˆ˜ (๊ธฐ๋ณธ 100๊ฐœ)"), ): """๊ธฐ์ˆ ์  ์ง€ํ‘œ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ ์ข…๋ชฉ ์กฐํšŒ ์„ ํƒํ•œ ๊ธฐ์ˆ ์  ์ง€ํ‘œ ์กฐ๊ฑด์— ๋ถ€ํ•ฉํ•˜๋Š” ์ข…๋ชฉ์„ KOSPI ์‹œ๊ฐ€์ด์•ก ์ƒ์œ„ ์ข…๋ชฉ ์ค‘์—์„œ ์ฐพ์•„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. + + ๐Ÿ”ฅ ์Šน๋ฅ  ํ–ฅ์ƒ ๊ธฐ๋Šฅ: + - ๋ณ€๋™์„ฑ ๋ŒํŒŒ: ๋ž˜๋ฆฌ ์œŒ๋ฆฌ์—„์Šค ์ „๋žต (๊ณ ์Šน๋ฅ ) + - ๊ฑฐ๋ž˜๋Ÿ‰ ํ•„ํ„ฐ: ํ‰๊ท  ๋Œ€๋น„ ๊ฑฐ๋ž˜๋Ÿ‰ ํ™•์ธ + - ์ด๊ฒฉ๋„ ํ•„ํ„ฐ: 20์ผ์„ ๊ณผ์˜ ๊ฑฐ๋ฆฌ ์ œํ•œ (์ถ”๊ฒฉ ๋งค์ˆ˜ ๋ฐฉ์ง€) + - ์‹œ์žฅ ์ง€์ˆ˜ ํ•„ํ„ฐ: ์ฝ”์Šคํ”ผ ์ง€์ˆ˜ ํ™•์ธ (ํ•˜๋ฝ์žฅ ๋ณด์ˆ˜์  ๋Œ€์‘) """ + # ๐Ÿ”ฅ ์‹œ์žฅ ์ง€์ˆ˜ ํ™•์ธ (๊ฐ€์žฅ ์ค‘์š”ํ•œ ์Šน๋ฅ  ๊ฒฐ์ • ์š”์†Œ) + market_regime = await check_market_regime() + logger.info(f"๐Ÿ“Š ์‹œ์žฅ ์ƒํƒœ: {market_regime['message']}") + criteria = RecommendationCriteria( use_macd=use_macd, use_rsi=use_rsi, @@ -601,13 +1016,17 @@ async def get_recommendations( use_moving_average=use_moving_average, use_stochastic=use_stochastic, use_adx=use_adx, - use_obv=use_obv + use_obv=use_obv, + use_volatility_breakout=use_volatility_breakout, + min_volume_ratio=min_volume_ratio, + max_disparity=max_disparity ) # ๋ชจ๋“  ์ง€ํ‘œ๊ฐ€ ๊บผ์ ธ์žˆ์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ - if not any([use_macd, use_rsi, use_bollinger, use_moving_average, use_stochastic, use_adx, use_obv]): + if not any([use_macd, use_rsi, use_bollinger, use_moving_average, use_stochastic, use_adx, use_obv, use_volatility_breakout]): criteria.use_rsi = True criteria.use_moving_average = True + criteria.use_volatility_breakout = True # ๊ธฐ๋ณธ๊ฐ’์— ๋ณ€๋™์„ฑ ๋ŒํŒŒ ์ถ”๊ฐ€ # ๋ถ„์„ํ•  ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ ๊ฐ€์ ธ์˜ค๊ธฐ (KIS API์—์„œ ์‹ค์‹œ๊ฐ„ ์กฐํšŒ) all_stocks = await get_all_tradable_stocks() @@ -652,11 +1071,18 @@ async def get_recommendations( if errors: logger.error(f"Encountered {len(errors)} errors during analysis") - # None ๋ฐ ์˜ˆ์™ธ ์ œ๊ฑฐ ํ›„ ์ ์ˆ˜์ˆœ ์ •๋ ฌ - recommendations = [r for r in all_results if r is not None and not isinstance(r, Exception)] + # None ๋ฐ ์˜ˆ์™ธ ์ œ๊ฑฐ ํ›„ 3000์› ์ดํ•˜ ์ข…๋ชฉ ํ•„ํ„ฐ๋ง, ์ ์ˆ˜์ˆœ ์ •๋ ฌ + regime_min_score = market_regime.get("min_score", 1) + recommendations = [ + r for r in all_results + if r is not None + and not isinstance(r, Exception) + and r.current_price >= 3000 # ๐Ÿšซ ๋™์ „์ฃผ ์ œ์™ธ + and r.score >= regime_min_score # ๐Ÿป ํ•˜๋ฝ์žฅ ์ตœ์†Œ ์ ์ˆ˜ ํ•„ํ„ฐ + ] recommendations.sort(key=lambda x: x.score, reverse=True) - - logger.info(f"Found {len(recommendations)} recommendations out of {len(stocks_to_analyze)} analyzed stocks") + + logger.info(f"Found {len(recommendations)} recommendations out of {len(stocks_to_analyze)} analyzed stocks (min_score={regime_min_score})") if not recommendations: logger.warning("No recommendations found! Criteria might be too strict or API failed") @@ -665,6 +1091,7 @@ async def get_recommendations( "total": len(recommendations), "analyzed_stocks": len(stocks_to_analyze), "criteria": criteria.dict(), + "market_regime": market_regime, # ๐Ÿ”ฅ ์‹œ์žฅ ์ƒํƒœ ์ •๋ณด ์ถ”๊ฐ€ "recommendations": recommendations[:20] # ์ƒ์œ„ 20๊ฐœ๋งŒ ๋ฐ˜ํ™˜ } @@ -672,6 +1099,7 @@ async def get_recommendations( @router.get("/rule-based") async def get_rule_based_recommendations( realtime: bool = Query(False, description="์‹ค์‹œ๊ฐ„ ๋ถ„์„ ์—ฌ๋ถ€ (๋А๋ฆผ, 30์ดˆ)"), + auto_trade: bool = Query(True, description="์ž๋™๋งค๋งค ์‹คํ–‰ ์—ฌ๋ถ€"), db: AsyncSession = Depends(get_db) ): """ @@ -679,6 +1107,7 @@ async def get_rule_based_recommendations( - realtime=false (๊ธฐ๋ณธ): DB์—์„œ ์กฐํšŒ (๋น ๋ฆ„, 0.1์ดˆ) - ๋งค์ผ ์•„์นจ 9์‹œ ๋ฐ์ดํ„ฐ - realtime=true: KIS API ์‹ค์‹œ๊ฐ„ ๋ถ„์„ (๋А๋ฆผ, 30์ดˆ) + - auto_trade=true (๊ธฐ๋ณธ): ์กฐ๊ฑด์— ๋งž๋Š” ์ข…๋ชฉ์„ ์ž๋™์œผ๋กœ ๋งค๋งค ํ™œ์„ฑํ™”๋œ ๋ชจ๋“  ๋งค๋งค ๊ทœ์น™์„ ํ™•์ธํ•˜๊ณ , ์กฐ๊ฑด์— ๋งž๋Š” ์ข…๋ชฉ์„ ์ž๋™์œผ๋กœ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. """ @@ -686,11 +1115,59 @@ async def get_rule_based_recommendations( # ์‹ค์‹œ๊ฐ„ ๋ชจ๋“œ: ๊ธฐ์กด ๋ฐฉ์‹ (KIS API ์ง์ ‘ ํ˜ธ์ถœ) if realtime: logger.info("๐Ÿ”ด Real-time mode: Fetching live data from KIS API (slow)") - return await get_rule_based_recommendations_realtime() + result = await get_rule_based_recommendations_realtime() + else: + # DB ๋ชจ๋“œ: ๋น ๋ฅธ ์กฐํšŒ + logger.info("๐ŸŸข DB mode: Fetching from cached analysis (fast)") + result = await get_rule_based_recommendations_from_db(db) - # DB ๋ชจ๋“œ: ๋น ๋ฅธ ์กฐํšŒ - logger.info("๐ŸŸข DB mode: Fetching from cached analysis (fast)") - return await get_rule_based_recommendations_from_db(db) + # ์ž๋™๋งค๋งค ์‹คํ–‰ (๋ฐฑ๊ทธ๋ผ์šด๋“œ) + if auto_trade and result.get("recommendations_by_rule"): + asyncio.create_task(execute_auto_trade_background(result, db)) + logger.info("๐Ÿค– Auto-trade task created in background") + + return result + + +def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool: + """RSI+๋ณผ๋ฆฐ์ € ํ‰๊ท ํšŒ๊ท€ ๋งค์ˆ˜ ์กฐ๊ฑด ํ™•์ธ (CLAUDE.md ์„น์…˜ 7.2) + + Bull: RSI โ‰ค 25 + BB ํ•˜๋‹จ -3% ~ +1.5% + (๊ฑฐ๋ž˜๋Ÿ‰ 200% OR ์กฐ๊ฑด์€ real-time check_buy_signal์—์„œ ์ฒ˜๋ฆฌ) + Bear: RSI โ‰ค 35 + BB ํ•˜๋‹จ ์ดํ•˜ + """ + rsi_val = float(stock.rsi) if stock.rsi else None + close = int(stock.current_price) if stock.current_price else 0 + bb_lower = float(stock.bollinger_lower) if stock.bollinger_lower else None + bb_upper = float(stock.bollinger_upper) if stock.bollinger_upper else None + bb_middle = float(stock.bollinger_middle) if stock.bollinger_middle else None + + if not all([rsi_val, close, bb_lower, bb_upper, bb_middle]): + return False + + # ๊ฐ€๊ฒฉ ํ•„ํ„ฐ (์„น์…˜ 6.4) + if close < settings.MIN_PRICE_KRW: + return False + + regime = MarketRegime.BULL if market_regime_str == "bull" else MarketRegime.BEAR + + # ADX ํ•„ํ„ฐ: ๊ฐ•ํ•œ ์ถ”์„ธ ์ค‘ ํ‰๊ท ํšŒ๊ท€ ์ง„์ž… ์ฐจ๋‹จ (ADX > 30์ด๋ฉด ํšก๋ณด ์•„๋‹˜) + adx_val = float(stock.adx_14) if getattr(stock, "adx_14", None) else None + adx_strong_trend = adx_val is not None and adx_val > 30 + + if regime == MarketRegime.BULL: + rsi_threshold = conditions.get("rsi_bull_max", 25) + bb_lower_pct = conditions.get("bb_lower_pct", 3.0) / 100 # -3% + bb_upper_pct = conditions.get("bb_upper_pct", 1.5) / 100 # +1.5% + in_bb_zone = bb_lower * (1 - bb_lower_pct) <= close <= bb_lower * (1 + bb_upper_pct) + if adx_strong_trend: + return False # ๊ฐ•์ถ”์„ธ ์ค‘ ํ‰๊ท ํšŒ๊ท€ ์ฐจ๋‹จ + return rsi_val <= rsi_threshold and in_bb_zone + else: + rsi_threshold = conditions.get("rsi_bear_max", 35) + if adx_strong_trend: + return False # ๊ฐ•์ถ”์„ธ ์ค‘ ํ‰๊ท ํšŒ๊ท€ ์ฐจ๋‹จ + return rsi_val <= rsi_threshold and close <= bb_lower async def get_rule_based_recommendations_from_db(db: AsyncSession): @@ -698,134 +1175,184 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession): logger.info("Rule-based recommendations from DB") - # ์ƒ˜ํ”Œ ๊ทœ์น™ ์ƒ์„ฑ (์‹ค์ œ๋กœ๋Š” DB์—์„œ ์กฐํšŒ) + # JSON ํŒŒ์ผ์—์„œ ๊ทœ์น™ ๋กœ๋“œ + import json + from pathlib import Path from collections import namedtuple + Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active']) - active_rules = [ - Rule( - id=1, - name="RSI ๊ณผ๋งค๋„ ๋ฐ˜๋“ฑ", - description="RSI 40 ์ดํ•˜ ๊ณผ๋งค๋„ ์ข…๋ชฉ", - rule_type="BUY", - priority=1, - conditions={"rsi_threshold": 40}, - actions={"take_profit": 5.0, "stop_loss": 3.0, "position_sizing": {"target_ratio": 0.15}}, - is_active=True - ), - Rule( - id=2, - name="๋ณผ๋ฆฐ์ €๋ฐด๋“œ ํ•˜๋‹จ", - description="๋ณผ๋ฆฐ์ €๋ฐด๋“œ ํ•˜๋‹จ ๊ทผ์ฒ˜ ๋ฐ˜๋“ฑ", - rule_type="BUY", - priority=3, - conditions={"bb_threshold": 0.2}, - actions={"take_profit": 7.0, "stop_loss": 4.0, "position_sizing": {"target_ratio": 0.15}}, - is_active=True - ) - ] + try: + config_path = Path(__file__).parent.parent.parent.parent / "trading_rules_config.json" + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + rules_data = config.get("rules", []) + active_rules = [] + + for idx, rule_data in enumerate(rules_data, start=1): + if rule_data.get("is_active", False): + active_rules.append(Rule( + id=idx, + name=rule_data["name"], + description=rule_data["description"], + rule_type=rule_data["rule_type"].upper(), + priority=rule_data.get("priority", idx), + conditions=rule_data.get("conditions", {}), + actions=rule_data.get("actions", {}), + is_active=True + )) + + logger.info(f"Loaded {len(active_rules)} active trading rules from config file") + except Exception as e: + logger.error(f"Failed to load trading rules config: {e}") + # ํด๋ฐฑ: ๊ธฐ๋ณธ ๊ทœ์น™ ์‚ฌ์šฉ + active_rules = [ + Rule( + id=1, + name="RSI ๊ณผ๋งค๋„ ๋ฐ˜๋“ฑ", + description="RSI 30 ์ดํ•˜ ๊ณผ๋งค๋„ ์ข…๋ชฉ", + rule_type="BUY", + priority=1, + conditions={"rsi_threshold": 30}, + actions={ + "take_profit": 5.0, + "stop_loss": 3.0, + "position_sizing": {"target_ratio": 0.15} + }, + is_active=True + ) + ] + logger.warning(f"Using fallback rule: {len(active_rules)} rule(s)") logger.info(f"Found {len(active_rules)} active trading rules") - # ์˜ค๋Š˜ ๋‚ ์งœ์˜ ๋ถ„์„ ๋ฐ์ดํ„ฐ ์กฐํšŒ + # ์˜ค๋Š˜ ๋‚ ์งœ์˜ ๋ถ„์„ ๋ฐ์ดํ„ฐ ์กฐํšŒ (์ค‘๋ณต ์ข…๋ชฉ์€ score ๋†’์€ ๊ฒƒ ์šฐ์„ ) today = date.today() result = await db.execute( select(StockAnalysis) .where(StockAnalysis.analysis_date >= today) .order_by(StockAnalysis.score.desc()) ) - all_analyzed_stocks = result.scalars().all() + raw_stocks = result.scalars().all() + # ์ข…๋ชฉ์ฝ”๋“œ ๊ธฐ์ค€ ์ค‘๋ณต ์ œ๊ฑฐ (score ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ ํ›„ ์ฒซ ๋ฒˆ์งธ๋งŒ) + seen = set() + all_analyzed_stocks = [] + for s in raw_stocks: + if s.stock_code not in seen: + seen.add(s.stock_code) + all_analyzed_stocks.append(s) - if not all_analyzed_stocks: - logger.warning(f"No analysis data found for {today}. Run batch_daily_analysis.py first!") - # โœ… ์•ˆ์ „์žฅ์น˜: DB์— ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์‹ค์‹œ๊ฐ„ ๋ถ„์„์œผ๋กœ ์ž๋™ ํด๋ฐฑ - try: - logger.info("Falling back to realtime analysis due to empty DB cache...") - fallback = await get_rule_based_recommendations_realtime() - # ๋ฉ”์‹œ์ง€ ๋ณด๊ฐ• - base_msg = fallback.get("message") or "" - fallback["message"] = ( - f"โš ๏ธ ๋ฐฐ์น˜ ์บ์‹œ ์—†์Œ โ†’ ์‹ค์‹œ๊ฐ„ ๋ถ„์„์œผ๋กœ ๋Œ€์ฒด ({today}). " - + base_msg - ) - return fallback - except Exception as e: - logger.error(f"Realtime fallback failed: {e}") - return { - "total": 0, - "active_rules": len(active_rules), - "analyzed_stocks": 0, - "recommendations": [], - "recommendations_by_rule": [], - "rules_applied": [], - "message": f"๋ฐฐ์น˜ ๋ถ„์„ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค ({today}). ์‹ค์‹œ๊ฐ„ ๋ถ„์„ ํด๋ฐฑ๋„ ์‹คํŒจ: {e}" - } + # DB์— ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ + if not all_analyzed_stocks or len(all_analyzed_stocks) < 1: + logger.warning(f"No analysis data found ({len(all_analyzed_stocks) if all_analyzed_stocks else 0} stocks). Run batch analysis first.") + return { + "total": 0, + "active_rules": len(active_rules), + "analyzed_stocks": 0, + "analyzed_stock_list": [], + "recommendations": [], + "recommendations_by_rule": [], + "rules_applied": [], + "message": "โš ๏ธ ๋ฐฐ์น˜ ๋ถ„์„ ๋ฐ์ดํ„ฐ ์—†์Œ. 'make batch-test' ๋ช…๋ น์–ด๋กœ ๋ถ„์„ ์‹คํ–‰ ํ•„์š”." + } logger.info(f"Found {len(all_analyzed_stocks)} stocks in DB for {today}") # ๊ทœ์น™๋ณ„๋กœ ๋งค์นญ๋˜๋Š” ์ข…๋ชฉ ์ฐพ๊ธฐ rule_matches = [] recommendations_by_rule = [] - + stock_to_rules = {} # ์ข…๋ชฉ์ฝ”๋“œ -> ๋งค์นญ๋œ ๊ทœ์น™ ๋ฆฌ์ŠคํŠธ ๋งคํ•‘ + + # ์‹œ์žฅ ๋ ˆ์ง ํŒ๋ณ„ (์„น์…˜ 7.1: 200์ผ MA ๊ธฐ๋ฐ˜) + market_regime_info = await check_market_regime() + current_market_regime = market_regime_info.get("regime_200d", "bear") + for rule in active_rules: logger.info(f"Applying rule: {rule.name}") - + conditions = rule.conditions actions = rule.actions - - # DB์—์„œ ์กฐํšŒํ•œ ์ข…๋ชฉ ์ค‘ ๊ทœ์น™์— ๋งž๋Š” ์ข…๋ชฉ ํ•„ํ„ฐ๋ง + rule_matched_stocks = [] - + for stock in all_analyzed_stocks: matches = True - - # RSI ์กฐ๊ฑด ์ฒดํฌ - if "rsi_threshold" in conditions: - rsi_threshold = conditions["rsi_threshold"] - if not stock.rsi or stock.rsi > rsi_threshold: - matches = False - - # ๋ณผ๋ฆฐ์ €๋ฐด๋“œ ํ•˜๋‹จ ์ฒดํฌ - if "bb_threshold" in conditions: - if not stock.bollinger_lower or not stock.current_price: - matches = False - elif stock.current_price > stock.bollinger_lower * 1.05: # ํ•˜๋‹จ 5% ์ด๋‚ด - matches = False - - if matches: - rule_matched_stocks.append(stock) - + + # === ์ „๋žต ํƒ€์ž…๋ณ„ ๋ถ„๊ธฐ === + + # 1) ํ‰๊ท ํšŒ๊ท€ ์ „๋žต (RSI + ๋ณผ๋ฆฐ์ € ๋ฐ˜๋“ฑ) + if rule.rule_type == "MEAN_REVERSION": + matches = is_mean_reversion_candidate(stock, conditions, current_market_regime) + + # 2) ์ถ”์„ธ/๊ณจ๋“ ํฌ๋กœ์Šค ์ „๋žต (๋‚˜์ค‘์— ๋ณ„๋„ ํ•จ์ˆ˜๋กœ ๋ถ„๋ฆฌ ์˜ˆ์ •) + elif rule.rule_type == "TREND": + # ์—ฌ๊ธฐ์„œ๋Š” ๋‹น์žฅ ๋น„ํ™œ์„ฑํ™”ํ•˜๊ฑฐ๋‚˜, ๋งค์šฐ ๋ณด์ˆ˜์ ์œผ๋กœ๋งŒ ์‚ฌ์šฉ + # matches = is_trend_candidate(stock, conditions) + matches = False # ์ผ๋‹จ ํ‰๊ท ํšŒ๊ท€ ์•ˆ์ •ํ™” ์ „๊นŒ์ง€ ๋น„ํ™œ์„ฑํ™” + + # 3) ๋ฐ•์Šค๊ถŒ ๋ŒํŒŒ / ๋ชจ๋ฉ˜ํ…€ ์ „๋žต (์œ„ํ—˜ ์ถ•) + elif rule.rule_type in ("BREAKOUT", "MOMENTUM"): + # matches = is_breakout_or_momentum_candidate(stock, conditions) + matches = False # ์ดˆ๊ธฐ์—๋Š” ๋„๊ณ , ๋‚˜์ค‘์— ๋ณ„๋„ ์—”์ง„์œผ๋กœ + + # ๊ธฐํƒ€ ํƒ€์ž…์€ ํ˜„์žฌ ์Šคํ‚ต + else: + matches = False + + if not matches: + continue + + # ์—ฌ๊ธฐ๊นŒ์ง€ ์™”์œผ๋ฉด rule_matched_stocks์— ์ถ”๊ฐ€ + rule_matched_stocks.append(stock) + # ์ ์ˆ˜์ˆœ ์ •๋ ฌ rule_matched_stocks.sort(key=lambda x: x.score, reverse=True) - - # ๊ทœ์น™๋ณ„๋กœ ์ข…๋ชฉ ์ €์žฅ + + # ์ดํ•˜ ๋กœ์ง์€ ๊ทธ๋Œ€๋กœ (Top N, rule_matches, stock_to_rules, ์‹ค์‹œ๊ฐ„ ๊ฐ€๊ฒฉ ์—…๋ฐ์ดํŠธ ๋“ฑ) rule_stocks = [] - for stock in rule_matched_stocks[:10]: # ๊ทœ์น™๋‹น ์ƒ์œ„ 10๊ฐœ + for stock in rule_matched_stocks[:10]: stock_dict = { "stock_code": stock.stock_code, "stock_name": stock.stock_name, - "current_price": stock.current_price, - "change_rate": float(stock.change_rate) if stock.change_rate else 0, + "current_price": int(stock.current_price) if stock.current_price else 0, + "change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0, "volume": stock.volume, - "rsi": float(stock.rsi) if stock.rsi else None, - "ma5": float(stock.ma5) if stock.ma5 else None, - "ma20": float(stock.ma20) if stock.ma20 else None, - "stochastic_k": float(stock.stochastic_k) if stock.stochastic_k else None, + "rsi": round(float(stock.rsi), 2) if stock.rsi else None, + "ma5": int(stock.ma5) if stock.ma5 else None, + "ma20": int(stock.ma20) if stock.ma20 else None, + "stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None, "signals": stock.signals or [], "score": stock.score, + "ml_score": stock.ml_score, + "ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None, + "ml_signal": stock.ml_signal, "price_date": stock.analysis_date.strftime("%Y%m%d"), "rule_id": rule.id, "rule_name": rule.name, "rule_description": rule.description, "rule_priority": rule.priority, - "position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.15), - "take_profit": actions.get("take_profit", 5.0), - "stop_loss": actions.get("stop_loss", 3.0) + "position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.10), + "take_profit": actions.get("take_profit", 3.0), + "stop_loss": actions.get("stop_loss", -1.5), + "max_position_pct": actions.get("max_position_pct", 0.12), + "max_add_buys": actions.get("max_add_buys", 3), + "add_buy_cooldown_min": actions.get("add_buy_cooldown_min", 15), } rule_stocks.append(stock_dict) rule_matches.append(stock_dict) - - # ๊ทœ์น™๋ณ„ ๊ทธ๋ฃน ์ถ”๊ฐ€ + + if stock.stock_code not in stock_to_rules: + stock_to_rules[stock.stock_code] = [] + + rule_exists = any(r["rule_id"] == rule.id for r in stock_to_rules[stock.stock_code]) + if not rule_exists: + stock_to_rules[stock.stock_code].append({ + "rule_id": rule.id, + "rule_name": rule.name, + "priority": rule.priority, + }) + if rule_stocks: recommendations_by_rule.append({ "rule_id": rule.id, @@ -834,22 +1361,82 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession): "rule_type": rule.rule_type, "priority": rule.priority, "matched_count": len(rule_matched_stocks), - "stocks": rule_stocks + "stocks": rule_stocks, }) - + logger.info(f"Rule '{rule.name}' matched {len(rule_matched_stocks)} stocks, showing top {len(rule_stocks)}") - - # ์šฐ์„ ์ˆœ์œ„์™€ ์ ์ˆ˜๋กœ ์ •๋ ฌ - rule_matches.sort(key=lambda x: (x["rule_priority"], -x["score"])) - + + # ๊ทœ์น™ ๋งค์นญ ๊ฒฐ๊ณผ๊ฐ€ ์—†์œผ๋ฉด ๋ถ„์„ ์ข…๋ชฉ ์ƒ์œ„ 20๊ฐœ๋ฅผ ํด๋ฐฑ์œผ๋กœ ์ถ”๊ฐ€ + if not rule_matches and all_analyzed_stocks: + logger.info("No rule matches found. Using top scored analyzed stocks as fallback recommendations.") + already_added = set() + fallback_stocks = [] + for stock in all_analyzed_stocks[:20]: + if stock.stock_code in already_added: + continue + already_added.add(stock.stock_code) + stock_dict = { + "stock_code": stock.stock_code, + "stock_name": stock.stock_name, + "current_price": int(stock.current_price) if stock.current_price else 0, + "change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0, + "volume": stock.volume, + "rsi": round(float(stock.rsi), 2) if stock.rsi else None, + "ma5": int(stock.ma5) if stock.ma5 else None, + "ma20": int(stock.ma20) if stock.ma20 else None, + "stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None, + "signals": stock.signals or [], + "score": stock.score, + "ml_score": stock.ml_score, + "ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None, + "ml_signal": stock.ml_signal, + "price_date": stock.analysis_date.strftime("%Y%m%d"), + "rule_id": 0, + "rule_name": "๊ณ ์ ์ˆ˜ ๋ถ„์„ ์ข…๋ชฉ", + "rule_description": "๋งค๋งค ๊ทœ์น™ ์กฐ๊ฑด ๋ฏธ์ถฉ์กฑ - ๋ถ„์„ ์ ์ˆ˜ ๊ธฐ์ค€ ์ƒ์œ„ ์ข…๋ชฉ", + "rule_priority": 99, + "position_ratio": 0.10, + "take_profit": 3.0, + "stop_loss": -1.5, + "max_position_pct": 0.10, + "max_add_buys": 2, + "add_buy_cooldown_min": 30, + } + fallback_stocks.append(stock_dict) + rule_matches.append(stock_dict) + + if fallback_stocks: + recommendations_by_rule.append({ + "rule_id": 0, + "rule_name": "๊ณ ์ ์ˆ˜ ๋ถ„์„ ์ข…๋ชฉ", + "rule_description": "ํ˜„์žฌ ๋งค๋งค ๊ทœ์น™ ์กฐ๊ฑด ๋ฏธ์ถฉ์กฑ - ๋ถ„์„ ์ ์ˆ˜ ๊ธฐ์ค€ ์ƒ์œ„ ์ข…๋ชฉ", + "rule_type": "ANALYSIS", + "priority": 99, + "matched_count": len(fallback_stocks), + "stocks": fallback_stocks, + }) + logger.info(f"Added {len(fallback_stocks)} fallback stocks from analyzed_stock_list") + return { "total": len(rule_matches), "active_rules": len(active_rules), "analyzed_stocks": len(all_analyzed_stocks), - "recommendations": rule_matches[:20], + "analyzed_stock_list": [ + { + "stock_code": s.stock_code, + "stock_name": s.stock_name, + "current_price": int(s.current_price) if s.current_price else 0, + "change_rate": round(float(s.change_rate), 2) if s.change_rate else 0, + "rsi": round(float(s.rsi), 2) if s.rsi else None, + "stochastic_k": round(float(s.stochastic_k), 2) if s.stochastic_k else None, + "signals": s.signals or [], + "score": s.score or 0, + } + for s in all_analyzed_stocks + ], + "recommendations": rule_matches, "recommendations_by_rule": recommendations_by_rule, "rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules], - "message": f"โœ… DB ์บ์‹œ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ ({today} ๊ธฐ์ค€)" } @@ -876,7 +1463,14 @@ async def get_rule_based_recommendations_realtime(): rule_type="BUY", priority=1, conditions={"rsi_threshold": 40}, - actions={"take_profit": 5.0, "stop_loss": 3.0, "position_sizing": {"target_ratio": 0.15}}, + actions={ + "take_profit": 3.0, + "stop_loss": -1.5, + "position_sizing": {"target_ratio": 0.10}, + "max_position_pct": 0.12, + "max_add_buys": 3, + "add_buy_cooldown_min": 15 + }, is_active=True ), Rule( @@ -886,7 +1480,14 @@ async def get_rule_based_recommendations_realtime(): rule_type="BUY", priority=2, conditions={"sma_short": 5, "sma_long": 20}, - actions={"take_profit": 10.0, "stop_loss": 5.0, "position_sizing": {"target_ratio": 0.2}}, + actions={ + "take_profit": 3.0, + "stop_loss": -1.5, + "position_sizing": {"target_ratio": 0.10}, + "max_position_pct": 0.12, + "max_add_buys": 3, + "add_buy_cooldown_min": 15 + }, is_active=True ), Rule( @@ -896,7 +1497,14 @@ async def get_rule_based_recommendations_realtime(): rule_type="BUY", priority=3, conditions={"bb_threshold": 0.2}, - actions={"take_profit": 7.0, "stop_loss": 4.0, "position_sizing": {"target_ratio": 0.15}}, + actions={ + "take_profit": 3.0, + "stop_loss": -1.5, + "position_sizing": {"target_ratio": 0.10}, + "max_position_pct": 0.12, + "max_add_buys": 3, + "add_buy_cooldown_min": 15 + }, is_active=True ), ] @@ -913,13 +1521,13 @@ async def get_rule_based_recommendations_realtime(): use_moving_average=True ) - # ๋ถ„์„ํ•  ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ + # ๋ถ„์„ํ•  ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ all_stocks = await get_all_tradable_stocks() - stocks_to_analyze = all_stocks[:50] # 50๊ฐœ ์ข…๋ชฉ๋งŒ + stocks_to_analyze = all_stocks[:20] # 20๊ฐœ ์ข…๋ชฉ์œผ๋กœ ๋‹จ์ถ• (์†๋„ ๊ฐœ์„ ) - # ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ - batch_size = 5 - delay_between_batches = 1.2 + # ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ (์†๋„ ๊ฐœ์„ ) + batch_size = 5 # ๋ฐฐ์น˜ ํฌ๊ธฐ + delay_between_batches = 0.3 # ๋”œ๋ ˆ์ด ์ถ”๊ฐ€ ๊ฐ์†Œ batch_results = [] for i in range(0, len(stocks_to_analyze), batch_size): @@ -979,9 +1587,9 @@ async def get_rule_based_recommendations_realtime(): logger.info(f"Found {len(active_rules)} active trading rules") - # ๋ถ„์„ํ•  ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ (ํƒ€์ž„์•„์›ƒ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด 20๊ฐœ๋กœ ์ œํ•œ) + # ๋ถ„์„ํ•  ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ (ํƒ€์ž„์•„์›ƒ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด 10๊ฐœ๋กœ ์ œํ•œ) all_stocks = await get_all_tradable_stocks() - stocks_to_analyze = all_stocks[:20] # ์ƒ์œ„ 20๊ฐœ ์ข…๋ชฉ์œผ๋กœ ์ œํ•œ + stocks_to_analyze = all_stocks[:10] # ์ƒ์œ„ 10๊ฐœ ์ข…๋ชฉ์œผ๋กœ ์ œํ•œ # โœ… ์ตœ์ ํ™”: ๋ชจ๋“  ์ข…๋ชฉ์„ ํ•œ ๋ฒˆ๋งŒ ๋ถ„์„ (๋ชจ๋“  ์ง€ํ‘œ ํฌํ•จ) logger.info(f"Analyzing {len(stocks_to_analyze)} stocks once with all indicators...") @@ -1031,6 +1639,10 @@ async def get_rule_based_recommendations_realtime(): rule_matched_stocks = [] for stock in valid_analyzed_stocks: + # ETF/๋ ˆ๋ฒ„๋ฆฌ์ง€/์ธ๋ฒ„์Šค/KODEX/์„ ๋ฌผ ์ข…๋ชฉ ์ œ์™ธ + etf_keywords = ["ETF", "etf", "๋ ˆ๋ฒ„๋ฆฌ์ง€", "์ธ๋ฒ„์Šค", "KODEX", "์„ ๋ฌผ"] + if any(k in stock.stock_name for k in etf_keywords): + continue # ๊ทœ์น™ ์กฐ๊ฑด ์ฒดํฌ matches = True @@ -1040,6 +1652,20 @@ async def get_rule_based_recommendations_realtime(): if not any(f"RSI" in signal for signal in stock.signals): matches = False + # Trend filter: only allow long entries in uptrend + if TREND_FILTER_ENABLED: + try: + t_ok = False + if stock.ma20 is not None and stock.ma60 is not None: + t_ok = (stock.ma20 > stock.ma60) and (stock.current_price > stock.ma20) + else: + # Fallback compute from price history is skipped here (already done in analyze_stock) + t_ok = False + if not t_ok: + matches = False + except Exception: + matches = False + if matches: rule_matched_stocks.append(stock) @@ -1055,9 +1681,12 @@ async def get_rule_based_recommendations_realtime(): "rule_name": rule.name, "rule_description": rule.description, "rule_priority": rule.priority, - "position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.15), - "take_profit": actions.get("take_profit", 5.0), - "stop_loss": actions.get("stop_loss", 3.0) + "position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.10), + "take_profit": actions.get("take_profit", 3.0), + "stop_loss": actions.get("stop_loss", -1.5), + "max_position_pct": actions.get("max_position_pct", 0.12), + "max_add_buys": actions.get("max_add_buys", 3), + "add_buy_cooldown_min": actions.get("add_buy_cooldown_min", 15) } rule_stocks.append(stock_with_rule) rule_matches.append(stock_with_rule) @@ -1105,6 +1734,50 @@ async def get_rule_based_recommendations_realtime(): stock["current_price"] = updated["current_price"] stock["change_rate"] = updated["change_rate"] + # ์‹ค์‹œ๊ฐ„ ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ DB์— ์ €์žฅ + from app.models.models import StockAnalysis + from sqlalchemy.ext.asyncio import AsyncSession + from app.core.database import AsyncSessionLocal + import datetime + + db = AsyncSessionLocal() + today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + for rec in top_recommendations: + # ์ค‘๋ณต ์ €์žฅ ๋ฐฉ์ง€: ์˜ค๋Š˜์ž+์ข…๋ชฉ์ฝ”๋“œ ๊ธฐ์ค€ + exists = await db.execute( + StockAnalysis.__table__.select().where( + StockAnalysis.stock_code == rec["stock_code"], + StockAnalysis.analysis_date == today + ) + ) + if not exists.first(): + stock_obj = StockAnalysis( + stock_code=rec["stock_code"], + stock_name=rec["stock_name"], + analysis_date=today, + current_price=rec["current_price"], + change_rate=rec.get("change_rate", 0), + volume=rec.get("volume", 0), + rsi=rec.get("rsi", None), + ma5=rec.get("ma5", None), + ma20=rec.get("ma20", None), + ma60=rec.get("ma60", None), + stochastic_k=rec.get("stochastic_k", None), + stochastic_d=rec.get("stochastic_d", None), + bollinger_upper=rec.get("bollinger_upper", None), + bollinger_lower=rec.get("bollinger_lower", None), + bollinger_middle=rec.get("bollinger_middle", None), + signals=rec.get("signals", []), + score=rec.get("score", 0), + ml_score=rec.get("ml_score"), + ml_confidence=rec.get("ml_confidence"), + ml_signal=rec.get("ml_signal"), + ) + db.add(stock_obj) + try: + await db.commit() + finally: + await db.close() return { "total": len(rule_matches), "active_rules": len(active_rules), @@ -1115,10 +1788,171 @@ async def get_rule_based_recommendations_realtime(): } +async def _mcp_validate_buy(stock: dict) -> tuple: + """MCP ์„œ๋ฒ„์— AI ๋งค์ˆ˜ ๊ฒ€์ฆ ์š”์ฒญ + + Returns: + (approved: bool, reason: str) + - ์˜ค๋ฅ˜/ํƒ€์ž„์•„์›ƒ ์‹œ (True, "mcp_unavailable") โ€” fail-open (๋งค๋งค ์ฐจ๋‹จ ์•ˆ ํ•จ) + """ + if not settings.MCP_VALIDATION_ENABLED: + return True, "mcp_disabled" + + try: + payload = { + "analysis_type": "RULE_VALIDATION", + "data": { + "rule": { + "id": stock.get("rule_id"), + "name": stock.get("rule_name", "auto_trade"), + }, + "market_data": { + "stock_code": stock.get("stock_code"), + "stock_name": stock.get("stock_name"), + "current_price": stock.get("current_price"), + "signals": stock.get("signals", []), + "score": stock.get("score"), + "ml_signal": stock.get("ml_signal"), + "ml_confidence": stock.get("ml_confidence"), + "ma20": stock.get("ma20"), + "ma60": stock.get("ma60"), + } + } + } + + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{settings.MCP_SERVER_URL}/analyze", + json=payload + ) + resp.raise_for_status() + data = resp.json() + + result = data.get("result", {}) + decision = result.get("decision", "HOLD") + confidence = float(result.get("confidence", 0.0)) + + logger.info( + f"๐Ÿค– MCP ๊ฒ€์ฆ: {stock.get('stock_name')} โ†’ " + f"decision={decision}, confidence={confidence:.2f}" + ) + + if decision == "BUY" and confidence >= settings.MCP_CONFIDENCE_THRESHOLD: + return True, f"mcp_approved(conf={confidence:.2f})" + else: + return False, f"mcp_rejected(decision={decision}, conf={confidence:.2f})" + + except Exception as e: + logger.warning(f"โš ๏ธ MCP ๊ฒ€์ฆ ์‹คํŒจ (์Šคํ‚ตํ•˜๊ณ  ๊ณ„์†): {e}") + return True, "mcp_unavailable" # ์žฅ์•  ์‹œ ๋งค๋งค ์ฐจ๋‹จ ์•ˆ ํ•จ + + +async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): + """๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์ž๋™๋งค๋งค ์‹คํ–‰""" + try: + trade_results = [] + total_processed = 0 + skipped_held = 0 # ์ด๋ฏธ ๋ณด์œ  ์ค‘์ธ ์ข…๋ชฉ ์ˆ˜ + + # ๐Ÿป ํ•˜๋ฝ์žฅ ๋ฐฉ์–ด: ์‹œ์žฅ ๋ ˆ์ง์— ๋”ฐ๋ผ ๋งค๋งค ๊ฑด์ˆ˜ ์ œํ•œ + market_regime = None + if settings.BEAR_MARKET_FILTER_ENABLED: + market_regime = await check_market_regime() + logger.info(f"๐Ÿป ์ž๋™๋งค๋งค ์‹œ์žฅ ์ƒํƒœ: {market_regime['message']}") + + regime_max_trades = market_regime.get("max_trades", 20) if market_regime else 20 + position_multiplier = market_regime.get("position_multiplier", 1.0) if market_regime else 1.0 + max_trades = min(20, regime_max_trades) + + # ํ˜„์žฌ ๋ณด์œ  ์ค‘์ธ ์ข…๋ชฉ ์กฐํšŒ + from app.models.models import Portfolio + result = await db.execute( + select(Portfolio).where(Portfolio.quantity > 0) + ) + held_stocks = {p.stock_code for p in result.scalars().all()} + if held_stocks: + logger.info(f"ํ˜„์žฌ ๋ณด์œ  ์ค‘: {len(held_stocks)}๊ฐœ ์ข…๋ชฉ") + + # โœ… ์ค‘๋ณต ์ œ๊ฑฐ: ๋ชจ๋“  ๊ทœ์น™ ๊ทธ๋ฃน์˜ ์ข…๋ชฉ์„ ํ•ฉ์ณ์„œ ์ค‘๋ณต ์ œ๊ฑฐ + unique_stocks = {} + for rule_group in recommendations_response["recommendations_by_rule"]: + for stock in rule_group["stocks"]: + stock_code = stock.get("stock_code") + # ์šฐ์„ ์ˆœ์œ„๊ฐ€ ๋†’์€ ๊ทœ์น™์˜ ์ข…๋ชฉ์„ ์šฐ์„  (priority๊ฐ€ ๋‚ฎ์„์ˆ˜๋ก ์šฐ์„ ์ˆœ์œ„ ๋†’์Œ) + if stock_code not in unique_stocks: + unique_stocks[stock_code] = stock + elif stock.get("rule_priority", 999) < unique_stocks[stock_code].get("rule_priority", 999): + unique_stocks[stock_code] = stock + + # ์šฐ์„ ์ˆœ์œ„์™€ ์ ์ˆ˜๋กœ ์ •๋ ฌ + sorted_stocks = sorted( + unique_stocks.values(), + key=lambda s: (s.get("rule_priority", 999), -s.get("score", 0)) + ) + + logger.info(f"๐Ÿ“Š ์ž๋™๋งค๋งค ๋Œ€์ƒ: ์ด {len(sorted_stocks)}๊ฐœ ์ข…๋ชฉ (์ค‘๋ณต ์ œ๊ฑฐ ์™„๋ฃŒ)") + + for stock in sorted_stocks[:max_trades]: + # ๐Ÿ”„ ์ด๋ฏธ ๋ณด์œ  ์ค‘์ธ ์ข…๋ชฉ๋„ ์ถ”๊ฐ€ ๋งค์ˆ˜ ํ—ˆ์šฉ (์ผ์ผ 2ํšŒ ์ œํ•œ ๋‚ด์—์„œ) + # if stock.get("stock_code") in held_stocks: + # skipped_held += 1 + # logger.debug(f"โญ๏ธ {stock.get('stock_name')} - ์ด๋ฏธ ๋ณด์œ ์ค‘, ๊ฑด๋„ˆ๋œ€") + # continue + + try: + # ๐Ÿป ํ•˜๋ฝ์žฅ ํฌ์ง€์…˜ ์ถ•์†Œ ๋น„์œจ ์ „๋‹ฌ + stock["_position_multiplier"] = position_multiplier + result = await execute_auto_trade(stock, db) + total_processed += 1 + + if result["action"] in ["BUY", "SELL"]: + trade_results.append(result) + logger.info(f"โœ… {result['action']} {result['stock']} - {result.get('quantity')}์ฃผ") + + # Rate Limiting: KIS API ํ˜ธ์ถœ ๊ฐ„ 0.5์ดˆ ๋Œ€๊ธฐ + await asyncio.sleep(0.5) + + except Exception as e: + logger.error(f"Error executing auto-trade for {stock.get('stock_code')}: {e}") + continue + + logger.info(f"๐ŸŽฏ ์ž๋™๋งค๋งค ์™„๋ฃŒ: {len(trade_results)}๊ฑด ์‹คํ–‰ / {total_processed}๊ฐœ ๋ถ„์„ / {skipped_held}๊ฐœ ๋ณด์œ ์ค‘") + + except Exception as e: + logger.error(f"โŒ Background auto-trade error: {e}") + + +async def _background_sync_portfolio(): + """๋ฐฑ๊ทธ๋ผ์šด๋“œ ํฌํŠธํด๋ฆฌ์˜ค ๋™๊ธฐํ™” (์ƒˆ ์„ธ์…˜ ์‚ฌ์šฉ)""" + try: + from app.api.endpoints.portfolio import sync_portfolio + async with AsyncSessionLocal() as session: + await sync_portfolio(session, force=True) + except Exception as e: + logger.error(f"Background portfolio sync failed: {e}") + + +async def _delayed_sync_portfolio(delay_seconds: int = 3): + """์ง€์—ฐ ํฌํŠธํด๋ฆฌ์˜ค ๋™๊ธฐํ™” (KIS API ์—…๋ฐ์ดํŠธ ๋Œ€๊ธฐ)""" + try: + await asyncio.sleep(delay_seconds) + from app.api.endpoints.portfolio import sync_portfolio + from app.core.database import AsyncSessionLocal + + async with AsyncSessionLocal() as session: + try: + await sync_portfolio(session, force=True) + logger.info(f"๐Ÿ”„ Delayed portfolio sync completed after {delay_seconds}s") + except Exception as inner_e: + logger.error(f"Sync execution failed: {inner_e}") + except Exception as e: + logger.error(f"Delayed portfolio sync failed: {e}") + + # ์ž๋™ ๋งค๋งค ์‹คํ–‰ ํ•จ์ˆ˜ async def execute_auto_trade(stock: dict, db: AsyncSession): """ - ์ถ”์ฒœ ์ข…๋ชฉ์— ๋Œ€ํ•ด ์ž๋™ ๋งค๋งค ์‹คํ–‰ + ์ถ”์ฒœ ์ข…๋ชฉ์— ๋Œ€ํ•ด ์ž๋™ ๋งค๋งค ์‹คํ–‰ (๊ตญ๋‚ด + ํ•ด์™ธ ์ฃผ์‹) - ๋งค์ˆ˜ ์‹ ํ˜ธ: ์ •๋ฐฐ์—ด, RSI < 30 ๋“ฑ - ๋งค๋„ ์‹ ํ˜ธ: ์—ญ๋ฐฐ์—ด, RSI > 70, ์†์ต๋ฅ  ๊ธฐ์ค€ """ @@ -1132,6 +1966,55 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): logger.debug(f"Invalid stock data for auto trade: {stock}") return {"action": "SKIP", "reason": "invalid_data"} + # ๊ตญ๋‚ด/ํ•ด์™ธ ๊ตฌ๋ถ„ (6์ž๋ฆฌ ์ˆซ์ž๋ฉด ๊ตญ๋‚ด, ์•ŒํŒŒ๋ฒณ ํฌํ•จ์ด๋ฉด ํ•ด์™ธ) + is_domestic = stock_code.isdigit() and len(stock_code) == 6 + exchange_code = stock.get("exchange_code", "NASD") # ๊ธฐ๋ณธ๊ฐ’: ๋‚˜์Šค๋‹ฅ + + # ๊ฐ€๊ฒฉ ํ•„ํ„ฐ (CLAUDE.md ์„น์…˜ 6.4) + if is_domestic: + if current_price < settings.MIN_PRICE_KRW: + logger.info(f"๊ฐ€๊ฒฉ ํ•„ํ„ฐ: {stock_name} ({current_price:,.0f}์› < {settings.MIN_PRICE_KRW:,}์›)") + return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name} + else: + if current_price < settings.MIN_PRICE_USD: + logger.info(f"๊ฐ€๊ฒฉ ํ•„ํ„ฐ: {stock_name} (${current_price:.2f} < ${settings.MIN_PRICE_USD:.2f})") + return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name} + + # MDD ์ฒดํฌ (์„น์…˜ 6.3) โ€” async lock์œผ๋กœ thread-safe + if not await mdd_manager.can_open_new_position(): + logger.info(f"MDD ์ œํ•œ: ์‹ ๊ทœ ์ง„์ž… ์ค‘๋‹จ ์ƒํƒœ - {stock_name} ์Šคํ‚ต") + return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name} + + # ์—ฐ์† ์†์‹ค ๊ธˆ์ง€ ์ฒดํฌ (์„น์…˜ 7.4) + if loss_tracker.is_banned(stock_code): + ban_info = loss_tracker.get_ban_info(stock_code) + logger.info(f"์—ฐ์† ์†์‹ค ๊ธˆ์ง€: {stock_name} ({ban_info})") + return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name} + + # ๋ณด์œ  ์ข…๋ชฉ ์ˆ˜ ์ฒดํฌ (์„น์…˜ 6.1: ์ตœ๋Œ€ 20์ข…๋ชฉ) + held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) + held_count = len(held_result.scalars().all()) + if held_count >= settings.MAX_HOLDINGS_COUNT: + logger.info(f"๋ณด์œ  ์ข…๋ชฉ ํ•œ๋„: {held_count}/{settings.MAX_HOLDINGS_COUNT} - {stock_name} ์Šคํ‚ต") + return {"action": "SKIP", "reason": "max_holdings_reached", "stock": stock_name} + + # 0. ๋งค๋„ ํ›„ ์žฌ์ง„์ž… ๊ธˆ์ง€ (Cooldown) - ์ตœ๊ทผ 24์‹œ๊ฐ„ ๋‚ด ๋งค๋„ ์ด๋ ฅ ํ™•์ธ + # ๊ฐ™์€ ๋‚  ์†์ ˆ์นœ ์ข…๋ชฉ์„ ๋‹ค์‹œ ์‚ฌ๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ + from app.models.models import TradeHistory + cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) + + recent_sell = await db.execute( + select(TradeHistory).where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "SELL", + TradeHistory.status == "EXECUTED", + TradeHistory.executed_at >= cutoff_time + ) + ) + if recent_sell.first(): + logger.info(f"๐Ÿšซ Cooldown active: {stock_name}({stock_code}) was sold today. Skipping BUY.") + return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name} + # ์ด๋ฏธ ๋ณด์œ  ์ค‘์ธ์ง€ ํ™•์ธ result = await db.execute( select(Portfolio).where( @@ -1174,27 +2057,246 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): ] should_sell = any(k in text_signals for k in sell_keywords) + # ๐Ÿค– ML ์•™์ƒ๋ธ” ๊ธฐ๋ฐ˜ ๋งค์ˆ˜/๋งค๋„ ํŒ๋‹จ (์„ค์ • ํ™œ์„ฑํ™” ์‹œ) + ml_score_trade = stock.get("ml_score") + ml_confidence_trade = stock.get("ml_confidence") + ml_signal_trade = stock.get("ml_signal") + + if settings.ML_ENSEMBLE_ENABLED and ml_score_trade is not None: + # ML ์‹ ํ˜ธ๊ฐ€ BUY์ด๊ณ  ์‹ ๋ขฐ๋„/์Šค์ฝ”์–ด ์ถฉ๋ถ„ํ•˜๋ฉด ๋งค์ˆ˜ + if (ml_signal_trade == "BUY" + and ml_score_trade >= settings.ML_ENSEMBLE_BUY_THRESHOLD + and (ml_confidence_trade or 0) >= settings.ML_ENSEMBLE_CONFIDENCE_MIN): + should_buy = True + logger.info(f"๐Ÿค– ML ๋งค์ˆ˜ ์‹ ํ˜ธ: {stock_name} (score={ml_score_trade}, conf={ml_confidence_trade:.2f})") + # ML ์‹ ํ˜ธ๊ฐ€ SELL์ด๋ฉด ๋งค๋„ ๊ฐ•ํ™” + elif ml_signal_trade == "SELL" and (ml_confidence_trade or 0) >= 0.5: + should_sell = True + logger.info(f"๐Ÿค– ML ๋งค๋„ ์‹ ํ˜ธ: {stock_name} (score={ml_score_trade}, conf={ml_confidence_trade:.2f})") + # ML ์‹ ๋ขฐ๋„๊ฐ€ ๋‚ฎ์œผ๋ฉด ๋งค์ˆ˜ ์–ต์ œ + elif ml_signal_trade == "HOLD" and (ml_confidence_trade or 0) < settings.ML_ENSEMBLE_CONFIDENCE_MIN: + should_buy = False + logger.info(f"๐Ÿค– ML ๊ด€๋ง ์‹ ํ˜ธ๋กœ ๋งค์ˆ˜ ์–ต์ œ: {stock_name}") + + # โœ… ๊ทœ์น™ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ ์ข…๋ชฉ๋„ ์•ˆ์ „์žฅ์น˜ ์ ์šฉ (์ด๊ฒฉ๋„ ๊ณผ์—ด, ๊ธ‰๋“ฑ ์ฒดํฌ) + has_rule = stock.get("rule_id") is not None or stock.get("rule_name") is not None + if has_rule: + # ์ด๊ฒฉ๋„ ๊ณผ์—ด ์‹œ ๋งค์ˆ˜ ๊ธˆ์ง€ + if "์ด๊ฒฉ๋„ ๊ณผ์—ด" in text_signals or "๋งค์ˆ˜๊ธˆ์ง€" in text_signals: + logger.info(f"๐Ÿšซ ๊ทœ์น™ ์ถ”์ฒœ์ด์ง€๋งŒ ์ด๊ฒฉ๋„ ๊ณผ์—ด๋กœ ๋งค์ˆ˜ ๊ธˆ์ง€: {stock_name}") + return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"} + + # ๊ธ‰๋“ฑ ์ข…๋ชฉ ์ถ”๊ฒฉ ๊ธˆ์ง€ ์ฒดํฌ + if "๊ธ‰๋“ฑ ์ข…๋ชฉ ์ถ”๊ฒฉ ๊ธˆ์ง€" in text_signals or "๊ธ‰๋“ฑ๊ณผ์—ด" in text_signals: + logger.info(f"๐Ÿšซ ๊ทœ์น™ ์ถ”์ฒœ์ด์ง€๋งŒ ๊ธ‰๋“ฑ/๊ณผ์—ด ๊ธˆ์ง€: {stock_name}") + return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"} + + should_buy = True + logger.info(f"๐Ÿ“‹ ๊ทœ์น™ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ: {stock_name} - {stock.get('rule_name', '๋งค์ˆ˜๊ทœ์น™')}") + # ๋งค์ˆ˜ ๋กœ์ง (์‹ ํ˜ธ ๋˜๋Š” ์ ์ˆ˜ ๊ธฐ๋ฐ˜) - # ์กฐ๊ฑด ์™„ํ™”: ์ ์ˆ˜ ๊ธฐ์ค€ 1์  ์ด์ƒ์œผ๋กœ ๋งค์ˆ˜ ํ—ˆ์šฉ (์‹ ํ˜ธ๊ฐ€ ์•ฝํ•ด๋„ ์†Œ์•ก ์ง„์ž…) - if (should_buy or (score_val >= 1)) and not holding: - # ํˆฌ์ž ๊ฐ€๋Šฅ ๊ธˆ์•ก์˜ 10% ๋งค์ˆ˜ (์˜ˆ์ˆ˜๊ธˆ 1000๋งŒ์› ๊ธฐ์ค€ 100๋งŒ์›) - available_cash = 10000000 # ์˜ˆ์ˆ˜๊ธˆ (์‹ค์ œ๋กœ๋Š” API์—์„œ ์กฐํšŒํ•ด์•ผ ํ•จ) - invest_amount = available_cash * 0.10 # 10% ํˆฌ์ž + # ์ถ”๊ฐ€๋งค์ˆ˜ ํ—ˆ์šฉ: ๋ณด์œ  ์ค‘์ด์–ด๋„ ๋น„์ค‘ ํ•œ๋„ ๋‚ด์—์„œ ์ถ”๋งค ๊ฐ€๋Šฅ + if (should_buy or (score_val >= 1)): + # ๐Ÿšซ ์ถ”๊ฒฉ๋งค์ˆ˜ ๋ฐฉ์ง€: ๊ธ‰๋“ฑ ์ข…๋ชฉ ํ•„ํ„ฐ + try: + if is_domestic: + chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=10) + chart_data = chart_resp.get("output2", []) + else: + chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D") + chart_data = chart_resp.get("output2", []) + + if len(chart_data) >= 4: + # ๊ตญ๋‚ด/ํ•ด์™ธ ๋ฐ์ดํ„ฐ ํ‚ค ์ฐจ์ด ์ฒ˜๋ฆฌ + if is_domestic: + closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-4:]] + else: + closes = [float(item.get("clos", 0)) for item in chart_data[-4:]] + + if len(closes) >= 4: + price_3days_ago = closes[0] + recent_gain = ((current_price - price_3days_ago) / price_3days_ago) * 100 if price_3days_ago > 0 else 0 + if recent_gain > 10: # 3์ผ๊ฐ„ 10% ์ด์ƒ ๊ธ‰๋“ฑ + logger.info(f"๐Ÿšซ ๊ธ‰๋“ฑ ์ข…๋ชฉ ์ถ”๊ฒฉ ๊ธˆ์ง€: {stock_name} (3์ผ +{recent_gain:.1f}%)") + return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"} + except Exception as e: + logger.warning(f"โš ๏ธ ๊ธ‰๋“ฑ ์ฒดํฌ ์‹คํŒจ, ๊ณ„์† ์ง„ํ–‰: {e}") + + # Enforce trend filter for buys + if TREND_FILTER_ENABLED: + try: + ma20 = stock.get("ma20") + ma60 = stock.get("ma60") + # If moving averages not provided, fetch quick chart to compute + if ma20 is None or ma60 is None: + if is_domestic: + chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D") + closes_local = [float(it.get("stck_clpr", 0)) for it in (chart_resp.get("output") or [])] + else: + chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D") + closes_local = [float(it.get("clos", 0)) for it in (chart_resp.get("output2") or [])] + + if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG): + ma20 = _ma(closes_local, TREND_MA_SHORT) + ma60 = _ma(closes_local, TREND_MA_LONG) + # ํ—ˆ์šฉ ์˜ค์ฐจ ์ ์šฉ + if not (ma20 and ma60 and (float(ma20) >= float(ma60) * (1 - TREND_TOLERANCE_PCT)) and (current_price >= float(ma20) * (1 - TREND_TOLERANCE_PCT))): + logger.info(f"๐Ÿšซ Trend filter rejected BUY: {stock_name} (price={current_price}, ma20={ma20}, ma60={ma60}, tol={TREND_TOLERANCE_PCT})") + return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter"} + except Exception as e: + logger.info(f"โš ๏ธ Trend filter check failed, skipping buy as safe default: {e}") + return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"} + # ํฌํŠธํด๋ฆฌ์˜ค ์š”์•ฝ ์ •๋ณด + try: + from app.api.endpoints.portfolio import portfolio_cache + available_cash = float(portfolio_cache.summary.get("available_cash", 0)) if portfolio_cache.summary else 0.0 + total_assets = float(portfolio_cache.summary.get("total_assets", 0)) if portfolio_cache.summary else 0.0 + except Exception: + available_cash, total_assets = 0.0, 0.0 + + # ๐Ÿ”ฅ ํ•˜๋ฃจ ๋งค๋งค ํšŸ์ˆ˜ ์ œํ•œ (๋‹น์ผ 0์‹œ๋ถ€ํ„ฐ ์นด์šดํŠธ) + daily_cutoff = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) + daily_buy_check = await db.execute( + select(TradeHistory) + .where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "BUY", + TradeHistory.status.in_(['EXECUTED', 'COMPLETED']), # COMPLETED๋„ ํฌํ•จ + TradeHistory.created_at >= daily_cutoff, + ) + ) + daily_buys = daily_buy_check.scalars().all() + daily_buy_count = len(daily_buys) + + # ๐Ÿšซ ํ•˜๋ฃจ ์ตœ๋Œ€ 2ํšŒ ๋งค๋งค ์ œํ•œ (์‹ ๊ทœ + ์ถ”๊ฐ€) + max_daily_trades = 2 + if daily_buy_count >= max_daily_trades: + logger.info(f"๐Ÿšซ ์ผ์ผ ๋งค๋งค ํ•œ๋„ ์ดˆ๊ณผ: {stock_name} ๋‹น์ผ {daily_buy_count}/{max_daily_trades}ํšŒ") + return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"} + + # ๊ธฐ๋ณธ ํŒŒ๋ผ๋ฏธํ„ฐ (๊ถŒ์žฅ ์•ˆ์ „ ํ”„๋กœํŒŒ์ผ, ๊ทœ์น™์—์„œ ์˜ค๋ฒ„๋ผ์ด๋“œ ๊ฐ€๋Šฅ) + target_ratio = float(stock.get("position_ratio", 0.10) or 0.10) # ๋ชฉํ‘œ ๋น„์ค‘(์ž์‚ฐ ๋Œ€๋น„) + max_position_pct = float(stock.get("max_position_pct", 0.12) or 0.12) # ์ตœ๋Œ€ ๋น„์ค‘ ์บก + cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) # ์ถ”๊ฐ€๋งค์ˆ˜ ์ฟจ๋‹ค์šด(๋ถ„) - 30๋ถ„์œผ๋กœ ์ฆ๊ฐ€ + + # ๐Ÿ”ฅ ์ฟจ๋‹ค์šด ์ฒดํฌ (๋ณด์œ  ์—ฌ๋ถ€์™€ ๊ด€๊ณ„์—†์ด ํ•ญ์ƒ ์ฒดํฌ) + # ํฌํŠธํด๋ฆฌ์˜ค ๋™๊ธฐํ™”๊ฐ€ ๋А๋ฆด ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, DB ๊ฑฐ๋ž˜ ๋‚ด์—ญ ๊ธฐ์ค€์œผ๋กœ ์ฒดํฌ + cooldown_check = await db.execute( + select(TradeHistory) + .where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "BUY", + TradeHistory.status.in_(['EXECUTED', 'COMPLETED']), + TradeHistory.created_at >= daily_cutoff, + ) + .order_by(desc(TradeHistory.created_at)) + ) + recent_buys = cooldown_check.scalars().all() + last_buy_at = recent_buys[0].created_at if recent_buys else None + + # ์ฟจ๋‹ค์šด ์ฒดํฌ + if last_buy_at and (datetime.now() - last_buy_at).total_seconds() < cooldown_min * 60: + logger.info(f"โณ ์ถ”๊ฐ€๋งค์ˆ˜ ๋Œ€๊ธฐ: {stock_name} {cooldown_min}๋ถ„ ์ฟจ๋‹ค์šด ๋ฏธ๊ฒฝ๊ณผ") + return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"} + + # ๋ชฉํ‘œ/์ตœ๋Œ€ ๋น„์ค‘ ๊ธฐ๋ฐ˜ ํˆฌ์ž๊ธˆ ๊ณ„์‚ฐ (์ด์ „์— ์ตœ๊ทผ ๋งค์ˆ˜ ๊ธฐ๋ก ์ฒดํฌ) + # ๋ณด์œ ์ค‘์ด ์•„๋‹ˆ๋”๋ผ๋„(์•„์ง ์‹ฑํฌ ์•ˆ๋จ) ์ตœ๊ทผ ๋งค์ˆ˜ ์‹œ๋„๊ฐ€ ์žˆ์œผ๋ฉด ์Šคํ‚ต + recent_buy_cutoff = _get_kst_now() - timedelta(minutes=5) + check_recent = await db.execute( + select(TradeHistory).where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "BUY", + TradeHistory.created_at >= recent_buy_cutoff, + TradeHistory.status.in_(['EXECUTED', 'COMPLETED', 'PENDING']) + ) + ) + if check_recent.first(): + logger.info(f"โณ ์ตœ๊ทผ 5๋ถ„ ๋‚ด ๋งค์ˆ˜ ์ด๋ ฅ ์กด์žฌ: {stock_name} - ์Šคํ‚ต") + return {"action": "SKIP", "stock": stock_name, "reason": "recent_buy_5min"} + + # โœ… MCP AI ๊ฒ€์ฆ (BUY ์ตœ์ข… ํ™•์ธ - ๋ชจ๋“  ์‚ฌ์ „ ํ•„ํ„ฐ ํ†ต๊ณผ ํ›„) + mcp_ok, mcp_reason = await _mcp_validate_buy(stock) + if not mcp_ok: + logger.info(f"๐Ÿšซ MCP AI ๋งค์ˆ˜ ๊ฑฐ๋ถ€: {stock_name} ({mcp_reason})") + return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason} + + if total_assets > 0: + current_value = (holding.quantity * current_price) if holding else 0.0 + desired_value = total_assets * target_ratio + max_allowed_value = total_assets * max_position_pct + add_value = max(0.0, desired_value - current_value) + add_value = min(add_value, max(0.0, max_allowed_value - current_value)) + invest_amount = min(add_value, available_cash) + else: + # ์ด์ž์‚ฐ ๋ฏธํ™•์ • ์‹œ ๋ณด์ˆ˜์ ์œผ๋กœ ํ˜„๊ธˆ์˜ 10% + invest_amount = available_cash * 0.10 + + # ๐Ÿป ํ•˜๋ฝ์žฅ ํฌ์ง€์…˜ ์ถ•์†Œ ์ ์šฉ + pos_mult = float(stock.get("_position_multiplier", 1.0)) + if pos_mult < 1.0: + original_amount = invest_amount + invest_amount = invest_amount * pos_mult + logger.info(f"๐Ÿป ํ•˜๋ฝ์žฅ ํฌ์ง€์…˜ ์ถ•์†Œ: {stock_name} {original_amount:,.0f}์› โ†’ {invest_amount:,.0f}์› (x{pos_mult})") + quantity = int(invest_amount / current_price) - + + logger.info(f"๐Ÿ’ฐ {stock_name}({stock_code}): ์˜ˆ์ˆ˜๊ธˆ={available_cash:,.0f}์›, ํˆฌ์ž๊ธˆ์•ก={invest_amount:,.0f}์›, ์ˆ˜๋Ÿ‰={quantity}์ฃผ, ๊ฐ€๊ฒฉ={current_price:,.0f}์›") + if quantity > 0: logger.info(f"๐ŸŸข ๋งค์ˆ˜ ์‹ ํ˜ธ ๊ฐ์ง€: {stock_name}({stock_code}) - {signals}") - # KIS API๋กœ ๋งค์ˆ˜ ์ฃผ๋ฌธ + # KIS API๋กœ ๋งค์ˆ˜ ์ฃผ๋ฌธ (์ง€์ •๊ฐ€: ํ˜„์žฌ๊ฐ€๋กœ ์ฃผ๋ฌธ) try: - order_result = await korea_investment_api.place_order( - stock_code=stock_code, - order_type="BUY", - quantity=quantity, - price=None # ์‹œ์žฅ๊ฐ€ ์ฃผ๋ฌธ - ) + if is_domestic: + order_result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="BUY", + quantity=quantity, + price=int(current_price) # ์ง€์ •๊ฐ€ ์ฃผ๋ฌธ (ํ˜„์žฌ๊ฐ€) + ) + else: + order_result = await korea_investment_api.place_overseas_order( + symbol=stock_code, + order_type="BUY", + quantity=quantity, + price=float(current_price), # ํ•ด์™ธ ์ฃผ์‹์€ ๋‹ฌ๋Ÿฌ ๋‹จ์œ„ + exchange_code=exchange_code + ) + + # ์ฃผ๋ฌธ ๊ฒฐ๊ณผ ์ƒ์„ธ ๋กœ๊น… + logger.info(f"๐Ÿ“ ๋งค์ˆ˜ ์ฃผ๋ฌธ ๊ฒฐ๊ณผ: {stock_name}({stock_code}) - rt_cd={order_result.get('rt_cd')}, msg={order_result.get('msg1')}, odno={order_result.get('odno')}") + logger.debug(f"Full order result: {order_result}") + + # ์ค‘๋ณต ๊ฑฐ๋ž˜ ๋ฐฉ์ง€: ์ตœ๊ทผ 10์ดˆ ์ด๋‚ด ๋™์ผ ์ข…๋ชฉ ๋™์ผ ํƒ€์ž… ์ฃผ๋ฌธ ํ™•์ธ + recent_cutoff = _get_kst_now() - timedelta(seconds=10) + duplicate_check = await db.execute( + select(TradeHistory).where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "BUY", + TradeHistory.created_at >= recent_cutoff + ) + ) + recent_duplicate = duplicate_check.scalar_one_or_none() + + if recent_duplicate: + logger.warning(f"โš ๏ธ ์ค‘๋ณต ๋งค์ˆ˜ ๋ฐฉ์ง€: {stock_name} - 10์ดˆ ์ด๋‚ด ๋™์ผ ์ฃผ๋ฌธ ์กด์žฌ") + return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} + + # ๊ฑฐ๋ž˜ ์ด๋ ฅ ์ €์žฅ (์ƒ์„ธ ์ •๋ณด ํฌํ•จ) + total_amount = Decimal(str(current_price)) * quantity + entry_reason = f"๋งค์ˆ˜ ์‹ ํ˜ธ: {', '.join(signals[:3])}. ์ ์ˆ˜: {score_val}์ " + + success = order_result.get("rt_cd") == "0" + error_msg = order_result.get("msg1", "") if not success else "" + + # ์ฃผ๋ฌธ ์ƒํƒœ ๊ฒฐ์ • + if success: + status = "COMPLETED" # ์ฒด๊ฒฐ ์™„๋ฃŒ + elif order_result.get("rt_cd") == "1": + status = "PENDING" # ์ฃผ๋ฌธ ์ ‘์ˆ˜๋จ, ์ฒด๊ฒฐ ๋Œ€๊ธฐ + else: + status = "FAILED" # ์ฃผ๋ฌธ ์‹คํŒจ - # ๊ฑฐ๋ž˜ ์ด๋ ฅ ์ €์žฅ trade = TradeHistory( rule_id=stock.get("rule_id"), stock_code=stock_code, @@ -1202,18 +2304,26 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): order_type="BUY", order_price=Decimal(str(current_price)), order_quantity=quantity, - executed_price=Decimal(str(current_price)), - executed_quantity=quantity, - status="EXECUTED" if order_result.get("rt_cd") == "0" else "FAILED", + executed_price=Decimal(str(current_price)) if success else None, + executed_quantity=quantity if success else 0, + total_amount=total_amount, + status=status, order_id=order_result.get("odno"), - message=f"์ž๋™๋งค์ˆ˜: {' | '.join(signals[:2])}", - created_at=datetime.now() + message=f"์ž๋™๋งค์ˆ˜: {'์ถ”๊ฐ€๋งค์ˆ˜' if holding else '์‹ ๊ทœ'} | {' | '.join(signals[:2])} | {error_msg if not success else '์ฒด๊ฒฐ์™„๋ฃŒ'}", + entry_reason=entry_reason, + signals=signals, + created_at=_get_kst_now(), + executed_at=_get_kst_now() if success else None ) db.add(trade) await db.commit() - success = order_result.get("rt_cd") == "0" - logger.info(f"โœ… ๋งค์ˆ˜ ์ฃผ๋ฌธ ์™„๋ฃŒ: {stock_name} {quantity}์ฃผ @ {current_price}์› (success={success})") + if success: + logger.info(f"โœ… ๋งค์ˆ˜ ์ฒด๊ฒฐ ์™„๋ฃŒ: {stock_name} {quantity}์ฃผ @ {current_price}์›") + elif status == "PENDING": + logger.info(f"โณ ๋งค์ˆ˜ ์ฃผ๋ฌธ ์ ‘์ˆ˜: {stock_name} {quantity}์ฃผ @ {current_price}์› - ์ฒด๊ฒฐ ๋Œ€๊ธฐ์ค‘") + else: + logger.warning(f"โŒ ๋งค์ˆ˜ ์ฃผ๋ฌธ ์‹คํŒจ: {stock_name} {quantity}์ฃผ - {error_msg}") # Optimistic local ํฌํŠธํด๋ฆฌ์˜ค ์—…๋ฐ์ดํŠธ if success: if holding: @@ -1236,16 +2346,26 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): ) db.add(new_holding) await db.commit() + # ํฌํŠธํด๋ฆฌ์˜ค ์บ์‹œ ์ฆ‰์‹œ ์—…๋ฐ์ดํŠธ try: from app.api.endpoints.portfolio import portfolio_cache - portfolio_cache.mark_changed() - except Exception: - logger.debug("Portfolio cache not available for mark_changed") + # ์บ์‹œ ์™„์ „ ์ดˆ๊ธฐํ™” - ๋‹ค์Œ ์กฐํšŒ์‹œ ๊ฐ•์ œ๋กœ KIS API ํ˜ธ์ถœ + portfolio_cache.holdings = [] + portfolio_cache.summary = None + portfolio_cache.last_synced = None + portfolio_cache.last_trade_id = None + portfolio_cache.change_flag = True + logger.info(f"๐Ÿ”„ Portfolio cache CLEARED after BUY {stock_name}({stock_code})") + except Exception as e: + logger.error(f"Portfolio cache clear failed after BUY: {e}") return {"action": "BUY", "stock": stock_name, "quantity": quantity, "price": current_price, "success": success} except Exception as e: logger.error(f"โŒ ๋งค์ˆ˜ ์ฃผ๋ฌธ ์‹คํŒจ: {stock_name} - {e}") return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} + else: + logger.debug(f"โš ๏ธ {stock_name}({stock_code}): ๋งค์ˆ˜ ์ˆ˜๋Ÿ‰ 0 (๊ฐ€๊ฒฉ={current_price:,.0f}์›, ์˜ˆ์ˆ˜๊ธˆ={available_cash:,.0f}์›)") + return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name} # ๋งค๋„ ๋กœ์ง elif should_sell and holding: @@ -1253,8 +2373,8 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): profit_loss_rate = float(holding.profit_loss_rate or 0) # ์ต์ ˆ/์†์ ˆ ์กฐ๊ฑด ํ™•์ธ - take_profit = stock.get("take_profit", 5.0) # 5% ์ต์ ˆ - stop_loss = stock.get("stop_loss", -3.0) # 3% ์†์ ˆ + take_profit = stock.get("take_profit", 3.0) # 3% ์ต์ ˆ(๊ถŒ์žฅ) + stop_loss = stock.get("stop_loss", -1.5) # -1.5% ์†์ ˆ(๊ถŒ์žฅ) should_execute_sell = ( profit_loss_rate >= take_profit or # ์ต์ ˆ @@ -1266,14 +2386,68 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): logger.info(f"๐Ÿ”ด ๋งค๋„ ์‹ ํ˜ธ ๊ฐ์ง€: {stock_name}({stock_code}) - ์†์ต๋ฅ : {profit_loss_rate:.2f}% - {signals}") try: - order_result = await korea_investment_api.place_order( - stock_code=stock_code, - order_type="SELL", - quantity=quantity, - price=None # ์‹œ์žฅ๊ฐ€ ์ฃผ๋ฌธ - ) + if is_domestic: + order_result = await korea_investment_api.place_order( + stock_code=stock_code, + order_type="SELL", + quantity=quantity, + price=int(current_price) # ์ง€์ •๊ฐ€ ์ฃผ๋ฌธ (ํ˜„์žฌ๊ฐ€) + ) + else: + order_result = await korea_investment_api.place_overseas_order( + symbol=stock_code, + order_type="SELL", + quantity=quantity, + price=float(current_price), # ํ•ด์™ธ ์ฃผ์‹์€ ๋‹ฌ๋Ÿฌ ๋‹จ์œ„ + exchange_code=exchange_code + ) + + # ์ฃผ๋ฌธ ๊ฒฐ๊ณผ ์ƒ์„ธ ๋กœ๊น… + logger.info(f"๐Ÿ“ ๋งค๋„ ์ฃผ๋ฌธ ๊ฒฐ๊ณผ: {stock_name}({stock_code}) - rt_cd={order_result.get('rt_cd')}, msg={order_result.get('msg1')}, odno={order_result.get('odno')}") + logger.debug(f"Full order result: {order_result}") + + # ์ค‘๋ณต ๊ฑฐ๋ž˜ ๋ฐฉ์ง€: ์ตœ๊ทผ 10์ดˆ ์ด๋‚ด ๋™์ผ ์ข…๋ชฉ ๋™์ผ ํƒ€์ž… ์ฃผ๋ฌธ ํ™•์ธ + recent_cutoff = _get_kst_now() - timedelta(seconds=10) + duplicate_check = await db.execute( + select(TradeHistory).where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "SELL", + TradeHistory.created_at >= recent_cutoff + ) + ) + recent_duplicate = duplicate_check.scalar_one_or_none() + + if recent_duplicate: + logger.warning(f"โš ๏ธ ์ค‘๋ณต ๋งค๋„ ๋ฐฉ์ง€: {stock_name} - 10์ดˆ ์ด๋‚ด ๋™์ผ ์ฃผ๋ฌธ ์กด์žฌ") + return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} + + # ๊ฑฐ๋ž˜ ์ด๋ ฅ ์ €์žฅ (์ƒ์„ธ ์ •๋ณด ํฌํ•จ) + total_amount = Decimal(str(current_price)) * quantity + avg_price = float(holding.average_price or current_price) + profit_loss = (current_price - avg_price) * quantity + + # ๋งค๋„ ์ด์œ  ์ƒ์„ธ ์ž‘์„ฑ + exit_reasons = [] + if profit_loss_rate >= take_profit: + exit_reasons.append(f"์ต์ ˆ ๋‹ฌ์„ฑ ({profit_loss_rate:.2f}% >= {take_profit}%)") + if profit_loss_rate <= stop_loss: + exit_reasons.append(f"์†์ ˆ ์‹คํ–‰ ({profit_loss_rate:.2f}% <= {stop_loss}%)") + if should_sell: + exit_reasons.append(f"๋งค๋„ ์‹ ํ˜ธ: {', '.join(signals[:2])}") + + exit_reason = ". ".join(exit_reasons) + f". ํ‰๊ท ๋‹จ๊ฐ€: {avg_price:,.0f}์›, ๋งค๋„๊ฐ€: {current_price:,.0f}์›, ์†์ต: {profit_loss:,.0f}์›" + + success = order_result.get("rt_cd") == "0" + error_msg = order_result.get("msg1", "") if not success else "" + + # ์ฃผ๋ฌธ ์ƒํƒœ ๊ฒฐ์ • + if success: + status = "COMPLETED" # ์ฒด๊ฒฐ ์™„๋ฃŒ + elif order_result.get("rt_cd") == "1": + status = "PENDING" # ์ฃผ๋ฌธ ์ ‘์ˆ˜๋จ, ์ฒด๊ฒฐ ๋Œ€๊ธฐ + else: + status = "FAILED" # ์ฃผ๋ฌธ ์‹คํŒจ - # ๊ฑฐ๋ž˜ ์ด๋ ฅ ์ €์žฅ trade = TradeHistory( rule_id=stock.get("rule_id"), stock_code=stock_code, @@ -1281,34 +2455,45 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): order_type="SELL", order_price=Decimal(str(current_price)), order_quantity=quantity, - executed_price=Decimal(str(current_price)), - executed_quantity=quantity, - status="EXECUTED" if order_result.get("rt_cd") == "0" else "FAILED", + executed_price=Decimal(str(current_price)) if success else None, + executed_quantity=quantity if success else 0, + total_amount=total_amount, + status=status, order_id=order_result.get("odno"), - message=f"์ž๋™๋งค๋„: ์†์ต๋ฅ  {profit_loss_rate:.2f}% | {' | '.join(signals[:2])}", - created_at=datetime.now() + message=f"์ž๋™๋งค๋„: ์†์ต๋ฅ  {profit_loss_rate:.2f}% | {' | '.join(signals[:2])} | {error_msg if not success else '์ฒด๊ฒฐ์™„๋ฃŒ'}", + exit_reason=exit_reason, + signals=signals, + net_profit_loss=Decimal(str(profit_loss)) if success else None, + profit_loss_rate=Decimal(str(profit_loss_rate)) if success else None, + created_at=_get_kst_now(), + executed_at=_get_kst_now() if success else None ) db.add(trade) await db.commit() - success = order_result.get("rt_cd") == "0" - logger.info(f"โœ… ๋งค๋„ ์ฃผ๋ฌธ ์™„๋ฃŒ: {stock_name} {quantity}์ฃผ @ {current_price}์› (success={success})") if success: - # ํฌํŠธํด๋ฆฌ์˜ค์—์„œ ์ˆ˜๋Ÿ‰ 0 ์ฒ˜๋ฆฌ - holding.current_price = Decimal(str(current_price)) - holding.quantity = 0 - # ์†์ต ๊ณ„์‚ฐ (๋‹จ์ˆœ: (ํ˜„์žฌ๊ฐ€ - ํ‰๊ท ๋‹จ๊ฐ€)*์›๋ž˜์ˆ˜๋Ÿ‰) - if holding.average_price: - raw_pl = (current_price - float(holding.average_price)) * quantity - holding.profit_loss = Decimal(str(raw_pl)) - rate = (raw_pl / (float(holding.average_price) * quantity) * 100) if holding.average_price and quantity > 0 else 0 - holding.profit_loss_rate = Decimal(str(rate)) + logger.info(f"โœ… ๋งค๋„ ์ฃผ๋ฌธ ์„ฑ๊ณต: {stock_name} {quantity}์ฃผ @ {current_price}์›") + else: + logger.warning(f"โŒ ๋งค๋„ ์ฃผ๋ฌธ ์‹คํŒจ: {stock_name} {quantity}์ฃผ - {error_msg}") + + if success: + # ํฌํŠธํด๋ฆฌ์˜ค์—์„œ ์™„์ „ ์‚ญ์ œ + await db.delete(holding) await db.commit() + logger.info(f"๐Ÿ—‘๏ธ Deleted {stock_name}({stock_code}) from portfolio DB") + + # ํฌํŠธํด๋ฆฌ์˜ค ์บ์‹œ ์™„์ „ ์ดˆ๊ธฐํ™” try: from app.api.endpoints.portfolio import portfolio_cache - portfolio_cache.mark_changed() - except Exception: - logger.debug("Portfolio cache not available for mark_changed") + # ์บ์‹œ ์™„์ „ ์ดˆ๊ธฐํ™” - ๋‹ค์Œ ์กฐํšŒ์‹œ ๊ฐ•์ œ๋กœ KIS API ํ˜ธ์ถœ + portfolio_cache.holdings = [] + portfolio_cache.summary = None + portfolio_cache.last_synced = None + portfolio_cache.last_trade_id = None + portfolio_cache.change_flag = True + logger.info(f"๐Ÿ”„ Portfolio cache CLEARED after SELL {stock_name}({stock_code})") + except Exception as e: + logger.error(f"Portfolio cache clear failed after SELL: {e}") return {"action": "SELL", "stock": stock_name, "quantity": quantity, "price": current_price, "profit_loss": profit_loss_rate, "success": success} except Exception as e: @@ -1316,8 +2501,8 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} # ์•„๋ฌด ๊ฒƒ๋„ ์‹คํ–‰ ์•ˆ๋จ - logger.debug( - f"HOLD decision for {stock_code}: buy={should_buy}, sell={should_sell}, holding={bool(holding)}, score={score_val}, signals={signals}" + logger.info( + f"โธ๏ธ HOLD: {stock_name}({stock_code}) - ๋งค์ˆ˜์‹ ํ˜ธ={should_buy}, ๋งค๋„์‹ ํ˜ธ={should_sell}, ๋ณด์œ ์ค‘={bool(holding)}, ์ ์ˆ˜={score_val}" ) return {"action": "HOLD", "stock": stock_name} @@ -1376,3 +2561,83 @@ async def execute_auto_trading(db: AsyncSession = Depends(get_db)): except Exception as e: logger.error(f"โŒ ์ž๋™ ๋งค๋งค ์‹คํ–‰ ์˜ค๋ฅ˜: {e}") raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/ai/{stock_code}") +async def get_ai_recommendation(stock_code: str): + """MCP ์„œ๋ฒ„๋ฅผ ํ†ตํ•œ AI ์ข…๋ชฉ ๋ถ„์„ ๋ฐ ๋‰ด์Šค ์กฐํšŒ""" + try: + # 1. ์‹œ์žฅ ๋ฐ์ดํ„ฐ ์กฐํšŒ (Backend์—์„œ ์ง์ ‘ KIS API ํ˜ธ์ถœ) + price_data = {} + try: + price_data = await korea_investment_api.get_current_price(stock_code) + except Exception as e: + logger.warning(f"Failed to fetch price data locally: {e}") + + # 2. MCP ์„œ๋ฒ„์— ๋‰ด์Šค ์กฐํšŒ ์š”์ฒญ + news_data = [] + try: + async with httpx.AsyncClient() as client: + mcp_url = f"{settings.MCP_SERVER_URL}/tools/call" + payload = { + "tool_name": "get_news", + "parameters": {"stock_code": stock_code, "limit": 3} + } + response = await client.post(mcp_url, json=payload, timeout=10.0) + if response.status_code == 200: + result = response.json() + if result.get("status") == "success": + # MCP might wrap it in 'result' -> 'news' or just 'news' + data = result.get("result", {}) + if "news" in data: + news_data = data["news"] + else: + news_data = data # Fallback if direct list + except Exception as e: + logger.warning(f"Failed to fetch news via MCP: {e}") + + # 3. AI ๋ถ„์„ ์š”์ฒญ (MCP Analyze) + analysis_result = "AI ๋ถ„์„์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + try: + async with httpx.AsyncClient() as client: + analyze_url = f"{settings.MCP_SERVER_URL}/analyze" + + # ์‹œ์žฅ ๋ฐ์ดํ„ฐ ๋‹จ์ˆœํ™” + market_summary = {} + if "output" in price_data: + output = price_data["output"] + market_summary = { + "current_price": output.get("stck_prpr"), + "change_rate": output.get("prdy_ctrt"), + "volume": output.get("acml_vol"), + "high": output.get("stck_hgpr"), + "low": output.get("stck_lwpr") + } + + payload = { + "analysis_type": "MARKET_ANALYSIS", + "data": { + "stock_code": stock_code, + "market_data": market_summary, + "news": news_data + } + } + response = await client.post(analyze_url, json=payload, timeout=45.0) # Analysis takes time + if response.status_code == 200: + analysis_res = response.json() + # If successful, returns {"analysis_type":..., "result": "Markdown text/JSON"} + if "result" in analysis_res: + analysis_result = analysis_res["result"] + except Exception as e: + logger.error(f"Failed to fetch AI analysis: {e}") + + return { + "stock_code": stock_code, + "analysis": analysis_result, + "news": news_data, + "market_data": price_data.get("output", {}) + } + + except Exception as e: + logger.error(f"AI Recommendation error: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/endpoints/risk_management.py b/backend/app/api/endpoints/risk_management.py new file mode 100644 index 0000000..adad59f --- /dev/null +++ b/backend/app/api/endpoints/risk_management.py @@ -0,0 +1,473 @@ +""" +๐Ÿ”ฅ ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ๋ฐ ์„ฑ๊ณผ ํ‰๊ฐ€ API + +- MDD (Maximum Drawdown) ์ถ”์  +- Sharpe Ratio ๊ณ„์‚ฐ +- ์—ฐ์† ์†์‹ค ์ œํ•œ +- ์ผ์ผ ์†์‹ค ํ•œ๋„ +- ํฌ์ง€์…˜ ์‚ฌ์ด์ง• ์ œํ•œ +""" + +from fastapi import APIRouter, HTTPException, Query, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, func, desc +from datetime import datetime, timedelta, date +from decimal import Decimal +from typing import List, Optional +import logging +import numpy as np + +from app.core.database import get_db +from app.models.models import TradeHistory, TradingSession, Portfolio + +router = APIRouter() +logger = logging.getLogger(__name__) + +# ๐Ÿ”ฅ ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ์„ค์ • +MAX_POSITION_SIZE_RATIO = 0.20 # ๊ณ„์ขŒ์˜ 20%๊นŒ์ง€๋งŒ +MAX_DAILY_LOSS_RATIO = 0.03 # ์ผ์ผ 3% ์†์‹ค ์‹œ ์ค‘๋‹จ +MAX_CONSECUTIVE_LOSSES = 3 # 3์—ฐ์† ์†์‹ค ์‹œ ์ค‘๋‹จ +COMMISSION_RATE = 0.00015 # ์ˆ˜์ˆ˜๋ฃŒ 0.015% +TRADE_TAX_RATE = 0.0023 # ๊ฑฐ๋ž˜์„ธ 0.23% (๋งค๋„ ์‹œ๋งŒ) +TOTAL_COST_RATE = COMMISSION_RATE * 2 + TRADE_TAX_RATE # ์™•๋ณต ์•ฝ 0.26% + + +@router.get("/risk-metrics") +async def get_risk_metrics( + days: int = Query(30, description="๋ถ„์„ ๊ธฐ๊ฐ„ (์ผ)"), + db: AsyncSession = Depends(get_db) +): + """ + ๐Ÿ“Š ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ๋ฉ”ํŠธ๋ฆญ ์กฐํšŒ + + - Maximum Drawdown (MDD): ์ตœ๋Œ€ ์†์‹ค ๊นŠ์ด + - ๋ˆ„์  ์†์ต ๊ณก์„  + - ํ˜„์žฌ ๊ณ„์ขŒ ์ƒํƒœ + """ + try: + since_date = datetime.utcnow() - timedelta(days=days) + + # ๋งค๋„ ์™„๋ฃŒ๋œ ๊ฑฐ๋ž˜๋งŒ ์กฐํšŒ (์‹คํ˜„ ์†์ต) + result = await db.execute( + select(TradeHistory) + .where(TradeHistory.created_at >= since_date) + .where(TradeHistory.order_type == "SELL") + .where(TradeHistory.status.in_(["EXECUTED", "FILLED"])) + .order_by(TradeHistory.executed_at) + ) + trades = result.scalars().all() + + if not trades: + return { + "max_drawdown": 0, + "max_drawdown_pct": 0, + "current_equity": 0, + "peak_equity": 0, + "trades_count": 0, + "message": "๊ฑฐ๋ž˜ ๋‚ด์—ญ์ด ์—†์Šต๋‹ˆ๋‹ค" + } + + # ๋ˆ„์  ์†์ต ๊ณ„์‚ฐ + cumulative_pl = 0 + peak = 0 + max_drawdown = 0 + equity_curve = [] + drawdown_curve = [] + + for trade in trades: + # ์ˆœ์†์ต ์‚ฌ์šฉ (์—†์œผ๋ฉด total_amount ์‚ฌ์šฉ) + pl = float(trade.net_profit_loss) if trade.net_profit_loss else 0 + cumulative_pl += pl + equity_curve.append({ + "date": trade.executed_at.isoformat() if trade.executed_at else trade.created_at.isoformat(), + "equity": cumulative_pl + }) + + # ์ตœ๊ณ ์  ๊ฐฑ์‹  + if cumulative_pl > peak: + peak = cumulative_pl + + # Drawdown ๊ณ„์‚ฐ + drawdown = peak - cumulative_pl + drawdown_pct = (drawdown / peak * 100) if peak > 0 else 0 + drawdown_curve.append({ + "date": trade.executed_at.isoformat() if trade.executed_at else trade.created_at.isoformat(), + "drawdown": drawdown, + "drawdown_pct": round(drawdown_pct, 2) + }) + + if drawdown > max_drawdown: + max_drawdown = drawdown + + max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0 + + # MDD ์œ„ํ—˜ ๋ ˆ๋ฒจ + risk_level = "์•ˆ์ „" + if max_drawdown_pct > 30: + risk_level = "์œ„ํ—˜ (์‹œ์Šคํ…œ ์ค‘์ง€ ๊ถŒ์žฅ)" + elif max_drawdown_pct > 20: + risk_level = "๊ฒฝ๊ณ  (ํฌ์ง€์…˜ ์ถ•์†Œ ๊ถŒ์žฅ)" + elif max_drawdown_pct > 10: + risk_level = "์ฃผ์˜" + + return { + "max_drawdown": round(float(max_drawdown), 2), + "max_drawdown_pct": round(max_drawdown_pct, 2), + "current_equity": round(cumulative_pl, 2), + "peak_equity": round(peak, 2), + "trades_count": len(trades), + "risk_level": risk_level, + "equity_curve": equity_curve[-50:], # ์ตœ๊ทผ 50๊ฐœ๋งŒ + "drawdown_curve": drawdown_curve[-50:] + } + + except Exception as e: + logger.error(f"Error calculating risk metrics: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/sharpe-ratio") +async def get_sharpe_ratio( + days: int = Query(30, description="๋ถ„์„ ๊ธฐ๊ฐ„ (์ผ)"), + db: AsyncSession = Depends(get_db) +): + """ + ๐Ÿ“ˆ Sharpe Ratio ๊ณ„์‚ฐ + + ์ˆ˜์ต๋ฅ  ๋Œ€๋น„ ๋ณ€๋™์„ฑ(๋ฆฌ์Šคํฌ)์„ ์ธก์ • + - > 2.0: ๋งค์šฐ ์šฐ์ˆ˜ + - > 1.0: ์šฐ์ˆ˜ + - < 1.0: ๊ฐœ์„  ํ•„์š” + """ + try: + since_date = datetime.utcnow() - timedelta(days=days) + + result = await db.execute( + select(TradeHistory) + .where(TradeHistory.created_at >= since_date) + .where(TradeHistory.order_type == "SELL") + .where(TradeHistory.status.in_(["EXECUTED", "FILLED"])) + .where(TradeHistory.profit_loss_rate.isnot(None)) + ) + trades = result.scalars().all() + + if len(trades) < 2: + return { + "sharpe_ratio": None, + "avg_return": 0, + "std_return": 0, + "trades_count": len(trades), + "message": "๊ฑฐ๋ž˜๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค (์ตœ์†Œ 2๊ฑด ํ•„์š”)" + } + + # ๊ฐ ๊ฑฐ๋ž˜์˜ ์ˆ˜์ต๋ฅ  ์ถ”์ถœ + returns = [float(trade.profit_loss_rate) / 100 for trade in trades if trade.profit_loss_rate] + + if not returns: + return { + "sharpe_ratio": None, + "message": "์ˆ˜์ต๋ฅ  ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค" + } + + avg_return = np.mean(returns) + std_return = np.std(returns) + + # Sharpe Ratio ๊ณ„์‚ฐ (์—ฐ๊ฐ„ํ™”: 252 ๊ฑฐ๋ž˜์ผ ๊ธฐ์ค€) + # ๋ฌด์œ„ํ—˜ ์ˆ˜์ต๋ฅ ์€ 0์œผ๋กœ ๊ฐ€์ • + sharpe = (avg_return * np.sqrt(252)) / std_return if std_return > 0 else 0 + + # ํ‰๊ฐ€ + if sharpe > 2: + interpretation = "Excellent (๋งค์šฐ ์šฐ์ˆ˜)" + elif sharpe > 1: + interpretation = "Good (์šฐ์ˆ˜)" + elif sharpe > 0: + interpretation = "Fair (๋ณดํ†ต)" + else: + interpretation = "Poor (๊ฐœ์„  ํ•„์š”)" + + return { + "sharpe_ratio": round(sharpe, 3), + "avg_return": round(avg_return * 100, 2), # % + "std_return": round(std_return * 100, 2), # % + "annualized_return": round(avg_return * 252 * 100, 2), # % + "interpretation": interpretation, + "trades_count": len(returns) + } + + except Exception as e: + logger.error(f"Error calculating Sharpe ratio: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/session/today") +async def get_today_session(db: AsyncSession = Depends(get_db)): + """ + ๐Ÿ“… ์˜ค๋Š˜์˜ ํŠธ๋ ˆ์ด๋”ฉ ์„ธ์…˜ ์กฐํšŒ + + - ์—ฐ์† ์†์‹ค/์Šน๋ฆฌ ์ถ”์  + - ์ผ์ผ ์†์ต + - ๊ฑฐ๋ž˜ ์ค‘์ง€ ์ƒํƒœ + """ + try: + today = date.today() + + result = await db.execute( + select(TradingSession) + .where(TradingSession.session_date == today) + ) + session = result.scalar_one_or_none() + + if not session: + # ์˜ค๋Š˜ ์„ธ์…˜์ด ์—†์œผ๋ฉด ์ƒ์„ฑ + session = TradingSession( + session_date=today, + consecutive_losses=0, + consecutive_wins=0, + daily_profit_loss=0, + daily_trades_count=0, + is_paused=False + ) + db.add(session) + await db.commit() + await db.refresh(session) + + return { + "session_date": session.session_date.isoformat(), + "consecutive_losses": session.consecutive_losses, + "consecutive_wins": session.consecutive_wins, + "daily_profit_loss": float(session.daily_profit_loss) if session.daily_profit_loss else 0, + "daily_trades_count": session.daily_trades_count, + "is_paused": session.is_paused, + "pause_reason": session.pause_reason, + "starting_balance": float(session.starting_balance) if session.starting_balance else None, + "ending_balance": float(session.ending_balance) if session.ending_balance else None, + "peak_balance": float(session.peak_balance) if session.peak_balance else None + } + + except Exception as e: + logger.error(f"Error getting today's session: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/check-risk-limits") +async def check_risk_limits( + order_value: float = Query(..., description="์ฃผ๋ฌธ ๊ธˆ์•ก"), + account_balance: float = Query(..., description="๊ณ„์ขŒ ์ž”๊ณ "), + db: AsyncSession = Depends(get_db) +): + """ + โš ๏ธ ์ฃผ๋ฌธ ์ „ ๋ฆฌ์Šคํฌ ์ œํ•œ ํ™•์ธ + + - ํฌ์ง€์…˜ ์‚ฌ์ด์ฆˆ ์ œํ•œ + - ์ผ์ผ ์†์‹ค ํ•œ๋„ + - ์—ฐ์† ์†์‹ค ์ œํ•œ + """ + try: + # 1. ์˜ค๋Š˜ ์„ธ์…˜ ํ™•์ธ + today = date.today() + result = await db.execute( + select(TradingSession) + .where(TradingSession.session_date == today) + ) + session = result.scalar_one_or_none() + + if not session: + session = TradingSession(session_date=today, starting_balance=Decimal(str(account_balance))) + db.add(session) + await db.commit() + await db.refresh(session) + + # 2. ๊ฑฐ๋ž˜ ์ค‘์ง€ ์ƒํƒœ ํ™•์ธ + if session.is_paused: + return { + "allowed": False, + "reason": session.pause_reason or "๊ฑฐ๋ž˜๊ฐ€ ์ค‘์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "risk_level": "BLOCKED" + } + + # 3. ์ผ์ผ ์†์‹ค ํ•œ๋„ ํ™•์ธ + daily_loss_ratio = abs(float(session.daily_profit_loss)) / account_balance if account_balance > 0 else 0 + if session.daily_profit_loss < 0 and daily_loss_ratio > MAX_DAILY_LOSS_RATIO: + # ์ž๋™ ์ค‘์ง€ + session.is_paused = True + session.pause_reason = f"์ผ์ผ ์†์‹ค ํ•œ๋„ ๋„๋‹ฌ ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)" + await db.commit() + + return { + "allowed": False, + "reason": session.pause_reason, + "risk_level": "CRITICAL", + "daily_loss": float(session.daily_profit_loss), + "daily_loss_ratio": round(daily_loss_ratio * 100, 2) + } + + # 4. ์—ฐ์† ์†์‹ค ์ œํ•œ ํ™•์ธ + if session.consecutive_losses >= MAX_CONSECUTIVE_LOSSES: + session.is_paused = True + session.pause_reason = f"์—ฐ์† {session.consecutive_losses}ํšŒ ์†์‹ค ๋ฐœ์ƒ" + await db.commit() + + return { + "allowed": False, + "reason": session.pause_reason, + "risk_level": "CRITICAL", + "consecutive_losses": session.consecutive_losses + } + + # 5. ํฌ์ง€์…˜ ์‚ฌ์ด์ฆˆ ์ œํ•œ ํ™•์ธ + position_ratio = order_value / account_balance if account_balance > 0 else 0 + if position_ratio > MAX_POSITION_SIZE_RATIO: + return { + "allowed": False, + "reason": f"ํฌ์ง€์…˜ ์‚ฌ์ด์ฆˆ ์ดˆ๊ณผ ({position_ratio*100:.1f}% > {MAX_POSITION_SIZE_RATIO*100}%)", + "risk_level": "HIGH", + "position_ratio": round(position_ratio * 100, 2), + "max_allowed": round(MAX_POSITION_SIZE_RATIO * 100, 2) + } + + # ๋ชจ๋“  ์ œํ•œ ํ†ต๊ณผ + warnings = [] + if session.consecutive_losses >= 2: + warnings.append(f"์—ฐ์† {session.consecutive_losses}ํšŒ ์†์‹ค ์ค‘ (์ฃผ์˜)") + if daily_loss_ratio > MAX_DAILY_LOSS_RATIO * 0.5: + warnings.append(f"์ผ์ผ ์†์‹ค {daily_loss_ratio*100:.1f}% (์ฃผ์˜)") + + return { + "allowed": True, + "risk_level": "WARNING" if warnings else "OK", + "warnings": warnings, + "position_ratio": round(position_ratio * 100, 2), + "daily_loss_ratio": round(daily_loss_ratio * 100, 2), + "consecutive_losses": session.consecutive_losses + } + + except Exception as e: + logger.error(f"Error checking risk limits: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/update-session") +async def update_trading_session( + trade_result: str = Query(..., description="win ๋˜๋Š” loss"), + profit_loss: float = Query(..., description="๊ฑฐ๋ž˜ ์†์ต"), + db: AsyncSession = Depends(get_db) +): + """ + ๐Ÿ”„ ๊ฑฐ๋ž˜ ๊ฒฐ๊ณผ์— ๋”ฐ๋ผ ์„ธ์…˜ ์—…๋ฐ์ดํŠธ + + - ์—ฐ์† ์†์‹ค/์Šน๋ฆฌ ์นด์šดํ„ฐ + - ์ผ์ผ ์†์ต ๋ˆ„์  + """ + try: + today = date.today() + result = await db.execute( + select(TradingSession) + .where(TradingSession.session_date == today) + ) + session = result.scalar_one_or_none() + + if not session: + session = TradingSession(session_date=today) + db.add(session) + + # ์†์ต ๋ˆ„์  + current_pl = float(session.daily_profit_loss) if session.daily_profit_loss else 0 + session.daily_profit_loss = Decimal(str(current_pl + profit_loss)) + session.daily_trades_count += 1 + + # ์—ฐ์† ์†์‹ค/์Šน๋ฆฌ ์—…๋ฐ์ดํŠธ + if trade_result == "loss": + session.consecutive_losses += 1 + session.consecutive_wins = 0 + elif trade_result == "win": + session.consecutive_wins += 1 + session.consecutive_losses = 0 + + await db.commit() + await db.refresh(session) + + return { + "session_date": session.session_date.isoformat(), + "consecutive_losses": session.consecutive_losses, + "consecutive_wins": session.consecutive_wins, + "daily_profit_loss": float(session.daily_profit_loss), + "daily_trades_count": session.daily_trades_count, + "is_paused": session.is_paused + } + + except Exception as e: + logger.error(f"Error updating session: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/win-rate") +async def get_win_rate( + days: int = Query(30, description="๋ถ„์„ ๊ธฐ๊ฐ„ (์ผ)"), + db: AsyncSession = Depends(get_db) +): + """ + ๐Ÿ“Š ์Šน๋ฅ  ๋ฐ ์†์ต ํ†ต๊ณ„ + """ + try: + since_date = datetime.utcnow() - timedelta(days=days) + + result = await db.execute( + select(TradeHistory) + .where(TradeHistory.created_at >= since_date) + .where(TradeHistory.order_type == "SELL") + .where(TradeHistory.status.in_(["EXECUTED", "FILLED"])) + ) + trades = result.scalars().all() + + if not trades: + return { + "win_rate": 0, + "total_trades": 0, + "message": "๊ฑฐ๋ž˜ ๋‚ด์—ญ์ด ์—†์Šต๋‹ˆ๋‹ค" + } + + wins = 0 + losses = 0 + total_profit = 0 + total_loss = 0 + + for trade in trades: + pl = float(trade.net_profit_loss) if trade.net_profit_loss else 0 + if pl > 0: + wins += 1 + total_profit += pl + elif pl < 0: + losses += 1 + total_loss += abs(pl) + + total_trades = wins + losses + win_rate = (wins / total_trades * 100) if total_trades > 0 else 0 + + # Profit Factor (์ด ์ด์ต / ์ด ์†์‹ค) + profit_factor = (total_profit / total_loss) if total_loss > 0 else 0 + + # ํ‰๊ท  ์Šน๋ฆฌ/์†์‹ค + avg_win = total_profit / wins if wins > 0 else 0 + avg_loss = total_loss / losses if losses > 0 else 0 + + # ์†์ต๋น„ + risk_reward = avg_win / avg_loss if avg_loss > 0 else 0 + + return { + "win_rate": round(win_rate, 2), + "total_trades": total_trades, + "wins": wins, + "losses": losses, + "total_profit": round(total_profit, 2), + "total_loss": round(total_loss, 2), + "net_profit": round(total_profit - total_loss, 2), + "profit_factor": round(profit_factor, 2), + "avg_win": round(avg_win, 2), + "avg_loss": round(avg_loss, 2), + "risk_reward_ratio": round(risk_reward, 2) + } + + except Exception as e: + logger.error(f"Error calculating win rate: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/endpoints/tensorflow_predictions.py b/backend/app/api/endpoints/tensorflow_predictions.py new file mode 100644 index 0000000..21c083f --- /dev/null +++ b/backend/app/api/endpoints/tensorflow_predictions.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, HTTPException, Query +from typing import List +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _get_predictor(): + try: + from app.services.tensorflow_predictor import TensorFlowPredictor, TensorFlowNotAvailable + try: + return TensorFlowPredictor() + except TensorFlowNotAvailable as e: + raise HTTPException(status_code=503, detail=f"TensorFlow predictor unavailable: {e}") + except Exception as e: + raise HTTPException(status_code=503, detail=f"TensorFlow unavailable: {e}") + + +@router.get("/tf-predictions") +async def get_tensorflow_predictions( + codes: str = Query("005930,000660,035420", description="Comma-separated stock codes"), + top: int = Query(10, ge=1, le=50, description="Top N by expected return") +): + predictor = _get_predictor() + stock_codes: List[str] = [c.strip() for c in codes.split(",") if c.strip()] + results = [] + for code in stock_codes: + pred = await predictor.predict_stock(code) + results.append(pred) + # Filter out errors, sort by expected_return + valid = [r for r in results if isinstance(r, dict) and "expected_return" in r] + valid.sort(key=lambda x: x.get("expected_return", 0.0), reverse=True) + return { + "total": len(valid), + "requested": stock_codes, + "predictions": valid[:top], + "errors": [r for r in results if "error" in r] + } diff --git a/backend/app/api/endpoints/trades.py b/backend/app/api/endpoints/trades.py index f6fec31..1a7ca38 100644 --- a/backend/app/api/endpoints/trades.py +++ b/backend/app/api/endpoints/trades.py @@ -1,15 +1,24 @@ +""" +์ฃผ๋ฌธ/์ฒด๊ฒฐ API ์—”๋“œํฌ์ธํŠธ + +- GET /: ๊ฑฐ๋ž˜ ์ด๋ ฅ ์กฐํšŒ (์†์ต ํฌํ•จ) +- GET /stats/summary: ๊ฑฐ๋ž˜ ํ†ต๊ณ„ +- DELETE /pending: PENDING/FAILED ๊ฑฐ๋ž˜ ์ •๋ฆฌ +""" from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from typing import List, Optional from datetime import datetime, timedelta from decimal import Decimal +import logging from app.core.database import get_db from app.models.models import TradeHistory from pydantic import BaseModel router = APIRouter() +logger = logging.getLogger(__name__) class TradeHistoryResponse(BaseModel): @@ -22,11 +31,17 @@ class TradeHistoryResponse(BaseModel): order_quantity: int executed_price: Optional[Decimal] executed_quantity: Optional[int] + total_amount: Optional[Decimal] status: str order_id: Optional[str] message: Optional[str] + entry_reason: Optional[str] + exit_reason: Optional[str] + signals: Optional[List[str]] created_at: datetime executed_at: Optional[datetime] + profit_loss: Optional[Decimal] = None + profit_loss_rate: Optional[float] = None class Config: from_attributes = True @@ -42,7 +57,7 @@ async def get_trade_history( days: int = 30, db: AsyncSession = Depends(get_db) ): - """๊ฑฐ๋ž˜ ์ด๋ ฅ ์กฐํšŒ""" + """๊ฑฐ๋ž˜ ์ด๋ ฅ ์กฐํšŒ (์†์ต ํฌํ•จ)""" query = select(TradeHistory) # ๋‚ ์งœ ํ•„ํ„ฐ @@ -66,7 +81,64 @@ async def get_trade_history( result = await db.execute(query) trades = result.scalars().all() - return trades + + # ์†์ต ๊ณ„์‚ฐ์„ ์œ„ํ•ด ๋งค์ˆ˜/๋งค๋„ ๋งค์นญ + trade_responses = [] + buy_trades = {} # stock_code -> list of buy trades + + for trade in trades: + response = TradeHistoryResponse.model_validate(trade) + + # ๋งค๋„ ์‹œ ์†์ต ์ •๋ณด ์„ค์ • + if trade.order_type == "SELL" and trade.status == "EXECUTED": + import re + + # 1. DB์— ์ €์žฅ๋œ ๊ฐ’ ๋จผ์ € ์‚ฌ์šฉ + if hasattr(trade, 'net_profit_loss') and trade.net_profit_loss is not None: + response.profit_loss = trade.net_profit_loss + logger.debug(f"โœ… DB์—์„œ ์†์ต ๊ธˆ์•ก ๋กœ๋“œ: {response.profit_loss}") + + if hasattr(trade, 'profit_loss_rate') and trade.profit_loss_rate is not None: + response.profit_loss_rate = float(trade.profit_loss_rate) + logger.debug(f"โœ… DB์—์„œ ์†์ต๋ฅ  ๋กœ๋“œ: {response.profit_loss_rate}%") + + # 2. DB ๊ฐ’์ด ์—†์œผ๋ฉด exit_reason/message์—์„œ ํŒŒ์‹ฑ (์ด์ „ ๋ฐ์ดํ„ฐ ํ˜ธํ™˜์„ฑ) + if response.profit_loss is None or response.profit_loss_rate is None: + text_to_parse = trade.exit_reason or trade.message or "" + logger.debug(f"๐Ÿ” ์†์ต ํŒŒ์‹ฑ ์‹œ๋„ - ์ข…๋ชฉ: {trade.stock_name}, ํ…์ŠคํŠธ: {text_to_parse[:100]}") + + # ์†์ต ๊ธˆ์•ก ์ถ”์ถœ + if response.profit_loss is None: + profit_match = re.search(r'์†์ต:\s*([+-]?[\d,]+)์›', text_to_parse) + if profit_match: + profit_str = profit_match.group(1).replace(',', '') + try: + response.profit_loss = Decimal(profit_str) + logger.info(f"โœ… ํŒŒ์‹ฑ์œผ๋กœ ์†์ต ๊ธˆ์•ก ์ถ”์ถœ: {response.profit_loss}") + except Exception as e: + logger.error(f"โŒ ์†์ต ๊ธˆ์•ก ๋ณ€ํ™˜ ์‹คํŒจ: {e}") + + # ์†์ต๋ฅ  ์ถ”์ถœ + if response.profit_loss_rate is None: + message_rate_match = re.search(r'์†์ต๋ฅ \s*([+-]?[\d.]+)%', trade.message or "") + if message_rate_match: + try: + response.profit_loss_rate = float(message_rate_match.group(1)) + logger.info(f"โœ… ํŒŒ์‹ฑ์œผ๋กœ ์†์ต๋ฅ  ์ถ”์ถœ (message): {response.profit_loss_rate}%") + except Exception as e: + logger.error(f"โŒ ์†์ต๋ฅ  ๋ณ€ํ™˜ ์‹คํŒจ: {e}") + else: + rate_match = re.search(r'([+-]?[\d.]+)%', text_to_parse) + if rate_match: + try: + response.profit_loss_rate = float(rate_match.group(1)) + logger.info(f"โœ… ํŒŒ์‹ฑ์œผ๋กœ ์†์ต๋ฅ  ์ถ”์ถœ (exit_reason): {response.profit_loss_rate}%") + except Exception as e: + logger.error(f"โŒ ์†์ต๋ฅ  ๋ณ€ํ™˜ ์‹คํŒจ: {e}") + + trade_responses.append(response) + + return trade_responses @router.get("/{trade_id}", response_model=TradeHistoryResponse) @@ -116,3 +188,23 @@ async def get_trade_stats( "success_rate": executed_trades / total_trades if total_trades > 0 else 0, "total_amount": total_amount } + + +@router.delete("/pending") +async def delete_pending_trades(db: AsyncSession = Depends(get_db)): + """์žฅ ๋งˆ๊ฐ ํ›„ PENDING๊ณผ FAILED ์ƒํƒœ์˜ ๋งค์ˆ˜ ์‹œ๋„ ๋‚ด์—ญ ์ •๋ฆฌ""" + result = await db.execute( + select(TradeHistory).where( + TradeHistory.status.in_(['PENDING', 'FAILED']) + ) + ) + pending_trades = result.scalars().all() + count = len(pending_trades) + + for trade in pending_trades: + await db.delete(trade) + await db.commit() + + logger.info(f"โœ… ์ •๋ฆฌ ์™„๋ฃŒ: {count}๊ฐœ์˜ PENDING/FAILED ๋งค์ˆ˜ ์‹œ๋„ ์‚ญ์ œ") + + return {"deleted_count": count, "message": f"Cleaned up {count} pending/failed trades"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c8db21d..1373d9c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,3 +1,9 @@ +""" +์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค์ • ๋ชจ๋“ˆ + +- pydantic-settings ๊ธฐ๋ฐ˜ .env ํŒŒ์ผ ๋กœ๋“œ +- DB, Redis, KIS API, AI ํ”„๋กœ๋ฐ”์ด๋” ๋“ฑ ์ „์—ญ ์„ค์ • ๊ด€๋ฆฌ +""" import os from pathlib import Path from pydantic_settings import BaseSettings @@ -12,7 +18,7 @@ class Settings(BaseSettings): # Application APP_NAME: str = "Auto Stock Trading System" ENVIRONMENT: str = "development" - DEBUG: bool = True + DEBUG: bool = False # ์šด์˜ ๊ธฐ๋ณธ๊ฐ’ False (.env์—์„œ DEBUG=true๋กœ ๊ฐœ๋ฐœ ๋ชจ๋“œ ํ™œ์„ฑํ™”) SECRET_KEY: str = "change-this-secret-key" # Server @@ -28,35 +34,78 @@ class Settings(BaseSettings): # RabbitMQ RABBITMQ_URL: str = "amqp://autostock:autostock_password@localhost:5672/" - # Korea Investment API + # Korea Investment API - ๋ชจ๋“œ ์„ ํƒ + KIS_MODE: str = "virtual" # "virtual" (๋ชจ์˜ํˆฌ์ž) ๋˜๋Š” "real" (์‹ค์ „ํˆฌ์ž) + + # ๋ชจ์˜ํˆฌ์ž ์„ค์ • + KIS_VIRTUAL_APP_KEY: str = "" + KIS_VIRTUAL_APP_SECRET: str = "" + KIS_VIRTUAL_ACCOUNT_NO: str = "" + KIS_VIRTUAL_URL: str = "https://openapivts.koreainvestment.com:29443" + + # ์‹ค์ „ํˆฌ์ž ์„ค์ • + KIS_REAL_APP_KEY: str = "" + KIS_REAL_APP_SECRET: str = "" + KIS_REAL_ACCOUNT_NO: str = "" + KIS_REAL_URL: str = "https://openapi.koreainvestment.com:9443" + + # ์‹ค์ œ ์‚ฌ์šฉ๋˜๋Š” KIS API ์„ค์ • (KIS_MODE์— ๋”ฐ๋ผ ์ž๋™ ๊ฒฐ์ •) KOREA_INVESTMENT_APP_KEY: str = "" KOREA_INVESTMENT_APP_SECRET: str = "" KOREA_INVESTMENT_ACCOUNT_NUMBER: str = "" KOREA_INVESTMENT_API_BASE_URL: str = "https://openapi.koreainvestment.com:9443" - - # Alias for backward compatibility with .env file + + # ๋ ˆ๊ฑฐ์‹œ ํ˜ธํ™˜ (์ง์ ‘ ์ง€์ • ์‹œ ์šฐ์„ ) KIS_APP_KEY: str = "" KIS_APP_SECRET: str = "" KIS_ACCOUNT_NO: str = "" KIS_BASE_URL: str = "" - + def __init__(self, **kwargs): super().__init__(**kwargs) - # Use KIS_* values if KOREA_INVESTMENT_* are empty - if not self.KOREA_INVESTMENT_APP_KEY and self.KIS_APP_KEY: - self.KOREA_INVESTMENT_APP_KEY = self.KIS_APP_KEY - if not self.KOREA_INVESTMENT_APP_SECRET and self.KIS_APP_SECRET: - self.KOREA_INVESTMENT_APP_SECRET = self.KIS_APP_SECRET - if not self.KOREA_INVESTMENT_ACCOUNT_NUMBER and self.KIS_ACCOUNT_NO: - self.KOREA_INVESTMENT_ACCOUNT_NUMBER = self.KIS_ACCOUNT_NO - if self.KIS_BASE_URL: - self.KOREA_INVESTMENT_API_BASE_URL = self.KIS_BASE_URL - + self._apply_kis_mode() + + def _apply_kis_mode(self): + """KIS_MODE์— ๋”ฐ๋ผ ๋ชจ์˜/์‹ค์ „ API ์„ค์ •์„ ์ž๋™ ์„ ํƒ""" + is_real = self.KIS_MODE.lower() == "real" + + # 1์ˆœ์œ„: KIS_MODE์— ๋”ฐ๋ฅธ VIRTUAL/REAL ๋ณ€์ˆ˜ + if is_real: + mode_key = self.KIS_REAL_APP_KEY + mode_secret = self.KIS_REAL_APP_SECRET + mode_account = self.KIS_REAL_ACCOUNT_NO + mode_url = self.KIS_REAL_URL + else: + mode_key = self.KIS_VIRTUAL_APP_KEY + mode_secret = self.KIS_VIRTUAL_APP_SECRET + mode_account = self.KIS_VIRTUAL_ACCOUNT_NO + mode_url = self.KIS_VIRTUAL_URL + + # 2์ˆœ์œ„: ๋ ˆ๊ฑฐ์‹œ KIS_APP_KEY ๋“ฑ (์ง์ ‘ ์ง€์ •๋œ ๊ฒฝ์šฐ) + fallback_key = self.KIS_APP_KEY + fallback_secret = self.KIS_APP_SECRET + fallback_account = self.KIS_ACCOUNT_NO + fallback_url = self.KIS_BASE_URL + + # ์ตœ์ข… ์ ์šฉ (mode > legacy > KOREA_INVESTMENT_ ๊ธฐ์กด๊ฐ’) + self.KOREA_INVESTMENT_APP_KEY = mode_key or fallback_key or self.KOREA_INVESTMENT_APP_KEY + self.KOREA_INVESTMENT_APP_SECRET = mode_secret or fallback_secret or self.KOREA_INVESTMENT_APP_SECRET + self.KOREA_INVESTMENT_ACCOUNT_NUMBER = mode_account or fallback_account or self.KOREA_INVESTMENT_ACCOUNT_NUMBER + self.KOREA_INVESTMENT_API_BASE_URL = mode_url or fallback_url or self.KOREA_INVESTMENT_API_BASE_URL + + @property + def is_virtual_trading(self) -> bool: + """๋ชจ์˜ํˆฌ์ž ๋ชจ๋“œ ์—ฌ๋ถ€""" + return self.KIS_MODE.lower() != "real" + # Claude AI ANTHROPIC_API_KEY: str = "" # MCP Server MCP_SERVER_URL: str = "http://localhost:8001" + # MCP AI ๊ฒ€์ฆ (์ž๋™๋งค๋งค ์ „ AI ์ตœ์ข… ํŒ๋‹จ) + MCP_VALIDATION_ENABLED: bool = False # ๊ธฐ๋ณธ OFF (.env์—์„œ True๋กœ ํ™œ์„ฑํ™”) + MCP_CONFIDENCE_THRESHOLD: float = 0.5 # AI ์‹ ๋ขฐ๋„ ์ตœ์†Œ๊ฐ’ (0.0~1.0) # News API NEWS_API_KEY: str = "" @@ -64,11 +113,78 @@ class Settings(BaseSettings): # Logging LOG_LEVEL: str = "INFO" - # Trading Settings - MAX_POSITION_SIZE: float = 0.1 # 10% of portfolio - MAX_DAILY_TRADES: int = 10 - STOP_LOSS_PERCENT: float = -5.0 - TAKE_PROFIT_PERCENT: float = 15.0 + # Trading Settings (CLAUDE.md ์„น์…˜ 6 ๊ธฐ๋ฐ˜) + MAX_POSITION_SIZE: float = 0.04 # 1์ข…๋ชฉ๋‹น ์ตœ๋Œ€ 3-4% (์„น์…˜ 6.1) + MAX_DAILY_TRADES: int = 5 + STOP_LOSS_PERCENT: float = -6.0 # ์ €๋ณ€๋™ ์ข…๋ชฉ ์†์ ˆ -6% (์„น์…˜ 6.2) + TAKE_PROFIT_PERCENT: float = 5.0 # ์ต์ ˆ ๊ธฐ๋ณธ๊ฐ’ + AUTO_TRADE_ENABLED: bool = True + + # ํฌ์ง€์…˜ ๊ทœ์น™ (์„น์…˜ 6.1) + MAX_HOLDINGS_COUNT: int = 20 # ๋™์‹œ ๋ณด์œ  ์ตœ๋Œ€ 20์ข…๋ชฉ + LEVERAGE_ETF_MAX_RATIO: float = 0.20 # ๋ ˆ๋ฒ„๋ฆฌ์ง€/์ธ๋ฒ„์Šค ETF 20% ์ด๋‚ด + + # ATR ๊ธฐ๋ฐ˜ ์†์ ˆ/ํŠธ๋ ˆ์ผ๋ง (์„น์…˜ 6.2) + STOP_LOSS_HIGH_VOL: float = -10.0 # ๊ณ ๋ณ€๋™ ์ข…๋ชฉ ์†์ ˆ -10% + ATR_STOP_MULTIPLIER: float = 2.5 # 2.5 ร— ATR ์†์ ˆ + ATR_TRAILING_MULTIPLIER: float = 1.5 # 1.5 ร— ATR ํŠธ๋ ˆ์ผ๋ง + + # MDD ๊ด€๋ฆฌ (์„น์…˜ 6.3) + MDD_PAUSE_THRESHOLD: float = -10.0 # -10% MDD: ์‹ ๊ทœ์ง„์ž… ์ค‘๋‹จ + 30% ์ถ•์†Œ + MDD_FORCE_THRESHOLD: float = -20.0 # -20% MDD: 50% ๊ฐ•์ œ ์ถ•์†Œ + + # ๊ฐ€๊ฒฉ ํ•„ํ„ฐ (์„น์…˜ 6.4) + MIN_PRICE_KRW: int = 5000 # ๊ตญ๋‚ด 5,000์› ์ดํ•˜ ๋งค์ˆ˜ ๊ธˆ์ง€ + MIN_PRICE_USD: float = 4.0 # ๋ฏธ๊ตญ $4 ์ดํ•˜ ๋งค์ˆ˜ ๊ธˆ์ง€ + + # ์—ฐ์† ์†์‹ค ๊ด€๋ฆฌ (์„น์…˜ 7.4) + CONSECUTIVE_LOSS_BAN_COUNT: int = 3 # 3ํšŒ ์—ฐ์† ์†์‹ค + CONSECUTIVE_LOSS_BAN_DAYS: int = 30 # 30๊ฑฐ๋ž˜์ผ ์ง„์ž… ๊ธˆ์ง€ + + # ํ‰๊ท ํšŒ๊ท€ ์ „๋žต ์ž๋ณธ ๋ฐฐ๋ถ„ (์„น์…˜ 7.5) + MEAN_REVERSION_MAX_CAPITAL: float = 0.30 # ์ „์ฒด ์ž๋ณธ์˜ 30% ์ด๋‚ด + + # Trend filter (MA-based) + TREND_FILTER_ENABLED: bool = False + TREND_MA_SHORT: int = 20 + TREND_MA_LONG: int = 60 + TREND_TOLERANCE_PCT: float = 0.005 + + # ๊ธ‰๋“ฑ ๊ณผ์—ด ํ•„ํ„ฐ (๊ณ ์  ์ถ”๊ฒฉ ๋งค์ˆ˜ ๋ฐฉ์ง€) + OVERHEAT_FILTER_ENABLED: bool = True + OVERHEAT_3DAY_MAX: float = 15.0 + OVERHEAT_5DAY_MAX: float = 20.0 + OVERHEAT_10DAY_MAX: float = 30.0 + OVERHEAT_DISPARITY_MAX: float = 108.0 + + # ํ•˜๋ฝ์žฅ ๋ฐฉ์–ด ํ•„ํ„ฐ + BEAR_MARKET_FILTER_ENABLED: bool = True + BEAR_MARKET_MIN_SCORE: int = 5 + BEAR_MARKET_MAX_TRADES: int = 3 + BEAR_MARKET_POSITION_RATIO: float = 0.5 + + # ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์„ค์ • (CLAUDE.md ์„น์…˜ 8.2) + DAY_TRADE_ENABLED: bool = True # ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ํ™œ์„ฑํ™” + DAY_TRADE_MAX_POSITIONS: int = 3 # ๋™์‹œ ์ตœ๋Œ€ ์ข…๋ชฉ ์ˆ˜ + DAY_TRADE_CAPITAL_RATIO: float = 0.25 # ์ „์ฒด ์ž๋ณธ ๋Œ€๋น„ ์ตœ๋Œ€ 25% + DAY_TRADE_PER_STOCK_RATIO: float = 0.10 # ์ข…๋ชฉ๋‹น ์ตœ๋Œ€ 10% + DAY_TRADE_STOP_LOSS_PCT: float = 0.02 # ์ข…๋ชฉ ์†์ ˆ -2% + DAY_TRADE_DAILY_LOSS_LIMIT: float = -0.03 # ์ผ์ผ ์†์‹ค ํ•œ๋„ -3% + DAY_TRADE_EOD_FLAT_HOUR: int = 15 # EOD ๊ฐ•์ œ ์ฒญ์‚ฐ ์‹œ (15์‹œ) + DAY_TRADE_EOD_FLAT_MIN: int = 20 # EOD ๊ฐ•์ œ ์ฒญ์‚ฐ ๋ถ„ (20๋ถ„) + DAY_TRADE_ORB_VOL_MULT: float = 1.5 # ORB ๋ŒํŒŒ ๊ฑฐ๋ž˜๋Ÿ‰ ๋ฐฐ์ˆ˜ + DAY_TRADE_TRAILING_TRIGGER: float = 0.015 # ํŠธ๋ ˆ์ผ๋ง ๋ฐœ๋™ ์ˆ˜์ต๋ฅ  + + # ML / TensorFlow integration toggle + ML_TF_ENABLED: bool = True # ML ๋ชจ๋ธ ๊ธฐ๋ฐ˜ ๋ณด์กฐ์ง€ํ‘œ ์‚ฌ์šฉ + + # ML Ensemble (LightGBM + LSTM + RF) + ML_ENSEMBLE_ENABLED: bool = True + ML_ENSEMBLE_MODEL_DIR: str = "models/ensemble" + ML_ENSEMBLE_RULE_WEIGHT: float = 0.3 # ๋ฃฐ๋ฒ ์ด์Šค ์Šค์ฝ”์–ด ๋น„์ค‘ + ML_ENSEMBLE_ML_WEIGHT: float = 0.7 # ML ์Šค์ฝ”์–ด ๋น„์ค‘ + ML_ENSEMBLE_BUY_THRESHOLD: int = 60 # ๋งค์ˆ˜ ์ตœ์†Œ ์•™์ƒ๋ธ” ์Šค์ฝ”์–ด (0~100) + ML_ENSEMBLE_CONFIDENCE_MIN: float = 0.5 # ์ตœ์†Œ ์‹ ๋ขฐ๋„ # API Rate Limiting & Retry Settings (๋ชจ์˜ํˆฌ์ž API ์•ˆ์ „ ์šด์˜) API_MAX_RETRIES: int = 3 # ์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜ diff --git a/backend/app/core/database.py b/backend/app/core/database.py index c446c1c..adce267 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,3 +1,9 @@ +""" +๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ๋ชจ๋“ˆ + +- SQLAlchemy async ์—”์ง„ ๋ฐ ์„ธ์…˜ ํŒฉํ† ๋ฆฌ (PostgreSQL + asyncpg) +- Base ๋ชจ๋ธ ์„ ์–ธ, get_db ์˜์กด์„ฑ ์ œ๊ณต +""" from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker @@ -10,14 +16,19 @@ if DATABASE_URL.startswith("postgresql://"): engine = create_async_engine( DATABASE_URL, - echo=settings.DEBUG, + echo=False, # SQL ๋กœ๊ทธ ๋น„ํ™œ์„ฑํ™” (์„ฑ๋Šฅ ๊ฐœ์„ ) future=True, pool_pre_ping=True, - pool_size=10, - max_overflow=20, + pool_size=20, # ์ฆ๊ฐ€ + max_overflow=30, # ์ฆ๊ฐ€ + pool_recycle=3600, # 1์‹œ๊ฐ„๋งˆ๋‹ค ์—ฐ๊ฒฐ ์žฌ์ƒ์„ฑ + pool_timeout=30, # ํƒ€์ž„์•„์›ƒ ์„ค์ • connect_args={ "timeout": 60, "command_timeout": 60, + "server_settings": { + "jit": "off" # JIT ๋น„ํ™œ์„ฑํ™”๋กœ ์„ฑ๋Šฅ ๊ฐœ์„  + } } ) @@ -25,14 +36,20 @@ AsyncSessionLocal = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False, + autocommit=False, + autoflush=False, ) Base = declarative_base() async def get_db(): + """DB ์„ธ์…˜ ์ƒ์„ฑ ๋ฐ ๊ด€๋ฆฌ (์•ˆ์ „ํ•œ ์ข…๋ฃŒ ๋ณด์žฅ)""" async with AsyncSessionLocal() as session: try: yield session + except Exception: + await session.rollback() + raise finally: await session.close() diff --git a/backend/app/core/json_helpers.py b/backend/app/core/json_helpers.py new file mode 100644 index 0000000..3985243 --- /dev/null +++ b/backend/app/core/json_helpers.py @@ -0,0 +1,13 @@ +"""๊ณต์šฉ JSON ์ง๋ ฌํ™” ํ—ฌํผ (CLAUDE.md ์ฝ”๋”ฉ ๊ทœ์น™: json.dumps ๋Œ€์‹  _dumps() ์‚ฌ์šฉ)""" +import json +import os + + +def _dumps(obj, **kwargs) -> str: + """ํ† ํฐ ์ ˆ์•ฝ ๋ชจ๋“œ์— ๋”ฐ๋ผ compact/pretty JSON ์ง๋ ฌํ™”""" + compact = os.getenv("JSON_COMPACT", "false").lower() == "true" + token_save = os.getenv("TOKEN_SAVE_MODE", "false").lower() == "true" + + if compact or token_save: + return json.dumps(obj, ensure_ascii=False, separators=(',', ':'), **kwargs) + return json.dumps(obj, ensure_ascii=False, indent=2, **kwargs) diff --git a/backend/app/core/market_schedule.py b/backend/app/core/market_schedule.py new file mode 100644 index 0000000..1e5163d --- /dev/null +++ b/backend/app/core/market_schedule.py @@ -0,0 +1,59 @@ +""" +ํ•œ๊ตญ ์ฃผ์‹์‹œ์žฅ ์šด์˜์‹œ๊ฐ„ ๊ด€๋ฆฌ + +- KST ๊ธฐ์ค€ ์žฅ ์‹œ์ž‘/์ข…๋ฃŒ ์‹œ๊ฐ„ ํŒ๋ณ„ +- ๊ณตํœด์ผ, ์ฃผ๋ง ํœด์žฅ ์ฒดํฌ +""" +from datetime import datetime, time +try: + import pytz + KST = pytz.timezone('Asia/Seoul') +except ImportError: + # pytz๊ฐ€ ์—†์œผ๋ฉด ๊ธฐ๋ณธ timezone ์‚ฌ์šฉ (ํ™˜๊ฒฝ๋ณ€์ˆ˜์— ์˜์กด) + from datetime import timezone, timedelta + KST = timezone(timedelta(hours=9)) + +def is_market_open() -> bool: + """ + ํ˜„์žฌ ํ•œ๊ตญ ์ฆ์‹œ ์ •๊ทœ์žฅ ์šด์˜ ์‹œ๊ฐ„์ธ์ง€ ํ™•์ธ + (ํ‰์ผ 09:00 ~ 15:30, ๊ณตํœด์ผ ์ œ์™ธ ๋กœ์ง์€ ์•„์ง ๋ฏธํฌํ•จ) + """ + now = datetime.now(KST) + + # 1. ์ฃผ๋ง ์ฒดํฌ (์›”=0, ..., ์ผ=6) + if now.weekday() >= 5: + return False + + # 2. ์‹œ๊ฐ„ ์ฒดํฌ (09:00 ~ 15:30) + current_time = now.time() + market_start = time(9, 0) + market_end = time(15, 30) + + return market_start <= current_time <= market_end + + +def is_opening_range_period() -> bool: + """Opening Range ์ˆ˜์ง‘ ๊ตฌ๊ฐ„ (09:00~09:29)""" + now = datetime.now(KST) + if now.weekday() >= 5: + return False + t = now.time() + return time(9, 0) <= t < time(9, 30) + + +def is_day_trade_entry_window() -> bool: + """๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์‹ ๊ทœ ์ง„์ž… ๊ฐ€๋Šฅ ์‹œ๊ฐ„ (09:30~14:59)""" + now = datetime.now(KST) + if now.weekday() >= 5: + return False + t = now.time() + return time(9, 30) <= t < time(15, 0) + + +def is_eod_flat_time() -> bool: + """EOD ๊ฐ•์ œ ์ฒญ์‚ฐ ๊ตฌ๊ฐ„ (15:20~15:30)""" + now = datetime.now(KST) + if now.weekday() >= 5: + return False + t = now.time() + return time(15, 20) <= t <= time(15, 30) diff --git a/backend/app/main.py b/backend/app/main.py index d77abe1..d5aea45 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,14 +1,26 @@ +""" +FastAPI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ง„์ž…์  + +- CORS, ๋ฏธ๋“ค์›จ์–ด ์„ค์ • +- WebSocket ์‹ค์‹œ๊ฐ„ ์‹œ์„ธ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ +- ๋ผ์šฐํ„ฐ ๋“ฑ๋ก ๋ฐ startup/shutdown ์ด๋ฒคํŠธ ๊ด€๋ฆฌ +""" from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager +import contextlib +import asyncio +import json import logging import time from app.core.config import settings from app.api import api_router from app.services.websocket_manager import websocket_manager -from app.core.database import engine, Base +from app.core.database import engine, Base, AsyncSessionLocal from app.models import models # ๋ชจ๋ธ ์ž„ํฌํŠธ +from app.core.market_schedule import is_market_open +from sqlalchemy import text # ๋กœ๊น… ์„ค์ • logging.basicConfig( @@ -19,17 +31,213 @@ logging.basicConfig( logger = logging.getLogger(__name__) +async def auto_trade_scheduler(): + """1๋ถ„ ๊ฐ„๊ฒฉ ์ž๋™๋งค๋งค ์Šค์ผ€์ค„๋Ÿฌ. + ์‹ค์‹œ๊ฐ„ ๋ถ„์„์„ ์ˆ˜ํ–‰ํ•˜๊ณ , ๋ณด์œ  ์ข…๋ชฉ ์†์ ˆ/์ต์ ˆ์„ ์ฒดํฌํ•˜๋ฉฐ, ์ž๋™๋งค๋งค๋ฅผ ์‹คํ–‰ํ•œ๋‹ค. + """ + # ์ง€์—ฐ ์‹œ์ž‘์œผ๋กœ ์•ฑ ๋ถ€ํŒ… ์•ˆ์ •ํ™” + await asyncio.sleep(2) + from app.api.endpoints.recommendations import ( + get_rule_based_recommendations_realtime, + execute_auto_trade_background, + ) + from app.api.endpoints.portfolio import check_and_execute_exit_trades + + logger.info("โฑ๏ธ Auto-trade scheduler started (every 60s)") + try: + while True: + try: + # ์žฅ ์šด์˜ ์‹œ๊ฐ„ ์ฒดํฌ + if not is_market_open(): + logger.info("๐Ÿ’ค Market is closed. Skipping auto-trade cycle. Next check in 30 minutes.") + # ์žฅ ์ข…๋ฃŒ ์‹œ์—๋Š” 30๋ถ„(1800์ดˆ) ๋Œ€๊ธฐํ•˜์—ฌ ๋ฆฌ์†Œ์Šค ์ ˆ์•ฝ + await asyncio.sleep(1800) + continue + + async with AsyncSessionLocal() as session: + # 1. ๋ณด์œ  ์ข…๋ชฉ ์†์ ˆ/์ต์ ˆ ์ฒดํฌ (๋จผ์ € ์‹คํ–‰) + logger.info("๐Ÿ’ฐ Checking portfolio for stop-loss/take-profit...") + try: + await check_and_execute_exit_trades(session) + except Exception as e: + logger.error(f"Exit trade check error: {e}", exc_info=True) + + # 2. ์ž๋™๋งค๋งค๊ฐ€ ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ์—๋งŒ ์‹ ๊ทœ ๋งค์ˆ˜ ์ง„ํ–‰ + if settings.AUTO_TRADE_ENABLED: + logger.info("๐Ÿ”„ Starting real-time analysis cycle...") + rec = await get_rule_based_recommendations_realtime() + + if rec and rec.get("recommendations_by_rule"): + logger.info(f"๐Ÿ“Š Found recommendations, executing auto-trade") + await execute_auto_trade_background(rec, session) + else: + logger.info("โญ๏ธ Auto-trade skipped: no recommendations matched rules") + else: + logger.info("โธ๏ธ Auto-trade disabled - only monitoring stop-loss/take-profit") + except Exception as e: + logger.error(f"โŒ Auto-trade scheduler error: {e}", exc_info=True) + + await asyncio.sleep(60) + except asyncio.CancelledError: + logger.info("โน๏ธ Auto-trade scheduler stopped") + raise + + +async def day_trade_scheduler(): + """30์ดˆ ๊ฐ„๊ฒฉ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์Šค์ผ€์ค„๋Ÿฌ. + + - Opening Range ์ˆ˜์ง‘ (09:00~09:30) + - ์ง„์ž… ์‹ ํ˜ธ ์Šค์บ” + ์ฃผ๋ฌธ ์‹คํ–‰ (09:30~15:00) + - EOD ๊ฐ•์ œ ์ฒญ์‚ฐ (15:20~15:30) + - ์žฅ ์™ธ ์‹œ๊ฐ„: 30๋ถ„ ์Šฌ๋ฆฝ + """ + await asyncio.sleep(5) # ๋ถ€ํŒ… ์•ˆ์ •ํ™” ๋Œ€๊ธฐ + from app.api.endpoints.day_trading import run_day_trading_cycle + + logger.info("๐Ÿ“ˆ Day-trade scheduler started (every 30s)") + try: + while True: + if not is_market_open(): + await asyncio.sleep(1800) + continue + if not settings.DAY_TRADE_ENABLED: + await asyncio.sleep(60) + continue + try: + result = await run_day_trading_cycle() + phase = result.get("phase", "") + if result.get("entries"): + logger.info(f"๐Ÿ“ˆ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ง„์ž…: {result['entries']}") + if result.get("exits"): + logger.info(f"๐Ÿ“‰ ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ฒญ์‚ฐ: {result['exits']}") + if phase not in ("market_closed", "outside_window"): + logger.debug(f"Day-trade cycle [{phase}]: {result.get('time')}") + except Exception as e: + logger.error(f"โŒ Day-trade scheduler error: {e}", exc_info=True) + await asyncio.sleep(30) + except asyncio.CancelledError: + logger.info("โน๏ธ Day-trade scheduler stopped") + raise + + @asynccontextmanager async def lifespan(app: FastAPI): """์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹œ์ž‘/์ข…๋ฃŒ ์‹œ ์‹คํ–‰๋  ์ฝ”๋“œ""" logger.info("Starting Auto Stock Trading System...") - + kis_mode = settings.KIS_MODE.upper() + logger.info(f"KIS API Mode: {'๋ชจ์˜ํˆฌ์ž (VIRTUAL)' if settings.is_virtual_trading else '์‹ค์ „ํˆฌ์ž (REAL)'}") + logger.info(f"KIS Base URL: {settings.KOREA_INVESTMENT_API_BASE_URL}") + logger.info(f"KIS Account: {settings.KOREA_INVESTMENT_ACCOUNT_NUMBER}") + # DB ํ…Œ์ด๋ธ” ์ž๋™ ์ƒ์„ฑ try: logger.info(f"Connecting to database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'unknown'}") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - logger.info("โœ… Database tables created successfully") + # Ensure trade_history columns exist (self-heal without external migration) + await conn.execute(text(""" + ALTER TABLE trade_history + ADD COLUMN IF NOT EXISTS total_amount NUMERIC(15, 2), + ADD COLUMN IF NOT EXISTS entry_reason TEXT, + ADD COLUMN IF NOT EXISTS exit_reason TEXT, + ADD COLUMN IF NOT EXISTS signals JSONB, + ADD COLUMN IF NOT EXISTS entry_trade_id INTEGER, + ADD COLUMN IF NOT EXISTS slippage NUMERIC(10, 2), + ADD COLUMN IF NOT EXISTS commission NUMERIC(10, 2), + ADD COLUMN IF NOT EXISTS net_profit_loss NUMERIC(15, 2), + ADD COLUMN IF NOT EXISTS profit_loss_rate NUMERIC(10, 4); + """)) + await conn.execute(text(""" + UPDATE trade_history + SET total_amount = executed_price * executed_quantity + WHERE total_amount IS NULL AND executed_price IS NOT NULL AND executed_quantity IS NOT NULL; + """)) + # stock_analysis์— ATR / ADX / ML ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (CLAUDE.md ์„น์…˜ 6.2) + await conn.execute(text(""" + ALTER TABLE stock_analysis + ADD COLUMN IF NOT EXISTS atr_14 NUMERIC(15, 2), + ADD COLUMN IF NOT EXISTS adx_14 NUMERIC(10, 2), + ADD COLUMN IF NOT EXISTS ml_score INTEGER, + ADD COLUMN IF NOT EXISTS ml_confidence NUMERIC(5, 4), + ADD COLUMN IF NOT EXISTS ml_signal VARCHAR(10); + """)) + # active_trades์— ๋ถ„ํ•  ์ฒญ์‚ฐ ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (CLAUDE.md ์„น์…˜ 7.3) + await conn.execute(text(""" + ALTER TABLE active_trades + ADD COLUMN IF NOT EXISTS remaining_pct NUMERIC(5, 2) DEFAULT 1.0, + ADD COLUMN IF NOT EXISTS highest_since_entry NUMERIC(15, 2), + ADD COLUMN IF NOT EXISTS strategy_name VARCHAR(100); + """)) + # Risk Management ํ…Œ์ด๋ธ” ์ƒ์„ฑ + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS trading_sessions ( + id SERIAL PRIMARY KEY, + session_date DATE UNIQUE NOT NULL, + consecutive_losses INTEGER DEFAULT 0, + daily_profit_loss NUMERIC(15, 2) DEFAULT 0, + is_paused BOOLEAN DEFAULT FALSE, + pause_reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + # ์ข…๋ชฉ๋ณ„ ์—ฐ์† ์†์‹ค ban ์˜์†ํ™” ํ…Œ์ด๋ธ” (CLAUDE.md ์„น์…˜ 7.4) + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS stock_bans ( + stock_code VARCHAR(20) PRIMARY KEY, + consecutive_losses INTEGER DEFAULT 0 NOT NULL, + ban_until TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + logger.info("โœ… Database tables created successfully (and columns verified)") + + # ์—ฐ์† ์†์‹ค ban ์ƒํƒœ ๋ณต์› (์žฌ์‹œ์ž‘ ํ›„์—๋„ ์œ ์ง€ โ€” CLAUDE.md ์„น์…˜ 7.4) + try: + from app.services.loss_tracker import loss_tracker + async with AsyncSessionLocal() as session: + await loss_tracker.load_from_db(session) + except Exception as e: + logger.warning(f"โš ๏ธ loss_tracker ์ดˆ๊ธฐํ™” ์‹คํŒจ (non-critical): {e}") + + # ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ๋ฐ ์˜ค๋ž˜๋œ ๋ฐ์ดํ„ฐ ์ž๋™ ์ •๋ฆฌ + try: + from app.models.models import StockAnalysis, TradeHistory, Portfolio + from sqlalchemy import delete + from datetime import date, timedelta + + async with AsyncSessionLocal() as session: + # ์˜ค๋ž˜๋œ stock_analysis ์‚ญ์ œ (7์ผ ์ด์ „) + cutoff_date = date.today() - timedelta(days=7) + result = await session.execute( + delete(StockAnalysis).where(StockAnalysis.analysis_date < cutoff_date) + ) + if result.rowcount > 0: + logger.info(f"๐Ÿ—‘๏ธ Deleted {result.rowcount} old stock_analysis records") + + # PENDING/FAILED ์˜ค๋ž˜๋œ ๊ฑฐ๋ž˜ ์‚ญ์ œ (1์ผ ์ด์ „) + cutoff_datetime = date.today() - timedelta(days=1) + result = await session.execute( + delete(TradeHistory).where( + TradeHistory.status.in_(['PENDING', 'FAILED']), + TradeHistory.created_at < cutoff_datetime + ) + ) + if result.rowcount > 0: + logger.info(f"๐Ÿ—‘๏ธ Deleted {result.rowcount} old PENDING/FAILED trades") + + # ์ˆ˜๋Ÿ‰ 0์ธ ํฌํŠธํด๋ฆฌ์˜ค ์‚ญ์ œ + result = await session.execute( + delete(Portfolio).where(Portfolio.quantity <= 0) + ) + if result.rowcount > 0: + logger.info(f"๐Ÿ—‘๏ธ Deleted {result.rowcount} empty portfolio records") + + await session.commit() + logger.info("โœ… Test data cleanup completed") + except Exception as e: + logger.warning(f"โš ๏ธ Test data cleanup failed (non-critical): {e}") + except ConnectionRefusedError as e: logger.error(f"โŒ Database connection refused: {e}") logger.error("Please check if PostgreSQL server is running and accessible") @@ -40,9 +248,24 @@ async def lifespan(app: FastAPI): logger.error(traceback.format_exc()) raise + # ์Šค์ผ€์ค„๋Ÿฌ ์‹œ์ž‘ + app.state.auto_trade_task = asyncio.create_task(auto_trade_scheduler()) + app.state.day_trade_task = asyncio.create_task(day_trade_scheduler()) + yield - + # ์ข…๋ฃŒ ์‹œ ์ •๋ฆฌ ์ž‘์—… + # ์Šค์ผ€์ค„๋Ÿฌ ์ค‘๋‹จ + for task_attr in ("auto_trade_task", "day_trade_task"): + try: + task = getattr(app.state, task_attr, None) + if task: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + except Exception as e: + logger.warning(f"Failed to stop {task_attr} gracefully: {e}") + logger.info("Shutting down Auto Stock Trading System...") await engine.dispose() @@ -105,7 +328,16 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): try: while True: data = await websocket.receive_text() - # ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ + # ping/pong ํ•˜ํŠธ๋น„ํŠธ ์ฒ˜๋ฆฌ + try: + msg = json.loads(data) + if msg.get("type") == "ping": + await websocket_manager.send_personal_message( + json.dumps({"type": "pong"}), client_id + ) + continue + except (json.JSONDecodeError, AttributeError): + pass await websocket_manager.send_personal_message( f"Echo: {data}", client_id ) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index dcc4f38..eee8b42 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,10 +1,66 @@ -from datetime import datetime +""" +SQLAlchemy ORM ๋ชจ๋ธ ์ •์˜ + +- StockDailyPrice: ์ผ๋ณ„ ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ +- Portfolio: ํฌํŠธํด๋ฆฌ์˜ค ๋ณด์œ  ์ข…๋ชฉ +- TradeHistory: ์ฃผ๋ฌธ/์ฒด๊ฒฐ ์ด๋ ฅ +- TradingRule: ๋งค๋งค ๊ทœ์น™ ์„ค์ • +""" +from datetime import datetime, date from typing import Optional, List -from sqlalchemy import Column, Integer, String, Numeric, Boolean, DateTime, Text, ARRAY +from sqlalchemy import Column, Integer, BigInteger, String, Numeric, Boolean, DateTime, Text, ARRAY, Date, ForeignKey, UniqueConstraint from sqlalchemy.dialects.postgresql import JSONB from app.core.database import Base +class StockDailyPrice(Base): + """KRX ์ผ๋ด‰ ํžˆ์Šคํ† ๋ฆฌ์ปฌ ๋ฐ์ดํ„ฐ (pykrx ์ˆ˜์ง‘)""" + __tablename__ = "stock_daily_prices" + __table_args__ = ( + UniqueConstraint('stock_code', 'trade_date', name='uq_stock_daily'), + ) + + id = Column(Integer, primary_key=True, index=True) + stock_code = Column(String(20), nullable=False, index=True) + stock_name = Column(String(100)) + trade_date = Column(Date, nullable=False, index=True) + open_price = Column(Integer) + high_price = Column(Integer) + low_price = Column(Integer) + close_price = Column(Integer) + volume = Column(BigInteger) + change_rate = Column(Numeric(10, 4)) + market_cap = Column(BigInteger, nullable=True) + + +class TradingSession(Base): + """๐Ÿ”ฅ ์ผ์ผ ํŠธ๋ ˆ์ด๋”ฉ ์„ธ์…˜ ๊ด€๋ฆฌ (๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ)""" + __tablename__ = "trading_sessions" + + id = Column(Integer, primary_key=True, index=True) + session_date = Column(Date, default=date.today, unique=True, index=True) + + # ์—ฐ์† ์†์‹ค ์ถ”์  + consecutive_losses = Column(Integer, default=0) + consecutive_wins = Column(Integer, default=0) + + # ์ผ์ผ ์†์ต + daily_profit_loss = Column(Numeric(15, 2), default=0) + daily_trades_count = Column(Integer, default=0) + + # ๋ฆฌ์Šคํฌ ์ œํ•œ ์ƒํƒœ + is_paused = Column(Boolean, default=False) # 3์—ฐํŒจ ๋˜๋Š” ์ผ์ผ ์†์‹ค ํ•œ๋„ ๋„๋‹ฌ ์‹œ True + pause_reason = Column(String(200), nullable=True) + + # ๊ณ„์ขŒ ์ •๋ณด + starting_balance = Column(Numeric(15, 2)) + ending_balance = Column(Numeric(15, 2)) + peak_balance = Column(Numeric(15, 2)) # ๋‹น์ผ ์ตœ๊ณ  ์ž”๊ณ  + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class TradingRule(Base): __tablename__ = "trading_rules" @@ -32,9 +88,21 @@ class TradeHistory(Base): order_quantity = Column(Integer, nullable=False) executed_price = Column(Numeric(15, 2)) executed_quantity = Column(Integer) + total_amount = Column(Numeric(15, 2)) # ์ด ๊ฑฐ๋ž˜ ๊ธˆ์•ก status = Column(String(50), nullable=False) order_id = Column(String(100)) message = Column(Text) + entry_reason = Column(Text) # ์ง„์ž… ์ด์œ  (๋งค์ˆ˜ ์‹ ํ˜ธ ์ƒ์„ธ) + exit_reason = Column(Text) # ๋งค๋„ ์ด์œ  (๋งค๋„ ์‹ ํ˜ธ ์ƒ์„ธ) + signals = Column(JSONB) # ๊ธฐ์ˆ ์  ์‹ ํ˜ธ ๋ชฉ๋ก + + # ๐Ÿ”ฅ ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ํ•„๋“œ ์ถ”๊ฐ€ + entry_trade_id = Column(Integer, nullable=True) # ๋งค๋„ ์‹œ ์–ฐ๊ฒฐ๋œ ๋งค์ˆ˜ ๊ฑฐ๋ž˜ ID + slippage = Column(Numeric(15, 2), nullable=True) # ์Šฌ๋ฆฌํ”ผ์ง€ (์ฒด๊ฒฐ๊ฐ€ - ์ฃผ๋ฌธ๊ฐ€) + commission = Column(Numeric(15, 2), nullable=True) # ์ˆ˜์ˆ˜๋ฃŒ + net_profit_loss = Column(Numeric(15, 2), nullable=True) # ์ˆ˜์ˆ˜๋ฃŒ ์ฐจ๊ฐ ํ›„ ์ˆœ์†์ต + profit_loss_rate = Column(Numeric(10, 4), nullable=True) # ์ˆ˜์ต๋ฅ  (%) + created_at = Column(DateTime, default=datetime.utcnow, index=True) executed_at = Column(DateTime) @@ -135,7 +203,107 @@ class StockAnalysis(Base): # ๋ถ„์„ ๊ฒฐ๊ณผ signals = Column(JSONB) # ["RSI ๊ณผ๋งค๋„", "๊ณจ๋“ ํฌ๋กœ์Šค" ๋“ฑ] score = Column(Integer, default=0) - + + # ATR (์„น์…˜ 6.2 ATR ๊ธฐ๋ฐ˜ ์†์ ˆ) + atr_14 = Column(Numeric(15, 2), nullable=True) + + # ADX (์ถ”์„ธ ๊ฐ•๋„ โ€” ํ‰๊ท ํšŒ๊ท€ ํ•„ํ„ฐ์šฉ) + adx_14 = Column(Numeric(10, 2), nullable=True) + + # ML ์•™์ƒ๋ธ” ์Šค์ฝ”์–ด + ml_score = Column(Integer, nullable=True) # ์•™์ƒ๋ธ” ์Šค์ฝ”์–ด (0~100) + ml_confidence = Column(Numeric(5, 4), nullable=True) # ์‹ ๋ขฐ๋„ (0~1) + ml_signal = Column(String(10), nullable=True) # BUY/HOLD/SELL + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class ActiveTrade(Base): + """ํ˜„์žฌ ํ™œ์„ฑํ™”๋œ(๋ณด์œ  ์ค‘์ธ) ๊ฑฐ๋ž˜๋ฅผ ์ถ”์ ํ•˜๊ธฐ ์œ„ํ•œ ๋ชจ๋ธ""" + __tablename__ = "active_trades" + + id = Column(Integer, primary_key=True, index=True) + stock_code = Column(String(20), nullable=False) + stock_name = Column(String(100)) + quantity = Column(Integer, nullable=False) + entry_price = Column(Numeric(15, 2), nullable=False) + current_price = Column(Numeric(15, 2)) + profit_loss = Column(Numeric(15, 2)) + profit_loss_rate = Column(Numeric(10, 4)) + stop_loss = Column(Numeric(15, 2)) + take_profit = Column(Numeric(15, 2)) + order_id = Column(String(100)) + entry_reason = Column(Text) + status = Column(String(20), default="active") # active, closed + # ๋ถ„ํ•  ์ฒญ์‚ฐ ์ง€์› (์„น์…˜ 6.2, 7.3) + remaining_pct = Column(Numeric(5, 2), default=1.0) # ์ž”์—ฌ ๋น„์œจ (1.0=์ „๋Ÿ‰, 0.5=์ ˆ๋ฐ˜ ์ฒญ์‚ฐ ํ›„) + highest_since_entry = Column(Numeric(15, 2), nullable=True) # ์ง„์ž… ํ›„ ์ตœ๊ณ ๊ฐ€ (ํŠธ๋ ˆ์ผ๋ง ์Šคํƒ‘์šฉ) + strategy_name = Column(String(100), nullable=True) # ์ „๋žต๋ช… (MEAN_REVERSION ๋“ฑ) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class TradeJournal(Base): + __tablename__ = "trade_journals" + + id = Column(Integer, primary_key=True, index=True) + trade_id = Column(Integer) # ForeignKey("trade_history.id") - models.py ๋‚ด ์ˆœํ™˜ ์ฐธ์กฐ ๋ฐฉ์ง€ ์œ„ํ•ด ์ •์ˆ˜๋กœ ์ •์˜ + stock_code = Column(String(20), nullable=False, index=True) + stock_name = Column(String(100)) + journal_date = Column(DateTime, default=datetime.utcnow) + content = Column(Text) + strategy_name = Column(String(100)) + mood = Column(String(50)) + tags = Column(JSONB) + screenshot_url = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class StockBan(Base): + """์ข…๋ชฉ๋ณ„ ์—ฐ์† ์†์‹ค ๊ธˆ์ง€ ๊ธฐ๋ก (CLAUDE.md ์„น์…˜ 7.4) + + ์žฌ์‹œ์ž‘ ํ›„์—๋„ ban ์ƒํƒœ ์œ ์ง€๋ฅผ ์œ„ํ•ด DB์— ์˜์†ํ™”. + """ + __tablename__ = "stock_bans" + + stock_code = Column(String(20), primary_key=True) + consecutive_losses = Column(Integer, default=0, nullable=False) + ban_until = Column(DateTime, nullable=True) # None์ด๋ฉด ๊ธˆ์ง€ ์•„๋‹˜ + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class WatchlistStock(Base): + """๋ถ„์„์ข…๋ชฉ โ€” ์žฅ์ค‘ ๋ฏธ๋‹ˆ๋ฐฐ์น˜๊ฐ€ ๊ด€๋ฆฌํ•˜๋Š” ํ™œ์„ฑ ๊ด€์‹ฌ ์ข…๋ชฉ. + + ์•„์นจ ๋ฐฐ์น˜(batch_daily_analysis)๊ฐ€ score ์ƒ์œ„ 50๊ฐœ๋กœ ์ดˆ๊ธฐํ™”ํ•˜๊ณ , + ์žฅ์ค‘ ๋ฏธ๋‹ˆ๋ฐฐ์น˜(batch_intraday_analysis)๊ฐ€ 10๋ถ„๋งˆ๋‹ค ์ง„์ž…/์ œ๊ฑฐ๋ฅผ ๊ด€๋ฆฌํ•œ๋‹ค. + """ + __tablename__ = "watchlist_stocks" + + id = Column(Integer, primary_key=True, index=True) + stock_code = Column(String(20), nullable=False, index=True) + stock_name = Column(String(100), nullable=False) + added_at = Column(DateTime, nullable=False) + added_reason = Column(String(50), nullable=False, default="morning_batch") + score_at_add = Column(Integer, nullable=True) + is_active = Column(Boolean, default=True, nullable=False, index=True) + removed_at = Column(DateTime, nullable=True) + removed_reason = Column(String(50), nullable=True) + + +class WatchlistHistory(Base): + """๋ถ„์„์ข…๋ชฉ ์Šค์ฝ”์–ด ํžˆ์Šคํ† ๋ฆฌ โ€” 10๋ถ„๋งˆ๋‹ค ์ ์žฌ๋˜๋Š” ์‹œ๊ณ„์—ด ๋ฐ์ดํ„ฐ.""" + __tablename__ = "watchlist_history" + + id = Column(Integer, primary_key=True, index=True) + stock_code = Column(String(20), nullable=False, index=True) + stock_name = Column(String(100), nullable=False) + recorded_at = Column(DateTime, nullable=False, index=True) + score = Column(Integer, nullable=True) + current_price = Column(Numeric(15, 2), nullable=True) + change_rate = Column(Numeric(10, 4), nullable=True) + rsi = Column(Numeric(10, 4), nullable=True) + signals = Column(JSONB, nullable=True) diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py new file mode 100644 index 0000000..2abe27e --- /dev/null +++ b/backend/app/services/day_trading_strategy.py @@ -0,0 +1,472 @@ +""" +๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ „๋žต (CLAUDE.md ์„น์…˜ 8.2 ํ™•์žฅ) + +์ „๋žต 3์ข…: + 1. ORB โ€” Opening Range Breakout (09:00~09:30 ๊ณ ์  ๋ŒํŒŒ) + 2. VWAP โ€” VWAP ์ง€์ง€ + ๊ณผ๋งค๋„ ๋ฐ˜๋“ฑ + 3. RSI_BB โ€” 5๋ถ„๋ด‰ RSI+๋ณผ๋ฆฐ์ € ๊ณผ๋งค๋„ ๋ฐ˜๋“ฑ + +๊ณตํ†ต ๊ทœ์น™: + - EOD ๊ฐ•์ œ ์ฒญ์‚ฐ: 15:20 ์ดํ›„ ์ „๋Ÿ‰ ์‹œ์žฅ๊ฐ€ ๋งค๋„ + - ์ผ์ผ ์†์‹ค ํ•œ๋„: ์ „์ฒด ์ž๋ณธ -3% ์ดˆ๊ณผ ์‹œ ์‹ ๊ทœ ์ง„์ž… ์ค‘๋‹จ + - ๋™์‹œ ์ตœ๋Œ€ 3์ข…๋ชฉ, ์ข…๋ชฉ๋‹น ์ตœ๋Œ€ 10% ์ž๋ณธ + - ์ „์ฒด ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ž๋ณธ: ์ด์ž์‚ฐ์˜ ์ตœ๋Œ€ 25% (CLAUDE.md 8.2) + - ์ˆ˜์ˆ˜๋ฃŒ+์Šฌ๋ฆฌํ”ผ์ง€ ํฌํ•จ ๊ธฐ์ค€: 0.5% (๋งค์ˆ˜ 0.015% + ๋งค๋„ 0.3% + ์Šฌ๋ฆฌํ”ผ์ง€ 0.2%) +""" +import logging +from typing import Optional, Dict, List, Tuple +from dataclasses import dataclass +from datetime import time + +logger = logging.getLogger(__name__) + +# โ”€โ”€ ์žฅ ์‹œ๊ฐ„ ์ƒ์ˆ˜ (KST) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +MARKET_OPEN = time(9, 0) +ORB_END = time(9, 30) # Opening Range ์ง‘๊ณ„ ์ข…๋ฃŒ +ENTRY_CUTOFF = time(15, 0) # ์‹ ๊ทœ ์ง„์ž… ๋งˆ๊ฐ (์ดํ›„๋Š” ์ฒญ์‚ฐ๋งŒ) +EOD_FLAT = time(15, 20) # EOD ๊ฐ•์ œ ์ฒญ์‚ฐ ์‹œ์ž‘ +MARKET_CLOSE = time(15, 30) + +# โ”€โ”€ ๋ฆฌ์Šคํฌ ํŒŒ๋ผ๋ฏธํ„ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +MAX_CAPITAL_RATIO = 0.10 # ์ข…๋ชฉ๋‹น ์ตœ๋Œ€ 10% +MAX_DAY_TRADE_RATIO = 0.25 # ์ „์ฒด ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ตœ๋Œ€ 25% +MAX_POSITIONS = 3 # ๋™์‹œ ์ตœ๋Œ€ 3์ข…๋ชฉ +DAILY_LOSS_LIMIT = -0.03 # ์ผ์ผ ์†์‹ค ํ•œ๋„ -3% +STOP_LOSS_PCT = 0.02 # ์ข…๋ชฉ ์†์ ˆ -2% +TRAILING_TRIGGER = 0.015 # ํŠธ๋ ˆ์ผ๋ง ๋ฐœ๋™ +1.5% +TRAILING_ATR_MULT = 1.0 # ํŠธ๋ ˆ์ผ๋ง ATR ๋ฐฐ์ˆ˜ +ORB_BREAKOUT_MARGIN = 0.002 # ORB ๋ŒํŒŒ ์ตœ์†Œ ์—ฌ์œ  +0.2% +COST_PCT = 0.005 # ์ˆ˜์ˆ˜๋ฃŒ+์Šฌ๋ฆฌํ”ผ์ง€ ํ•ฉ์‚ฐ (๋งค์ˆ˜+๋งค๋„) + + +# โ”€โ”€ ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@dataclass +class MinuteCandle: + """KIS API output2 ๋ฐ์ดํ„ฐ ์ •๊ทœํ™” ๊ฒฐ๊ณผ""" + date: str # YYYYMMDD + time: str # HHMMSS + open: float + high: float + low: float + close: float + volume: int + cum_volume: int # ๋ˆ„์  ๊ฑฐ๋ž˜๋Ÿ‰ (acml_vol) + cum_amount: float # ๋ˆ„์  ๊ฑฐ๋ž˜๋Œ€๊ธˆ ์› (acml_tr_pbmn) + + +@dataclass +class OpeningRange: + high: float + low: float + total_volume: int + avg_vol_per_min: float # ๋ถ„๋‹น ํ‰๊ท  ๊ฑฐ๋ž˜๋Ÿ‰ (ORB ๊ธฐ๊ฐ„) + + +@dataclass +class DayTradeSignal: + stock_code: str + stock_name: str + strategy: str # "ORB" | "VWAP_BOUNCE" | "RSI_BB" + entry_price: float + stop_loss: float + target_price: float + reason: str + confidence: float # 0.0 ~ 1.0 + + +@dataclass +class DayTradeExit: + action: str # "SELL_ALL" + reason: str + sell_pct: float = 1.0 + + +# โ”€โ”€ ๋ถ„๋ด‰ ํŒŒ์‹ฑ ํ—ฌํผ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def parse_minute_candles(raw_output2: list) -> List[MinuteCandle]: + """KIS API output2 ๋ฆฌ์ŠคํŠธ๋ฅผ MinuteCandle ๋ฆฌ์ŠคํŠธ๋กœ ๋ณ€ํ™˜ + + KIS ์‘๋‹ต ํ•„๋“œ: + stck_bsop_date / stck_cntg_hour / stck_oprc / stck_hgpr / + stck_lwpr / stck_prpr (์ข…๊ฐ€) / cntg_vol / acml_vol / acml_tr_pbmn + """ + candles: List[MinuteCandle] = [] + for row in raw_output2: + try: + candles.append(MinuteCandle( + date=row.get("stck_bsop_date", ""), + time=row.get("stck_cntg_hour", ""), + open=float(row.get("stck_oprc", 0) or 0), + high=float(row.get("stck_hgpr", 0) or 0), + low=float(row.get("stck_lwpr", 0) or 0), + close=float(row.get("stck_prpr", 0) or 0), + volume=int(row.get("cntg_vol", 0) or 0), + cum_volume=int(row.get("acml_vol", 0) or 0), + cum_amount=float(row.get("acml_tr_pbmn", 0) or 0), + )) + except (ValueError, TypeError): + continue + return candles + + +def aggregate_to_5min(candles: List[MinuteCandle]) -> List[MinuteCandle]: + """1๋ถ„๋ด‰ โ†’ 5๋ถ„๋ด‰ ์ง‘๊ณ„ (09:00 ๊ธฐ์ค€ 5๋ถ„ ๋ฒ„ํ‚ท)""" + if not candles: + return [] + + buckets: Dict[int, List[MinuteCandle]] = {} + for c in candles: + if not c.time: + continue + h = int(c.time[:2]) + m = int(c.time[2:4]) + key = (h * 60 + m) // 5 + buckets.setdefault(key, []).append(c) + + result: List[MinuteCandle] = [] + for key in sorted(buckets): + bkt = buckets[key] + result.append(MinuteCandle( + date=bkt[0].date, + time=bkt[0].time, + open=bkt[0].open, + high=max(b.high for b in bkt), + low=min(b.low for b in bkt), + close=bkt[-1].close, + volume=sum(b.volume for b in bkt), + cum_volume=bkt[-1].cum_volume, + cum_amount=bkt[-1].cum_amount, + )) + return result + + +# โ”€โ”€ ์ง€ํ‘œ ๊ณ„์‚ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def calculate_vwap(candles: List[MinuteCandle]) -> Optional[float]: + """VWAP = ๋ˆ„์ ๊ฑฐ๋ž˜๋Œ€๊ธˆ / ๋ˆ„์ ๊ฑฐ๋ž˜๋Ÿ‰ (๋‹น์ผ 09:00 ๊ธฐ์ค€ ๋ˆ„์ )""" + if not candles: + return None + last = candles[-1] + if last.cum_volume <= 0: + return None + return last.cum_amount / last.cum_volume + + +def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[OpeningRange]: + """09:00~09:29 ๋ถ„๋ด‰์œผ๋กœ Opening Range (H/L) ๊ณ„์‚ฐ""" + orb = [c for c in candles_1min if "090000" <= c.time <= "092959"] + if len(orb) < 5: + return None + total_vol = sum(c.volume for c in orb) + return OpeningRange( + high=max(c.high for c in orb), + low=min(c.low for c in orb), + total_volume=total_vol, + avg_vol_per_min=total_vol / len(orb), + ) + + +def _rsi(candles: List[MinuteCandle], period: int = 14) -> Optional[float]: + closes = [c.close for c in candles] + if len(closes) < period + 1: + return None + gains, losses = [], [] + for i in range(-period, 0): + d = closes[i] - closes[i - 1] + gains.append(max(d, 0)) + losses.append(max(-d, 0)) + avg_gain = sum(gains) / period + avg_loss = sum(losses) / period + if avg_loss == 0: + return 100.0 + return 100 - (100 / (1 + avg_gain / avg_loss)) + + +def _bollinger(candles: List[MinuteCandle], period: int = 20) -> Optional[Tuple[float, float, float]]: + closes = [c.close for c in candles] + if len(closes) < period: + return None + window = closes[-period:] + mid = sum(window) / period + std = (sum((p - mid) ** 2 for p in window) / period) ** 0.5 + return (mid + 2 * std, mid, mid - 2 * std) + + +def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]: + if len(candles) < period + 1: + return None + trs = [] + for i in range(1, len(candles)): + h, l, cp = candles[i].high, candles[i].low, candles[i - 1].close + trs.append(max(h - l, abs(h - cp), abs(l - cp))) + if len(trs) < period: + return None + return sum(trs[-period:]) / period + + +# โ”€โ”€ ์ง„์ž… ์‹ ํ˜ธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def check_orb_signal( + stock_code: str, + stock_name: str, + candles_1min: List[MinuteCandle], + orb: OpeningRange, + current_price: float, +) -> Optional[DayTradeSignal]: + """ORB ์ƒํ–ฅ ๋ŒํŒŒ ๋งค์ˆ˜ ์‹ ํ˜ธ + + ์กฐ๊ฑด: + 1. ํ˜„์žฌ๊ฐ€ > ORB ๊ณ ์  ร— 1.002 (0.2% ์ด์ƒ ๋ŒํŒŒ) + 2. ์ตœ๊ทผ 1๋ถ„๋ด‰ ๊ฑฐ๋ž˜๋Ÿ‰ โ‰ฅ ORB ๋ถ„๋‹นํ‰๊ท  ร— 1.5 (๊ฑฐ๋ž˜๋Ÿ‰ ํญ๋ฐœ) + 3. ์ง์ „ 5๋ถ„๋ด‰์ด ์–‘๋ด‰ (์Œ๋ด‰์ด๋ฉด Fake-out ์œ„ํ—˜) + """ + if not orb or current_price <= 0: + return None + + if current_price < orb.high * (1 + ORB_BREAKOUT_MARGIN): + return None + + # ์ตœ๊ทผ 1๋ถ„๋ด‰ ๊ฑฐ๋ž˜๋Ÿ‰ ์ฒดํฌ + if not candles_1min: + return None + recent_vol = candles_1min[-1].volume + if recent_vol < orb.avg_vol_per_min * 1.5: + return None + + # ์ง์ „ 5๋ถ„๋ด‰ ์–‘๋ด‰ ํ™•์ธ + candles_5m = aggregate_to_5min(candles_1min) + if len(candles_5m) >= 2 and candles_5m[-2].close < candles_5m[-2].open: + return None # ์ง์ „ 5๋ถ„๋ด‰ ์Œ๋ด‰ โ†’ ์Šคํ‚ต + + stop_loss = max(orb.low, current_price * (1 - STOP_LOSS_PCT)) + risk = current_price - stop_loss + # ์ˆ˜์ˆ˜๋ฃŒ ๊ณ ๋ ค: ๋ชฉํ‘œ = ์ง„์ž…๊ฐ€ + 2R + ์ˆ˜์ˆ˜๋ฃŒ ๋ณด์ „ + target = current_price + max(risk * 2.0, current_price * 0.03) + + vol_ratio = recent_vol / orb.avg_vol_per_min + confidence = min(0.85, 0.65 + vol_ratio * 0.05) + + return DayTradeSignal( + stock_code=stock_code, + stock_name=stock_name, + strategy="ORB", + entry_price=current_price, + stop_loss=round(stop_loss, 0), + target_price=round(target, 0), + reason=( + f"ORB ์ƒํ–ฅ ๋ŒํŒŒ: ORB๊ณ ={orb.high:.0f}, " + f"ํ˜„์žฌ={current_price:.0f}, ๊ฑฐ๋ž˜๋Ÿ‰={vol_ratio:.1f}๋ฐฐ" + ), + confidence=round(confidence, 2), + ) + + +def check_vwap_bounce_signal( + stock_code: str, + stock_name: str, + candles_5min: List[MinuteCandle], + vwap: float, + current_price: float, +) -> Optional[DayTradeSignal]: + """VWAP ์ง€์ง€ ๋ฐ˜๋“ฑ ๋งค์ˆ˜ ์‹ ํ˜ธ + + ์กฐ๊ฑด: + 1. ํ˜„์žฌ๊ฐ€๊ฐ€ VWAP์˜ -1% ~ 0.5% ๊ตฌ๊ฐ„ (VWAP ๊ทผ์ฒ˜) + 2. 5๋ถ„๋ด‰ RSI(14) โ‰ค 40 + 3. ์ตœ๊ทผ ๋ด‰ ๊ฑฐ๋ž˜๋Ÿ‰ โ‰ฅ ์ง์ „ ๋ด‰ ร— 1.3 + 4. ์ตœ๊ทผ 5๋ถ„๋ด‰ ์–‘๋ด‰ ์ „ํ™˜ (close > open) + """ + if not candles_5min or vwap <= 0 or current_price <= 0: + return None + + if not (vwap * 0.99 <= current_price <= vwap * 1.005): + return None + + rsi = _rsi(candles_5min, 14) + if rsi is None or rsi > 40: + return None + + if len(candles_5min) < 2: + return None + if candles_5min[-1].volume < candles_5min[-2].volume * 1.3: + return None + + # ์ตœ๊ทผ ๋ด‰ ์–‘๋ด‰ ํ™•์ธ + last = candles_5min[-1] + if last.close <= last.open: + return None + + atr = _atr(candles_5min, 14) + stop_loss = (current_price - 1.5 * atr) if atr else current_price * (1 - STOP_LOSS_PCT) + target = (current_price + 2.0 * atr) if atr else vwap * 1.03 + + return DayTradeSignal( + stock_code=stock_code, + stock_name=stock_name, + strategy="VWAP_BOUNCE", + entry_price=current_price, + stop_loss=round(stop_loss, 0), + target_price=round(target, 0), + reason=( + f"VWAP ๋ฐ˜๋“ฑ: VWAP={vwap:.0f}, RSI={rsi:.1f}, " + f"ํ˜„์žฌ={current_price:.0f}" + ), + confidence=0.65, + ) + + +def check_rsi_bb_signal( + stock_code: str, + stock_name: str, + candles_5min: List[MinuteCandle], + current_price: float, +) -> Optional[DayTradeSignal]: + """5๋ถ„๋ด‰ RSI + ๋ณผ๋ฆฐ์ € ๊ณผ๋งค๋„ ๋ฐ˜๋“ฑ ์‹ ํ˜ธ + + ์กฐ๊ฑด: + 1. RSI(14) โ‰ค 30 (๊ฐ•ํ•œ ๊ณผ๋งค๋„) + 2. ์ข…๊ฐ€ โ‰ค BB ํ•˜๋‹จ + 3. ์ง์ „ ๋ด‰ ๋Œ€๋น„ ๊ฑฐ๋ž˜๋Ÿ‰ โ‰ฅ 1.3๋ฐฐ + 4. ์ตœ๊ทผ ๋ด‰ ์–‘๋ด‰ (์Œ๋ด‰์ด๋ฉด ์Šคํ‚ต) + """ + if not candles_5min or current_price <= 0: + return None + + rsi = _rsi(candles_5min, 14) + if rsi is None or rsi > 30: + return None + + bb = _bollinger(candles_5min, 20) + if bb is None: + return None + bb_upper, bb_middle, bb_lower = bb + + if current_price > bb_lower * 1.01: + return None + + if len(candles_5min) < 2: + return None + if candles_5min[-1].volume < candles_5min[-2].volume * 1.3: + return None + + last = candles_5min[-1] + if last.close <= last.open: + return None + + atr = _atr(candles_5min, 14) + stop_loss = (current_price - 1.5 * atr) if atr else current_price * (1 - STOP_LOSS_PCT) + target = bb_middle # BB ์ค‘์•™์„ ๊นŒ์ง€ ๋ฐ˜๋“ฑ ๋ชฉํ‘œ + + return DayTradeSignal( + stock_code=stock_code, + stock_name=stock_name, + strategy="RSI_BB", + entry_price=current_price, + stop_loss=round(stop_loss, 0), + target_price=round(target, 0), + reason=( + f"5๋ถ„๋ด‰ ๊ณผ๋งค๋„ ๋ฐ˜๋“ฑ: RSI={rsi:.1f}, " + f"BBํ•˜๋‹จ={bb_lower:.0f}, ํ˜„์žฌ={current_price:.0f}" + ), + confidence=0.60, + ) + + +# โ”€โ”€ ์ฒญ์‚ฐ ์‹ ํ˜ธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def check_day_trade_exit( + entry_price: float, + current_price: float, + stop_loss: float, + target_price: float, + highest_since_entry: float, + candles_5min: List[MinuteCandle], + current_time: time, +) -> Optional[DayTradeExit]: + """๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ์ฒญ์‚ฐ ์กฐ๊ฑด ์ฒดํฌ (์šฐ์„ ์ˆœ์œ„ ์ˆœ) + + 1. EOD ๊ฐ•์ œ ์ฒญ์‚ฐ (15:20 ์ดํ›„) + 2. ์†์ ˆ (ํ˜„์žฌ๊ฐ€ โ‰ค stop_loss) + 3. ๋ชฉํ‘œ๊ฐ€ ๋‹ฌ์„ฑ (ํ˜„์žฌ๊ฐ€ โ‰ฅ target_price) + 4. ํŠธ๋ ˆ์ผ๋ง ์Šคํƒ‘ (1.5% ์ˆ˜์ต ํ›„ ATR ํŠธ๋ ˆ์ผ) + 5. 5๋ถ„๋ด‰ RSI โ‰ฅ 70 (๊ณผ๋งค์ˆ˜ ์ฒญ์‚ฐ) + """ + # 1. EOD ๊ฐ•์ œ ์ฒญ์‚ฐ + if current_time >= EOD_FLAT: + return DayTradeExit( + action="SELL_ALL", + reason=f"EOD ๊ฐ•์ œ ์ฒญ์‚ฐ ({current_time.strftime('%H:%M')} โ‰ฅ 15:20)", + ) + + # 2. ์†์ ˆ + if current_price <= stop_loss: + loss_pct = (current_price - entry_price) / entry_price * 100 + return DayTradeExit( + action="SELL_ALL", + reason=f"์†์ ˆ {loss_pct:.1f}% (ํ˜„์žฌ={current_price:.0f} โ‰ค ์†์ ˆ๊ฐ€={stop_loss:.0f})", + ) + + # 3. ๋ชฉํ‘œ๊ฐ€ ๋‹ฌ์„ฑ + if current_price >= target_price: + gain_pct = (current_price - entry_price) / entry_price * 100 + return DayTradeExit( + action="SELL_ALL", + reason=f"๋ชฉํ‘œ๊ฐ€ ๋‹ฌ์„ฑ +{gain_pct:.1f}% (ํ˜„์žฌ={current_price:.0f} โ‰ฅ ๋ชฉํ‘œ={target_price:.0f})", + ) + + # 4. ํŠธ๋ ˆ์ผ๋ง ์Šคํƒ‘ (1.5% ์ด์ƒ ์ˆ˜์ต ์‹œ ๋ฐœ๋™) + profit_pct = (highest_since_entry - entry_price) / entry_price + if profit_pct >= TRAILING_TRIGGER: + atr = _atr(candles_5min, 14) + if atr: + trailing_stop = highest_since_entry - TRAILING_ATR_MULT * atr + if current_price <= trailing_stop: + return DayTradeExit( + action="SELL_ALL", + reason=( + f"ํŠธ๋ ˆ์ผ๋ง ์Šคํƒ‘: ์ตœ๊ณ ={highest_since_entry:.0f}, " + f"ํŠธ๋ ˆ์ผ={trailing_stop:.0f}, ํ˜„์žฌ={current_price:.0f}" + ), + ) + + # 5. ๊ณผ๋งค์ˆ˜ ์ฒญ์‚ฐ (RSI โ‰ฅ 70) + rsi = _rsi(candles_5min, 14) + if rsi is not None and rsi >= 70: + gain_pct = (current_price - entry_price) / entry_price * 100 + if gain_pct > COST_PCT * 100: # ์ˆ˜์ˆ˜๋ฃŒ ์ด์ƒ ์ˆ˜์ต์ธ ๊ฒฝ์šฐ๋งŒ + return DayTradeExit( + action="SELL_ALL", + reason=f"๊ณผ๋งค์ˆ˜ ์ฒญ์‚ฐ: RSI={rsi:.1f}โ‰ฅ70, ์ˆ˜์ต={gain_pct:.1f}%", + ) + + return None + + +# โ”€โ”€ ํฌ์ง€์…˜ ์‚ฌ์ด์ง• โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def calculate_day_trade_position_size( + total_assets: float, + available_cash: float, + current_day_trade_amount: float, +) -> float: + """๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ํฌ์ง€์…˜ ํฌ๊ธฐ ๊ณ„์‚ฐ + + - ์ข…๋ชฉ๋‹น ์ตœ๋Œ€: ์ด์ž์‚ฐ ร— 10% + - ์ „์ฒด ๋ฐ์ดํŠธ๋ ˆ์ด๋”ฉ ํ•ฉ์‚ฐ: ์ด์ž์‚ฐ ร— 25% (CLAUDE.md 8.2) + - ๋™์‹œ ์ตœ๋Œ€ 3์ข…๋ชฉ (MAX_POSITIONS) + """ + max_total = total_assets * MAX_DAY_TRADE_RATIO + remaining_budget = max_total - current_day_trade_amount + per_position_max = total_assets * MAX_CAPITAL_RATIO + return max(0.0, min(per_position_max, remaining_budget, available_cash)) + + +def is_daily_loss_exceeded( + total_assets: float, + realized_pnl_today: float, +) -> bool: + """์ผ์ผ ์†์‹ค ํ•œ๋„ ์ดˆ๊ณผ ์—ฌ๋ถ€ (์ „์ฒด ์ž๋ณธ -3%)""" + if total_assets <= 0: + return False + return (realized_pnl_today / total_assets) <= DAILY_LOSS_LIMIT diff --git a/backend/app/services/feature_engineering.py b/backend/app/services/feature_engineering.py new file mode 100644 index 0000000..9403811 --- /dev/null +++ b/backend/app/services/feature_engineering.py @@ -0,0 +1,381 @@ +""" +ํ”ผ์ฒ˜ ์—”์ง€๋‹ˆ์–ด๋ง ๋ชจ๋“ˆ + +KIS API ์ฐจํŠธ ๋ฐ์ดํ„ฐ(OHLCV)์—์„œ ML ๋ชจ๋ธ์šฉ ํ”ผ์ฒ˜๋ฅผ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค. +- ๊ธฐ๋ณธ ํ”ผ์ฒ˜ 12๊ฐœ: RSI, MACD, ์Šคํ† ์บ์Šคํ‹ฑ, ๋ณผ๋ฆฐ์ €, MA๋น„์œจ, ๊ฑฐ๋ž˜๋Ÿ‰๋น„์œจ +- ํŒŒ์ƒ ํ”ผ์ฒ˜ 8๊ฐœ: ์ˆ˜์ต๋ฅ , ๋ณ€๋™์„ฑ, ๋ชจ๋ฉ˜ํ…€, ํฌ๋กœ์Šค, ADX, ์ด๊ฒฉ๋„ +""" + +import numpy as np +import pandas as pd +from typing import Optional, Dict, List, Tuple + + +def _ema(values: np.ndarray, period: int) -> np.ndarray: + """์ง€์ˆ˜์ด๋™ํ‰๊ท  ๊ณ„์‚ฐ""" + result = np.full_like(values, np.nan, dtype=float) + if len(values) < period: + return result + result[period - 1] = np.mean(values[:period]) + multiplier = 2.0 / (period + 1) + for i in range(period, len(values)): + result[i] = (values[i] - result[i - 1]) * multiplier + result[i - 1] + return result + + +def chart_data_to_dataframe(chart_data: list) -> pd.DataFrame: + """KIS API ์ฐจํŠธ ๋ฐ์ดํ„ฐ(์ตœ์‹ ์ˆœ)๋ฅผ DataFrame(์‹œ๊ฐ„์ˆœ)์œผ๋กœ ๋ณ€ํ™˜""" + rows = [] + for item in chart_data: + rows.append({ + "date": item.get("stck_bsop_date", ""), + "open": float(item.get("stck_oprc", 0) or 0), + "high": float(item.get("stck_hgpr", 0) or 0), + "low": float(item.get("stck_lwpr", 0) or 0), + "close": float(item.get("stck_clpr", 0) or 0), + "volume": float(item.get("acml_vol", 0) or 0), + }) + df = pd.DataFrame(rows) + # KIS API๋Š” ์ตœ์‹ ์ˆœ โ†’ ์‹œ๊ฐ„์ˆœ์œผ๋กœ ๋’ค์ง‘๊ธฐ + df = df.iloc[::-1].reset_index(drop=True) + return df + + +def db_data_to_dataframe(rows: list) -> pd.DataFrame: + """DB StockDailyPrice ๋ ˆ์ฝ”๋“œ๋ฅผ DataFrame(์‹œ๊ฐ„์ˆœ)์œผ๋กœ ๋ณ€ํ™˜. + + Args: + rows: StockDailyPrice ๊ฐ์ฒด ๋ฆฌ์ŠคํŠธ ๋˜๋Š” dict ๋ฆฌ์ŠคํŠธ + (trade_date, open_price, high_price, low_price, close_price, volume) + + Returns: + chart_data_to_dataframe()๊ณผ ๋™์ผํ•œ ํ˜•ํƒœ์˜ DataFrame + columns: date, open, high, low, close, volume + """ + records = [] + for row in rows: + if isinstance(row, dict): + records.append({ + "date": str(row["trade_date"]), + "open": float(row["open_price"]), + "high": float(row["high_price"]), + "low": float(row["low_price"]), + "close": float(row["close_price"]), + "volume": float(row["volume"]), + }) + else: + records.append({ + "date": str(row.trade_date), + "open": float(row.open_price), + "high": float(row.high_price), + "low": float(row.low_price), + "close": float(row.close_price), + "volume": float(row.volume), + }) + df = pd.DataFrame(records) + # trade_date ๊ธฐ์ค€ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ + df = df.sort_values("date").reset_index(drop=True) + return df + + +def compute_rsi(close: np.ndarray, period: int = 14) -> np.ndarray: + """RSI ๊ณ„์‚ฐ""" + result = np.full_like(close, np.nan, dtype=float) + if len(close) < period + 1: + return result + delta = np.diff(close) + gain = np.where(delta > 0, delta, 0.0) + loss = np.where(delta < 0, -delta, 0.0) + + avg_gain = np.mean(gain[:period]) + avg_loss = np.mean(loss[:period]) + + for i in range(period, len(delta)): + avg_gain = (avg_gain * (period - 1) + gain[i]) / period + avg_loss = (avg_loss * (period - 1) + loss[i]) / period + if avg_loss == 0: + result[i + 1] = 100.0 + else: + rs = avg_gain / avg_loss + result[i + 1] = 100.0 - (100.0 / (1.0 + rs)) + # ์ฒซ ๋ฒˆ์งธ ์œ ํšจ ๊ฐ’ + if avg_loss == 0: + result[period] = 100.0 + else: + result[period] = 100.0 - (100.0 / (1.0 + np.mean(gain[:period]) / max(np.mean(loss[:period]), 1e-10))) + return result + + +def extract_features(df: pd.DataFrame) -> pd.DataFrame: + """DataFrame(OHLCV ์‹œ๊ฐ„์ˆœ)์—์„œ ์ „์ฒด ํ”ผ์ฒ˜๋ฅผ ๊ณ„์‚ฐํ•˜์—ฌ ๋ฐ˜ํ™˜. + + ๋ฐ˜ํ™˜๋˜๋Š” DataFrame์€ ์ž…๋ ฅ๊ณผ ๊ฐ™์€ ์ธ๋ฑ์Šค๋ฅผ ๊ฐ–๋˜, ๊ฐ ํ–‰๋งˆ๋‹ค + ํ•ด๋‹น ์‹œ์ ๊นŒ์ง€์˜ ์ •๋ณด๋งŒ์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ณ„์‚ฐ๋œ 20๊ฐœ ํ”ผ์ฒ˜ ์ปฌ๋Ÿผ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. + ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ๋ถ€์กฑ ๊ตฌ๊ฐ„์€ NaN์ด ๋ฉ๋‹ˆ๋‹ค. + """ + close = df["close"].values.astype(float) + high = df["high"].values.astype(float) + low = df["low"].values.astype(float) + volume = df["volume"].values.astype(float) + n = len(close) + + features: Dict[str, np.ndarray] = {} + + # --- ๊ธฐ๋ณธ ํ”ผ์ฒ˜ (12๊ฐœ) --- + + # 1-2. RSI (14์ผ, 7์ผ) + features["rsi_14"] = compute_rsi(close, 14) + features["rsi_7"] = compute_rsi(close, 7) + + # 3-5. MACD (12, 26, signal=9) + ema12 = _ema(close, 12) + ema26 = _ema(close, 26) + macd_line = ema12 - ema26 + macd_signal = _ema(macd_line[~np.isnan(macd_line)], 9) + # macd_signal์„ ์›๋ž˜ ๊ธธ์ด์— ๋งž์ถ”๊ธฐ + full_signal = np.full(n, np.nan, dtype=float) + valid_start = np.argmax(~np.isnan(macd_line)) + if len(macd_signal) > 0: + full_signal[valid_start:valid_start + len(macd_signal)] = macd_signal + features["macd"] = macd_line + features["macd_signal"] = full_signal + features["macd_hist"] = macd_line - full_signal + + # 6-7. ์Šคํ† ์บ์Šคํ‹ฑ %K, %D (14์ผ) + stoch_k = np.full(n, np.nan, dtype=float) + stoch_d = np.full(n, np.nan, dtype=float) + for i in range(13, n): + h14 = np.max(high[i - 13:i + 1]) + l14 = np.min(low[i - 13:i + 1]) + if h14 != l14: + stoch_k[i] = (close[i] - l14) / (h14 - l14) * 100.0 + # %D = %K์˜ 3์ผ SMA + for i in range(15, n): + vals = stoch_k[i - 2:i + 1] + if not np.any(np.isnan(vals)): + stoch_d[i] = np.mean(vals) + features["stochastic_k"] = stoch_k + features["stochastic_d"] = stoch_d + + # 8. ๋ณผ๋ฆฐ์ € ๋ฐด๋“œ ์œ„์น˜ (0~1) + bb_pos = np.full(n, np.nan, dtype=float) + for i in range(19, n): + window = close[i - 19:i + 1] + mid = np.mean(window) + std = np.std(window, ddof=0) + if std > 0: + upper = mid + 2 * std + lower = mid - 2 * std + bb_pos[i] = (close[i] - lower) / (upper - lower) + features["bb_position"] = bb_pos + + # 9-11. MA ๋น„์œจ (ํ˜„์žฌ๊ฐ€ / MA) + for period, name in [(5, "ma5_ratio"), (20, "ma20_ratio"), (60, "ma60_ratio")]: + ratio = np.full(n, np.nan, dtype=float) + for i in range(period - 1, n): + ma = np.mean(close[i - period + 1:i + 1]) + if ma > 0: + ratio[i] = close[i] / ma + features[name] = ratio + + # 12. ๊ฑฐ๋ž˜๋Ÿ‰ ๋น„์œจ (ํ˜„์žฌ / 20์ผ ํ‰๊ท ) + vol_ratio = np.full(n, np.nan, dtype=float) + for i in range(19, n): + avg_vol = np.mean(volume[i - 19:i + 1]) + if avg_vol > 0: + vol_ratio[i] = volume[i] / avg_vol + features["volume_ratio"] = vol_ratio + + # --- ํŒŒ์ƒ ํ”ผ์ฒ˜ (8๊ฐœ) --- + + # 13-15. ๋‹จ๊ธฐ ์ˆ˜์ต๋ฅ  + for days, name in [(1, "change_rate_1d"), (3, "change_rate_3d"), (5, "change_rate_5d")]: + cr = np.full(n, np.nan, dtype=float) + for i in range(days, n): + if close[i - days] > 0: + cr[i] = (close[i] - close[i - days]) / close[i - days] + features[name] = cr + + # 16. 20์ผ ๋ณ€๋™์„ฑ (์ˆ˜์ต๋ฅ ์˜ ํ‘œ์ค€ํŽธ์ฐจ) + vol20 = np.full(n, np.nan, dtype=float) + returns = np.full(n, np.nan, dtype=float) + for i in range(1, n): + if close[i - 1] > 0: + returns[i] = (close[i] - close[i - 1]) / close[i - 1] + for i in range(20, n): + window = returns[i - 19:i + 1] + valid = window[~np.isnan(window)] + if len(valid) >= 10: + vol20[i] = np.std(valid, ddof=1) + features["volatility_20d"] = vol20 + + # 17. 5์ผ ๋ชจ๋ฉ˜ํ…€ (ํ˜„์žฌ๊ฐ€ / 5์ผ์ „๊ฐ€) + mom = np.full(n, np.nan, dtype=float) + for i in range(5, n): + if close[i - 5] > 0: + mom[i] = close[i] / close[i - 5] + features["price_momentum"] = mom + + # 18. MA ํฌ๋กœ์Šค ์ƒํƒœ (-1, 0, 1) + ma_cross = np.full(n, 0.0, dtype=float) + for i in range(59, n): + ma5 = np.mean(close[i - 4:i + 1]) + ma20 = np.mean(close[i - 19:i + 1]) + ma60 = np.mean(close[i - 59:i + 1]) + if ma5 > ma20 > ma60: + ma_cross[i] = 1.0 # ์ •๋ฐฐ์—ด + elif ma5 < ma20 < ma60: + ma_cross[i] = -1.0 # ์—ญ๋ฐฐ์—ด + features["ma_cross"] = ma_cross + + # 19. ์ถ”์„ธ ๊ฐ•๋„ (๊ฐ„์ด ADX, True Range ๊ธฐ๋ฐ˜) + adx_val = np.full(n, np.nan, dtype=float) + tr = np.zeros(n, dtype=float) + for i in range(1, n): + tr[i] = max(high[i] - low[i], abs(high[i] - close[i - 1]), abs(low[i] - close[i - 1])) + for i in range(13, n): + avg_tr = np.mean(tr[i - 13:i + 1]) + current_tr = tr[i] + if avg_tr > 0: + adx_val[i] = current_tr / avg_tr + features["trend_strength"] = adx_val + + # 20. ์ด๊ฒฉ๋„ (ํ˜„์žฌ๊ฐ€ / 20์ผ ์ดํ‰์„  * 100) + disp = np.full(n, np.nan, dtype=float) + for i in range(19, n): + ma20 = np.mean(close[i - 19:i + 1]) + if ma20 > 0: + disp[i] = close[i] / ma20 * 100.0 + features["disparity_20"] = disp + + feat_df = pd.DataFrame(features, index=df.index) + return feat_df + + +# ํ”ผ์ฒ˜ ์ปฌ๋Ÿผ ์ˆœ์„œ (๋ชจ๋ธ ํ•™์Šต/์ถ”๋ก  ์‹œ ์ผ๊ด€์„ฑ ์œ ์ง€) +FEATURE_COLUMNS = [ + "rsi_14", "rsi_7", + "macd", "macd_signal", "macd_hist", + "stochastic_k", "stochastic_d", + "bb_position", + "ma5_ratio", "ma20_ratio", "ma60_ratio", + "volume_ratio", + "change_rate_1d", "change_rate_3d", "change_rate_5d", + "volatility_20d", + "price_momentum", + "ma_cross", + "trend_strength", + "disparity_20", +] + +# LSTM ์‹œํ€€์Šค์— ์‚ฌ์šฉํ•  ํ”ผ์ฒ˜ (5๊ฐœ) +LSTM_SEQUENCE_FEATURES = ["close", "volume", "rsi_14", "ma20_ratio", "volatility_20d"] + + +def prepare_training_data( + df: pd.DataFrame, + features_df: pd.DataFrame, + forecast_days: int = 3, + up_threshold: float = 0.015, + down_threshold: float = -0.015, +) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ํ•™์Šต ๋ฐ์ดํ„ฐ ์ค€๋น„. + + Returns: + X_tabular: (N, 20) ํƒญ๋ทธ๋Ÿฌ ํ”ผ์ฒ˜ + X_seq: (N, 60, 5) LSTM ์‹œํ€€์Šค ํ”ผ์ฒ˜ + y: (N,) ๋ ˆ์ด๋ธ” (0=DOWN, 1=HOLD, 2=UP) + valid_indices: ์œ ํšจํ•œ ํ–‰ ์ธ๋ฑ์Šค + """ + close = df["close"].values.astype(float) + n = len(close) + + # ๋ ˆ์ด๋ธ” ์ƒ์„ฑ: forecast_days ํ›„ ์ˆ˜์ต๋ฅ  + labels = np.full(n, np.nan, dtype=float) + for i in range(n - forecast_days): + if close[i] > 0: + future_return = (close[i + forecast_days] - close[i]) / close[i] + if future_return >= up_threshold: + labels[i] = 2 # UP + elif future_return <= down_threshold: + labels[i] = 0 # DOWN + else: + labels[i] = 1 # HOLD + + # ์œ ํšจํ•œ ํ–‰ ์ฐพ๊ธฐ (ํ”ผ์ฒ˜์™€ ๋ ˆ์ด๋ธ” ๋ชจ๋‘ NaN ์•„๋‹Œ ํ–‰) + tabular = features_df[FEATURE_COLUMNS].values + seq_window = 60 + + valid_mask = np.ones(n, dtype=bool) + valid_mask[:seq_window] = False # ์‹œํ€€์Šค ์œˆ๋„์šฐ ํ™•๋ณด + valid_mask[np.isnan(labels)] = False + # ํƒญ๋ทธ๋Ÿฌ ํ”ผ์ฒ˜ NaN ์ฒดํฌ + for i in range(n): + if valid_mask[i] and np.any(np.isnan(tabular[i])): + valid_mask[i] = False + + valid_indices = np.where(valid_mask)[0] + + if len(valid_indices) == 0: + return np.array([]), np.array([]), np.array([]), np.array([]) + + # ํƒญ๋ทธ๋Ÿฌ ํ”ผ์ฒ˜ + X_tabular = tabular[valid_indices] + + # ๋ ˆ์ด๋ธ” + y = labels[valid_indices].astype(int) + + # LSTM ์‹œํ€€์Šค ํ”ผ์ฒ˜ ์ค€๋น„ + # ์‹œํ€€์Šค์— ์‚ฌ์šฉํ•  raw ํ”ผ์ฒ˜ ๊ฒฐํ•ฉ + seq_cols = [] + for col in LSTM_SEQUENCE_FEATURES: + if col in df.columns: + seq_cols.append(df[col].values.astype(float)) + elif col in features_df.columns: + seq_cols.append(features_df[col].values.astype(float)) + else: + seq_cols.append(np.zeros(n, dtype=float)) + seq_raw = np.column_stack(seq_cols) # (n, 5) + + X_seq = np.zeros((len(valid_indices), seq_window, len(LSTM_SEQUENCE_FEATURES)), dtype=float) + for j, idx in enumerate(valid_indices): + X_seq[j] = seq_raw[idx - seq_window:idx] + + return X_tabular, X_seq, y, valid_indices + + +def extract_single_features(chart_data: list) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: + """๋‹จ์ผ ์ข…๋ชฉ ์ฐจํŠธ ๋ฐ์ดํ„ฐ์—์„œ ์ตœ์‹  ์‹œ์ ์˜ ํ”ผ์ฒ˜๋ฅผ ์ถ”์ถœ. + + Returns: + tabular_features: (20,) ํƒญ๋ทธ๋Ÿฌ ํ”ผ์ฒ˜ ๋˜๋Š” None + sequence_features: (60, 5) LSTM ์‹œํ€€์Šค ๋˜๋Š” None + """ + if not chart_data or len(chart_data) < 61: + return None, None + + df = chart_data_to_dataframe(chart_data) + features_df = extract_features(df) + + # ๋งˆ์ง€๋ง‰ ํ–‰์˜ ํƒญ๋ทธ๋Ÿฌ ํ”ผ์ฒ˜ + last_row = features_df[FEATURE_COLUMNS].iloc[-1].values + if np.any(np.isnan(last_row)): + return None, None + + # LSTM ์‹œํ€€์Šค (์ตœ๊ทผ 60์ผ) + seq_cols = [] + for col in LSTM_SEQUENCE_FEATURES: + if col in df.columns: + seq_cols.append(df[col].values.astype(float)) + elif col in features_df.columns: + seq_cols.append(features_df[col].values.astype(float)) + else: + seq_cols.append(np.zeros(len(df), dtype=float)) + seq_raw = np.column_stack(seq_cols) + + if len(seq_raw) < 60: + return None, None + + sequence = seq_raw[-60:] # (60, 5) + + return last_row, sequence diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 5bedd02..9625d54 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -1,9 +1,12 @@ import httpx from typing import Dict, Any, Optional, Deque -from datetime import datetime +from datetime import datetime, timedelta import logging import asyncio import time +import json +import os +from pathlib import Path from collections import deque from app.core.config import settings @@ -65,6 +68,9 @@ class APICallMonitor: class KoreaInvestmentAPI: """ํ•œ๊ตญํˆฌ์ž์ฆ๊ถŒ API ํด๋ผ์ด์–ธํŠธ (์•ˆ์ „ํ•œ ์žฌ์‹œ๋„ ๋ฐ ์“ฐ๋กœํ‹€๋ง ์ ์šฉ)""" + + # ํ† ํฐ ์บ์‹œ ํŒŒ์ผ ๊ฒฝ๋กœ โ€” ์‚ฌ์šฉ์ž ํ™ˆ ๋””๋ ‰ํ† ๋ฆฌ ํ•˜์œ„์— ์ €์žฅ (/tmp๋Š” ๋ณด์•ˆ ์ทจ์•ฝ) + TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache.json" def __init__(self): self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL @@ -85,42 +91,148 @@ class KoreaInvestmentAPI: # ๋™์‹œ ์š”์ฒญ ์ œํ•œ (์„ธ๋งˆํฌ์–ด) self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS) + # ํ† ํฐ ๋ฐœ๊ธ‰ Lock (์ค‘๋ณต ๋ฐœ๊ธ‰ ๋ฐฉ์ง€) + self._token_lock = asyncio.Lock() + # ๋ชจ๋‹ˆํ„ฐ๋ง self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW) + + # ์‹œ์ž‘ ์‹œ ์„ค์ • ๋กœ๊น… (ํ‚ค ๋งˆ์Šคํ‚น) + logger.info(f"๐Ÿ”ง KIS API ์ดˆ๊ธฐํ™”: mode={settings.KIS_MODE}, " + f"base_url={self.base_url}, " + f"app_key={self.app_key[:6]}..., " + f"account={self.account_number[:4]}****{self.account_number[-2:]}") + + # ์บ์‹œ๋œ ํ† ํฐ ๋กœ๋“œ + self._load_token_from_cache() + + def _load_token_from_cache(self): + """ํŒŒ์ผ์—์„œ ํ† ํฐ ๋กœ๋“œ (์•ฑ ์‹œ์ž‘ ์‹œ ๋™๊ธฐ ํ˜ธ์ถœ)""" + try: + cache_file = Path(self.TOKEN_CACHE_FILE) + if cache_file.exists(): + with open(cache_file, 'r') as f: + cache = json.load(f) + self.access_token = cache.get('access_token') + expires_str = cache.get('expires_at') + if expires_str: + self.token_expires_at = datetime.fromisoformat(expires_str) + if self.access_token and datetime.now() < self.token_expires_at: + remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600 + logger.info(f"๐Ÿ’พ ์บ์‹œ ํŒŒ์ผ์—์„œ ํ† ํฐ ๋กœ๋“œ ์„ฑ๊ณต (๋‚จ์€ ์‹œ๊ฐ„: {remaining:.1f}์‹œ๊ฐ„)") + else: + logger.info("๐Ÿ’พ ์บ์‹œ ํŒŒ์ผ์˜ ํ† ํฐ์ด ๋งŒ๋ฃŒ๋จ") + self.access_token = None + self.token_expires_at = None + except Exception as e: + logger.warning(f"โš ๏ธ ํ† ํฐ ์บ์‹œ ํŒŒ์ผ ๋กœ๋“œ ์‹คํŒจ: {e}") + + def _write_token_file(self): + """ํ† ํฐ์„ ํŒŒ์ผ์— ๋™๊ธฐ ์ €์žฅ (asyncio.to_thread ๋‚ด๋ถ€์—์„œ ์‹คํ–‰)""" + cache_file = Path(self.TOKEN_CACHE_FILE) + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache = { + 'access_token': self.access_token, + 'expires_at': self.token_expires_at.isoformat() if self.token_expires_at else None, + } + with open(cache_file, 'w') as f: + json.dump(cache, f) + # ์†Œ์œ ์ž๋งŒ ์ฝ๊ธฐ/์“ฐ๊ธฐ ๊ฐ€๋Šฅํ•˜๋„๋ก ํผ๋ฏธ์…˜ ์„ค์ • (๋ณด์•ˆ) + os.chmod(cache_file, 0o600) + logger.debug("๐Ÿ’พ ํ† ํฐ ์บ์‹œ ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ") + + async def _save_token_to_cache(self): + """ํ† ํฐ์„ ํŒŒ์ผ์— ๋น„๋™๊ธฐ ์ €์žฅ (์ด๋ฒคํŠธ ๋ฃจํ”„ ๋ธ”๋กœํ‚น ๋ฐฉ์ง€)""" + try: + await asyncio.to_thread(self._write_token_file) + except Exception as e: + logger.warning(f"โš ๏ธ ํ† ํฐ ์บ์‹œ ํŒŒ์ผ ์ €์žฅ ์‹คํŒจ: {e}") async def _get_access_token(self) -> str: - """์ ‘๊ทผ ํ† ํฐ ๋ฐœ๊ธ‰""" + """์ ‘๊ทผ ํ† ํฐ ๋ฐœ๊ธ‰ (Lock์œผ๋กœ ์ค‘๋ณต ๋ฐœ๊ธ‰ ๋ฐฉ์ง€)""" + # ์บ์‹œ๋œ ํ† ํฐ์ด ์œ ํšจํ•˜๋ฉด ์žฌ์‚ฌ์šฉ (23์‹œ๊ฐ„ ์œ ํšจ) if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at: + remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600 + logger.info(f"โœ… ์บ์‹œ๋œ ํ† ํฐ ์žฌ์‚ฌ์šฉ (๋‚จ์€ ์‹œ๊ฐ„: {remaining:.1f}์‹œ๊ฐ„)") return self.access_token - async with httpx.AsyncClient() as client: - response = await client.post( - f"{self.base_url}/oauth2/tokenP", - json={ - "grant_type": "client_credentials", - "appkey": self.app_key, - "appsecret": self.app_secret - } - ) - - if response.status_code == 200: - data = response.json() - self.access_token = data["access_token"] - # ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์„ค์ • (์ผ๋ฐ˜์ ์œผ๋กœ 24์‹œ๊ฐ„) - from datetime import timedelta - self.token_expires_at = datetime.now() + timedelta(hours=23) + # ๐Ÿ”’ Lock ํš๋“ - ๋™์‹œ ์š”์ฒญ ์‹œ ํ•˜๋‚˜๋งŒ ํ† ํฐ ๋ฐœ๊ธ‰ + async with self._token_lock: + # Lock ๋Œ€๊ธฐ ์ค‘ ๋‹ค๋ฅธ ์š”์ฒญ์ด ํ† ํฐ ๋ฐœ๊ธ‰ํ–ˆ์„ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์žฌํ™•์ธ + if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at: + remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600 + logger.info(f"โœ… ๋Œ€๊ธฐ ์ค‘ ํ† ํฐ ๋ฐœ๊ธ‰๋จ, ์žฌ์‚ฌ์šฉ (๋‚จ์€ ์‹œ๊ฐ„: {remaining:.1f}์‹œ๊ฐ„)") return self.access_token - else: - raise Exception(f"Failed to get access token: {response.text}") + + # ํ† ํฐ ๋ฐœ๊ธ‰ ์‹œ๋„ ์ „์— ๋งˆ์ง€๋ง‰ ์‹œ๋„ ์‹œ๊ฐ„ ํ™•์ธ (1๋ถ„ ์ œํ•œ ๋Œ€์‘) + if hasattr(self, '_last_token_request_time'): + elapsed = (datetime.now() - self._last_token_request_time).total_seconds() + if elapsed < 70: # 70์ดˆ ๋Œ€๊ธฐ (์—ฌ์œ ์žˆ๊ฒŒ) + # ๊ธฐ์กด ํ† ํฐ์ด ์žˆ์œผ๋ฉด ๊ณ„์† ์‚ฌ์šฉ + if self.access_token: + logger.warning(f"โณ ํ† ํฐ ๋ฐœ๊ธ‰ 1๋ถ„ ์ œํ•œ ({elapsed:.0f}์ดˆ ๊ฒฝ๊ณผ) - ๊ธฐ์กด ํ† ํฐ ์žฌ์‚ฌ์šฉ") + # ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์—ฐ์žฅ (์ถ”๊ฐ€ 1์‹œ๊ฐ„) + self.token_expires_at = datetime.now() + timedelta(hours=1) + return self.access_token + + wait_time = 70 - elapsed + logger.warning(f"โณ ํ† ํฐ ๋ฐœ๊ธ‰ 1๋ถ„ ์ œํ•œ: {wait_time:.1f}์ดˆ ๋Œ€๊ธฐ ์ค‘...") + await asyncio.sleep(wait_time + 1) + + logger.info("๐Ÿ”‘ ์ƒˆ๋กœ์šด ํ† ํฐ ๋ฐœ๊ธ‰ ์š”์ฒญ...") + self._last_token_request_time = datetime.now() + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + f"{self.base_url}/oauth2/tokenP", + json={ + "grant_type": "client_credentials", + "appkey": self.app_key, + "appsecret": self.app_secret + }, + timeout=30.0 + ) + + if response.status_code == 200: + data = response.json() + self.access_token = data["access_token"] + # ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์„ค์ • (์ผ๋ฐ˜์ ์œผ๋กœ 24์‹œ๊ฐ„) + self.token_expires_at = datetime.now() + timedelta(hours=23) + + # ํŒŒ์ผ์— ์ €์žฅ (๋น„๋™๊ธฐ) + await self._save_token_to_cache() + + logger.info(f"โœ… ์ƒˆ ํ† ํฐ ๋ฐœ๊ธ‰ ์™„๋ฃŒ (๋งŒ๋ฃŒ: {self.token_expires_at.strftime('%Y-%m-%d %H:%M')})") + return self.access_token + else: + error_msg = response.text + logger.error(f"โŒ ํ† ํฐ ๋ฐœ๊ธ‰ ์‹คํŒจ: {response.status_code} - {error_msg}") + + # ํ† ํฐ ๋ฐœ๊ธ‰ ์‹คํŒจ ์‹œ์—๋„ ๊ธฐ์กด ํ† ํฐ์ด ์žˆ๋‹ค๋ฉด ๊ณ„์† ์‚ฌ์šฉ ์‹œ๋„ + if self.access_token: + logger.warning(f"โš ๏ธ ํ† ํฐ ๋ฐœ๊ธ‰ ์‹คํŒจํ–ˆ์ง€๋งŒ ๊ธฐ์กด ํ† ํฐ ์žฌ์‚ฌ์šฉ ์‹œ๋„") + # ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์—ฐ์žฅ + self.token_expires_at = datetime.now() + timedelta(hours=1) + return self.access_token + raise Exception(f"Failed to get access token: {error_msg}") + except Exception as e: + logger.error(f"โŒ ํ† ํฐ ์š”์ฒญ ์ค‘ ์˜ค๋ฅ˜: {e}") + # ๊ธฐ์กด ํ† ํฐ์ด ์žˆ์œผ๋ฉด ์‚ฌ์šฉ + if self.access_token: + logger.warning(f"โš ๏ธ ํ† ํฐ ์š”์ฒญ ์‹คํŒจ, ๊ธฐ์กด ํ† ํฐ ์žฌ์‚ฌ์šฉ") + self.token_expires_at = datetime.now() + timedelta(hours=1) + return self.access_token + raise - async def _make_request(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]: + async def _make_request(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any): """API ์š”์ฒญ ์‹คํ–‰ (์•ˆ์ „ํ•œ ์žฌ์‹œ๋„ + ์“ฐ๋กœํ‹€๋ง + ๋ชจ๋‹ˆํ„ฐ๋ง)""" # ๋™์‹œ ์š”์ฒญ ์ˆ˜ ์ œํ•œ (์„ธ๋งˆํฌ์–ด) async with self.semaphore: - return await self._make_request_with_retry(method, endpoint, tr_id, **kwargs) + return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs) - async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]: + async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any): """์žฌ์‹œ๋„ ๋กœ์ง์„ ํฌํ•จํ•œ ์‹ค์ œ ์š”์ฒญ ์‹คํ–‰""" start_time = time.time() retry_count = 0 @@ -146,6 +258,8 @@ class KoreaInvestmentAPI: "appsecret": self.app_secret, "tr_id": tr_id, } + if extra_headers: + headers.update(extra_headers) # ํƒ€์ž„์•„์›ƒ ์„ค์ • timeout = httpx.Timeout(self.timeout, connect=10.0) @@ -170,7 +284,7 @@ class KoreaInvestmentAPI: f"(์ž„๊ณ„๊ฐ’: {settings.API_ERROR_THRESHOLD:.1%})" ) - return response.json() + return response.json(), response.headers # API ํ˜ธ์ถœ ์ œํ•œ ์ดˆ๊ณผ (429) ๋˜๋Š” ์„œ๋ฒ„ ์˜ค๋ฅ˜ (5xx) elif response.status_code in [429, 500, 502, 503, 504]: @@ -199,8 +313,12 @@ class KoreaInvestmentAPI: error_msg = f"API ํด๋ผ์ด์–ธํŠธ ์˜ค๋ฅ˜ (์žฌ์‹œ๋„ ์•ˆํ•จ): {response.status_code} - {response.text}" logger.error(f"โŒ {error_msg}") - raise Exception(error_msg) - + # ์—๋Ÿฌ ์‘๋‹ต๋„ json๊ณผ headers ํŠœํ”Œ๋กœ ๋ฐ˜ํ™˜ ์‹œ๋„ + try: + return response.json(), response.headers + except Exception: + return {"rt_cd": str(response.status_code), "msg1": error_msg, "output": None}, None + # ๊ธฐํƒ€ ์˜ค๋ฅ˜ else: duration = time.time() - start_time @@ -242,7 +360,7 @@ class KoreaInvestmentAPI: async def get_current_price(self, stock_code: str) -> Dict[str, Any]: """ํ˜„์žฌ๊ฐ€ ์กฐํšŒ""" try: - result = await self._make_request( + result, _ = await self._make_request( "GET", "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100", @@ -256,39 +374,292 @@ class KoreaInvestmentAPI: logger.error(f"Error fetching price for {stock_code}: {e}") raise - async def get_daily_chart(self, stock_code: str, period: str = "D") -> Dict[str, Any]: - """์ผ๋ด‰/์ฃผ๋ด‰/์›”๋ด‰ ์กฐํšŒ + async def get_daily_chart(self, stock_code: str, period: str = "D", limit: int = 240) -> Dict[str, Any]: + """์ผ๋ด‰/์ฃผ๋ด‰/์›”๋ด‰ ์กฐํšŒ (ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ง€์›์œผ๋กœ ์ตœ๋Œ€ 240๊ฐœ๊นŒ์ง€ ์กฐํšŒ)""" + all_output2 = [] - KIS API๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์ตœ๊ทผ 30~100๊ฐœ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ - ๋” ๋งŽ์€ ๋ฐ์ดํ„ฐ๊ฐ€ ํ•„์š”ํ•˜๋ฉด ๋ฐ˜๋ณต ํ˜ธ์ถœ ํ•„์š” (ํŽ˜์ด์ง€๋„ค์ด์…˜ ์—†์Œ) - """ + # 1. ์ฒซ ์š”์ฒญ + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + "FID_PERIOD_DIV_CODE": period, + "FID_ORG_ADJ_PRC": "0" # 0: ์ˆ˜์ •์ฃผ๊ฐ€, 1: ์›์ฃผ๊ฐ€ + } try: - result = await self._make_request( + result, headers = await self._make_request( "GET", "/uapi/domestic-stock/v1/quotations/inquire-daily-price", "FHKST01010400", + params=params + ) + + # FHKST01010400์€ 'output' (๋ฐฐ์—ด) ๋˜๋Š” 'output2' (๋ฐฐ์—ด)๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ + # ์‹ค์ „/๋ชจ์˜ํˆฌ์ž ํ™˜๊ฒฝ์— ๋”ฐ๋ผ ํ‚ค ์ด๋ฆ„์ด ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ + data_key = 'output2' if isinstance(result.get('output2'), list) and result.get('output2') else \ + 'output' if isinstance(result.get('output'), list) and result.get('output') else None + + if not result or result.get('rt_cd') != '0' or data_key is None: + logger.warning(f"No chart data for {stock_code}: {result.get('msg1', 'Unknown')}") + return result + + all_output2.extend(result.get(data_key, [])) + tr_cont = headers.get('tr_cont') + + # 2. ์—ฐ์† ์กฐํšŒ (ํ•„์š” ์‹œ) + while tr_cont in ('F', 'M') and len(all_output2) < limit: + await asyncio.sleep(self.min_request_interval + 0.1) # Rate limit + extra_headers = {'tr_cont': 'Y'} + + result_cont, headers_cont = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/quotations/inquire-daily-price", + "FHKST01010400", + params=params, + extra_headers=extra_headers + ) + + if result_cont and result_cont.get('rt_cd') == '0': + cont_key = 'output2' if isinstance(result_cont.get('output2'), list) else 'output' + all_output2.extend(result_cont.get(cont_key, [])) + + tr_cont = headers_cont.get('tr_cont') if headers_cont else None + + # 3. ์ตœ์ข… ๊ฒฐ๊ณผ ์กฐํ•ฉ + final_result = result # ์ฒซ ํŽ˜์ด์ง€ ์‘๋‹ต์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์กฐํ•ฉ + final_result['output2'] = all_output2 + final_result['msg1'] = f"OK (fetched {len(all_output2)} data points)" + + logger.info(f"โœ… Fetched {len(all_output2)} chart data points for {stock_code}") + return final_result + + except Exception as e: + logger.error(f"Error fetching paginated chart for {stock_code}: {e}") + raise + + async def get_overseas_price(self, symbol: str, exchange_code: str = "NASD") -> Dict[str, Any]: + """ํ•ด์™ธ์ฃผ์‹ ํ˜„์žฌ๊ฐ€ ์กฐํšŒ (NASD, NYSE, AMEX)""" + try: + # ๊ฑฐ๋ž˜์†Œ ์ฝ”๋“œ ๋ณด์ • + exch_map = {"NASD": "NAS", "NYSE": "NYS", "AMEX": "AMS"} + formatted_exchange = exch_map.get(exchange_code.upper(), exchange_code.upper()) + + # ๋ชจ์˜ํˆฌ์ž ์—ฌ๋ถ€์— ๋”ฐ๋ผ TR_ID๊ฐ€ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธ ํ•„์š”ํ•˜๋‚˜, + # ์‹œ์„ธ ์กฐํšŒ๋Š” ์‹ค์ „/๋ชจ์˜ ๊ณตํ†ต์ธ ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์Œ (HHDFS00000300) + + result, _ = await self._make_request( + "GET", + "/uapi/overseas-price/v1/quotations/price", + "HHDFS00000300", params={ - "FID_COND_MRKT_DIV_CODE": "J", - "FID_INPUT_ISCD": stock_code, - "FID_PERIOD_DIV_CODE": period, - "FID_ORG_ADJ_PRC": "0" # 0: ์ˆ˜์ •์ฃผ๊ฐ€, 1: ์›์ฃผ๊ฐ€ + "AUTH": "", + "EXCD": exchange_code.upper(), # NASD, NYSE, AMEX + "SYMB": symbol.upper() } ) return result except Exception as e: - logger.error(f"Error fetching chart for {stock_code}: {e}") + logger.error(f"Error fetching overseas price for {symbol}: {e}") raise - + + async def get_minute_chart( + self, + stock_code: str, + ref_time: str = "", + include_past: bool = True, + ) -> Dict[str, Any]: + """๊ตญ๋‚ด์ฃผ์‹ ๋ถ„๋ด‰์ฐจํŠธ ์กฐํšŒ (FHKST03010200) + + KIS API: /uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice + ํ•œ ๋ฒˆ ํ˜ธ์ถœ์— ์ตœ๋Œ€ 30๊ฐœ ๋ถ„๋ด‰ ๋ฐ˜ํ™˜. ์ „์ฒด ์žฅ ๋ฐ์ดํ„ฐ๋Š” ์—ฌ๋Ÿฌ ๋ฒˆ ํ˜ธ์ถœ ํ•„์š”. + + Args: + stock_code: ์ข…๋ชฉ์ฝ”๋“œ (e.g. "005930") + ref_time: ์กฐํšŒ ๊ธฐ์ค€ ์‹œ๊ฐ„ (HHMMSS). ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ํ˜„์žฌ ์‹œ๊ฐ„ + include_past: True = ๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ํฌํ•จ (Y), False = ๋‹น์ผ๋งŒ (N) + + Returns: + { + "output1": { ํ˜„์žฌ๊ฐ€ ์š”์•ฝ }, + "output2": [ + { + "stck_bsop_date": "20260222", # ๋‚ ์งœ YYYYMMDD + "stck_cntg_hour": "091500", # ์‹œ๊ฐ„ HHMMSS + "stck_prpr": "71800", # ์ข…๊ฐ€(ํ˜„์žฌ๊ฐ€) + "stck_oprc": "71500", # ์‹œ๊ฐ€ + "stck_hgpr": "72000", # ๊ณ ๊ฐ€ + "stck_lwpr": "71400", # ์ €๊ฐ€ + "cntg_vol": "12345", # ๋ถ„๋ด‰ ๊ฑฐ๋ž˜๋Ÿ‰ + "acml_vol": "987654", # ๋ˆ„์  ๊ฑฐ๋ž˜๋Ÿ‰ + "acml_tr_pbmn": "70896123456", # ๋ˆ„์  ๊ฑฐ๋ž˜๋Œ€๊ธˆ(์›) + }, ... + ] + } + """ + try: + if not ref_time: + from datetime import datetime + import pytz + kst = pytz.timezone("Asia/Seoul") + ref_time = datetime.now(kst).strftime("%H%M%S") + + params = { + "FID_ETC_CLS_CODE": "", + "FID_INPUT_ISCD": stock_code, + "FID_INPUT_HOUR_1": ref_time, + "FID_PW_DATA_INCU_YN": "Y" if include_past else "N", + } + result, _ = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", + "FHKST03010200", + params=params, + ) + return result + except Exception as e: + logger.error(f"Error fetching minute chart for {stock_code}: {e}") + raise + + async def get_minute_chart_full_day( + self, + stock_code: str, + market_open: str = "090000", + ) -> list: + """๋‹น์ผ ์ „์ฒด ๋ถ„๋ด‰ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ (์—ฌ๋Ÿฌ ๋ฒˆ ํ˜ธ์ถœ ์ง‘๊ณ„) + + 09:00๋ถ€ํ„ฐ ํ˜„์žฌ๊นŒ์ง€์˜ ๋ชจ๋“  1๋ถ„๋ด‰์„ ์ˆœ์„œ๋Œ€๋กœ ๋ฐ˜ํ™˜. + ํ•œ ๋ฒˆ ํ˜ธ์ถœ์— 30๊ฐœ์”ฉ โ†’ ์ตœ๋Œ€ ~13ํšŒ ํ˜ธ์ถœ ํ•„์š” (390๋ถ„/30). + ์‹œ๊ฐ„ ์—ญ์ˆœ ๋ฐ์ดํ„ฐ๋ฅผ ์ •์ˆœ์œผ๋กœ ๋ณ€ํ™˜ํ•ด์„œ ๋ฐ˜ํ™˜. + + Returns: + List[dict] โ€” output2 ๋ ˆ์ฝ”๋“œ ๋ชฉ๋ก (์˜ค๋ž˜๋œ ๊ฒƒ โ†’ ์ตœ์‹  ์ˆœ) + """ + from datetime import datetime + import pytz + kst = pytz.timezone("Asia/Seoul") + now_str = datetime.now(kst).strftime("%H%M%S") + + all_candles: list = [] + ref = now_str + seen_times: set = set() + + for _ in range(15): # ์ตœ๋Œ€ 15ํšŒ (450๋ถ„, ์—ฌ์œ  ์žˆ๊ฒŒ) + try: + result = await self.get_minute_chart(stock_code, ref_time=ref) + except Exception as e: + logger.warning(f"Minute chart fetch failed for {stock_code} at {ref}: {e}") + break + + candles = result.get("output2") if isinstance(result.get("output2"), list) else [] + if not candles: + break + + new_added = 0 + for c in candles: + t = c.get("stck_cntg_hour", "") + if t and t not in seen_times: + seen_times.add(t) + all_candles.append(c) + new_added += 1 + + # ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ์‹œ๊ฐ„ ํ™•์ธ + times = [c.get("stck_cntg_hour", "999999") for c in candles if c.get("stck_cntg_hour")] + oldest = min(times) if times else "000000" + + if oldest <= market_open or new_added == 0: + break + + ref = oldest + await asyncio.sleep(self.min_request_interval + 0.05) + + # ์˜ค๋ž˜๋œ ๊ฒƒ โ†’ ์ตœ์‹  ์ˆœ์œผ๋กœ ์ •๋ ฌ + all_candles.sort(key=lambda c: c.get("stck_cntg_hour", "")) + return all_candles + + async def get_overseas_daily_chart(self, symbol: str, exchange_code: str = "NASD", period: str = "D") -> Dict[str, Any]: + """ํ•ด์™ธ์ฃผ์‹ ์ฐจํŠธ ์กฐํšŒ""" + try: + # ๊ธฐ๊ฐ„ ์ฝ”๋“œ: O(์ผ), P(์ฃผ), Q(์›”) - KIS ํ•ด์™ธ์ฃผ์‹ ๋ฌธ์„œ ๊ธฐ์ค€ + # ๋˜๋Š” 0(์ผ), 1(์ฃผ), 2(์›”) + gubn = "0" + if period == "W": gubn = "1" + elif period == "M": gubn = "2" + + result, _ = await self._make_request( + "GET", + "/uapi/overseas-price/v1/quotations/dailyprice", + "HHDFS76240000", + params={ + "AUTH": "", + "EXCD": exchange_code.upper(), + "SYMB": symbol.upper(), + "GUBN": gubn, + "BYMD": "", + "MODP": "1" + } + ) + return result + except Exception as e: + logger.error(f"Error fetching overseas chart for {symbol}: {e}") + raise + + async def place_overseas_order( + self, + symbol: str, + order_type: str, # "BUY" or "SELL" + quantity: int, + price: float, # ํ•ด์™ธ์ฃผ์‹์€ ์ง€์ •๊ฐ€(Limit)๊ฐ€ ๊ธฐ๋ณธ + exchange_code: str = "NASD" + ) -> Dict[str, Any]: + """ํ•ด์™ธ์ฃผ์‹ ์ฃผ๋ฌธ (๋ฏธ๊ตญ์ฃผ์‹ ๊ธฐ์ค€)""" + try: + account_number_prefix = self.account_number[:8] + account_number_suffix = self.account_number[8:] + + # ๋ชจ์˜/์‹ค์ „ ๊ตฌ๋ถ„ + is_virtual = "vts" in self.base_url.lower() + + # TR_ID ์„ค์ • (๋ฏธ๊ตญ ๋งค์ˆ˜/๋งค๋„) + if order_type == "BUY": + tr_id = "VTTT1002U" if is_virtual else "TTTT1002U" # ๋ฏธ๊ตญ ๋งค์ˆ˜ + else: + tr_id = "VTTT1006U" if is_virtual else "TTTT1006U" # ๋ฏธ๊ตญ ๋งค๋„ + + # ํ•ด์™ธ์ฃผ์‹ ์ฃผ๋ฌธ (๋ฏธ๊ตญ) + # OVRS_EXCG_CD: NASD, NYSE, AMEX + result, _ = await self._make_request( + "POST", + "/uapi/overseas-stock/v1/trading/order", + tr_id, + json={ + "CANO": account_number_prefix, + "ACNT_PRDT_CD": account_number_suffix, + "OVRS_EXCG_CD": exchange_code.upper(), + "PDNO": symbol.upper(), + "ORD_QTY": str(quantity), + "OVRS_ORD_UNPR": str(price), + "ORD_SVR_DVSN_CD": "0", + "ORD_DVSN": "00" # 00: ์ง€์ •๊ฐ€ + } + ) + return result + except Exception as e: + logger.error(f"Error placing overseas order: {e}") + raise + async def get_balance(self) -> Dict[str, Any]: """๊ณ„์ขŒ ์ž”๊ณ  ์กฐํšŒ""" try: account_number_prefix = self.account_number[:8] account_number_suffix = self.account_number[8:] - result = await self._make_request( + # ๋ชจ์˜ํˆฌ์ž ํ™˜๊ฒฝ ํ™•์ธ (URL์— 'vts' ํฌํ•จ ์—ฌ๋ถ€๋กœ ํŒ๋‹จ) + is_virtual = "vts" in self.base_url.lower() + tr_id = "VTTC8434R" if is_virtual else "TTTC8434R" + + result, _ = await self._make_request( "GET", "/uapi/domestic-stock/v1/trading/inquire-balance", - "TTTC8434R", + tr_id, params={ "CANO": account_number_prefix, "ACNT_PRDT_CD": account_number_suffix, @@ -303,6 +674,10 @@ class KoreaInvestmentAPI: "CTX_AREA_NK100": "" } ) + logger.info(f"๐Ÿ“‹ Balance API Full Response: {result}") + logger.info(f"๐Ÿ“‹ Balance API Keys: {result.keys()}") + if result.get('rt_cd') != '0': + logger.error(f"โŒ Balance API error - rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}") return result except Exception as e: logger.error(f"Error fetching balance: {e}") @@ -317,20 +692,35 @@ class KoreaInvestmentAPI: ) -> Dict[str, Any]: """์ฃผ๋ฌธ ์‹คํ–‰""" try: + # ํ™˜๊ฒฝ ์„ค์ • ํ™•์ธ + if not self.app_key or not self.app_secret or not self.account_number: + error_msg = f"KIS API ์„ค์ • ๋ˆ„๋ฝ: app_key={bool(self.app_key)}, secret={bool(self.app_secret)}, account={bool(self.account_number)}" + logger.error(error_msg) + return { + "rt_cd": "-1", + "msg1": error_msg, + "odno": None + } + account_number_prefix = self.account_number[:8] account_number_suffix = self.account_number[8:] - # ๋งค์ˆ˜/๋งค๋„ ๊ตฌ๋ถ„ + # ๋ชจ์˜ํˆฌ์ž ํ™˜๊ฒฝ ํ™•์ธ + is_virtual = "vts" in self.base_url.lower() + + # ๋งค์ˆ˜/๋งค๋„ ๊ตฌ๋ถ„ (๋ชจ์˜ํˆฌ์ž๋Š” V ์ ‘๋‘์‚ฌ) if order_type == "BUY": - tr_id = "TTTC0802U" # ํ˜„๊ธˆ ๋งค์ˆ˜ + tr_id = "VTTC0802U" if is_virtual else "TTTC0802U" # ํ˜„๊ธˆ ๋งค์ˆ˜ else: - tr_id = "TTTC0801U" # ํ˜„๊ธˆ ๋งค๋„ + tr_id = "VTTC0801U" if is_virtual else "TTTC0801U" # ํ˜„๊ธˆ ๋งค๋„ # ์ฃผ๋ฌธ ์œ ํ˜• (00: ์ง€์ •๊ฐ€, 01: ์‹œ์žฅ๊ฐ€) order_dvsn = "01" if price is None else "00" order_price = "0" if price is None else str(int(price)) - result = await self._make_request( + logger.info(f"๐Ÿ“ค ์ฃผ๋ฌธ ์š”์ฒญ: {order_type} {stock_code} {quantity}์ฃผ @{order_price}์› (tr_id={tr_id}, is_virtual={is_virtual})") + + result, _ = await self._make_request( "POST", "/uapi/domestic-stock/v1/trading/order-cash", tr_id, @@ -343,10 +733,16 @@ class KoreaInvestmentAPI: "ORD_UNPR": order_price } ) + + logger.info(f"๐Ÿ“ฅ ์ฃผ๋ฌธ ์‘๋‹ต: rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}, odno={result.get('odno')}") return result except Exception as e: - logger.error(f"Error placing order: {e}") - raise + logger.error(f"โŒ ์ฃผ๋ฌธ ์‹คํ–‰ ์˜ค๋ฅ˜: {e}", exc_info=True) + return { + "rt_cd": "-1", + "msg1": str(e), + "odno": None + } async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]: """์ฃผ๋ฌธ ์ทจ์†Œ""" @@ -354,10 +750,14 @@ class KoreaInvestmentAPI: account_number_prefix = self.account_number[:8] account_number_suffix = self.account_number[8:] - result = await self._make_request( + # ๋ชจ์˜ํˆฌ์ž ํ™˜๊ฒฝ ํ™•์ธ + is_virtual = "vts" in self.base_url.lower() + tr_id = "VTTC0803U" if is_virtual else "TTTC0803U" + + result, _ = await self._make_request( "POST", "/uapi/domestic-stock/v1/trading/order-rvsecncl", - "TTTC0803U", + tr_id, json={ "CANO": account_number_prefix, "ACNT_PRDT_CD": account_number_suffix, @@ -383,7 +783,7 @@ class KoreaInvestmentAPI: ) -> Dict[str, Any]: """์กฐ๊ฑด ๊ฒ€์ƒ‰์œผ๋กœ ์ข…๋ชฉ ์กฐํšŒ""" try: - result = await self._make_request( + result, _ = await self._make_request( "GET", "/uapi/domestic-stock/v1/quotations/inquire-search", "HHKST03900300", @@ -410,7 +810,7 @@ class KoreaInvestmentAPI: """์ „์ฒด ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ""" try: # ๊ฑฐ๋ž˜๋Œ€๊ธˆ ์ƒ์œ„ ์ข…๋ชฉ ์กฐํšŒ - result = await self._make_request( + result, _ = await self._make_request( "GET", "/uapi/domestic-stock/v1/quotations/volume-rank", "FHPST01710000", @@ -437,6 +837,32 @@ class KoreaInvestmentAPI: """๊ฑฐ๋ž˜๋Ÿ‰ ์ˆœ์œ„ ์กฐํšŒ (get_stock_list์™€ ๋™์ผํ•˜์ง€๋งŒ ๋ช…์‹œ์  ์ด๋ฆ„)""" return await self.get_stock_list(market) + async def get_orderbook(self, stock_code: str) -> Dict[str, Any]: + """ํ˜ธ๊ฐ€ ์ •๋ณด ์กฐํšŒ (๋งค์ˆ˜/๋งค๋„ ํ˜ธ๊ฐ€ 10๋‹จ๊ณ„)""" + try: + result, _ = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", + "FHKST01010200", + params={ + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code + } + ) + + # ๋””๋ฒ„๊น…์šฉ ๋กœ๊ทธ + logger.info(f"๐Ÿ“Š Orderbook raw response for {stock_code}:") + logger.info(f" rt_cd: {result.get('rt_cd')}, msg1: {result.get('msg1')}") + if result.get('output1'): + logger.info(f" output1 keys: {list(result['output1'].keys())[:10]}") + if result.get('output2'): + logger.info(f" output2 keys: {list(result['output2'].keys())[:10]}") + + return result + except Exception as e: + logger.error(f"Error fetching orderbook for {stock_code}: {e}") + raise + def get_monitor_stats(self) -> Dict[str, Any]: """API ํ˜ธ์ถœ ๋ชจ๋‹ˆํ„ฐ๋ง ํ†ต๊ณ„ ๋ฐ˜ํ™˜""" return self.monitor.get_stats() diff --git a/backend/app/services/loss_tracker.py b/backend/app/services/loss_tracker.py new file mode 100644 index 0000000..c58406f --- /dev/null +++ b/backend/app/services/loss_tracker.py @@ -0,0 +1,150 @@ +""" +์—ฐ์† ์†์‹ค ์ถ”์  - CLAUDE.md ์„น์…˜ 7.4 + +๋™์ผ ์ข…๋ชฉ์—์„œ 3ํšŒ ์ด์ƒ ์—ฐ์† ์†์‹ค ์‹œ 30๊ฑฐ๋ž˜์ผ ๋™์•ˆ ์‹ ๊ทœ ์ง„์ž… ๊ธˆ์ง€. +์žฌ์‹œ์ž‘ ํ›„์—๋„ ban ์ƒํƒœ ์œ ์ง€: DB(stock_bans ํ…Œ์ด๋ธ”) ์˜์†ํ™”. +""" +import logging +from datetime import datetime, timedelta +from typing import Dict, Optional + +logger = logging.getLogger(__name__) + + +class ConsecutiveLossTracker: + """์ข…๋ชฉ๋ณ„ ์—ฐ์† ์†์‹ค ์ถ”์  ๋ฐ ์ฟจ๋ง์˜คํ”„ ๊ด€๋ฆฌ""" + + def __init__(self, ban_count: int = 3, ban_calendar_days: int = 42): + """ + Args: + ban_count: ์—ฐ์† ์†์‹ค ํšŸ์ˆ˜ ์ž„๊ณ„๊ฐ’ (๊ธฐ๋ณธ 3) + ban_calendar_days: ๊ธˆ์ง€ ๊ธฐ๊ฐ„ (30๊ฑฐ๋ž˜์ผ โ‰ˆ 42์บ˜๋ฆฐ๋”์ผ) + """ + self.ban_count = ban_count + self.ban_calendar_days = ban_calendar_days + # {stock_code: {"consecutive_losses": int, "ban_until": datetime|None}} + self._tracker: Dict[str, dict] = {} + + def record_trade_result(self, stock_code: str, is_loss: bool): + """๋งค๋งค ๊ฒฐ๊ณผ ์ธ๋ฉ”๋ชจ๋ฆฌ ๊ธฐ๋ก (DB ์ €์žฅ์€ save_to_db ๋ณ„๋„ ํ˜ธ์ถœ) + + Args: + stock_code: ์ข…๋ชฉ์ฝ”๋“œ + is_loss: ์†์‹ค ์—ฌ๋ถ€ + """ + if stock_code not in self._tracker: + self._tracker[stock_code] = {"consecutive_losses": 0, "ban_until": None} + + entry = self._tracker[stock_code] + if is_loss: + entry["consecutive_losses"] += 1 + if entry["consecutive_losses"] >= self.ban_count: + entry["ban_until"] = datetime.now() + timedelta(days=self.ban_calendar_days) + logger.warning( + f"{stock_code}: {entry['consecutive_losses']}ํšŒ ์—ฐ์† ์†์‹ค โ†’ " + f"{entry['ban_until'].strftime('%Y-%m-%d')}๊นŒ์ง€ ์ง„์ž… ๊ธˆ์ง€" + ) + else: + entry["consecutive_losses"] = 0 + entry["ban_until"] = None + + async def save_to_db(self, db, stock_code: str): + """์ธ๋ฉ”๋ชจ๋ฆฌ ์ƒํƒœ๋ฅผ DB์— ์˜์†ํ™” (record_trade_result ํ›„ ํ˜ธ์ถœ)""" + try: + from sqlalchemy import select + from app.models.models import StockBan + + entry = self._tracker.get(stock_code) + if entry is None: + return + + result = await db.execute(select(StockBan).where(StockBan.stock_code == stock_code)) + record = result.scalar_one_or_none() + + if record is None: + record = StockBan( + stock_code=stock_code, + consecutive_losses=entry["consecutive_losses"], + ban_until=entry["ban_until"], + ) + db.add(record) + else: + record.consecutive_losses = entry["consecutive_losses"] + record.ban_until = entry["ban_until"] + record.updated_at = datetime.utcnow() + + await db.commit() + logger.debug(f"loss_tracker DB ์ €์žฅ ์™„๋ฃŒ: {stock_code}") + except Exception as e: + logger.error(f"loss_tracker DB ์ €์žฅ ์‹คํŒจ ({stock_code}): {e}") + + async def load_from_db(self, db): + """์•ฑ ์‹œ์ž‘ ์‹œ DB์—์„œ ban ์ƒํƒœ ๋ณต์›""" + try: + from sqlalchemy import select + from app.models.models import StockBan + + result = await db.execute(select(StockBan)) + records = result.scalars().all() + + now = datetime.now() + loaded = 0 + for rec in records: + # ๋งŒ๋ฃŒ๋œ ban์€ ์ดˆ๊ธฐํ™” + ban_until = rec.ban_until + if ban_until and now >= ban_until: + ban_until = None + consecutive_losses = 0 + else: + consecutive_losses = rec.consecutive_losses + + self._tracker[rec.stock_code] = { + "consecutive_losses": consecutive_losses, + "ban_until": ban_until, + } + if ban_until: + loaded += 1 + logger.info( + f"ban ๋ณต์›: {rec.stock_code} โ†’ {ban_until.strftime('%Y-%m-%d')}๊นŒ์ง€ ์ง„์ž… ๊ธˆ์ง€" + ) + + logger.info(f"loss_tracker DB ๋กœ๋“œ ์™„๋ฃŒ: {len(records)}๊ฑด ์ค‘ {loaded}๊ฑด ํ™œ์„ฑ ban") + except Exception as e: + logger.error(f"loss_tracker DB ๋กœ๋“œ ์‹คํŒจ: {e}") + + def is_banned(self, stock_code: str) -> bool: + """ํ•ด๋‹น ์ข…๋ชฉ์ด ์ง„์ž… ๊ธˆ์ง€ ์ƒํƒœ์ธ์ง€ ํ™•์ธ""" + entry = self._tracker.get(stock_code) + if not entry or not entry["ban_until"]: + return False + if datetime.now() >= entry["ban_until"]: + # ๊ธˆ์ง€ ๊ธฐ๊ฐ„ ๋งŒ๋ฃŒ โ†’ ๋ฆฌ์…‹ + entry["consecutive_losses"] = 0 + entry["ban_until"] = None + return False + return True + + def get_ban_info(self, stock_code: str) -> dict: + """์ข…๋ชฉ์˜ ์—ฐ์† ์†์‹ค ๋ฐ ๊ธˆ์ง€ ์ •๋ณด ์กฐํšŒ""" + entry = self._tracker.get(stock_code, {"consecutive_losses": 0, "ban_until": None}) + return { + "stock_code": stock_code, + "consecutive_losses": entry["consecutive_losses"], + "is_banned": self.is_banned(stock_code), + "ban_until": entry["ban_until"].isoformat() if entry["ban_until"] else None, + } + + +# ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค โ€” settings์—์„œ ban_count ์ฃผ์ž… +def _create_loss_tracker() -> ConsecutiveLossTracker: + try: + from app.core.config import settings + return ConsecutiveLossTracker( + ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT, + ban_calendar_days=42, # 30๊ฑฐ๋ž˜์ผ โ‰ˆ 42์บ˜๋ฆฐ๋”์ผ + ) + except Exception: + return ConsecutiveLossTracker() + + +loss_tracker = _create_loss_tracker() diff --git a/backend/app/services/mdd_manager.py b/backend/app/services/mdd_manager.py new file mode 100644 index 0000000..fdfbb93 --- /dev/null +++ b/backend/app/services/mdd_manager.py @@ -0,0 +1,80 @@ +""" +MDD(Maximum Drawdown) ๊ด€๋ฆฌ - CLAUDE.md ์„น์…˜ 6.3 + +- ํฌํŠธํด๋ฆฌ์˜ค ์ตœ๊ณ ์  ๋Œ€๋น„ ํ•˜๋ฝ๋ฅ  ์ถ”์  +- -10% ๋ŒํŒŒ ์‹œ: ์‹ ๊ทœ ์ง„์ž… ์ค‘๋‹จ + ๊ธฐ์กด ํฌ์ง€์…˜ 30% ์ถ•์†Œ +- -20% ๋ŒํŒŒ ์‹œ: ์ „์ฒด ํฌ์ง€์…˜ 50% ๊ฐ•์ œ ์ถ•์†Œ (๋ ˆ๋ฒ„๋ฆฌ์ง€/์ธ๋ฒ„์Šค ์šฐ์„ ) +""" +import asyncio +import logging +from typing import Dict + +logger = logging.getLogger(__name__) + + +class MDDManager: + """ํฌํŠธํด๋ฆฌ์˜ค MDD ๊ด€๋ฆฌ์ž (์‹ฑ๊ธ€ํ†ค์œผ๋กœ ์‚ฌ์šฉ)""" + + def __init__(self): + self.peak_balance: float = 0.0 + self.is_paused: bool = False + self.reduction_applied: str = None # None, "10pct", "20pct" + self._lock = asyncio.Lock() # ์Šค์ผ€์ค„๋Ÿฌ-sync ๋™์‹œ ์ ‘๊ทผ ๋ฐฉ์ง€ + + async def update(self, current_balance: float) -> Dict: + """MDD ์—…๋ฐ์ดํŠธ ๋ฐ ํ•„์š”ํ•œ ์กฐ์น˜ ๋ฐ˜ํ™˜ (thread-safe) + + Returns: + {"action": "NONE"/"REDUCE_AND_PAUSE"/"FORCE_REDUCE", + "drawdown": float, ...} + """ + async with self._lock: + if current_balance > self.peak_balance: + self.peak_balance = current_balance + self.is_paused = False + self.reduction_applied = None + + if self.peak_balance <= 0: + return {"action": "NONE", "drawdown": 0.0} + + drawdown_pct = ((current_balance - self.peak_balance) / self.peak_balance) * 100 + + # -20% ๋ŒํŒŒ: 50% ๊ฐ•์ œ ์ถ•์†Œ + if drawdown_pct <= -20 and self.reduction_applied != "20pct": + self.is_paused = True + self.reduction_applied = "20pct" + logger.warning(f"MDD {drawdown_pct:.1f}% - 50% ๊ฐ•์ œ ์ถ•์†Œ + ์‹ ๊ทœ์ง„์ž… ์ค‘๋‹จ") + return { + "action": "FORCE_REDUCE", + "drawdown": drawdown_pct, + "reduce_pct": 0.50, + "prioritize_leverage": True, + } + + # -10% ๋ŒํŒŒ: 30% ์ถ•์†Œ + ์‹ ๊ทœ์ง„์ž… ์ค‘๋‹จ + if drawdown_pct <= -10 and self.reduction_applied is None: + self.is_paused = True + self.reduction_applied = "10pct" + logger.warning(f"MDD {drawdown_pct:.1f}% - 30% ์ถ•์†Œ + ์‹ ๊ทœ์ง„์ž… ์ค‘๋‹จ") + return { + "action": "REDUCE_AND_PAUSE", + "drawdown": drawdown_pct, + "reduce_pct": 0.30, + } + + return {"action": "NONE", "drawdown": drawdown_pct} + + async def can_open_new_position(self) -> bool: + """์‹ ๊ทœ ํฌ์ง€์…˜ ์ง„์ž… ๊ฐ€๋Šฅ ์—ฌ๋ถ€ (thread-safe)""" + async with self._lock: + return not self.is_paused + + def reset(self): + """MDD ์ƒํƒœ ๋ฆฌ์…‹ (์ˆ˜๋™ ๋ณต๊ตฌ ์‹œ) โ€” ๋™๊ธฐ ๋ฉ”์„œ๋“œ, ๋‹จ๋… ํ˜ธ์ถœ ์‹œ์—๋งŒ ์‚ฌ์šฉ""" + self.is_paused = False + self.reduction_applied = None + logger.info("MDD ๊ด€๋ฆฌ์ž ๋ฆฌ์…‹") + + +# ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค +mdd_manager = MDDManager() diff --git a/backend/app/services/mean_reversion_strategy.py b/backend/app/services/mean_reversion_strategy.py new file mode 100644 index 0000000..f2e1686 --- /dev/null +++ b/backend/app/services/mean_reversion_strategy.py @@ -0,0 +1,340 @@ +""" +RSI + ๋ณผ๋ฆฐ์ € ๋ฐด๋“œ ํ‰๊ท ํšŒ๊ท€ ์ „๋žต (CLAUDE.md ์„น์…˜ 7.1-7.5) + +์ผ๋ด‰ ๊ธฐ์ค€, ์ „์ฒด ์ž๋ณธ์˜ ์ตœ๋Œ€ 30% ์ด๋‚ด์—์„œ๋งŒ ์šด์šฉ. +""" +import logging +from typing import Optional, Dict, List, Tuple +from dataclasses import dataclass +from enum import Enum + +logger = logging.getLogger(__name__) + + +class MarketRegime(Enum): + BULL = "bull" + BEAR = "bear" + + +@dataclass +class ExitSignal: + action: str # "SELL_PARTIAL", "SELL_ALL" + reason: str + sell_pct: float # 0.5 = 50%, 1.0 = ์ „๋Ÿ‰ + + +def calculate_atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]: + """ATR(Average True Range) ๊ณ„์‚ฐ + + TR = max(H-L, |H-Cprev|, |L-Cprev|) + ATR = SMA(TR, period) + """ + if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1: + return None + + true_ranges = [] + for i in range(1, len(highs)): + h, l, c_prev = highs[i], lows[i], closes[i - 1] + tr = max(h - l, abs(h - c_prev), abs(l - c_prev)) + true_ranges.append(tr) + + if len(true_ranges) < period: + return None + return sum(true_ranges[-period:]) / period + + +def calculate_adx(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]: + """Wilder's ADX(Average Directional Index) ๊ณ„์‚ฐ โ€” ์ถ”์„ธ ๊ฐ•๋„ ์ง€ํ‘œ + + ์•Œ๊ณ ๋ฆฌ์ฆ˜: + 1. TR = max(H-L, |H-Cprev|, |L-Cprev|) + 2. +DM = H-Hprev if (H-Hprev) > (Lprev-L) and > 0, else 0 + 3. -DM = Lprev-L if (Lprev-L) > (H-Hprev) and > 0, else 0 + 4. Wilder ํ‰ํ™œ: ATR14 = ATR14_prev - ATR14_prev/14 + TR (์ดˆ๊ธฐ๊ฐ’์€ 14์ผ ํ•ฉ์‚ฐ) + 5. +DI14 = 100 ร— (+DM14 / ATR14), -DI14 ๋™์ผ + 6. DX = 100 ร— |+DI14 - -DI14| / (+DI14 + -DI14) + 7. ADX = DX์˜ Wilder ํ‰ํ™œ + + ํ•ด์„ (ํ‰๊ท ํšŒ๊ท€ ์ „๋žต): + - ADX < 20: ํšก๋ณด์žฅ โ†’ ํ‰๊ท ํšŒ๊ท€ ์œ ๋ฆฌ + - ADX 20~30: ์ค‘๊ฐ„ ์ถ”์„ธ โ†’ ์กฐ๊ฑด๋ถ€ ํ—ˆ์šฉ + - ADX > 30: ๊ฐ•ํ•œ ์ถ”์„ธ โ†’ ํ‰๊ท ํšŒ๊ท€ ์œ„ํ—˜ (์ง„์ž… ์ฐจ๋‹จ) + """ + n = len(highs) + # ์ตœ์†Œ 2*period + 1๊ฐœ ํ•„์š” (์ดˆ๊ธฐ ํ‰ํ™œ + ADX ํ‰ํ™œ) + if n < period * 2 + 1: + return None + + trs: List[float] = [] + plus_dms: List[float] = [] + minus_dms: List[float] = [] + + for i in range(1, n): + h, l, c_prev = highs[i], lows[i], closes[i - 1] + h_prev, l_prev = highs[i - 1], lows[i - 1] + + tr = max(h - l, abs(h - c_prev), abs(l - c_prev)) + up_move = h - h_prev + down_move = l_prev - l + + plus_dm = up_move if up_move > down_move and up_move > 0 else 0.0 + minus_dm = down_move if down_move > up_move and down_move > 0 else 0.0 + + trs.append(tr) + plus_dms.append(plus_dm) + minus_dms.append(minus_dm) + + def _wilder_smooth(values: List[float]) -> List[float]: + """Wilder ํ‰ํ™œ: ์ดˆ๊ธฐ๊ฐ’ = ์ฒซ period๊ฐœ ํ•ฉ์‚ฐ, ์ดํ›„ ์žฌ๊ท€ ํ‰ํ™œ.""" + smoothed = [sum(values[:period])] + for v in values[period:]: + smoothed.append(smoothed[-1] - smoothed[-1] / period + v) + return smoothed + + atr_s = _wilder_smooth(trs) + plus_dm_s = _wilder_smooth(plus_dms) + minus_dm_s = _wilder_smooth(minus_dms) + + dxs: List[float] = [] + for atr_v, p_dm, m_dm in zip(atr_s, plus_dm_s, minus_dm_s): + if atr_v == 0: + dxs.append(0.0) + continue + plus_di = 100.0 * p_dm / atr_v + minus_di = 100.0 * m_dm / atr_v + di_sum = plus_di + minus_di + dxs.append(100.0 * abs(plus_di - minus_di) / di_sum if di_sum else 0.0) + + if len(dxs) < period: + return None + + adx_s = _wilder_smooth(dxs) + return round(adx_s[-1], 2) + + +def detect_market_regime(index_closes: List[float]) -> MarketRegime: + """์„น์…˜ 7.1: 200์ผ MA + ๊ธฐ์šธ๊ธฐ๋กœ ์ƒ์Šน์žฅ/ํ•˜๋ฝ์žฅ ํŒ๋ณ„ + + ์ƒ์Šน์žฅ(Bull): ์ง€์ˆ˜ > MA200 AND MA200 ๊ธฐ์šธ๊ธฐ ์–‘(+) + ํ•˜๋ฝ์žฅ(Bear): ๊ทธ ์™ธ + """ + if len(index_closes) < 205: + return MarketRegime.BEAR # ๋ฐ์ดํ„ฐ ๋ถ€์กฑ ์‹œ ๋ณด์ˆ˜์  + + ma200_current = sum(index_closes[-200:]) / 200 + ma200_5d_ago = sum(index_closes[-205:-5]) / 200 + slope_positive = ma200_current > ma200_5d_ago + + is_bull = index_closes[-1] > ma200_current and slope_positive + return MarketRegime.BULL if is_bull else MarketRegime.BEAR + + +def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]: + """RSI(Relative Strength Index) ๊ณ„์‚ฐ""" + if len(closes) < period + 1: + return None + + gains, losses = [], [] + for i in range(-period, 0): + diff = closes[i] - closes[i - 1] + gains.append(max(diff, 0)) + losses.append(max(-diff, 0)) + + avg_gain = sum(gains) / period + avg_loss = sum(losses) / period + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return 100 - (100 / (1 + rs)) + + +def calculate_bollinger(closes: List[float], period: int = 20) -> Optional[Tuple[float, float, float]]: + """๋ณผ๋ฆฐ์ € ๋ฐด๋“œ (20์ผ, 2ฯƒ) ๊ณ„์‚ฐ + + Returns: (upper, middle, lower) + """ + if len(closes) < period: + return None + window = closes[-period:] + middle = sum(window) / period + variance = sum((p - middle) ** 2 for p in window) / period + std = variance ** 0.5 + return (middle + 2 * std, middle, middle - 2 * std) + + +def is_reversal_candle(opens: List[float], highs: List[float], + lows: List[float], closes: List[float]) -> bool: + """ํ•˜๋ฝ์žฅ ๋งค์ˆ˜ ์กฐ๊ฑด์šฉ ๋˜๋Œ๋ฆผ ์บ”๋“ค ๊ฐ์ง€ (์„น์…˜ 7.2) + + ์–‘๋ด‰ ๋˜๋Š” ๊ธด ํ•˜๋‹จ ๊ผฌ๋ฆฌ(ํ•˜๋‹จ ๊ทธ๋ฆผ์ž > 2ร— ๋ชธํ†ต) + """ + if not closes: + return False + o, h, l, c = opens[-1], highs[-1], lows[-1], closes[-1] + body = abs(c - o) + lower_shadow = min(o, c) - l + is_bullish = c > o + has_long_lower_shadow = lower_shadow > 2 * max(body, 0.01) + return is_bullish or has_long_lower_shadow + + +def check_buy_signal( + regime: MarketRegime, + rsi: float, + close: float, + bb_upper: float, + bb_middle: float, + bb_lower: float, + avg_volume_20d: float, + current_volume: int, + prev_volume: int, + is_reversal: bool = False, +) -> Optional[Dict]: + """์„น์…˜ 7.2: ๋งค์ˆ˜ ์กฐ๊ฑด ํŒ๋ณ„ + + Bull: RSI(14) โ‰ค 25 (๊ณผ๋งค๋„) OR ๊ฑฐ๋ž˜๋Ÿ‰ โ‰ฅ 20์ผํ‰๊ท ร—200%, ์ข…๊ฐ€๊ฐ€ BB ํ•˜๋‹จ์„  -3% ~ +1.5% ๊ตฌ๊ฐ„ + Bear: RSI(14) โ‰ค 35 (๊ณผ๋งค๋„), ์ข…๊ฐ€ โ‰ค BB ํ•˜๋‹จ์„ , ๋˜๋Œ๋ฆผ ์บ”๋“ค ์ถœํ˜„, ์ „์ผ ๋Œ€๋น„ ๊ฑฐ๋ž˜๋Ÿ‰ ์ฆ๊ฐ€ + + ๊ณตํ†ต: ๊ฑฐ๋ž˜๋Œ€๊ธˆ โ‰ฅ 20์ผ ํ‰๊ท  + """ + if current_volume < avg_volume_20d: + return None + + if regime == MarketRegime.BULL: + rsi_ok = rsi <= 25 + vol_ok = current_volume >= avg_volume_20d * 2 # ๊ฑฐ๋ž˜๋Ÿ‰ 20์ผํ‰๊ท  200% ์ด์ƒ + if rsi_ok or vol_ok: + bb_low_minus = bb_lower * 0.97 # -3% + bb_low_plus = bb_lower * 1.015 # +1.5% + if bb_low_minus <= close <= bb_low_plus: + trigger = f"RSI({rsi:.1f})<=25" if rsi_ok else f"๊ฑฐ๋ž˜๋Ÿ‰({current_volume / avg_volume_20d * 100:.0f}%>=200%)" + return { + "signal": "BUY", + "strategy": "MEAN_REVERSION_BULL", + "split_plan": [0.50, 0.50], # 2ํšŒ ๋ถ„ํ•  ๋งค์ˆ˜ + "description": f"{trigger} + BB ํ•˜๋‹จ -3%~+1.5% (์ƒ์Šน์žฅ ๊ณผ๋งค๋„)", + } + + elif regime == MarketRegime.BEAR: + if rsi <= 35: # 40 โ†’ 35: ๋” ๋‚ฎ์€ ๊ฐ€๊ฒฉ์—์„œ ์ง„์ž… + # ํ•˜๋ฝ์žฅ ์ง„์ž… ํ•„ํ„ฐ: ๊ฑฐ๋ž˜๋Ÿ‰ ์ „์ผ ๋Œ€๋น„ ์ฆ๊ฐ€ ํ™•์ธ (๋งค์ˆ˜์„ธ ์œ ์ž…) + if close <= bb_lower and is_reversal and current_volume > prev_volume: + return { + "signal": "BUY", + "strategy": "MEAN_REVERSION_BEAR", + "split_plan": [0.30, 0.30, 0.40], # 3ํšŒ ๋ถ„ํ•  ๋งค์ˆ˜ + "description": f"RSI({rsi:.1f})<=35 + BB ํ•˜๋‹จ + ๋˜๋Œ๋ฆผ + ๊ฑฐ๋ž˜๋Ÿ‰โ†‘ (ํ•˜๋ฝ์žฅ ๊ณผ๋งค๋„)", + } + + return None + + +def check_exit_signal( + regime: MarketRegime, + rsi: float, + close: float, + bb_upper: float, + bb_middle: float, + bb_lower: float, + entry_price: float, + atr: float, + lowest_since_entry: float, + remaining_pct: float, +) -> Optional[ExitSignal]: + """์„น์…˜ 7.3 + 7.4: ์ฒญ์‚ฐ ์กฐ๊ฑด + + ์†์ ˆ: min(์ตœ์ €๊ฐ€ - 2.5ร—ATR, ์ง„์ž…๊ฐ€ - 8%) ์ค‘ ๋†’์€ ๊ฐ’(ํƒ€์ดํŠธํ•œ ๊ฒƒ) + 1์ฐจ ์ต์ ˆ: Bull 30% (ํŠธ๋ Œ๋“œ ํŒ”๋กœ์ž‰), Bear 50% (๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ) + 2์ฐจ ์ต์ ˆ(์ „๋Ÿ‰): RSI โ‰ฅ 60 ์ง„์ž… + ์ข…๊ฐ€ โ‰ฅ BB ์ƒ๋‹จ์„  ๊ทผ์ฒ˜ + """ + # ์†์ ˆ ์ฒดํฌ (์„น์…˜ 7.4) + atr_stop = lowest_since_entry - 2.5 * atr + pct_stop = entry_price * 0.92 # -8% + stop_price = max(atr_stop, pct_stop) # ๋†’์€ ๊ฐ’ = ํƒ€์ดํŠธํ•œ ์†์ ˆ + + if close <= stop_price: + return ExitSignal( + action="SELL_ALL", + reason=f"์†์ ˆ (close={close:.0f} <= stop={stop_price:.0f}, ATR={atr:.0f})", + sell_pct=1.0, + ) + + # 1์ฐจ ์ต์ ˆ: ์‹œ์žฅ ์ƒํƒœ๋ณ„ ๋น„์ค‘ ์กฐ์ • (RSI 45~50 ํšŒ๋ณต + BB ์ค‘์•™์„  ์ƒํšŒ) + if regime == MarketRegime.BULL: + # ์ƒ์Šน์žฅ: 30% ์ต์ ˆ (ํŠธ๋ Œ๋“œ ํŒ”๋กœ์ž‰) + if remaining_pct > 0.7 and 45 <= rsi <= 50 and close > bb_middle: + return ExitSignal( + action="SELL_PARTIAL", + reason=f"1์ฐจ ์ต์ ˆ(Bull 30%): RSI({rsi:.1f}) 45-50 ํšŒ๋ณต + BB์ค‘์•™({bb_middle:.0f}) ์ƒํšŒ", + sell_pct=0.30, + ) + else: + # ํ•˜๋ฝ์žฅ: 50% ์ต์ ˆ (๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ) + if remaining_pct > 0.5 and 45 <= rsi <= 50 and close > bb_middle: + return ExitSignal( + action="SELL_PARTIAL", + reason=f"1์ฐจ ์ต์ ˆ(Bear 50%): RSI({rsi:.1f}) 45-50 ํšŒ๋ณต + BB์ค‘์•™({bb_middle:.0f}) ์ƒํšŒ", + sell_pct=0.50, + ) + + # 2์ฐจ ์ต์ ˆ: ์ „๋Ÿ‰ ๋งค๋„ (RSI โ‰ฅ 60 ์ง„์ž… + BB ์ƒ๋‹จ์„  ๊ทผ์ฒ˜) + threshold = 0.7 if regime == MarketRegime.BULL else 0.5 + if remaining_pct <= threshold and rsi >= 60: + if close >= bb_upper * 0.99: + return ExitSignal( + action="SELL_ALL", + reason=f"2์ฐจ ์ต์ ˆ: RSI({rsi:.1f})>=60 ์ง„์ž… + BB์ƒ๋‹จ({bb_upper:.0f}) ๋„๋‹ฌ", + sell_pct=1.0, + ) + + return None + + +def calculate_position_size( + total_assets: float, + available_cash: float, + current_strategy_allocation: float, + regime: MarketRegime, + max_per_position: float = 0.04, + max_strategy_capital_bull: float = 0.70, + max_strategy_capital_bear: float = 0.30, +) -> float: + """์„น์…˜ 6.1 + 7.5: ํฌ์ง€์…˜ ์‚ฌ์ด์ง• (์‹œ์žฅ ์ƒํƒœ๋ณ„ ๋™์  ๋ฐฐ๋ถ„) + + - 1์ข…๋ชฉ๋‹น ์ตœ๋Œ€ 3-4% (max_per_position) + - ์ƒ์Šน์žฅ(Bull): ์ „๋žต ์ „์ฒด ์ตœ๋Œ€ 70% ์šด์šฉ (๊ณต๊ฒฉ์ ) + - ํ•˜๋ฝ์žฅ(Bear): ์ „๋žต ์ „์ฒด ์ตœ๋Œ€ 30% ์šด์šฉ (๋ณด์ˆ˜์ ) + """ + # ์‹œ์žฅ ์ƒํƒœ์— ๋”ฐ๋ผ ์ตœ๋Œ€ ์ž๋ณธ ๋น„์ค‘ ๊ฒฐ์ • + max_strategy_capital = max_strategy_capital_bull if regime == MarketRegime.BULL else max_strategy_capital_bear + + max_by_position = total_assets * max_per_position + remaining_budget = (total_assets * max_strategy_capital) - current_strategy_allocation + return max(0, min(max_by_position, remaining_budget, available_cash)) + + +def check_h1_breakout_with_ma5( + h1_closes: List[float], + h1_current_close: float, +) -> bool: + """์„น์…˜ 7.5: 60๋ถ„๋ด‰ 5์ผ์„  ๋ŒํŒŒ ํ™•์ธ (๊ฐ€์งœ ๋ฐ˜๋“ฑ ํ•„ํ„ฐ) + + 60๋ถ„๋ด‰ ๊ธฐ์ค€ 5์ผ(120์‹œ๊ฐ„ = 5์ผร—24์‹œ๊ฐ„) ์ด๋™ํ‰๊ท ์„  ์ƒํ–ฅ ๋ŒํŒŒ ์—ฌ๋ถ€ ํ™•์ธ + ์‹ค์ œ๋กœ๋Š” 5์ผ ๋ถ„๋Ÿ‰์˜ 60๋ถ„๋ด‰ ๋ฐ์ดํ„ฐ๊ฐ€ ํ•„์š”: 5์ผ ร— 6.5์‹œ๊ฐ„(์žฅ ์šด์˜) = ์•ฝ 30~40๊ฐœ ๋ด‰ + + Args: + h1_closes: 60๋ถ„๋ด‰ ์ข…๊ฐ€ ๋ฆฌ์ŠคํŠธ (์ตœ์†Œ 5๊ฐœ ์ด์ƒ) + h1_current_close: ํ˜„์žฌ 60๋ถ„๋ด‰ ์ข…๊ฐ€ + + Returns: + True: 5์ผ์„  ์ƒํ–ฅ ๋ŒํŒŒ, False: ๋ฏธ๋ŒํŒŒ + + Note: + - 60๋ถ„๋ด‰ ๋ฐ์ดํ„ฐ๊ฐ€ ์ถฉ๋ถ„ํ•˜์ง€ ์•Š์œผ๋ฉด False ๋ฐ˜ํ™˜ (๋ณด์ˆ˜์ ) + - ๋ณตํ•ฉ ํƒ€์ž„ํ”„๋ ˆ์ž„ ์ „๋žต์—์„œ ์„ ํƒ์ ์œผ๋กœ ์‚ฌ์šฉ + """ + if len(h1_closes) < 5: + return False # ๋ฐ์ดํ„ฐ ๋ถ€์กฑ ์‹œ ๋ณด์ˆ˜์  ๋Œ€์‘ + + ma5 = sum(h1_closes[-5:]) / 5 + return h1_current_close > ma5 diff --git a/backend/app/services/ml_ensemble.py b/backend/app/services/ml_ensemble.py new file mode 100644 index 0000000..d0a6bad --- /dev/null +++ b/backend/app/services/ml_ensemble.py @@ -0,0 +1,560 @@ +""" +ML ์•™์ƒ๋ธ” ๋ชจ๋ธ ์„œ๋น„์Šค + +3๊ฐœ ๋ชจ๋ธ์„ ๊ฒฐํ•ฉํ•˜์—ฌ ์ฃผ์‹ ๋งค์ˆ˜/๋งค๋„ ์‹ ํ˜ธ๋ฅผ ์˜ˆ์ธกํ•ฉ๋‹ˆ๋‹ค: +- LightGBM (40%): ํƒญ๋ทธ๋Ÿฌ ํ”ผ์ฒ˜ ๊ธฐ๋ฐ˜ ๋ถ„๋ฅ˜ +- PyTorch LSTM (35%): ์‹œ๊ณ„์—ด ํŒจํ„ด ํ•™์Šต +- Random Forest (25%): ํ”ผ์ฒ˜ ์กฐํ•ฉ ๊ธฐ๋ฐ˜ ๋ถ„๋ฅ˜ + +์•™์ƒ๋ธ” ์Šค์ฝ”์–ด: 0~100 (๋†’์„์ˆ˜๋ก ๋งค์ˆ˜ ์‹ ํ˜ธ ๊ฐ•ํ•จ) +""" + +import logging +import os +from pathlib import Path +from typing import Dict, Any, Optional, Tuple + +import numpy as np +import joblib + +from app.services.feature_engineering import ( + FEATURE_COLUMNS, + LSTM_SEQUENCE_FEATURES, + extract_single_features, + chart_data_to_dataframe, + extract_features, + prepare_training_data, +) + +logger = logging.getLogger(__name__) + +# ๋ชจ๋ธ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ +MODEL_DIR = Path(__file__).parent.parent.parent / "models" / "ensemble" + + +# โ”€โ”€โ”€ PyTorch LSTM ๋ชจ๋ธ ์ •์˜ โ”€โ”€โ”€ + +def _get_torch(): + """PyTorch lazy import""" + try: + import torch + import torch.nn as nn + return torch, nn + except ImportError: + return None, None + + +class StockLSTM: + """PyTorch LSTM ๋ชจ๋ธ ๋ž˜ํผ""" + + def __init__(self, input_size: int = 5, hidden_size: int = 64, num_layers: int = 2, + num_classes: int = 3, dropout: float = 0.3): + self.input_size = input_size + self.hidden_size = hidden_size + self.num_layers = num_layers + self.num_classes = num_classes + self.dropout = dropout + self.model = None + self.device = None + self._build() + + def _build(self): + torch, nn = _get_torch() + if torch is None: + logger.warning("PyTorch not available, LSTM model disabled") + return + + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + class LSTMNet(nn.Module): + def __init__(self_, input_size, hidden_size, num_layers, num_classes, dropout): + super().__init__() + self_.lstm = nn.LSTM( + input_size=input_size, + hidden_size=hidden_size, + num_layers=num_layers, + batch_first=True, + dropout=dropout if num_layers > 1 else 0, + ) + self_.fc1 = nn.Linear(hidden_size, 32) + self_.relu = nn.ReLU() + self_.drop = nn.Dropout(dropout) + self_.fc2 = nn.Linear(32, num_classes) + + def forward(self_, x): + out, _ = self_.lstm(x) + out = out[:, -1, :] # ๋งˆ์ง€๋ง‰ ํƒ€์ž„์Šคํ… + out = self_.drop(self_.relu(self_.fc1(out))) + out = self_.fc2(out) + return out + + self.model = LSTMNet( + self.input_size, self.hidden_size, self.num_layers, + self.num_classes, self.dropout, + ).to(self.device) + + def train_model(self, X_seq: np.ndarray, y: np.ndarray, + epochs: int = 50, lr: float = 0.001, batch_size: int = 32) -> Dict[str, float]: + """LSTM ๋ชจ๋ธ ํ•™์Šต""" + torch, nn = _get_torch() + if torch is None or self.model is None: + return {"error": "PyTorch not available"} + + from torch.utils.data import TensorDataset, DataLoader + + # ์‹œํ€€์Šค ํ”ผ์ฒ˜ ์ •๊ทœํ™” (์ฑ„๋„๋ณ„) + self.seq_mean = X_seq.mean(axis=(0, 1)) + self.seq_std = X_seq.std(axis=(0, 1)) + 1e-8 + X_norm = (X_seq - self.seq_mean) / self.seq_std + + X_tensor = torch.FloatTensor(X_norm).to(self.device) + y_tensor = torch.LongTensor(y).to(self.device) + + dataset = TensorDataset(X_tensor, y_tensor) + loader = DataLoader(dataset, batch_size=batch_size, shuffle=True) + + criterion = nn.CrossEntropyLoss() + optimizer = torch.optim.Adam(self.model.parameters(), lr=lr) + + self.model.train() + best_loss = float("inf") + + for epoch in range(epochs): + total_loss = 0.0 + correct = 0 + total = 0 + for X_batch, y_batch in loader: + optimizer.zero_grad() + outputs = self.model(X_batch) + loss = criterion(outputs, y_batch) + loss.backward() + optimizer.step() + + total_loss += loss.item() * len(y_batch) + _, predicted = torch.max(outputs, 1) + correct += (predicted == y_batch).sum().item() + total += len(y_batch) + + avg_loss = total_loss / total + accuracy = correct / total + if avg_loss < best_loss: + best_loss = avg_loss + + if (epoch + 1) % 10 == 0: + logger.info(f"LSTM Epoch [{epoch+1}/{epochs}] Loss: {avg_loss:.4f} Acc: {accuracy:.4f}") + + return {"final_loss": best_loss, "final_accuracy": accuracy} + + def predict_proba(self, X_seq: np.ndarray) -> np.ndarray: + """์˜ˆ์ธก ํ™•๋ฅ  ๋ฐ˜ํ™˜ (N, 3)""" + torch, nn = _get_torch() + if torch is None or self.model is None: + return np.full((len(X_seq), 3), 1.0 / 3) + + self.model.eval() + X_norm = (X_seq - self.seq_mean) / self.seq_std + X_tensor = torch.FloatTensor(X_norm).to(self.device) + + with torch.no_grad(): + outputs = self.model(X_tensor) + probs = torch.softmax(outputs, dim=1).cpu().numpy() + return probs + + def save(self, path: Path): + torch, _ = _get_torch() + if torch is None or self.model is None: + return + torch.save({ + "model_state": self.model.state_dict(), + "seq_mean": self.seq_mean, + "seq_std": self.seq_std, + "config": { + "input_size": self.input_size, + "hidden_size": self.hidden_size, + "num_layers": self.num_layers, + "num_classes": self.num_classes, + "dropout": self.dropout, + }, + }, path) + + def load(self, path: Path) -> bool: + torch, _ = _get_torch() + if torch is None: + return False + try: + checkpoint = torch.load(path, map_location=self.device, weights_only=False) + self.model.load_state_dict(checkpoint["model_state"]) + self.seq_mean = checkpoint["seq_mean"] + self.seq_std = checkpoint["seq_std"] + self.model.eval() + return True + except Exception as e: + logger.error(f"Failed to load LSTM model: {e}") + return False + + +# โ”€โ”€โ”€ ์•™์ƒ๋ธ” ๋ชจ๋ธ ํด๋ž˜์Šค โ”€โ”€โ”€ + +class MLEnsemble: + """3๊ฐœ ๋ชจ๋ธ(LightGBM + LSTM + RF)์„ ์•™์ƒ๋ธ”ํ•˜์—ฌ ์ฃผ์‹ ๋งค์ˆ˜/๋งค๋„ ์˜ˆ์ธก""" + + def __init__(self, model_dir: Path = MODEL_DIR, + weights: Tuple[float, float, float] = (0.40, 0.35, 0.25)): + self.model_dir = model_dir + self.weights = weights # (lgbm, lstm, rf) + + self.lgbm_model = None + self.lstm_model = None + self.rf_model = None + self.scaler = None # tabular feature scaler + + self.is_trained = False + self.training_metrics: Dict[str, Any] = {} + + def _ensure_dir(self): + self.model_dir.mkdir(parents=True, exist_ok=True) + + def train(self, X_tabular: np.ndarray, X_seq: np.ndarray, y: np.ndarray) -> Dict[str, Any]: + """์ „์ฒด ์•™์ƒ๋ธ” ํ•™์Šต + + Args: + X_tabular: (N, 20) ํƒญ๋ทธ๋Ÿฌ ํ”ผ์ฒ˜ + X_seq: (N, 60, 5) LSTM ์‹œํ€€์Šค + y: (N,) ๋ ˆ์ด๋ธ” (0=DOWN, 1=HOLD, 2=UP) + + Returns: + ํ•™์Šต ๋ฉ”ํŠธ๋ฆญ + """ + self._ensure_dir() + metrics = {} + + if len(X_tabular) < 50: + return {"error": f"ํ•™์Šต ๋ฐ์ดํ„ฐ ๋ถ€์กฑ: {len(X_tabular)}๊ฐœ (์ตœ์†Œ 50๊ฐœ ํ•„์š”)"} + + logger.info(f"์•™์ƒ๋ธ” ํ•™์Šต ์‹œ์ž‘: {len(X_tabular)}๊ฐœ ์ƒ˜ํ”Œ, ํด๋ž˜์Šค ๋ถ„ํฌ: {np.bincount(y)}") + + # ํƒญ๋ทธ๋Ÿฌ ํ”ผ์ฒ˜ ์Šค์ผ€์ผ๋ง + from sklearn.preprocessing import StandardScaler + self.scaler = StandardScaler() + X_scaled = self.scaler.fit_transform(X_tabular) + + # ํ•™์Šต/๊ฒ€์ฆ ๋ถ„๋ฆฌ (์‹œ๊ณ„์—ด์ด๋ฏ€๋กœ ๋’ค์ชฝ 20%๋ฅผ ๊ฒ€์ฆ์œผ๋กœ) + split_idx = int(len(X_scaled) * 0.8) + X_train, X_val = X_scaled[:split_idx], X_scaled[split_idx:] + y_train, y_val = y[:split_idx], y[split_idx:] + X_seq_train, X_seq_val = X_seq[:split_idx], X_seq[split_idx:] + + # 1. LightGBM + logger.info(">>> LightGBM ํ•™์Šต ์ค‘...") + try: + import lightgbm as lgb + self.lgbm_model = lgb.LGBMClassifier( + n_estimators=200, + max_depth=6, + learning_rate=0.05, + num_leaves=31, + min_child_samples=10, + subsample=0.8, + colsample_bytree=0.8, + reg_alpha=0.1, + reg_lambda=0.1, + random_state=42, + verbose=-1, + num_class=3, + objective="multiclass", + ) + self.lgbm_model.fit( + X_train, y_train, + eval_set=[(X_val, y_val)], + ) + lgbm_val_pred = self.lgbm_model.predict(X_val) + lgbm_acc = np.mean(lgbm_val_pred == y_val) + metrics["lgbm_accuracy"] = float(lgbm_acc) + logger.info(f"LightGBM ๊ฒ€์ฆ ์ •ํ™•๋„: {lgbm_acc:.4f}") + + # ํ”ผ์ฒ˜ ์ค‘์š”๋„ + importance = self.lgbm_model.feature_importances_ + top_features = sorted( + zip(FEATURE_COLUMNS, importance), + key=lambda x: x[1], reverse=True + )[:5] + metrics["lgbm_top_features"] = [ + {"name": name, "importance": int(imp)} for name, imp in top_features + ] + except Exception as e: + logger.error(f"LightGBM ํ•™์Šต ์‹คํŒจ: {e}") + metrics["lgbm_error"] = str(e) + + # 2. PyTorch LSTM + logger.info(">>> PyTorch LSTM ํ•™์Šต ์ค‘...") + try: + self.lstm_model = StockLSTM( + input_size=len(LSTM_SEQUENCE_FEATURES), + hidden_size=64, + num_layers=2, + num_classes=3, + dropout=0.3, + ) + lstm_metrics = self.lstm_model.train_model( + X_seq_train, y_train, + epochs=50, lr=0.001, batch_size=32, + ) + # ๊ฒ€์ฆ + lstm_val_probs = self.lstm_model.predict_proba(X_seq_val) + lstm_val_pred = np.argmax(lstm_val_probs, axis=1) + lstm_acc = np.mean(lstm_val_pred == y_val) + metrics["lstm_accuracy"] = float(lstm_acc) + metrics["lstm_training"] = lstm_metrics + logger.info(f"LSTM ๊ฒ€์ฆ ์ •ํ™•๋„: {lstm_acc:.4f}") + except Exception as e: + logger.error(f"LSTM ํ•™์Šต ์‹คํŒจ: {e}") + metrics["lstm_error"] = str(e) + + # 3. Random Forest + logger.info(">>> Random Forest ํ•™์Šต ์ค‘...") + try: + from sklearn.ensemble import RandomForestClassifier + self.rf_model = RandomForestClassifier( + n_estimators=100, + max_depth=8, + min_samples_split=10, + min_samples_leaf=5, + random_state=42, + n_jobs=-1, + ) + self.rf_model.fit(X_train, y_train) + rf_val_pred = self.rf_model.predict(X_val) + rf_acc = np.mean(rf_val_pred == y_val) + metrics["rf_accuracy"] = float(rf_acc) + logger.info(f"Random Forest ๊ฒ€์ฆ ์ •ํ™•๋„: {rf_acc:.4f}") + except Exception as e: + logger.error(f"Random Forest ํ•™์Šต ์‹คํŒจ: {e}") + metrics["rf_error"] = str(e) + + # ์•™์ƒ๋ธ” ๊ฒ€์ฆ + ensemble_probs = self._ensemble_predict_proba(X_val, X_seq_val) + if ensemble_probs is not None: + ensemble_pred = np.argmax(ensemble_probs, axis=1) + ensemble_acc = np.mean(ensemble_pred == y_val) + metrics["ensemble_accuracy"] = float(ensemble_acc) + logger.info(f"์•™์ƒ๋ธ” ๊ฒ€์ฆ ์ •ํ™•๋„: {ensemble_acc:.4f}") + + # ๋ชจ๋ธ ์ €์žฅ + self._save_models() + self.is_trained = True + self.training_metrics = metrics + + logger.info(f"์•™์ƒ๋ธ” ํ•™์Šต ์™„๋ฃŒ: {metrics}") + return metrics + + def _ensemble_predict_proba(self, X_tabular: np.ndarray, X_seq: np.ndarray) -> Optional[np.ndarray]: + """3๊ฐœ ๋ชจ๋ธ์˜ ๊ฐ€์ค‘ ํ‰๊ท  ํ™•๋ฅ """ + probs_list = [] + weights_list = [] + + # LightGBM + if self.lgbm_model is not None: + try: + lgbm_probs = self.lgbm_model.predict_proba(X_tabular) + probs_list.append(lgbm_probs) + weights_list.append(self.weights[0]) + except Exception: + pass + + # LSTM + if self.lstm_model is not None and self.lstm_model.model is not None: + try: + lstm_probs = self.lstm_model.predict_proba(X_seq) + probs_list.append(lstm_probs) + weights_list.append(self.weights[1]) + except Exception: + pass + + # RF + if self.rf_model is not None: + try: + rf_probs = self.rf_model.predict_proba(X_tabular) + probs_list.append(rf_probs) + weights_list.append(self.weights[2]) + except Exception: + pass + + if not probs_list: + return None + + # ๊ฐ€์ค‘ ํ‰๊ท  + total_weight = sum(weights_list) + weighted_probs = sum( + p * (w / total_weight) for p, w in zip(probs_list, weights_list) + ) + return weighted_probs + + def predict(self, chart_data: list) -> Dict[str, Any]: + """๋‹จ์ผ ์ข…๋ชฉ ์˜ˆ์ธก. + + Args: + chart_data: KIS API ์ฐจํŠธ ๋ฐ์ดํ„ฐ (์ตœ์‹ ์ˆœ) + + Returns: + { + "ensemble_score": int (0~100), + "confidence": float (0~1), + "signal": str ("BUY"/"HOLD"/"SELL"), + "model_scores": {"lgbm": ..., "lstm": ..., "rf": ...}, + "probabilities": {"up": ..., "hold": ..., "down": ...} + } + """ + if not self.is_trained: + return {"error": "๋ชจ๋ธ์ด ํ•™์Šต๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"} + + tabular, sequence = extract_single_features(chart_data) + if tabular is None or sequence is None: + return {"error": "ํ”ผ์ฒ˜ ์ถ”์ถœ ์‹คํŒจ (๋ฐ์ดํ„ฐ ๋ถ€์กฑ)", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"} + + # ์Šค์ผ€์ผ๋ง + tabular_scaled = self.scaler.transform(tabular.reshape(1, -1)) + sequence_batch = sequence.reshape(1, 60, len(LSTM_SEQUENCE_FEATURES)) + + # ์•™์ƒ๋ธ” ์˜ˆ์ธก + probs = self._ensemble_predict_proba(tabular_scaled, sequence_batch) + if probs is None: + return {"error": "๋ชจ๋“  ๋ชจ๋ธ ์˜ˆ์ธก ์‹คํŒจ", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"} + + prob_down, prob_hold, prob_up = probs[0] + + # ์•™์ƒ๋ธ” ์Šค์ฝ”์–ด: UP ํ™•๋ฅ  ๊ธฐ๋ฐ˜ (0~100) + ensemble_score = int(prob_up * 100) + + # ์‹ ๋ขฐ๋„: ์ตœ๊ณ  ํ™•๋ฅ ๊ณผ ๋‚˜๋จธ์ง€์˜ ์ฐจ์ด + max_prob = max(prob_down, prob_hold, prob_up) + confidence = float(max_prob) + + # ์‹ ํ˜ธ ๊ฒฐ์ • + if prob_up > prob_down and prob_up > prob_hold and prob_up > 0.4: + signal = "BUY" + elif prob_down > prob_up and prob_down > prob_hold and prob_down > 0.4: + signal = "SELL" + else: + signal = "HOLD" + + # ๊ฐœ๋ณ„ ๋ชจ๋ธ ์Šค์ฝ”์–ด + model_scores = {} + if self.lgbm_model is not None: + try: + lp = self.lgbm_model.predict_proba(tabular_scaled)[0] + model_scores["lgbm"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])} + except Exception: + pass + if self.lstm_model is not None and self.lstm_model.model is not None: + try: + lp = self.lstm_model.predict_proba(sequence_batch)[0] + model_scores["lstm"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])} + except Exception: + pass + if self.rf_model is not None: + try: + lp = self.rf_model.predict_proba(tabular_scaled)[0] + model_scores["rf"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])} + except Exception: + pass + + return { + "ensemble_score": ensemble_score, + "confidence": round(confidence, 4), + "signal": signal, + "model_scores": model_scores, + "probabilities": { + "up": round(float(prob_up), 4), + "hold": round(float(prob_hold), 4), + "down": round(float(prob_down), 4), + }, + } + + def _save_models(self): + """๋ชจ๋ธ ํŒŒ์ผ ์ €์žฅ""" + self._ensure_dir() + + if self.lgbm_model is not None: + joblib.dump(self.lgbm_model, self.model_dir / "lgbm_model.pkl") + if self.rf_model is not None: + joblib.dump(self.rf_model, self.model_dir / "rf_model.pkl") + if self.lstm_model is not None: + self.lstm_model.save(self.model_dir / "lstm_model.pt") + if self.scaler is not None: + joblib.dump(self.scaler, self.model_dir / "scaler.pkl") + + # ๋ฉ”ํŠธ๋ฆญ ์ €์žฅ + import json + with open(self.model_dir / "metrics.json", "w") as f: + json.dump(self.training_metrics, f, indent=2, ensure_ascii=False, default=str) + + logger.info(f"๋ชจ๋ธ ์ €์žฅ ์™„๋ฃŒ: {self.model_dir}") + + def load_models(self) -> bool: + """์ €์žฅ๋œ ๋ชจ๋ธ ๋กœ๋“œ""" + try: + scaler_path = self.model_dir / "scaler.pkl" + if not scaler_path.exists(): + logger.warning("์ €์žฅ๋œ ์•™์ƒ๋ธ” ๋ชจ๋ธ์ด ์—†์Šต๋‹ˆ๋‹ค") + return False + + self.scaler = joblib.load(scaler_path) + + lgbm_path = self.model_dir / "lgbm_model.pkl" + if lgbm_path.exists(): + self.lgbm_model = joblib.load(lgbm_path) + + rf_path = self.model_dir / "rf_model.pkl" + if rf_path.exists(): + self.rf_model = joblib.load(rf_path) + + lstm_path = self.model_dir / "lstm_model.pt" + if lstm_path.exists(): + self.lstm_model = StockLSTM( + input_size=len(LSTM_SEQUENCE_FEATURES), + hidden_size=64, num_layers=2, num_classes=3, dropout=0.3, + ) + self.lstm_model.load(lstm_path) + + # ๋ฉ”ํŠธ๋ฆญ ๋กœ๋“œ + metrics_path = self.model_dir / "metrics.json" + if metrics_path.exists(): + import json + with open(metrics_path) as f: + self.training_metrics = json.load(f) + + self.is_trained = True + logger.info("์•™์ƒ๋ธ” ๋ชจ๋ธ ๋กœ๋“œ ์™„๋ฃŒ") + return True + + except Exception as e: + logger.error(f"์•™์ƒ๋ธ” ๋ชจ๋ธ ๋กœ๋“œ ์‹คํŒจ: {e}") + return False + + def get_status(self) -> Dict[str, Any]: + """๋ชจ๋ธ ์ƒํƒœ ์กฐํšŒ""" + return { + "is_trained": self.is_trained, + "models": { + "lgbm": self.lgbm_model is not None, + "lstm": self.lstm_model is not None and self.lstm_model.model is not None, + "rf": self.rf_model is not None, + }, + "weights": {"lgbm": self.weights[0], "lstm": self.weights[1], "rf": self.weights[2]}, + "metrics": self.training_metrics, + "model_dir": str(self.model_dir), + } + + +# โ”€โ”€โ”€ ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค โ”€โ”€โ”€ + +_ensemble_instance: Optional[MLEnsemble] = None + + +def get_ensemble() -> MLEnsemble: + """์•™์ƒ๋ธ” ๋ชจ๋ธ ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค ๋ฐ˜ํ™˜""" + global _ensemble_instance + if _ensemble_instance is None: + _ensemble_instance = MLEnsemble() + _ensemble_instance.load_models() + return _ensemble_instance diff --git a/backend/app/services/tensorflow_predictor.py b/backend/app/services/tensorflow_predictor.py new file mode 100644 index 0000000..75ca2f1 --- /dev/null +++ b/backend/app/services/tensorflow_predictor.py @@ -0,0 +1,108 @@ +import logging +from typing import List, Dict, Any, Optional + +import numpy as np + +from app.services.korea_investment_api import korea_investment_api + +logger = logging.getLogger(__name__) + + +class TensorFlowNotAvailable(Exception): + pass + + +class TensorFlowPredictor: + """Lightweight wrapper around a Keras model + scaler. + + - Lazily attempts to import TensorFlow/Keras and load model files. + - If TensorFlow or model/scaler files are missing, raises TensorFlowNotAvailable + so callers can handle gracefully (e.g., return 503 or a friendly message). + - Fetches recent daily closes from KIS API. + """ + + def __init__(self, model_path: str = "models/lstm_model.h5", scaler_path: str = "models/scaler.pkl", time_steps: int = 60): + self.model_path = model_path + self.scaler_path = scaler_path + self.time_steps = time_steps + + try: + # Local import to avoid hard dependency when not installed + import tensorflow as tf # noqa: F401 + from tensorflow import keras # noqa: F401 + import joblib # noqa: F401 + except Exception as e: + logger.warning(f"TensorFlow or joblib not available: {e}") + raise TensorFlowNotAvailable("TensorFlow/joblib not installed in backend environment") + + # Load resources + try: + from tensorflow import keras + import joblib + + self.model = keras.models.load_model(self.model_path) + self.scaler = joblib.load(self.scaler_path) + except Exception as e: + logger.warning(f"Failed to load TF model/scaler: {e}") + raise TensorFlowNotAvailable(f"Model or scaler not found: {e}") + + async def fetch_kis_closes(self, stock_code: str, days: int = 60) -> np.ndarray: + """Fetch recent daily closes from KIS. Returns array oldestโ†’latest. + Uses korea_investment_api.get_daily_chart(stock_code, "D"). + """ + try: + resp = await korea_investment_api.get_daily_chart(stock_code, "D") + output = resp.get("output") or [] + if not output: + raise RuntimeError("empty_chart") + # KIS usually returns newest first; reverse to chronological + closes_desc = [float(item.get("stck_clpr", 0) or 0) for item in output] + closes = list(reversed(closes_desc)) + arr = np.array(closes[-days:], dtype=float) + if arr.size < days: + # If fewer than requested, still return what's available + logger.info(f"{stock_code}: only {arr.size} closes available (requested {days})") + return arr + except Exception as e: + logger.error(f"Failed to fetch KIS closes for {stock_code}: {e}") + raise + + async def predict_stock(self, stock_code: str) -> Dict[str, Any]: + """Return prediction dict with expected_return and simple signal.""" + try: + prices = await self.fetch_kis_closes(stock_code, days=self.time_steps) + if prices.size == 0: + raise RuntimeError("no_price_data") + + # Normalize and shape for LSTM (1, time_steps, 1) + scaled = self.scaler.transform(prices.reshape(-1, 1)) + if scaled.shape[0] < self.time_steps: + # Pad at the front if needed + pad = np.repeat(scaled[0:1], self.time_steps - scaled.shape[0], axis=0) + scaled = np.vstack([pad, scaled]) + X_input = scaled[-self.time_steps:].reshape(1, self.time_steps, 1) + + # Predict + predicted_scaled = self.model.predict(X_input, verbose=0) + predicted_price = float(self.scaler.inverse_transform(predicted_scaled)[0][0]) + current_price = float(prices[-1]) + + expected_return = ((predicted_price - current_price) / current_price * 100.0) if current_price > 0 else 0.0 + signal = "BUY" if predicted_price > current_price * 1.02 else "HOLD" + + return { + "stock_code": stock_code, + "current_price": current_price, + "predicted_price": predicted_price, + "expected_return": expected_return, + "signal": signal, + } + except TensorFlowNotAvailable: + raise + except Exception as e: + logger.error(f"Prediction failed for {stock_code}: {e}") + # Degrade gracefully + return { + "stock_code": stock_code, + "error": str(e), + } diff --git a/backend/app/services/watchlist.py b/backend/app/services/watchlist.py new file mode 100644 index 0000000..a8e3a76 --- /dev/null +++ b/backend/app/services/watchlist.py @@ -0,0 +1,63 @@ +""" +๋ถ„์„์ข…๋ชฉ(Watchlist) ๊ด€๋ฆฌ ๊ณต์šฉ ์„œ๋น„์Šค + +์•„์นจ ๋ฐฐ์น˜(batch_daily_analysis)์™€ ์žฅ์ค‘ ๋ฐฐ์น˜(batch_intraday_analysis)๊ฐ€ ๊ณต์œ ํ•˜๋Š” +์ดˆ๊ธฐํ™” ํ•จ์ˆ˜. ๊ฐ ๋ฐฐ์น˜ ์Šคํฌ๋ฆฝํŠธ๊ฐ€ ์ž์‹ ์˜ AsyncSession์„ ์ „๋‹ฌํ•ด ์‚ฌ์šฉํ•œ๋‹ค. +""" +from datetime import datetime, date +import logging + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.models import WatchlistStock, StockAnalysis + +logger = logging.getLogger(__name__) + +# ๋ถ„์„์ข…๋ชฉ ๊ด€๋ จ ์ƒ์ˆ˜ +WATCHLIST_INIT_SIZE: int = 50 # ์•„์นจ ๋ฐฐ์น˜ ์ดˆ๊ธฐํ™” ์ข…๋ชฉ ์ˆ˜ +WATCHLIST_MAX_SIZE: int = 100 # ์ตœ๋Œ€ ๊ด€๋ฆฌ ์ข…๋ชฉ ์ˆ˜ +WATCHLIST_ENTRY_SCORE: int = 60 # ์žฅ์ค‘ ์‹ ๊ทœ ์ง„์ž… ๊ธฐ์ค€ ์ ์ˆ˜ +WATCHLIST_EXIT_SCORE: int = 20 # ์žฅ์ค‘ ์ œ๊ฑฐ ๊ธฐ์ค€ ์ ์ˆ˜ + + +async def initialize_watchlist(session: AsyncSession, analysis_date: date) -> int: + """์•„์นจ ๋ฐฐ์น˜ ์™„๋ฃŒ ํ›„ ๋ถ„์„์ข…๋ชฉ ์ดˆ๊ธฐํ™”. + + ๊ธฐ์กด ํ™œ์„ฑ ์ข…๋ชฉ์„ ์ „๋ถ€ ๋น„ํ™œ์„ฑํ™”ํ•˜๊ณ , + ์˜ค๋Š˜ stock_analysis score ์ƒ์œ„ WATCHLIST_INIT_SIZE ๊ฐœ๋กœ ์ƒˆ๋กœ ์ฑ„์šด๋‹ค. + + Returns: ์ถ”๊ฐ€๋œ ์ข…๋ชฉ ์ˆ˜ + """ + now = datetime.now() + + # 1. ๊ธฐ์กด ํ™œ์„ฑ ์ข…๋ชฉ ์ „๋ถ€ ๋น„ํ™œ์„ฑํ™” + await session.execute( + update(WatchlistStock) + .where(WatchlistStock.is_active == True) + .values(is_active=False, removed_at=now, removed_reason="daily_reset") + ) + + # 2. ์˜ค๋Š˜ score ์ƒ์œ„ N๊ฐœ ์กฐํšŒ + result = await session.execute( + select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score) + .where(StockAnalysis.analysis_date == analysis_date) + .order_by(StockAnalysis.score.desc()) + .limit(WATCHLIST_INIT_SIZE) + ) + top_stocks = result.all() + + # 3. ์‹ ๊ทœ ํ™œ์„ฑ ์ข…๋ชฉ ๋“ฑ๋ก + for row in top_stocks: + session.add(WatchlistStock( + stock_code=row.stock_code, + stock_name=row.stock_name, + added_at=now, + added_reason="morning_batch", + score_at_add=row.score, + is_active=True, + )) + + await session.commit() + logger.info(f"๐Ÿ“‹ ๋ถ„์„์ข…๋ชฉ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ: {len(top_stocks)}๊ฐœ (๊ธฐ์ค€์ผ: {analysis_date})") + return len(top_stocks) diff --git a/backend/app/services/websocket_manager.py b/backend/app/services/websocket_manager.py index 5a57a60..f52a55a 100644 --- a/backend/app/services/websocket_manager.py +++ b/backend/app/services/websocket_manager.py @@ -1,6 +1,6 @@ from typing import Dict, List from fastapi import WebSocket -import json +from app.core.json_helpers import _dumps import logging logger = logging.getLogger(__name__) @@ -47,7 +47,7 @@ class WebSocketManager: async def broadcast_json(self, data: dict): """JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋ชจ๋“  ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ""" - message = json.dumps(data) + message = _dumps(data) await self.broadcast(message) async def send_market_update(self, stock_code: str, data: dict): diff --git a/backend/batch_daily_analysis.py b/backend/batch_daily_analysis.py index ddf7929..f35e09c 100644 --- a/backend/batch_daily_analysis.py +++ b/backend/batch_daily_analysis.py @@ -6,7 +6,6 @@ - ๊ธฐ์ˆ ์  ์ง€ํ‘œ ๊ณ„์‚ฐ - ๋งค๋งค ์‹ ํ˜ธ ์ƒ์„ฑ - DB ์ €์žฅ - ์‹คํ–‰: python batch_daily_analysis.py Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py """ @@ -19,14 +18,14 @@ from pathlib import Path # ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ Python ๊ฒฝ๋กœ์— ์ถ”๊ฐ€ sys.path.insert(0, str(Path(__file__).parent)) -from sqlalchemy import select, delete +from sqlalchemy import delete from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker from app.core.config import settings from app.models.models import StockAnalysis -from app.services.korea_investment_api import KoreaInvestmentAPI -from app.api.endpoints.recommendations import analyze_stock_with_indicators +from app.services.korea_investment_api import KoreaInvestmentAPI, korea_investment_api +from app.api.endpoints.recommendations import analyze_stock_for_batch import logging logging.basicConfig( @@ -37,8 +36,13 @@ logger = logging.getLogger(__name__) # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ +# SQLAlchemy asyncpg ๋“œ๋ผ์ด๋ฒ„ ์‚ฌ์šฉ (postgresql+asyncpg://) +database_url = settings.DATABASE_URL +if database_url.startswith("postgresql://"): + database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + engine = create_async_engine( - settings.DATABASE_URL, + database_url, echo=False, pool_pre_ping=True, pool_size=10, @@ -52,159 +56,214 @@ AsyncSessionLocal = sessionmaker( ) -async def get_all_tradable_stocks() -> list[tuple[str, str]]: - """๊ฑฐ๋ž˜ ๊ฐ€๋Šฅํ•œ ์ „์ฒด ์ข…๋ชฉ ์กฐํšŒ (์•ˆ์ „ํ•œ ์žฌ์‹œ๋„ ์ ์šฉ)""" - kis_api = KoreaInvestmentAPI() - - logger.info("Fetching all tradable stocks from KIS API...") - - stocks = [] - - # KOSPI ์ƒ์œ„ ์ข…๋ชฉ ์กฐํšŒ +async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) -> list[tuple[str, str]]: + """์‹œ๊ฐ€์ด์•ก ์ƒ์œ„ ์ข…๋ชฉ ์กฐํšŒ (pykrx ์‚ฌ์šฉ) + + Args: + kis_api: KIS API ์ธ์Šคํ„ด์Šค (ํ˜ธํ™˜์„ฑ ์œ ์ง€์šฉ, ๋ฏธ์‚ฌ์šฉ) + limit: ์กฐํšŒํ•  ์ข…๋ชฉ ์ˆ˜ (๊ธฐ๋ณธ 500๊ฐœ) + + Returns: + [(stock_code, stock_name), ...] ์‹œ๊ฐ€์ด์•ก ์ƒ์œ„ ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ + """ + logger.info(f"Fetching top {limit} stocks by market cap from pykrx...") + try: - # ์žฌ์‹œ๋„ ๋กœ์ง์ด ๋‚ด์žฅ๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ sleep ์ œ๊ฑฐ (์“ฐ๋กœํ‹€๋ง์€ ์ž๋™ ์ ์šฉ) - kospi_result = await kis_api.get_volume_rank("J") - if kospi_result and kospi_result.get("output"): - for item in kospi_result["output"]: - stocks.append(( - item["mksc_shrn_iscd"], - item["hts_kor_isnm"] - )) - logger.info(f"โœ… Found {len(stocks)} KOSPI stocks") + from pykrx import stock as pykrx_stock + import pandas as pd + + today = datetime.now().strftime("%Y%m%d") + stocks_with_cap = [] + + # ETF/ํŠน์ˆ˜ ์ข…๋ชฉ ์ œ์™ธ ํ‚ค์›Œ๋“œ + SKIP_KEYWORDS = [ + "ETF", "๋ ˆ๋ฒ„๋ฆฌ์ง€", "์ธ๋ฒ„์Šค", "KODEX", "TIGER", "ARIRANG", + "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", + "์„ ๋ฌผ", "์ŠคํŒฉ", "๋ฆฌ์ธ ", "SPAC", + ] + + # KOSPI + KOSDAQ ์ „์ฒด ์ข…๋ชฉ ์กฐํšŒ + for market in ["KOSPI", "KOSDAQ"]: + try: + # ์‹œ์žฅ ์ „์ฒด์˜ ์‹œ๊ฐ€์ด์•ก ๋ฐ์ดํ„ฐ๋ฅผ ํ•œ ๋ฒˆ์— ์กฐํšŒ (์ตœ์ ํ™”) + cap_df = pykrx_stock.get_market_cap_by_ticker(today, market=market) + logger.info(f" {market}: {len(cap_df)}๊ฐœ ์ข…๋ชฉ ์‹œ๊ฐ€์ด์•ก ์กฐํšŒ") + + for ticker in cap_df.index: + try: + name = pykrx_stock.get_market_ticker_name(ticker) + + # ETF/ํŠน์ˆ˜ ์ข…๋ชฉ ํ•„ํ„ฐ๋ง + if any(k in name for k in SKIP_KEYWORDS): + continue + + market_cap = cap_df.loc[ticker, "์‹œ๊ฐ€์ด์•ก"] + stocks_with_cap.append((ticker, name, market_cap)) + + except Exception as e: + logger.debug(f" Skip {ticker}: {e}") + continue + + except Exception as e: + logger.error(f"โŒ Failed to fetch {market} stocks: {e}") + + # ์‹œ๊ฐ€์ด์•ก ๊ธฐ์ค€ ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ + stocks_with_cap.sort(key=lambda x: x[2], reverse=True) + + # ์ƒ์œ„ N๊ฐœ ์„ ํƒ (์‹œ๊ฐ€์ด์•ก ์ œ์™ธ) + top_stocks = [(code, name) for code, name, _ in stocks_with_cap[:limit]] + + logger.info(f"โœ… Selected top {len(top_stocks)} stocks by market cap") + if top_stocks: + kospi_count = sum(1 for c, _ in top_stocks if c.startswith(('0', '1', '2', '3', '4', '5'))) + kosdaq_count = len(top_stocks) - kospi_count + logger.info(f" KOSPI: {kospi_count} stocks") + logger.info(f" KOSDAQ: {kosdaq_count} stocks") + + return top_stocks + except Exception as e: - logger.error(f"โŒ Failed to fetch KOSPI stocks: {e}") - - # KOSDAQ ์ƒ์œ„ ์ข…๋ชฉ ์กฐํšŒ - try: - kosdaq_result = await kis_api.get_volume_rank("Q") - if kosdaq_result and kosdaq_result.get("output"): - kospi_count = len(stocks) - for item in kosdaq_result["output"]: - stocks.append(( - item["mksc_shrn_iscd"], - item["hts_kor_isnm"] - )) - logger.info(f"โœ… Found {len(stocks) - kospi_count} KOSDAQ stocks") - except Exception as e: - logger.error(f"โŒ Failed to fetch KOSDAQ stocks: {e}") - - logger.info(f"๐Ÿ“Š Total tradable stocks: {len(stocks)}") - return stocks + logger.error(f"โŒ Failed to fetch stocks by market cap: {e}", exc_info=True) + logger.warning("โš ๏ธ Falling back to empty list") + return [] async def analyze_and_save_stock( - session: AsyncSession, stock_code: str, stock_name: str, analysis_date: datetime -) -> bool: - """์ข…๋ชฉ ๋ถ„์„ ํ›„ DB ์ €์žฅ""" +) -> str: + """์ข…๋ชฉ ๋ถ„์„ ํ›„ DB ์ €์žฅ (๋…๋ฆฝ ์„ธ์…˜ ์‚ฌ์šฉ โ€” ๋™์‹œ ์ฒ˜๋ฆฌ ์•ˆ์ „) + + ATR์€ analyze_stock() ๋‚ด๋ถ€์—์„œ ์ด๋ฏธ ๊ณ„์‚ฐ๋˜์–ด result.atr_14์— ํฌํ•จ๋จ. + ์ด์ค‘ API ํ˜ธ์ถœ ์—†์Œ. + + Returns: "ok" | "no_chart" | "price_filter" | "error" + """ try: - # ์ข…๋ชฉ ๋ถ„์„ (๊ธฐ์กด ํ•จ์ˆ˜ ์žฌ์‚ฌ์šฉ) - result = await analyze_stock_with_indicators(stock_code, stock_name) - + # ๋ฐฐ์น˜ ์ „์šฉ ํ•จ์ˆ˜ ์‚ฌ์šฉ (๊ฑฐ๋ž˜๋Ÿ‰/์ ์ˆ˜ ํ•„ํ„ฐ ์—†์ด ์ „ ์ข…๋ชฉ ์ง€ํ‘œ ์ €์žฅ) + result = await analyze_stock_for_batch(stock_code, stock_name) + if not result: - logger.warning(f"No analysis result for {stock_code}") - return False - - # DB์— ์ €์žฅ - analysis = StockAnalysis( - stock_code=stock_code, - stock_name=stock_name, - analysis_date=analysis_date, - current_price=result.current_price, - change_rate=result.change_rate, - volume=result.volume, - rsi=result.rsi, - ma5=result.ma5, - ma20=result.ma20, - ma60=result.ma60, - stochastic_k=result.stochastic_k, - stochastic_d=result.stochastic_d, - bollinger_upper=result.bollinger_upper, - bollinger_lower=result.bollinger_lower, - bollinger_middle=result.bollinger_middle, - signals=result.signals, - score=result.score - ) - - session.add(analysis) - await session.commit() - - logger.info(f"โœ… Saved analysis for {stock_code} ({stock_name}): score={result.score}") - return True - + # for_batch=True ์—์„œ None = ์ฐจํŠธ ๋ฐ์ดํ„ฐ ์—†์Œ or ๊ฐ€๊ฒฉ 0 ๋“ฑ ์‹ค์ œ ์˜ค๋ฅ˜ + logger.info(f"์ฐจํŠธ์—†์Œ {stock_code} ({stock_name}): KIS API ์ฐจํŠธ ๋ฐ์ดํ„ฐ ์—†์Œ") + return "no_chart" + + # 5,000์› ์ดํ•˜ ์ €๊ฐ€์ฃผ ์ œ์™ธ (CLAUDE.md 6.4) + if result.current_price and result.current_price < 5000: + logger.info(f"๊ฐ€๊ฒฉํ•„ํ„ฐ {stock_code} ({stock_name}): {result.current_price:.0f}์› < 5,000์›") + return "price_filter" + + async with AsyncSessionLocal() as session: + # ์ด๋ฏธ ์˜ค๋Š˜ ๋ถ„์„๋œ ์ข…๋ชฉ์ด๋ฉด ์‚ญ์ œ ํ›„ ์žฌ์‚ฝ์ž… (์ค‘๋ณต ๋ฐฉ์ง€) + await session.execute( + delete(StockAnalysis).where( + StockAnalysis.stock_code == stock_code, + StockAnalysis.analysis_date == analysis_date, + ) + ) + analysis = StockAnalysis( + stock_code=stock_code, + stock_name=stock_name, + analysis_date=analysis_date, + current_price=result.current_price, + change_rate=result.change_rate, + volume=result.volume, + rsi=result.rsi, + ma5=result.ma5, + ma20=result.ma20, + ma60=result.ma60, + stochastic_k=result.stochastic_k, + stochastic_d=result.stochastic_d, + bollinger_upper=result.bollinger_upper, + bollinger_lower=result.bollinger_lower, + bollinger_middle=result.bollinger_middle, + signals=result.signals, + score=result.score, + ml_score=result.ml_score, + ml_confidence=result.ml_confidence, + ml_signal=result.ml_signal, + atr_14=result.atr_14, + adx_14=result.adx_14, + ) + session.add(analysis) + await session.commit() + + ml_info = f", ml_score={result.ml_score}" if result.ml_score is not None else "" + logger.info(f"โœ… Saved analysis for {stock_code} ({stock_name}): score={result.score}{ml_info}") + return "ok" except Exception as e: logger.error(f"โŒ Failed to analyze {stock_code}: {e}") - await session.rollback() - return False + return "error" async def batch_analyze_all_stocks(limit: int = None): - """์ „์ฒด ์ข…๋ชฉ ๋ฐฐ์น˜ ๋ถ„์„ (์•ˆ์ „ํ•œ ์žฌ์‹œ๋„ ๋ฐ ์“ฐ๋กœํ‹€๋ง ์ ์šฉ)""" + """์ „์ฒด ์ข…๋ชฉ ๋ฐฐ์น˜ ๋ถ„์„ (๋™์‹œ ์ฒ˜๋ฆฌ + ์ด์ค‘ API ํ˜ธ์ถœ ์ œ๊ฑฐ๋กœ ๊ณ ์†ํ™”) + + ๋ณ€๊ฒฝ ์‚ฌํ•ญ: + - ์ˆœ์ฐจ ์ฒ˜๋ฆฌ(+3์ดˆ sleep) โ†’ asyncio.gather() ๋™์‹œ ์ฒ˜๋ฆฌ (BATCH_CONCURRENCY ์ข…๋ชฉ ๋ณ‘๋ ฌ) + - ์ด์ค‘ API ํ˜ธ์ถœ ์ œ๊ฑฐ: ATR์ด analyze_stock() ๋‚ด๋ถ€์—์„œ ๊ณ„์‚ฐ๋จ + - ๊ฐ ์ข…๋ชฉ๋ณ„ ๋…๋ฆฝ ์„ธ์…˜ ์‚ฌ์šฉ (๋™์‹œ ์ฒ˜๋ฆฌ ์•ˆ์ „) + - KIS API ๋‚ด๋ถ€ ์„ธ๋งˆํฌ์–ด(API_MAX_CONCURRENT_REQUESTS)๊ฐ€ ์ž์ฒด์ ์œผ๋กœ ์†๋„ ์ œ์–ด + """ start_time = datetime.now() analysis_date = date.today() - + + # ๋™์‹œ ์ฒ˜๋ฆฌ ์ข…๋ชฉ ์ˆ˜ (KIS API ๋‚ด๋ถ€ ์„ธ๋งˆํฌ์–ด๋กœ ์‹ค์ œ API ํ˜ธ์ถœ์€ ์ถ”๊ฐ€ ์ œ์–ด๋จ) + BATCH_CONCURRENCY = 5 + logger.info(f"๐Ÿš€ Starting daily batch analysis for {analysis_date}") - logger.info(f"โš™๏ธ Rate Limiting: {settings.API_MIN_REQUEST_INTERVAL}์ดˆ ๊ฐ„๊ฒฉ, ์ตœ๋Œ€ ์žฌ์‹œ๋„ {settings.API_MAX_RETRIES}ํšŒ") - - # KIS API ์ธ์Šคํ„ด์Šค + logger.info(f"โš™๏ธ ๋™์‹œ ์ฒ˜๋ฆฌ: {BATCH_CONCURRENCY}์ข…๋ชฉ, API ์„ธ๋งˆํฌ์–ด: {settings.API_MAX_CONCURRENT_REQUESTS}req") + + # KIS API ์ธ์Šคํ„ด์Šค (์ข…๋ชฉ ๋ชฉ๋ก ์กฐํšŒ์šฉ) kis_api = KoreaInvestmentAPI() - - async with AsyncSessionLocal() as session: - # 1. ์˜ค๋Š˜ ๋‚ ์งœ ๊ธฐ์กด ๋ฐ์ดํ„ฐ ์‚ญ์ œ - logger.info("๐Ÿ—‘๏ธ Deleting old analysis data...") - await session.execute( - delete(StockAnalysis).where( - StockAnalysis.analysis_date >= analysis_date - ) - ) - await session.commit() - logger.info("โœ… Old data deleted") - - # 2. ์ „์ฒด ์ข…๋ชฉ ์กฐํšŒ - all_stocks = await get_all_tradable_stocks() - - if limit: - all_stocks = all_stocks[:limit] - logger.info(f"๐Ÿงช Limited to {limit} stocks for testing") - - # 3. ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ (์“ฐ๋กœํ‹€๋ง์€ KIS API ํด๋ž˜์Šค์—์„œ ์ž๋™ ์ฒ˜๋ฆฌ) - success_count = 0 - fail_count = 0 - - for i, (stock_code, stock_name) in enumerate(all_stocks, 1): - logger.info(f"[{i}/{len(all_stocks)}] Analyzing {stock_code} ({stock_name})...") - - success = await analyze_and_save_stock( - session, - stock_code, - stock_name, - analysis_date - ) - - if success: - success_count += 1 - else: - fail_count += 1 - - # 30๊ฐœ๋งˆ๋‹ค ํ†ต๊ณ„ ์ถœ๋ ฅ - if i % 30 == 0: - kis_api.log_monitor_stats() - + + # 1. ์‹œ๊ฐ€์ด์•ก ์ƒ์œ„ 500๊ฐœ ์ข…๋ชฉ ์กฐํšŒ (ETF/ํŠน์ˆ˜ ์ข…๋ชฉ ์ž๋™ ์ œ์™ธ) + market_cap_limit = 500 + all_stocks = await get_all_tradable_stocks(kis_api, limit=market_cap_limit) + logger.info(f"๐Ÿ“Š ์‹œ๊ฐ€์ด์•ก ์ƒ์œ„ {len(all_stocks)}๊ฐœ ์ข…๋ชฉ ์„ ์ • ์™„๋ฃŒ") + + # ํ…Œ์ŠคํŠธ์šฉ ์ถ”๊ฐ€ ์ œํ•œ (์˜ต์…˜) + filtered_stocks = all_stocks + if limit: + filtered_stocks = all_stocks[:limit] + logger.info(f"๐Ÿงช ํ…Œ์ŠคํŠธ ๋ชจ๋“œ: {limit}๊ฐœ ์ข…๋ชฉ์œผ๋กœ ์ œํ•œ") + + # 2. ๋™์‹œ ์ฒ˜๋ฆฌ (์„ธ๋งˆํฌ์–ด๋กœ ๋™์‹œ ์ฒ˜๋ฆฌ ์ข…๋ชฉ ์ˆ˜ ์ œํ•œ) + semaphore = asyncio.Semaphore(BATCH_CONCURRENCY) + total = len(filtered_stocks) + + async def analyze_one(i: int, stock_code: str, stock_name: str) -> bool: + async with semaphore: + logger.info(f"[{i}/{total}] Analyzing {stock_code} ({stock_name})...") + return await analyze_and_save_stock(stock_code, stock_name, analysis_date) + + tasks = [ + analyze_one(i, code, name) + for i, (code, name) in enumerate(filtered_stocks, 1) + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + success_count = sum(1 for r in results if r == "ok") + no_chart_count = sum(1 for r in results if r == "no_chart") + price_filter_count = sum(1 for r in results if r == "price_filter") + error_count = sum(1 for r in results if r == "error" or isinstance(r, Exception)) + # 4. ๊ฒฐ๊ณผ ์š”์•ฝ ๋ฐ ์ตœ์ข… ํ†ต๊ณ„ end_time = datetime.now() duration = (end_time - start_time).total_seconds() - + logger.info("=" * 60) logger.info(f"โœ… Batch analysis completed!") - logger.info(f"๐Ÿ“Š Success: {success_count} / Total: {len(all_stocks)}") - logger.info(f"โŒ Failed: {fail_count}") + logger.info(f"๐Ÿ“Š ์„ฑ๊ณต: {success_count} / ์ „์ฒด: {len(all_stocks)}") + logger.info(f" โ”œโ”€ ์ฐจํŠธ์—†์Œ: {no_chart_count}๊ฑด (๊ฑฐ๋ž˜์ •์ง€ยท์‹ ๊ทœ์ƒ์žฅยทํŠน์ˆ˜์ข…๋ชฉ)") + logger.info(f" โ”œโ”€ ๊ฐ€๊ฒฉํ•„ํ„ฐ: {price_filter_count}๊ฑด (5,000์› ๋ฏธ๋งŒ)") + logger.info(f" โ””โ”€ ์˜ค๋ฅ˜: {error_count}๊ฑด") logger.info(f"โฑ๏ธ Duration: {duration:.1f} seconds ({duration/60:.1f} minutes)") logger.info(f"๐Ÿ“… Analysis Date: {analysis_date}") - - # API ํ†ต๊ณ„ ์ถœ๋ ฅ - stats = kis_api.get_monitor_stats() + + # API ํ†ต๊ณ„ ์ถœ๋ ฅ (์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค ๊ธฐ์ค€) + stats = korea_investment_api.get_monitor_stats() logger.info(f"๐Ÿ“ก API ํ†ต๊ณ„:") logger.info(f" - ์ด ํ˜ธ์ถœ: {stats['total_calls']}๊ฑด") logger.info(f" - ์˜ค๋ฅ˜: {stats['total_errors']}๊ฑด ({stats['error_rate']:.1%})") @@ -212,6 +271,15 @@ async def batch_analyze_all_stocks(limit: int = None): logger.info(f" - ํ‰๊ท  ์‘๋‹ต ์‹œ๊ฐ„: {stats['avg_duration']:.2f}์ดˆ") logger.info("=" * 60) + # ๋ถ„์„์ข…๋ชฉ(watchlist) ์ดˆ๊ธฐํ™” โ€” score ์ƒ์œ„ 50๊ฐœ๋กœ ์žฅ์ค‘ ๋ฏธ๋‹ˆ๋ฐฐ์น˜ ๋Œ€์ƒ ์„ค์ • + try: + from app.services.watchlist import initialize_watchlist + async with AsyncSessionLocal() as session: + count = await initialize_watchlist(session, analysis_date) + logger.info(f"๐Ÿ“‹ ๋ถ„์„์ข…๋ชฉ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ: {count}๊ฐœ โ†’ ์žฅ์ค‘ ๋ฏธ๋‹ˆ๋ฐฐ์น˜ ์ค€๋น„") + except Exception as e: + logger.warning(f"โš ๏ธ ๋ถ„์„์ข…๋ชฉ ์ดˆ๊ธฐํ™” ์‹คํŒจ (non-critical): {e}") + async def main(): """๋ฉ”์ธ ์‹คํ–‰ ํ•จ์ˆ˜""" diff --git a/backend/batch_intraday_analysis.py b/backend/batch_intraday_analysis.py new file mode 100644 index 0000000..07e28c5 --- /dev/null +++ b/backend/batch_intraday_analysis.py @@ -0,0 +1,316 @@ +""" +์žฅ์ค‘ ๋ฏธ๋‹ˆ๋ฐฐ์น˜: ๋งค 10๋ถ„๋งˆ๋‹ค ์‹คํ–‰ (ํ‰์ผ 09:00~15:30 KST) + +[ํ๋ฆ„] +1. ์•„์นจ ๋ฐฐ์น˜(batch_daily_analysis.py)๊ฐ€ ๋ถ„์„์ข…๋ชฉ(watchlist)์„ score ์ƒ์œ„ 50๊ฐœ๋กœ ์ดˆ๊ธฐํ™” +2. ์žฅ์ค‘ ๋ฏธ๋‹ˆ๋ฐฐ์น˜๋Š” ๋ถ„์„์ข…๋ชฉ๋งŒ ์žฌ๋ถ„์„ํ•˜์—ฌ ์ตœ์‹  ์ง€ํ‘œ๋กœ stock_analysis ๊ฐฑ์‹  +3. ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ watchlist_history์— ์‹œ๊ณ„์—ด๋กœ ๋ˆ„์  ์ €์žฅ +4. score ๊ธฐ์ค€์œผ๋กœ ๋ถ„์„์ข…๋ชฉ ์ง„์ž…(โ‰ฅ60) / ์ œ๊ฑฐ(<20) ์ž๋™ ๊ด€๋ฆฌ + +์‹คํ–‰: python batch_intraday_analysis.py +Cron: */10 9-15 * * 1-5 docker exec autostock-backend python /app/batch_intraday_analysis.py >> /logs/batch_intraday.log 2>&1 +""" + +import asyncio +import sys +from datetime import datetime, date +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from sqlalchemy import select, delete, update, func +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +import logging + +from app.core.config import settings +from app.core.market_schedule import is_market_open +from app.models.models import StockAnalysis, WatchlistStock, WatchlistHistory +from app.api.endpoints.recommendations import analyze_stock_for_batch +from app.services.watchlist import ( + WATCHLIST_MAX_SIZE, + WATCHLIST_ENTRY_SCORE, + WATCHLIST_EXIT_SCORE, +) + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# ๋™์‹œ ์ฒ˜๋ฆฌ ์ข…๋ชฉ ์ˆ˜ (KIS API ์„ธ๋งˆํฌ์–ด๊ฐ€ ์ถ”๊ฐ€๋กœ ์†๋„ ์ œ์–ด) +BATCH_CONCURRENCY = 5 + +# DB ์—ฐ๊ฒฐ +database_url = settings.DATABASE_URL +if database_url.startswith("postgresql://"): + database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + +engine = create_async_engine( + database_url, + echo=False, + pool_pre_ping=True, + pool_size=5, + max_overflow=10, +) + +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_active_watchlist() -> list[tuple[str, str]]: + """ํ˜„์žฌ ํ™œ์„ฑ ๋ถ„์„์ข…๋ชฉ ๋ชฉ๋ก ์กฐํšŒ.""" + async with AsyncSessionLocal() as session: + result = await session.execute( + select(WatchlistStock.stock_code, WatchlistStock.stock_name) + .where(WatchlistStock.is_active == True) + ) + rows = result.all() + + if not rows: + logger.warning( + "โš ๏ธ ๋ถ„์„์ข…๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค. " + "์•„์นจ ๋ฐฐ์น˜(batch_daily_analysis.py)๊ฐ€ ๋จผ์ € ์‹คํ–‰๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”." + ) + else: + logger.info(f"๐Ÿ“‹ ๋ถ„์„์ข…๋ชฉ {len(rows)}๊ฐœ ๋Œ€์ƒ") + return [(row.stock_code, row.stock_name) for row in rows] + + +async def analyze_update_and_manage( + stock_code: str, + stock_name: str, + analysis_date: date, +) -> tuple[bool, int | None]: + """์ข…๋ชฉ ์žฌ๋ถ„์„ โ†’ stock_analysis ๊ฐฑ์‹  โ†’ watchlist_history ์ €์žฅ โ†’ watchlist ๊ด€๋ฆฌ. + + Returns: (success, score) + """ + try: + result = await analyze_stock_for_batch(stock_code, stock_name) + if not result: + logger.info(f"No result for {stock_code} (chart/price issue)") + return False, None + + # 5,000์› ์ดํ•˜ ์ €๊ฐ€์ฃผ โ†’ watchlist์—์„œ๋„ ์ œ๊ฑฐ + if result.current_price and result.current_price < 5000: + logger.debug(f"Skip {stock_code}: price={result.current_price:.0f}์› < 5,000์›") + async with AsyncSessionLocal() as session: + await session.execute( + update(WatchlistStock) + .where( + WatchlistStock.stock_code == stock_code, + WatchlistStock.is_active == True, + ) + .values( + is_active=False, + removed_at=datetime.now(), + removed_reason="price_filter", + ) + ) + await session.commit() + return False, None + + now = datetime.now() + score = result.score + + async with AsyncSessionLocal() as session: + # 1. stock_analysis ๊ฐฑ์‹  (DELETE + INSERT) + await session.execute( + delete(StockAnalysis).where( + StockAnalysis.stock_code == stock_code, + StockAnalysis.analysis_date == analysis_date, + ) + ) + session.add(StockAnalysis( + stock_code=stock_code, + stock_name=stock_name, + analysis_date=analysis_date, + current_price=result.current_price, + change_rate=result.change_rate, + volume=result.volume, + rsi=result.rsi, + ma5=result.ma5, + ma20=result.ma20, + ma60=result.ma60, + stochastic_k=result.stochastic_k, + stochastic_d=result.stochastic_d, + bollinger_upper=result.bollinger_upper, + bollinger_lower=result.bollinger_lower, + bollinger_middle=result.bollinger_middle, + signals=result.signals, + score=score, + ml_score=result.ml_score, + ml_confidence=result.ml_confidence, + ml_signal=result.ml_signal, + atr_14=result.atr_14, + adx_14=result.adx_14, + )) + + # 2. watchlist_history ์‹œ๊ณ„์—ด ๊ธฐ๋ก + session.add(WatchlistHistory( + stock_code=stock_code, + stock_name=stock_name, + recorded_at=now, + score=score, + current_price=result.current_price, + change_rate=result.change_rate, + rsi=result.rsi, + signals=result.signals, + )) + + # 3. score๊ฐ€ ๊ธฐ์ค€ ์ดํ•˜๋ฉด ๋ถ„์„์ข…๋ชฉ์—์„œ ์ œ๊ฑฐ + if score is not None and score < WATCHLIST_EXIT_SCORE: + await session.execute( + update(WatchlistStock) + .where( + WatchlistStock.stock_code == stock_code, + WatchlistStock.is_active == True, + ) + .values( + is_active=False, + removed_at=now, + removed_reason="score_drop", + ) + ) + logger.info( + f"๐Ÿšซ {stock_code} ({stock_name}) ๋ถ„์„์ข…๋ชฉ ์ œ๊ฑฐ: " + f"score={score} < {WATCHLIST_EXIT_SCORE}" + ) + + await session.commit() + + logger.info(f"โœ… {stock_code} ({stock_name}): score={score}") + return True, score + + except Exception as e: + logger.error(f"โŒ Failed {stock_code}: {e}") + return False, None + + +async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int: + """๋ถ„์„์ข…๋ชฉ์— ์—†๋Š” ๊ณ ์ ์ˆ˜ ์ข…๋ชฉ์„ ์‹ ๊ทœ ์ถ”๊ฐ€. + + Args: + analysis_date: ์˜ค๋Š˜ ๋‚ ์งœ (stock_analysis ๊ธฐ์ค€) + current_codes: ์ด๋ฒˆ ๋ฐฐ์น˜ ์‹œ์ž‘ ์‹œ์ ์˜ watchlist ์ฝ”๋“œ ์ง‘ํ•ฉ + + Returns: ์ถ”๊ฐ€๋œ ์ข…๋ชฉ ์ˆ˜ + """ + async with AsyncSessionLocal() as session: + # ํ˜„์žฌ ํ™œ์„ฑ ์ข…๋ชฉ ์ˆ˜ ํ™•์ธ + count_result = await session.execute( + select(func.count()) + .select_from(WatchlistStock) + .where(WatchlistStock.is_active == True) + ) + active_count = count_result.scalar() or 0 + + slots = WATCHLIST_MAX_SIZE - active_count + if slots <= 0: + logger.debug(f"Watchlist ์ตœ๋Œ€์น˜ ๋„๋‹ฌ ({active_count}/{WATCHLIST_MAX_SIZE})") + return 0 + + # stock_analysis์—์„œ ๊ณ ์ ์ˆ˜ ์ข…๋ชฉ ์ค‘ ํ˜„์žฌ watchlist์— ์—†๋Š” ๊ฒƒ ์กฐํšŒ + exclude_codes = list(current_codes) if current_codes else ["__none__"] + result = await session.execute( + select( + StockAnalysis.stock_code, + StockAnalysis.stock_name, + StockAnalysis.score, + ) + .where( + StockAnalysis.analysis_date == analysis_date, + StockAnalysis.score >= WATCHLIST_ENTRY_SCORE, + StockAnalysis.stock_code.notin_(exclude_codes), + ) + .order_by(StockAnalysis.score.desc()) + .limit(slots) + ) + candidates = result.all() + + if not candidates: + return 0 + + now = datetime.now() + for row in candidates: + session.add(WatchlistStock( + stock_code=row.stock_code, + stock_name=row.stock_name, + added_at=now, + added_reason="score_rise", + score_at_add=row.score, + is_active=True, + )) + + await session.commit() + logger.info( + f"โž• ๋ถ„์„์ข…๋ชฉ {len(candidates)}๊ฐœ ์‹ ๊ทœ ์ถ”๊ฐ€ " + f"(score โ‰ฅ {WATCHLIST_ENTRY_SCORE}, ํ˜„์žฌ {active_count + len(candidates)}๊ฐœ)" + ) + return len(candidates) + + +async def main(): + """์žฅ์ค‘ ๋ฏธ๋‹ˆ๋ฐฐ์น˜ ๋ฉ”์ธ""" + # ์žฅ์ค‘(09:00~15:30 KST ํ‰์ผ)์ด ์•„๋‹ˆ๋ฉด ์ฆ‰์‹œ ์ข…๋ฃŒ + if not is_market_open(): + logger.info("๐Ÿ“ด ์žฅ์ค‘์ด ์•„๋‹™๋‹ˆ๋‹ค (09:00~15:30 ํ‰์ผ). ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค.") + return + + start_time = datetime.now() + analysis_date = date.today() + logger.info( + f"โšก [Intraday] {start_time.strftime('%H:%M:%S')} ์‹œ์ž‘ โ€” " + f"๋ถ„์„์ข…๋ชฉ ์žฌ๋ถ„์„ (์ง„์ž…โ‰ฅ{WATCHLIST_ENTRY_SCORE}, ์ œ๊ฑฐ<{WATCHLIST_EXIT_SCORE})" + ) + + # ํ™œ์„ฑ ๋ถ„์„์ข…๋ชฉ ์กฐํšŒ + active_stocks = await get_active_watchlist() + if not active_stocks: + return + + # ์ด๋ฒˆ ๋ฐฐ์น˜ ์‹œ์ž‘ ์‹œ์ ์˜ ์ฝ”๋“œ ์ง‘ํ•ฉ (์‹ ๊ทœ ์ถ”๊ฐ€ ์‹œ ์ค‘๋ณต ๋ฐฉ์ง€์šฉ) + current_codes = {code for code, _ in active_stocks} + total = len(active_stocks) + + # ๋ณ‘๋ ฌ ์žฌ๋ถ„์„ + semaphore = asyncio.Semaphore(BATCH_CONCURRENCY) + + async def analyze_one(i: int, code: str, name: str) -> tuple[bool, int | None]: + async with semaphore: + logger.info(f"[{i}/{total}] {code} ({name})") + return await analyze_update_and_manage(code, name, analysis_date) + + tasks = [ + analyze_one(i, code, name) + for i, (code, name) in enumerate(active_stocks, 1) + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + success = sum(1 for r in results if isinstance(r, tuple) and r[0] is True) + removed = sum( + 1 for r in results + if isinstance(r, tuple) and r[0] is True + and r[1] is not None and r[1] < WATCHLIST_EXIT_SCORE + ) + fail = sum(1 for r in results if isinstance(r, tuple) and r[0] is False) + error = sum(1 for r in results if isinstance(r, Exception)) + + # ๊ณ ์ ์ˆ˜ ์‹ ๊ทœ ์ข…๋ชฉ ์ถ”๊ฐ€ + added = await refresh_watchlist(analysis_date, current_codes) + + duration = (datetime.now() - start_time).total_seconds() + logger.info( + f"โœ… [Intraday] ์™„๋ฃŒ: {success}/{total} ์„ฑ๊ณต, " + f"{removed} ์ œ๊ฑฐ, +{added} ์‹ ๊ทœ, " + f"{fail} ์‹คํŒจ, {error} ์˜ค๋ฅ˜ โ€” {duration:.1f}์ดˆ" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/cleanup_portfolio.py b/backend/cleanup_portfolio.py new file mode 100644 index 0000000..d62a0a5 --- /dev/null +++ b/backend/cleanup_portfolio.py @@ -0,0 +1,52 @@ +"""ํฌํŠธํด๋ฆฌ์˜ค ์ˆ˜๋Ÿ‰ 0์ธ ์ข…๋ชฉ ์ •๋ฆฌ ์Šคํฌ๋ฆฝํŠธ""" +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from app.core.config import settings +from app.models.models import Portfolio + +async def cleanup(): + # asyncpg ๋“œ๋ผ์ด๋ฒ„ ์‚ฌ์šฉ + database_url = settings.DATABASE_URL + if database_url.startswith("postgresql://"): + database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + + engine = create_async_engine(database_url) + AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with AsyncSessionLocal() as session: + # ์ˆ˜๋Ÿ‰ 0์ธ ์ข…๋ชฉ ์กฐํšŒ + result = await session.execute( + select(Portfolio).where(Portfolio.quantity == 0) + ) + zero_qty = result.scalars().all() + + print(f"์ˆ˜๋Ÿ‰ 0์ธ ์ข…๋ชฉ: {len(zero_qty)}๊ฐœ") + for p in zero_qty: + print(f" - {p.stock_name}({p.stock_code}): {p.quantity}์ฃผ") + + if zero_qty: + # ์‚ญ์ œ + await session.execute( + delete(Portfolio).where(Portfolio.quantity == 0) + ) + await session.commit() + print(f"\nโœ… {len(zero_qty)}๊ฐœ ์ข…๋ชฉ ์‚ญ์ œ ์™„๋ฃŒ") + else: + print("\n์ •๋ฆฌํ•  ์ข…๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.") + + # ์ตœ์ข… ํฌํŠธํด๋ฆฌ์˜ค ํ™•์ธ + result = await session.execute(select(Portfolio)) + all_portfolio = result.scalars().all() + print(f"\nํ˜„์žฌ ํฌํŠธํด๋ฆฌ์˜ค: {len(all_portfolio)}๊ฐœ") + for p in all_portfolio: + print(f" - {p.stock_name}({p.stock_code}): {p.quantity}์ฃผ") + +if __name__ == "__main__": + asyncio.run(cleanup()) diff --git a/backend/clear_test_data.py b/backend/clear_test_data.py new file mode 100644 index 0000000..29db8e3 --- /dev/null +++ b/backend/clear_test_data.py @@ -0,0 +1,58 @@ +""" +ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์‚ญ์ œ ์Šคํฌ๋ฆฝํŠธ +์žฌ์‹œ์ž‘ ์‹œ ์˜ค๋ž˜๋œ ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ์ž๋™์œผ๋กœ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. +""" + +import asyncio +import logging +from sqlalchemy import delete, select +from datetime import date, timedelta + +from app.core.database import AsyncSessionLocal +from app.models.models import StockAnalysis, TradeHistory, Portfolio + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def clear_test_data(): + """ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ๋ฐ ์˜ค๋ž˜๋œ ๋ถ„์„ ๋ฐ์ดํ„ฐ ์‚ญ์ œ""" + async with AsyncSessionLocal() as session: + try: + # 1. ์˜ค๋ž˜๋œ stock_analysis ๋ฐ์ดํ„ฐ ์‚ญ์ œ (7์ผ ์ด์ „) + cutoff_date = date.today() - timedelta(days=7) + result = await session.execute( + delete(StockAnalysis).where(StockAnalysis.analysis_date < cutoff_date) + ) + deleted_analysis = result.rowcount + logger.info(f"๐Ÿ—‘๏ธ Deleted {deleted_analysis} old stock_analysis records (before {cutoff_date})") + + # 2. PENDING/FAILED ์ƒํƒœ์˜ ์˜ค๋ž˜๋œ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์‚ญ์ œ (1์ผ ์ด์ „) + cutoff_datetime = date.today() - timedelta(days=1) + result = await session.execute( + delete(TradeHistory).where( + TradeHistory.status.in_(['PENDING', 'FAILED']), + TradeHistory.created_at < cutoff_datetime + ) + ) + deleted_trades = result.rowcount + logger.info(f"๐Ÿ—‘๏ธ Deleted {deleted_trades} old PENDING/FAILED trade records") + + # 3. ์ˆ˜๋Ÿ‰์ด 0์ธ ํฌํŠธํด๋ฆฌ์˜ค ํ•ญ๋ชฉ ์‚ญ์ œ + result = await session.execute( + delete(Portfolio).where(Portfolio.quantity <= 0) + ) + deleted_portfolio = result.rowcount + logger.info(f"๐Ÿ—‘๏ธ Deleted {deleted_portfolio} empty portfolio records") + + await session.commit() + logger.info("โœ… Test data cleanup completed successfully!") + + except Exception as e: + logger.error(f"โŒ Error during test data cleanup: {e}") + await session.rollback() + raise + + +if __name__ == "__main__": + asyncio.run(clear_test_data()) diff --git a/backend/collect_krx_data.py b/backend/collect_krx_data.py new file mode 100644 index 0000000..2fae511 --- /dev/null +++ b/backend/collect_krx_data.py @@ -0,0 +1,323 @@ +""" +KRX ํžˆ์Šคํ† ๋ฆฌ์ปฌ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์Šคํฌ๋ฆฝํŠธ + +pykrx๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ KOSPI/KOSDAQ ์ข…๋ชฉ์˜ ์ผ๋ด‰ OHLCV ๋ฐ์ดํ„ฐ๋ฅผ DB์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + +์‚ฌ์šฉ๋ฒ•: + python collect_krx_data.py # ์ „์ฒด ์ˆ˜์ง‘ (10๋…„, ์ฃผ์š” 200์ข…๋ชฉ) + python collect_krx_data.py --limit 5 --start-date 20240101 # ํ…Œ์ŠคํŠธ (5์ข…๋ชฉ, 2024๋…„~) + python collect_krx_data.py --update # ์ฆ๋ถ„ ์—…๋ฐ์ดํŠธ (๋งˆ์ง€๋ง‰ ์ €์žฅ์ผ ์ดํ›„) + python collect_krx_data.py --market kospi # KOSPI๋งŒ +""" + +import argparse +import asyncio +import logging +import sys +import time +from datetime import date, datetime, timedelta +from pathlib import Path + +import numpy as np +import pandas as pd +from pykrx import stock as pykrx_stock + +# ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ Python ๊ฒฝ๋กœ์— ์ถ”๊ฐ€ +sys.path.insert(0, str(Path(__file__).parent)) + +from sqlalchemy import text +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from app.core.database import AsyncSessionLocal, engine, Base +from app.models.models import StockDailyPrice + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +# ETF/ํŠน์ˆ˜ ์ข…๋ชฉ ์ œ์™ธ ํ‚ค์›Œ๋“œ +SKIP_KEYWORDS = [ + "ETF", "๋ ˆ๋ฒ„๋ฆฌ์ง€", "์ธ๋ฒ„์Šค", "KODEX", "TIGER", "ARIRANG", + "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", + "์„ ๋ฌผ", "์ŠคํŒฉ", "๋ฆฌ์ธ ", "SPAC", +] + + +def get_stock_list(market: str = "all", limit: int = 0) -> list: + """pykrx๋กœ ์ข…๋ชฉ ๋ชฉ๋ก ์กฐํšŒ + + Args: + market: "kospi", "kosdaq", "all" + limit: 0์ด๋ฉด ์ „์ฒด, N์ด๋ฉด ์ƒ์œ„ N๊ฐœ + + Returns: + [(stock_code, stock_name), ...] + """ + today = datetime.now().strftime("%Y%m%d") + stocks = [] + + markets = [] + if market in ("kospi", "all"): + markets.append("KOSPI") + if market in ("kosdaq", "all"): + markets.append("KOSDAQ") + + for mkt in markets: + try: + tickers = pykrx_stock.get_market_ticker_list(today, market=mkt) + for ticker in tickers: + name = pykrx_stock.get_market_ticker_name(ticker) + if any(k in name for k in SKIP_KEYWORDS): + continue + stocks.append((ticker, name)) + logger.info(f"{mkt}: {len(tickers)}๊ฐœ ์กฐํšŒ, ํ•„ํ„ฐ๋ง ํ›„ {len(stocks)}๊ฐœ") + except Exception as e: + logger.error(f"{mkt} ์ข…๋ชฉ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ: {e}") + + # ์ค‘๋ณต ์ œ๊ฑฐ + seen = set() + unique = [] + for code, name in stocks: + if code not in seen: + seen.add(code) + unique.append((code, name)) + + if limit > 0: + unique = unique[:limit] + + logger.info(f"์ด {len(unique)}๊ฐœ ์ข…๋ชฉ ์„ ์ •") + return unique + + +def fetch_ohlcv(stock_code: str, start_date: str, end_date: str) -> pd.DataFrame: + """pykrx๋กœ ์ผ๋ด‰ OHLCV ๋ฐ์ดํ„ฐ ์กฐํšŒ + + Returns: + DataFrame with columns: date, open, high, low, close, volume, change_rate, market_cap + """ + try: + df = pykrx_stock.get_market_ohlcv_by_date( + start_date, end_date, stock_code, adjusted=True + ) + if df.empty: + return pd.DataFrame() + + # ์‹œ๊ฐ€์ด์•ก ์กฐํšŒ + try: + cap_df = pykrx_stock.get_market_cap_by_date(start_date, end_date, stock_code) + if not cap_df.empty: + df["์‹œ๊ฐ€์ด์•ก"] = cap_df["์‹œ๊ฐ€์ด์•ก"] + except Exception: + df["์‹œ๊ฐ€์ด์•ก"] = None + + df = df.reset_index() + df.columns = [c.replace("๋‚ ์งœ", "date") for c in df.columns] + + result = pd.DataFrame({ + "date": df.iloc[:, 0], # ๋‚ ์งœ + "open": df["์‹œ๊ฐ€"].astype(int), + "high": df["๊ณ ๊ฐ€"].astype(int), + "low": df["์ €๊ฐ€"].astype(int), + "close": df["์ข…๊ฐ€"].astype(int), + "volume": df["๊ฑฐ๋ž˜๋Ÿ‰"].astype(int), + "change_rate": df["๋“ฑ๋ฝ๋ฅ "] / 100.0 if "๋“ฑ๋ฝ๋ฅ " in df.columns else 0, + "market_cap": df.get("์‹œ๊ฐ€์ด์•ก"), + }) + + # ๊ฑฐ๋ž˜๋Ÿ‰ 0์ธ ๋‚  ์ œ๊ฑฐ (๊ฑฐ๋ž˜์ •์ง€์ผ) + result = result[result["volume"] > 0].reset_index(drop=True) + return result + + except Exception as e: + logger.error(f"{stock_code} OHLCV ์กฐํšŒ ์‹คํŒจ: {e}") + return pd.DataFrame() + + +async def create_tables(): + """DB ํ…Œ์ด๋ธ” ์ƒ์„ฑ""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("DB ํ…Œ์ด๋ธ” ํ™•์ธ/์ƒ์„ฑ ์™„๋ฃŒ") + + +async def get_last_trade_date(session, stock_code: str) -> date | None: + """ํŠน์ • ์ข…๋ชฉ์˜ DB ๋‚ด ๋งˆ์ง€๋ง‰ ๊ฑฐ๋ž˜์ผ ์กฐํšŒ""" + result = await session.execute( + text( + "SELECT MAX(trade_date) FROM stock_daily_prices WHERE stock_code = :code" + ), + {"code": stock_code}, + ) + row = result.scalar() + return row + + +async def upsert_daily_prices(session, stock_code: str, stock_name: str, df: pd.DataFrame): + """DB์— ์ผ๋ด‰ ๋ฐ์ดํ„ฐ upsert (์ค‘๋ณต ์‹œ ์—…๋ฐ์ดํŠธ)""" + if df.empty: + return 0 + + records = [] + for _, row in df.iterrows(): + trade_date = row["date"] + if isinstance(trade_date, pd.Timestamp): + trade_date = trade_date.date() + + record = { + "stock_code": stock_code, + "stock_name": stock_name, + "trade_date": trade_date, + "open_price": int(row["open"]), + "high_price": int(row["high"]), + "low_price": int(row["low"]), + "close_price": int(row["close"]), + "volume": int(row["volume"]), + "change_rate": float(row["change_rate"]) if pd.notna(row["change_rate"]) else None, + "market_cap": int(row["market_cap"]) if pd.notna(row.get("market_cap")) else None, + } + records.append(record) + + # PostgreSQL ON CONFLICT upsert (batch) + batch_size = 500 + inserted = 0 + for i in range(0, len(records), batch_size): + batch = records[i:i + batch_size] + stmt = pg_insert(StockDailyPrice).values(batch) + stmt = stmt.on_conflict_do_update( + constraint="uq_stock_daily", + set_={ + "stock_name": stmt.excluded.stock_name, + "open_price": stmt.excluded.open_price, + "high_price": stmt.excluded.high_price, + "low_price": stmt.excluded.low_price, + "close_price": stmt.excluded.close_price, + "volume": stmt.excluded.volume, + "change_rate": stmt.excluded.change_rate, + "market_cap": stmt.excluded.market_cap, + }, + ) + await session.execute(stmt) + inserted += len(batch) + + await session.commit() + return inserted + + +async def collect_all( + market: str = "all", + limit: int = 0, + start_date: str = "20160101", + update_mode: bool = False, +): + """๋ฉ”์ธ ์ˆ˜์ง‘ ๋กœ์ง""" + await create_tables() + + end_date = datetime.now().strftime("%Y%m%d") + + # ์ข…๋ชฉ ๋ชฉ๋ก + stock_list = get_stock_list(market=market, limit=limit) + if not stock_list: + logger.error("์ข…๋ชฉ ๋ชฉ๋ก์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค") + return + + total = len(stock_list) + total_inserted = 0 + success_count = 0 + fail_count = 0 + + async with AsyncSessionLocal() as session: + for i, (stock_code, stock_name) in enumerate(stock_list): + try: + actual_start = start_date + + if update_mode: + last_date = await get_last_trade_date(session, stock_code) + if last_date: + # ๋งˆ์ง€๋ง‰ ์ €์žฅ์ผ ๋‹ค์Œ ๋‚ ๋ถ€ํ„ฐ + next_day = last_date + timedelta(days=1) + actual_start = next_day.strftime("%Y%m%d") + if next_day >= date.today(): + logger.info(f"[{i+1}/{total}] {stock_code} ({stock_name}): ์ด๋ฏธ ์ตœ์‹ ") + success_count += 1 + continue + + logger.info( + f"[{i+1}/{total}] {stock_code} ({stock_name}): " + f"{actual_start} ~ {end_date} ์ˆ˜์ง‘ ์ค‘..." + ) + + df = fetch_ohlcv(stock_code, actual_start, end_date) + if df.empty: + logger.warning(f" {stock_code}: ๋ฐ์ดํ„ฐ ์—†์Œ") + fail_count += 1 + continue + + count = await upsert_daily_prices(session, stock_code, stock_name, df) + total_inserted += count + success_count += 1 + logger.info(f" {stock_code}: {count}๊ฐœ ํ–‰ ์ €์žฅ (์ด {len(df)}์ผ)") + + except Exception as e: + logger.error(f" {stock_code} ({stock_name}) ์‹คํŒจ: {e}") + fail_count += 1 + # ์—๋Ÿฌ ์‹œ์—๋„ ๊ณ„์† ์ง„ํ–‰ + try: + await session.rollback() + except Exception: + pass + + # pykrx rate limit ๋ฐฉ์ง€ + time.sleep(0.5) + + logger.info("=" * 60) + logger.info(f"์ˆ˜์ง‘ ์™„๋ฃŒ!") + logger.info(f" ์„ฑ๊ณต: {success_count}/{total}") + logger.info(f" ์‹คํŒจ: {fail_count}/{total}") + logger.info(f" ์ด ์ €์žฅ ํ–‰: {total_inserted:,}") + logger.info("=" * 60) + + +def main(): + parser = argparse.ArgumentParser(description="KRX ํžˆ์Šคํ† ๋ฆฌ์ปฌ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘") + parser.add_argument( + "--market", + choices=["kospi", "kosdaq", "all"], + default="all", + help="์ˆ˜์ง‘ ์‹œ์žฅ (๊ธฐ๋ณธ: all)", + ) + parser.add_argument( + "--limit", type=int, default=0, help="์ข…๋ชฉ ์ˆ˜ ์ œํ•œ (0=์ „์ฒด, ํ…Œ์ŠคํŠธ์šฉ)" + ) + parser.add_argument( + "--start-date", + default="20160101", + help="์ˆ˜์ง‘ ์‹œ์ž‘์ผ YYYYMMDD (๊ธฐ๋ณธ: 20160101)", + ) + parser.add_argument( + "--update", + action="store_true", + help="์ฆ๋ถ„ ์—…๋ฐ์ดํŠธ ๋ชจ๋“œ (๋งˆ์ง€๋ง‰ ์ €์žฅ์ผ ์ดํ›„๋งŒ ์ˆ˜์ง‘)", + ) + args = parser.parse_args() + + logger.info(f"KRX ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์‹œ์ž‘") + logger.info(f" ์‹œ์žฅ: {args.market}") + logger.info(f" ์ข…๋ชฉ ์ œํ•œ: {'์ „์ฒด' if args.limit == 0 else args.limit}") + logger.info(f" ์‹œ์ž‘์ผ: {args.start_date}") + logger.info(f" ์ฆ๋ถ„ ๋ชจ๋“œ: {args.update}") + + start_time = time.time() + asyncio.run(collect_all( + market=args.market, + limit=args.limit, + start_date=args.start_date, + update_mode=args.update, + )) + elapsed = time.time() - start_time + logger.info(f"์ด ์†Œ์š”์‹œ๊ฐ„: {elapsed:.1f}์ดˆ ({elapsed/60:.1f}๋ถ„)") + + +if __name__ == "__main__": + main() diff --git a/backend/diagnose_autotrade.py b/backend/diagnose_autotrade.py new file mode 100644 index 0000000..48bf63e --- /dev/null +++ b/backend/diagnose_autotrade.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""์ž๋™๋งค๋งค ์ง„๋‹จ ์Šคํฌ๋ฆฝํŠธ""" + +import sys +import os +sys.path.insert(0, '/app') + +from datetime import datetime +import pytz + +# 1. ํ˜„์žฌ ์‹œ๊ฐ„ ํ™•์ธ +KST = pytz.timezone('Asia/Seoul') +now = datetime.now(KST) +print(f"๐Ÿ• ํ˜„์žฌ ์‹œ๊ฐ„ (KST): {now.strftime('%Y-%m-%d %H:%M:%S %A')}") +print(f" ์š”์ผ: {now.weekday()} (0=์›”, 6=์ผ)") +current_minutes = now.hour * 60 + now.minute +print(f" ์‹œ๊ฐ„(๋ถ„): {current_minutes} (์žฅ์‹œ๊ฐ„: 540-930)") + +# 2. ์žฅ ์šด์˜ ์‹œ๊ฐ„ ์ฒดํฌ +from app.core.market_schedule import is_market_open +market_status = is_market_open() +print(f"\n๐Ÿ“Š ์žฅ ์šด์˜ ์ƒํƒœ: {'โœ… OPEN (์ž๋™๋งค๋งค ๊ฐ€๋Šฅ)' if market_status else 'โŒ CLOSED (์ž๋™๋งค๋งค ์ค‘๋‹จ)'}") + +# 3. ์„ค์ • ํ™•์ธ +from app.core.config import settings +print(f"\nโš™๏ธ ์ž๋™๋งค๋งค ์„ค์ •:") +print(f" AUTO_TRADE_ENABLED: {settings.AUTO_TRADE_ENABLED}") +print(f" STOP_LOSS_PERCENT: {settings.STOP_LOSS_PERCENT}%") +print(f" TAKE_PROFIT_PERCENT: {settings.TAKE_PROFIT_PERCENT}%") +print(f" MAX_DAILY_TRADES: {settings.MAX_DAILY_TRADES}") +print(f" TREND_FILTER_ENABLED: {settings.TREND_FILTER_ENABLED}") + +# 4. API ํ‚ค ํ™•์ธ +print(f"\n๐Ÿ”‘ API ์ธ์ฆ ์ •๋ณด:") +print(f" KIS_APP_KEY: {'โœ… ์„ค์ •๋จ' if settings.KOREA_INVESTMENT_APP_KEY else 'โŒ ๋ฏธ์„ค์ •'}") +print(f" KIS_APP_SECRET: {'โœ… ์„ค์ •๋จ' if settings.KOREA_INVESTMENT_APP_SECRET else 'โŒ ๋ฏธ์„ค์ •'}") +print(f" KIS_ACCOUNT_NO: {'โœ… ์„ค์ •๋จ' if settings.KOREA_INVESTMENT_ACCOUNT_NUMBER else 'โŒ ๋ฏธ์„ค์ •'}") + +# 5. ์ž๋™๋งค๋งค ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ์ตœ์ข… ํŒ๋‹จ +can_trade = market_status and settings.AUTO_TRADE_ENABLED +print(f"\n๐ŸŽฏ ์ตœ์ข… ํŒ๋‹จ: {'โœ… ์ž๋™๋งค๋งค ์‹คํ–‰ ๊ฐ€๋Šฅ' if can_trade else 'โŒ ์ž๋™๋งค๋งค ์‹คํ–‰ ๋ถˆ๊ฐ€'}") + +if not can_trade: + reasons = [] + if not market_status: + reasons.append("- ์žฅ ๋งˆ๊ฐ ์‹œ๊ฐ„์ž…๋‹ˆ๋‹ค") + if not settings.AUTO_TRADE_ENABLED: + reasons.append("- AUTO_TRADE_ENABLED๊ฐ€ False์ž…๋‹ˆ๋‹ค") + if not settings.KOREA_INVESTMENT_APP_KEY: + reasons.append("- KIS API ์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + + print("\nโš ๏ธ ์ž๋™๋งค๋งค ๋ถˆ๊ฐ€ ์›์ธ:") + for r in reasons: + print(f" {r}") + +# 6. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ํ™•์ธ +print(f"\n๐Ÿ’พ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค:") +try: + from sqlalchemy import create_engine, text + engine = create_engine(settings.DATABASE_URL.replace('+asyncpg', '')) + with engine.connect() as conn: + result = conn.execute(text("SELECT COUNT(*) FROM portfolio")) + portfolio_count = result.scalar() + print(f" โœ… ์—ฐ๊ฒฐ ์„ฑ๊ณต") + print(f" ํฌํŠธํด๋ฆฌ์˜ค ์ข…๋ชฉ ์ˆ˜: {portfolio_count}") +except Exception as e: + print(f" โŒ ์—ฐ๊ฒฐ ์‹คํŒจ: {e}") + +print("\n" + "="*60) diff --git a/backend/fix_trade_history.py b/backend/fix_trade_history.py new file mode 100644 index 0000000..2a11dd4 --- /dev/null +++ b/backend/fix_trade_history.py @@ -0,0 +1,62 @@ +import asyncio +from app.core.database import AsyncSessionLocal +from app.models.models import TradeHistory, Portfolio +from sqlalchemy import select, and_ + +async def fix_trade_history(): + """์ž˜๋ชป๋œ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์ˆ˜์ •""" + async with AsyncSessionLocal() as db: + # ํ˜„๋Œ€ADM ํฌํŠธํด๋ฆฌ์˜ค ํ™•์ธ + result = await db.execute( + select(Portfolio).where(Portfolio.stock_code == "187660") + ) + portfolio = result.scalar_one_or_none() + + if portfolio: + actual_qty = portfolio.quantity + print(f"โœ… ์‹ค์ œ ๋ณด์œ  ์ˆ˜๋Ÿ‰: {actual_qty}์ฃผ") + else: + print("โŒ ํฌํŠธํด๋ฆฌ์˜ค์— ํ˜„๋Œ€ADM ์—†์Œ") + return + + # ํ˜„๋Œ€ADM ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์กฐํšŒ + result = await db.execute( + select(TradeHistory) + .where( + and_( + TradeHistory.stock_code == "187660", + TradeHistory.order_type == "BUY", + TradeHistory.status.in_(["COMPLETED", "EXECUTED"]) + ) + ) + .order_by(TradeHistory.created_at) + ) + trades = result.scalars().all() + + print(f"\n๐Ÿ“‹ DB ๊ฑฐ๋ž˜ ๋‚ด์—ญ: {len(trades)}๊ฑด") + total_db_qty = sum(t.executed_quantity or t.order_quantity for t in trades) + print(f"๐Ÿ“Š DB ์ด ๋งค์ˆ˜๋Ÿ‰: {total_db_qty}์ฃผ") + print(f"๐Ÿ“Š ์‹ค์ œ ๋ณด์œ ๋Ÿ‰: {actual_qty}์ฃผ") + print(f"โš ๏ธ ์ฐจ์ด: {total_db_qty - actual_qty}์ฃผ") + + # ๊ฐ€์žฅ ์ตœ๊ทผ ๊ฑฐ๋ž˜๋งŒ ๋‚จ๊ธฐ๊ณ  ๋‚˜๋จธ์ง€ ์‚ญ์ œ + if len(trades) > 1: + print(f"\n๐Ÿ—‘๏ธ ์ค‘๋ณต ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์‚ญ์ œ:") + for trade in trades[:-1]: # ๋งˆ์ง€๋ง‰ ๊ฑฐ๋ž˜ ์ œ์™ธํ•˜๊ณ  ์‚ญ์ œ + print(f" - ID {trade.id}: {trade.order_quantity}์ฃผ (์ƒ์„ฑ: {trade.created_at})") + await db.delete(trade) + + # ๋งˆ์ง€๋ง‰ ๊ฑฐ๋ž˜์˜ ์ˆ˜๋Ÿ‰์„ ์‹ค์ œ ๋ณด์œ ๋Ÿ‰์œผ๋กœ ์ˆ˜์ • + last_trade = trades[-1] + print(f"\nโœ๏ธ ๋งˆ์ง€๋ง‰ ๊ฑฐ๋ž˜ ์ˆ˜๋Ÿ‰ ์ˆ˜์ •:") + print(f" - ID {last_trade.id}: {last_trade.order_quantity}์ฃผ โ†’ {actual_qty}์ฃผ") + last_trade.executed_quantity = actual_qty + last_trade.order_quantity = actual_qty + + await db.commit() + print(f"\nโœ… ์ˆ˜์ • ์™„๋ฃŒ!") + else: + print("\nโœ… ์ค‘๋ณต ์—†์Œ") + +if __name__ == "__main__": + asyncio.run(fix_trade_history()) diff --git a/backend/force_sync_portfolio.py b/backend/force_sync_portfolio.py new file mode 100644 index 0000000..bcdf78e --- /dev/null +++ b/backend/force_sync_portfolio.py @@ -0,0 +1,45 @@ +"""ํฌํŠธํด๋ฆฌ์˜ค ๊ฐ•์ œ ๋™๊ธฐํ™” ์Šคํฌ๋ฆฝํŠธ""" +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from app.api.endpoints.portfolio import sync_portfolio, portfolio_cache +from app.core.database import get_db + +async def force_sync(): + print("๐Ÿ”„ ํฌํŠธํด๋ฆฌ์˜ค ๊ฐ•์ œ ๋™๊ธฐํ™” ์‹œ์ž‘...") + + # ์บ์‹œ ์ดˆ๊ธฐํ™” + portfolio_cache.holdings = [] + portfolio_cache.summary = None + portfolio_cache.last_synced = None + portfolio_cache.change_flag = True + + print("โœ… ์บ์‹œ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ") + + # DB ์„ธ์…˜ ์ƒ์„ฑ + from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession + from sqlalchemy.orm import sessionmaker + from app.core.config import settings + + database_url = settings.DATABASE_URL + if database_url.startswith("postgresql://"): + database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + + engine = create_async_engine(database_url) + AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with AsyncSessionLocal() as session: + try: + result = await sync_portfolio(session, force=True) + print(f"โœ… ๋™๊ธฐํ™” ์™„๋ฃŒ: {result}") + print(f"๐Ÿ“Š ๋ณด์œ  ์ข…๋ชฉ: {len(portfolio_cache.holdings)}๊ฐœ") + for h in portfolio_cache.holdings: + print(f" - {h.stock_name}({h.stock_code}): {h.quantity}์ฃผ") + except Exception as e: + print(f"โŒ ๋™๊ธฐํ™” ์‹คํŒจ: {e}") + +if __name__ == "__main__": + asyncio.run(force_sync()) diff --git a/backend/migrations/add_ml_ensemble_columns.sql b/backend/migrations/add_ml_ensemble_columns.sql new file mode 100644 index 0000000..f1ffcfd --- /dev/null +++ b/backend/migrations/add_ml_ensemble_columns.sql @@ -0,0 +1,6 @@ +-- ML ์•™์ƒ๋ธ” ์Šค์ฝ”์–ด ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (stock_analysis ํ…Œ์ด๋ธ”) +-- ์‹คํ–‰: psql -d autostock_db -f migrations/add_ml_ensemble_columns.sql + +ALTER TABLE stock_analysis ADD COLUMN IF NOT EXISTS ml_score INTEGER; +ALTER TABLE stock_analysis ADD COLUMN IF NOT EXISTS ml_confidence NUMERIC(5, 4); +ALTER TABLE stock_analysis ADD COLUMN IF NOT EXISTS ml_signal VARCHAR(10); diff --git a/backend/migrations/add_risk_management.sql b/backend/migrations/add_risk_management.sql new file mode 100644 index 0000000..2882a35 --- /dev/null +++ b/backend/migrations/add_risk_management.sql @@ -0,0 +1,39 @@ +-- ๐Ÿ”ฅ ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ ์ถ”๊ฐ€๋ฅผ ์œ„ํ•œ DB ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ + +-- 1. trading_sessions ํ…Œ์ด๋ธ” ์ƒ์„ฑ (์ผ์ผ ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ) +CREATE TABLE IF NOT EXISTS trading_sessions ( + id SERIAL PRIMARY KEY, + session_date DATE NOT NULL UNIQUE, + consecutive_losses INTEGER DEFAULT 0, + consecutive_wins INTEGER DEFAULT 0, + daily_profit_loss NUMERIC(15, 2) DEFAULT 0, + daily_trades_count INTEGER DEFAULT 0, + is_paused BOOLEAN DEFAULT FALSE, + pause_reason VARCHAR(200), + starting_balance NUMERIC(15, 2), + ending_balance NUMERIC(15, 2), + peak_balance NUMERIC(15, 2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_trading_sessions_date ON trading_sessions(session_date); + +-- 2. trade_history ํ…Œ์ด๋ธ”์— ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ํ•„๋“œ ์ถ”๊ฐ€ +ALTER TABLE trade_history +ADD COLUMN IF NOT EXISTS entry_trade_id INTEGER, +ADD COLUMN IF NOT EXISTS slippage NUMERIC(15, 2), +ADD COLUMN IF NOT EXISTS commission NUMERIC(15, 2), +ADD COLUMN IF NOT EXISTS net_profit_loss NUMERIC(15, 2), +ADD COLUMN IF NOT EXISTS profit_loss_rate NUMERIC(10, 4); + +-- 3. ๊ธฐ์กด ๋ฐ์ดํ„ฐ์— ๋Œ€ํ•œ ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • (์„ ํƒ์ ) +-- ๊ธฐ์กด ๋งค๋„ ๊ฑฐ๋ž˜์— ๋Œ€ํ•ด profit_loss_rate ๊ณ„์‚ฐ (๋งค์ˆ˜ ๊ฑฐ๋ž˜์™€ ํŽ˜์–ด๋ง ํ•„์š”) +-- ์ด ๋ถ€๋ถ„์€ ์‹ค์ œ ๋ฐ์ดํ„ฐ์— ๋งž๊ฒŒ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ํ•„์š” + +COMMENT ON TABLE trading_sessions IS '์ผ์ผ ํŠธ๋ ˆ์ด๋”ฉ ์„ธ์…˜ ๊ด€๋ฆฌ - ์—ฐ์† ์†์‹ค ์ถ”์  ๋ฐ ๋ฆฌ์Šคํฌ ์ œํ•œ'; +COMMENT ON COLUMN trade_history.entry_trade_id IS '๋งค๋„ ์‹œ ์—ฐ๊ฒฐ๋œ ๋งค์ˆ˜ ๊ฑฐ๋ž˜ ID'; +COMMENT ON COLUMN trade_history.slippage IS '์Šฌ๋ฆฌํ”ผ์ง€ (์ฒด๊ฒฐ๊ฐ€ - ์ฃผ๋ฌธ๊ฐ€)'; +COMMENT ON COLUMN trade_history.commission IS '์ˆ˜์ˆ˜๋ฃŒ'; +COMMENT ON COLUMN trade_history.net_profit_loss IS '์ˆ˜์ˆ˜๋ฃŒ ์ฐจ๊ฐ ํ›„ ์ˆœ์†์ต'; +COMMENT ON COLUMN trade_history.profit_loss_rate IS '์ˆ˜์ต๋ฅ  (%)'; diff --git a/backend/migrations/add_trade_history_columns.sql b/backend/migrations/add_trade_history_columns.sql new file mode 100644 index 0000000..73c2ae3 --- /dev/null +++ b/backend/migrations/add_trade_history_columns.sql @@ -0,0 +1,11 @@ +-- ๊ฑฐ๋ž˜ ์ด๋ ฅ ํ…Œ์ด๋ธ”์— ์ƒˆ๋กœ์šด ์ปฌ๋Ÿผ ์ถ”๊ฐ€ +ALTER TABLE trade_history +ADD COLUMN IF NOT EXISTS total_amount NUMERIC(15, 2), +ADD COLUMN IF NOT EXISTS entry_reason TEXT, +ADD COLUMN IF NOT EXISTS exit_reason TEXT, +ADD COLUMN IF NOT EXISTS signals JSONB; + +-- ๊ธฐ์กด ๋ฐ์ดํ„ฐ์— ๋Œ€ํ•œ total_amount ๊ณ„์‚ฐ +UPDATE trade_history +SET total_amount = executed_price * executed_quantity +WHERE total_amount IS NULL AND executed_price IS NOT NULL AND executed_quantity IS NOT NULL; diff --git a/backend/prefetch_token.py b/backend/prefetch_token.py new file mode 100644 index 0000000..8eef0cd --- /dev/null +++ b/backend/prefetch_token.py @@ -0,0 +1,48 @@ +""" +KIS API ํ† ํฐ ์„ ์ œ ๋ฐœ๊ธ‰ ์Šคํฌ๋ฆฝํŠธ + +๋งค์ผ ์˜ค์ „ 6์‹œ์— ์‹คํ–‰ํ•˜์—ฌ ํ† ํฐ์„ ๋ฏธ๋ฆฌ ๋ฐœ๊ธ‰ยท์บ์‹œํ•จ์œผ๋กœ์จ +์žฅ ์‹œ์ž‘ ์ „(8:50 ๋ฐฐ์น˜, ์ž๋™๋งค๋งค)์˜ ๋ถˆํ•„์š”ํ•œ ํ† ํฐ ๋ฐœ๊ธ‰ ์ง€์—ฐ์„ ๋ฐฉ์ง€ํ•œ๋‹ค. + +ํ† ํฐ ์œ ํšจ๊ธฐ๊ฐ„: 23์‹œ๊ฐ„ (6์‹œ ๋ฐœ๊ธ‰ โ†’ ๋‹ค์Œ๋‚  5์‹œ๊นŒ์ง€ ์œ ํšจ) +Cron: 0 6 * * 1-5 docker exec autostock-backend python /app/prefetch_token.py >> /logs/token_prefetch.log 2>&1 +""" + +import asyncio +import sys +from pathlib import Path +from datetime import datetime + +sys.path.insert(0, str(Path(__file__).parent)) + +import logging + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +async def main(): + from app.services.korea_investment_api import KoreaInvestmentAPI + + logger.info(f"๐Ÿ”‘ [{datetime.now().strftime('%H:%M:%S')}] ํ† ํฐ ์„ ์ œ ๋ฐœ๊ธ‰ ์‹œ์ž‘") + + api = KoreaInvestmentAPI() + + # ์บ์‹œ๋œ ํ† ํฐ์ด ์žˆ์–ด๋„ ๊ฐ•์ œ ์žฌ๋ฐœ๊ธ‰ (ํ† ํฐ ๋งŒ๋ฃŒ ์ž„๋ฐ• ๋ฐฉ์ง€) + api.access_token = None + api.token_expires_at = None + + try: + token = await api._get_access_token() + expires = api.token_expires_at.strftime('%Y-%m-%d %H:%M') if api.token_expires_at else "unknown" + logger.info(f"โœ… ํ† ํฐ ๋ฐœ๊ธ‰ ์™„๋ฃŒ โ€” ๋งŒ๋ฃŒ: {expires}") + except Exception as e: + logger.error(f"โŒ ํ† ํฐ ๋ฐœ๊ธ‰ ์‹คํŒจ: {e}") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9449ebb..da5cfcf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,21 +4,30 @@ pydantic>=2.5.0 pydantic-settings>=2.1.0 sqlalchemy>=2.0.36 asyncpg>=0.30.0 +psycopg2-binary>=2.9.9 alembic>=1.14.0 greenlet>=3.0.0 redis==5.0.1 aio-pika==9.3.1 websockets==12.0 -httpx==0.25.2 +httpx>=0.27.1 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 -python-multipart==0.0.6 -anthropic==0.7.8 +python-multipart>=0.0.9 +anthropic>=0.18.0 pandas>=2.2.0 numpy>=1.26.0 ta==0.11.0 python-dotenv==1.0.0 +pytz>=2024.1 celery==5.3.4 pytest==7.4.3 pytest-asyncio==0.21.1 feedparser==6.0.11 +joblib>=1.3.0 +pytz +# ML Ensemble +torch>=2.1.0 +lightgbm>=4.1.0 +scikit-learn>=1.3.0 +pykrx>=1.0.44 diff --git a/backend/trading_rules_config.json b/backend/trading_rules_config.json index 2330c85..81c17be 100644 --- a/backend/trading_rules_config.json +++ b/backend/trading_rules_config.json @@ -3,134 +3,83 @@ "min_cash_reserve_ratio": 0.2, "max_cash_reserve_ratio": 0.3, "min_cash_reserve_amount": 1000000, - "max_holdings_count": 5, - "max_position_size_ratio": 0.25, - "min_position_size_ratio": 0.05, + "max_holdings_count": 20, + "max_position_size_ratio": 0.04, + "min_position_size_ratio": 0.02, "max_sector_concentration": 0.4, + "leverage_etf_max_ratio": 0.20, + "mean_reversion_max_capital": 0.30, "rebalance_threshold": 0.1 }, "risk_management": { "max_daily_loss": 0.03, "max_daily_trades": 10, - "max_loss_per_trade": 0.05, + "max_loss_per_trade": 0.08, + "stop_loss_low_vol": -6.0, + "stop_loss_high_vol": -10.0, + "atr_stop_multiplier": 2.5, + "atr_trailing_multiplier": 1.5, "trailing_stop_loss": 0.02, - "daily_profit_target": 0.05 + "daily_profit_target": 0.05, + "mdd_pause_threshold": -10, + "mdd_force_threshold": -20, + "consecutive_loss_ban_count": 3, + "consecutive_loss_ban_days": 30, + "overheat_filter": { + "enabled": true, + "max_gain_3day": 15.0, + "max_gain_5day": 20.0, + "max_gain_10day": 30.0, + "max_disparity_20ma": 108.0 + }, + "bear_market_filter": { + "enabled": true, + "description": "์ฝ”์Šคํ”ผ 200์ผ MA ๊ธฐ๋ฐ˜ ํ•˜๋ฝ์žฅ ๋ฐฉ์–ด ์ „๋žต (์„น์…˜ 7.1)", + "conditions": { + "bull": "์ฝ”์Šคํ”ผ > MA200 AND MA200 ๊ธฐ์šธ๊ธฐ ์–‘(+)", + "bear": "์œ„ ์กฐ๊ฑด ๋ฏธ์ถฉ์กฑ" + }, + "bearish_actions": { + "min_score": 5, + "max_trades_per_day": 3, + "position_multiplier": 0.5 + }, + "strong_bearish_actions": { + "min_score": 8, + "max_trades_per_day": 1, + "position_multiplier": 0.1 + } + } }, "rules": [ { - "name": "RSI ๊ณผ๋งค๋„ ๋ฐ˜๋“ฑ ์ „๋žต", - "description": "RSI๊ฐ€ 30 ์ดํ•˜๋กœ ๊ณผ๋งค๋„ ์ƒํƒœ์ด๊ณ , ๊ฑฐ๋ž˜๋Ÿ‰์ด ํ‰๊ท ์˜ 150% ์ด์ƒ์ผ ๋•Œ ๋งค์ˆ˜", - "rule_type": "buy", + "name": "RSI+๋ณผ๋ฆฐ์ € ํ‰๊ท ํšŒ๊ท€", + "description": "RSI ๊ณผ๋งค๋„ + ๋ณผ๋ฆฐ์ € ํ•˜๋‹จ ํ‰๊ท ํšŒ๊ท€ ์ „๋žต (CLAUDE.md ์„น์…˜ 7)", + "rule_type": "MEAN_REVERSION", "is_active": true, "priority": 1, "conditions": { - "rsi_threshold": 30, - "volume_multiplier": 1.5, - "ma_proximity": 0.03, - "trend": "down" + "rsi_bull_max": 25, + "rsi_bear_max": 35, + "bb_proximity_pct": 1.0, + "min_price_krw": 5000, + "min_price_usd": 4.0, + "min_volume_vs_avg": 1.0 }, "actions": { "action_type": "buy", "position_sizing": { "method": "fixed_ratio", - "target_ratio": 0.15, - "min_amount": 500000 + "target_ratio": 0.04 }, + "stop_loss_atr_mult": 2.5, + "stop_loss_max_pct": -8.0, "take_profit": 5.0, - "stop_loss": 3.0 - } - }, - { - "name": "๊ณจ๋“ ํฌ๋กœ์Šค ์ถ”์„ธ ์ถ”์ข…", - "description": "20์ผ์„ ์ด 60์ผ์„ ์„ ์ƒํ–ฅ ๋ŒํŒŒํ•˜๊ณ  ์ƒ์Šน ์ถ”์„ธ๊ฐ€ ํ™•์ธ๋  ๋•Œ ๋งค์ˆ˜", - "rule_type": "buy", - "is_active": true, - "priority": 2, - "conditions": { - "sma_short": 20, - "sma_long": 60, - "macd_signal": "positive", - "volume_trend": "increasing" - }, - "actions": { - "action_type": "buy", - "position_sizing": { - "method": "fixed_ratio", - "target_ratio": 0.2, - "min_amount": 1000000 - }, - "take_profit": 10.0, - "stop_loss": 5.0 - } - }, - { - "name": "์ง€์ง€์„  ๋ฐ˜๋“ฑ ์ „๋žต", - "description": "์ฃผ์š” ์ง€์ง€์„ ์—์„œ ๋ฐ˜๋“ฑํ•˜๊ณ  ๊ธ์ •์ ์ธ ๋‰ด์Šค๋‚˜ ์ด๋ฒคํŠธ๊ฐ€ ์žˆ์„ ๋•Œ ๋งค์ˆ˜", - "rule_type": "buy", - "is_active": true, - "priority": 3, - "conditions": { - "support_level": "recent_low", - "bb_position": "lower_20", - "rsi_trend": "rising", - "volume_increase": true - }, - "actions": { - "action_type": "buy", - "position_sizing": { - "method": "fixed_ratio", - "target_ratio": 0.15, - "min_amount": 500000 - }, - "take_profit": 7.0, - "stop_loss": 4.0 - } - }, - { - "name": "๋ฐ•์Šค๊ถŒ ๋ŒํŒŒ ์ „๋žต", - "description": "๋ฐ•์Šค๊ถŒ ์ƒ๋‹จ์„ ๊ฐ•ํ•œ ๊ฑฐ๋ž˜๋Ÿ‰๊ณผ ํ•จ๊ป˜ ๋ŒํŒŒํ•  ๋•Œ ๋งค์ˆ˜", - "rule_type": "buy", - "is_active": true, - "priority": 4, - "conditions": { - "breakout_period": 20, - "volume_multiplier": 2.0, - "rsi_max": 70, - "trend": "up" - }, - "actions": { - "action_type": "buy", - "position_sizing": { - "method": "fixed_ratio", - "target_ratio": 0.2, - "min_amount": 1000000 - }, - "take_profit": 15.0, - "stop_loss": 5.0 - } - }, - { - "name": "๋ชจ๋ฉ˜ํ…€ ์ถ”์„ธ ์ „๋žต", - "description": "๊ฐ•ํ•œ ์ƒ์Šน ๋ชจ๋ฉ˜ํ…€์ด ์ง€์†๋˜๊ณ  ์žˆ์„ ๋•Œ ์ถ”๊ฐ€ ๋งค์ˆ˜", - "rule_type": "buy", - "is_active": false, - "priority": 5, - "conditions": { - "consecutive_up_days": 3, - "volume_trend": "increasing", - "macd_histogram": "increasing", - "rsi_min": 50, - "rsi_max": 70 - }, - "actions": { - "action_type": "buy", - "position_sizing": { - "method": "fixed_ratio", - "target_ratio": 0.15, - "min_amount": 500000 - }, - "take_profit": 12.0, - "stop_loss": 4.0 + "stop_loss": 6.0, + "exit_1_rsi": 50, + "exit_1_pct": 0.50, + "exit_2_rsi_min": 60, + "exit_2_rsi_max": 70 } } ] diff --git a/backend/train_ensemble.py b/backend/train_ensemble.py new file mode 100644 index 0000000..56fd9d9 --- /dev/null +++ b/backend/train_ensemble.py @@ -0,0 +1,306 @@ +""" +์•™์ƒ๋ธ” ๋ชจ๋ธ ํ•™์Šต ์Šคํฌ๋ฆฝํŠธ + +๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ ์„ ํƒํ•˜์—ฌ LightGBM + PyTorch LSTM + Random Forest ์•™์ƒ๋ธ” ๋ชจ๋ธ์„ ํ•™์Šตํ•ฉ๋‹ˆ๋‹ค. +- DB ๋ชจ๋“œ(๊ธฐ๋ณธ): stock_daily_prices ํ…Œ์ด๋ธ”์˜ KRX ํžˆ์Šคํ† ๋ฆฌ์ปฌ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ +- API ๋ชจ๋“œ: KIS API์—์„œ ์‹ค์‹œ๊ฐ„ ์ฐจํŠธ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ + +์‚ฌ์šฉ๋ฒ•: + python train_ensemble.py # DB ๊ธฐ๋ฐ˜ ์ „์ฒด ํ•™์Šต + python train_ensemble.py --source db --limit 20 # DB์—์„œ 20์ข…๋ชฉ + python train_ensemble.py --source api --test # KIS API๋กœ 10์ข…๋ชฉ (๊ธฐ์กด ๋ฐฉ์‹) + python train_ensemble.py --min-days 500 # ์ตœ์†Œ 500์ผ ๋ฐ์ดํ„ฐ ์žˆ๋Š” ์ข…๋ชฉ๋งŒ +""" + +import asyncio +import sys +import argparse +import logging +from pathlib import Path +from datetime import datetime + +import numpy as np +import pandas as pd + +# ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ Python ๊ฒฝ๋กœ์— ์ถ”๊ฐ€ +sys.path.insert(0, str(Path(__file__).parent)) + +from app.services.feature_engineering import ( + chart_data_to_dataframe, + db_data_to_dataframe, + extract_features, + prepare_training_data, +) +from app.services.ml_ensemble import MLEnsemble + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +# โ”€โ”€โ”€ DB ๊ธฐ๋ฐ˜ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ โ”€โ”€โ”€ + +async def collect_training_data_from_db(limit: int = 200, min_days: int = 200) -> tuple: + """DB์˜ stock_daily_prices์—์„œ ํ•™์Šต ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ + + Args: + limit: ์ตœ๋Œ€ ์ข…๋ชฉ ์ˆ˜ + min_days: ์ตœ์†Œ ๋ฐ์ดํ„ฐ ์ผ์ˆ˜ (์ด๋ณด๋‹ค ์ ์œผ๋ฉด ์ œ์™ธ) + + Returns: + (X_tabular, X_seq, y) + """ + from sqlalchemy import text + from app.core.database import AsyncSessionLocal + + all_X_tabular = [] + all_X_seq = [] + all_y = [] + + async with AsyncSessionLocal() as session: + # ์ถฉ๋ถ„ํ•œ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๋Š” ์ข…๋ชฉ ์กฐํšŒ + result = await session.execute( + text(""" + SELECT stock_code, stock_name, COUNT(*) as cnt + FROM stock_daily_prices + GROUP BY stock_code, stock_name + HAVING COUNT(*) >= :min_days + ORDER BY cnt DESC + LIMIT :limit + """), + {"min_days": min_days, "limit": limit}, + ) + stock_list = [(row[0], row[1], row[2]) for row in result.fetchall()] + + if not stock_list: + logger.error(f"DB์— {min_days}์ผ ์ด์ƒ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๋Š” ์ข…๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค") + logger.error("๋จผ์ € python collect_krx_data.py ๋ฅผ ์‹คํ–‰ํ•˜์„ธ์š”") + return np.array([]), np.array([]), np.array([]) + + logger.info(f"DB์—์„œ {len(stock_list)}๊ฐœ ์ข…๋ชฉ ์กฐํšŒ (์ตœ์†Œ {min_days}์ผ)") + + for i, (stock_code, stock_name, day_count) in enumerate(stock_list): + logger.info(f"[{i+1}/{len(stock_list)}] {stock_code} ({stock_name}): {day_count}์ผ ๋ฐ์ดํ„ฐ") + + try: + # DB์—์„œ OHLCV ์กฐํšŒ + rows = await session.execute( + text(""" + SELECT trade_date, open_price, high_price, low_price, + close_price, volume + FROM stock_daily_prices + WHERE stock_code = :code + ORDER BY trade_date ASC + """), + {"code": stock_code}, + ) + data = [ + { + "trade_date": r[0], + "open_price": r[1], + "high_price": r[2], + "low_price": r[3], + "close_price": r[4], + "volume": r[5], + } + for r in rows.fetchall() + ] + + if len(data) < min_days: + continue + + # DataFrame ๋ณ€ํ™˜ ๋ฐ ํ”ผ์ฒ˜ ์ถ”์ถœ + df = db_data_to_dataframe(data) + features_df = extract_features(df) + + # ํ•™์Šต ๋ฐ์ดํ„ฐ ์ค€๋น„ + X_tab, X_seq, y, indices = prepare_training_data( + df, features_df, + forecast_days=3, + up_threshold=0.015, + down_threshold=-0.015, + ) + + if len(X_tab) == 0: + logger.warning(f" {stock_code}: ์œ ํšจ ์ƒ˜ํ”Œ ์—†์Œ") + continue + + all_X_tabular.append(X_tab) + all_X_seq.append(X_seq) + all_y.append(y) + + logger.info( + f" {stock_code}: {len(X_tab)}๊ฐœ ์ƒ˜ํ”Œ " + f"(UP:{np.sum(y==2)} HOLD:{np.sum(y==1)} DOWN:{np.sum(y==0)})" + ) + + except Exception as e: + logger.error(f" {stock_code} ์ฒ˜๋ฆฌ ์‹คํŒจ: {e}") + + if not all_X_tabular: + return np.array([]), np.array([]), np.array([]) + + X_tabular = np.vstack(all_X_tabular) + X_seq = np.vstack(all_X_seq) + y = np.concatenate(all_y) + + return X_tabular, X_seq, y + + +# โ”€โ”€โ”€ KIS API ๊ธฐ๋ฐ˜ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ (๊ธฐ์กด ๋ฐฉ์‹) โ”€โ”€โ”€ + +async def collect_training_data_from_api(stock_list: list, kis_api) -> tuple: + """์—ฌ๋Ÿฌ ์ข…๋ชฉ์˜ ์ฐจํŠธ ๋ฐ์ดํ„ฐ๋ฅผ KIS API์—์„œ ์ˆ˜์ง‘ํ•˜์—ฌ ํ•™์Šต ๋ฐ์ดํ„ฐ๋กœ ๋ณ€ํ™˜""" + all_X_tabular = [] + all_X_seq = [] + all_y = [] + + for i, (stock_code, stock_name) in enumerate(stock_list): + logger.info(f"[{i+1}/{len(stock_list)}] ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘: {stock_code} ({stock_name})") + + try: + chart_resp = await kis_api.get_daily_chart(stock_code, "D") + chart_data = chart_resp.get("output", []) + + if not chart_data or len(chart_data) < 80: + logger.warning(f" {stock_code}: ๋ฐ์ดํ„ฐ ๋ถ€์กฑ ({len(chart_data) if chart_data else 0}์ผ)") + continue + + df = chart_data_to_dataframe(chart_data) + features_df = extract_features(df) + + X_tab, X_seq, y, indices = prepare_training_data( + df, features_df, + forecast_days=3, + up_threshold=0.015, + down_threshold=-0.015, + ) + + if len(X_tab) == 0: + logger.warning(f" {stock_code}: ์œ ํšจ ์ƒ˜ํ”Œ ์—†์Œ") + continue + + all_X_tabular.append(X_tab) + all_X_seq.append(X_seq) + all_y.append(y) + + logger.info(f" {stock_code}: {len(X_tab)}๊ฐœ ์ƒ˜ํ”Œ (UP:{np.sum(y==2)} HOLD:{np.sum(y==1)} DOWN:{np.sum(y==0)})") + + except Exception as e: + logger.error(f" {stock_code} ์ˆ˜์ง‘ ์‹คํŒจ: {e}") + + await asyncio.sleep(3) + + if not all_X_tabular: + return np.array([]), np.array([]), np.array([]) + + X_tabular = np.vstack(all_X_tabular) + X_seq = np.vstack(all_X_seq) + y = np.concatenate(all_y) + + return X_tabular, X_seq, y + + +async def get_stock_list_from_api(kis_api, limit: int = 50) -> list: + """๊ฑฐ๋ž˜๋Ÿ‰ ์ƒ์œ„ ์ข…๋ชฉ ์กฐํšŒ (KIS API)""" + stocks = [] + + try: + kospi = await kis_api.get_volume_rank("J") + if kospi and kospi.get("output"): + for item in kospi["output"]: + name = item.get("hts_kor_isnm", "") + skip_keywords = ["ETF", "๋ ˆ๋ฒ„๋ฆฌ์ง€", "์ธ๋ฒ„์Šค", "KODEX", "์„ ๋ฌผ", "TIGER", "ARIRANG"] + if any(k in name for k in skip_keywords): + continue + stocks.append((item["mksc_shrn_iscd"], name)) + except Exception as e: + logger.error(f"KOSPI ์ข…๋ชฉ ์กฐํšŒ ์‹คํŒจ: {e}") + + await asyncio.sleep(3) + + try: + kosdaq = await kis_api.get_volume_rank("Q") + if kosdaq and kosdaq.get("output"): + for item in kosdaq["output"]: + name = item.get("hts_kor_isnm", "") + skip_keywords = ["ETF", "๋ ˆ๋ฒ„๋ฆฌ์ง€", "์ธ๋ฒ„์Šค", "KODEX", "์„ ๋ฌผ", "TIGER", "ARIRANG"] + if any(k in name for k in skip_keywords): + continue + stocks.append((item["mksc_shrn_iscd"], name)) + except Exception as e: + logger.error(f"KOSDAQ ์ข…๋ชฉ ์กฐํšŒ ์‹คํŒจ: {e}") + + logger.info(f"์ด {len(stocks)}๊ฐœ ์ข…๋ชฉ ์กฐํšŒ ์™„๋ฃŒ") + return stocks[:limit] + + +# โ”€โ”€โ”€ ๋ฉ”์ธ โ”€โ”€โ”€ + +async def main(): + parser = argparse.ArgumentParser(description="์•™์ƒ๋ธ” ๋ชจ๋ธ ํ•™์Šต") + parser.add_argument("--source", choices=["db", "api"], default="db", + help="๋ฐ์ดํ„ฐ ์†Œ์Šค (๊ธฐ๋ณธ: db)") + parser.add_argument("--limit", type=int, default=200, + help="ํ•™์Šต ์ข…๋ชฉ ์ˆ˜ (๊ธฐ๋ณธ: 200)") + parser.add_argument("--test", action="store_true", + help="ํ…Œ์ŠคํŠธ ๋ชจ๋“œ (10์ข…๋ชฉ)") + parser.add_argument("--min-days", type=int, default=200, + help="DB ๋ชจ๋“œ: ์ตœ์†Œ ๋ฐ์ดํ„ฐ ์ผ์ˆ˜ (๊ธฐ๋ณธ: 200)") + args = parser.parse_args() + + limit = 10 if args.test else args.limit + logger.info(f"๋ฐ์ดํ„ฐ ์†Œ์Šค: {args.source.upper()}") + logger.info(f"{'ํ…Œ์ŠคํŠธ ๋ชจ๋“œ' if args.test else '์ „์ฒด ํ•™์Šต'}: {limit}๊ฐœ ์ข…๋ชฉ") + + start_time = datetime.now() + + # ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ + if args.source == "db": + logger.info("=" * 60) + logger.info("DB(stock_daily_prices)์—์„œ ํ•™์Šต ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์ค‘...") + X_tabular, X_seq, y = await collect_training_data_from_db( + limit=limit, min_days=args.min_days + ) + else: + # KIS API ๋ชจ๋“œ + from app.services.korea_investment_api import KoreaInvestmentAPI + kis_api = KoreaInvestmentAPI() + + stock_list = await get_stock_list_from_api(kis_api, limit=limit) + if not stock_list: + logger.error("์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + return + + logger.info("=" * 60) + logger.info("KIS API์—์„œ ํ•™์Šต ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์ค‘...") + X_tabular, X_seq, y = await collect_training_data_from_api(stock_list, kis_api) + + if len(X_tabular) == 0: + logger.error("ํ•™์Šต ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + return + + logger.info(f"์ด ํ•™์Šต ๋ฐ์ดํ„ฐ: {len(X_tabular):,}๊ฐœ ์ƒ˜ํ”Œ") + logger.info(f" ํด๋ž˜์Šค ๋ถ„ํฌ: UP={np.sum(y==2):,}, HOLD={np.sum(y==1):,}, DOWN={np.sum(y==0):,}") + + # ์•™์ƒ๋ธ” ํ•™์Šต + logger.info("=" * 60) + logger.info("์•™์ƒ๋ธ” ๋ชจ๋ธ ํ•™์Šต ์ค‘...") + ensemble = MLEnsemble() + metrics = ensemble.train(X_tabular, X_seq, y) + + # ๊ฒฐ๊ณผ ์ถœ๋ ฅ + duration = (datetime.now() - start_time).total_seconds() + logger.info("=" * 60) + logger.info("ํ•™์Šต ์™„๋ฃŒ!") + logger.info(f" ์†Œ์š” ์‹œ๊ฐ„: {duration:.1f}์ดˆ ({duration/60:.1f}๋ถ„)") + logger.info(f" ํ•™์Šต ๊ฒฐ๊ณผ:") + for key, value in metrics.items(): + logger.info(f" {key}: {value}") + logger.info("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/clear_test_data.sh b/clear_test_data.sh new file mode 100755 index 0000000..69f479f --- /dev/null +++ b/clear_test_data.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ๊ฐ•์ œ ์‚ญ์ œ ์Šคํฌ๋ฆฝํŠธ + +echo "๐Ÿ—‘๏ธ Deleting all test data from stock_analysis table..." + +docker exec -i autostock-postgres psql -U stock_admin -d stock << EOF +DELETE FROM stock_analysis; +DELETE FROM trade_history WHERE status IN ('PENDING', 'FAILED'); +DELETE FROM portfolio WHERE quantity <= 0; +SELECT 'Cleanup completed. Remaining stock_analysis records: ' || COUNT(*) FROM stock_analysis; +EOF + +echo "โœ… Test data cleanup completed!" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a87ce03..407acd5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,13 +1,12 @@ -version: '3' services: postgres: image: postgres:15-alpine container_name: autostock-postgres environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER:-stock} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-stock1234} + POSTGRES_DB: ${POSTGRES_DB:-stock} volumes: - postgres_data:/var/lib/postgresql/data - ./docker/init-db.sql:/docker-entrypoint-initdb.d/init.sql @@ -30,8 +29,8 @@ services: image: rabbitmq:3-management-alpine container_name: autostock-rabbitmq environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER:-guest} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS:-guest} ports: - "5672:5672" - "15672:15672" @@ -40,6 +39,72 @@ services: networks: - autostock-network + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: autostock-backend + volumes: + - ./backend:/app + ports: + - "8090:8000" + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-stock}:${POSTGRES_PASSWORD:-stock1234}@postgres:5432/${POSTGRES_DB:-stock} + - REDIS_URL=redis://redis:6379/0 + - RABBITMQ_URL=amqp://${RABBITMQ_DEFAULT_USER:-guest}:${RABBITMQ_DEFAULT_PASS:-guest}@rabbitmq:5672/ + - KIS_MODE=${KIS_MODE:-virtual} + - KIS_VIRTUAL_APP_KEY=${KIS_VIRTUAL_APP_KEY} + - KIS_VIRTUAL_APP_SECRET=${KIS_VIRTUAL_APP_SECRET} + - KIS_VIRTUAL_ACCOUNT_NO=${KIS_VIRTUAL_ACCOUNT_NO} + - KIS_VIRTUAL_URL=${KIS_VIRTUAL_URL:-https://openapivts.koreainvestment.com:29443} + - KIS_REAL_APP_KEY=${KIS_REAL_APP_KEY} + - KIS_REAL_APP_SECRET=${KIS_REAL_APP_SECRET} + - KIS_REAL_ACCOUNT_NO=${KIS_REAL_ACCOUNT_NO} + - KIS_REAL_URL=${KIS_REAL_URL:-https://openapi.koreainvestment.com:9443} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - MCP_SERVER_URL=http://mcp-server:8001 + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + depends_on: + - postgres + - redis + - rabbitmq + networks: + - autostock-network + + mcp-server: + build: + context: ./mcp-server + dockerfile: Dockerfile + container_name: autostock-mcp-server + volumes: + - ./mcp-server:/app + ports: + - "8001:8001" + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - LOG_LEVEL=debug + - DATABASE_URL=postgresql://${POSTGRES_USER:-stock}:${POSTGRES_PASSWORD:-stock1234}@postgres:5432/${POSTGRES_DB:-stock} + command: uvicorn main:app --host 0.0.0.0 --port 8001 --reload + networks: + - autostock-network + + frontend: + image: node:20-alpine + container_name: autostock-frontend + working_dir: /app + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "3000:3000" + environment: + - VITE_API_TARGET=http://backend:8000 + command: sh -c "npm install && npm run dev" + depends_on: + - backend + networks: + - autostock-network + volumes: postgres_data: redis_data: diff --git a/docker-compose.yml b/docker-compose.yml index 1132e73..09029db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # PostgreSQL Database postgres: @@ -92,16 +90,21 @@ services: - DATABASE_URL=${DATABASE_URL} - REDIS_URL=${REDIS_URL} - RABBITMQ_URL=${RABBITMQ_URL} - - KOREA_INVESTMENT_APP_KEY=${KOREA_INVESTMENT_APP_KEY} - - KOREA_INVESTMENT_APP_SECRET=${KOREA_INVESTMENT_APP_SECRET} - - KOREA_INVESTMENT_ACCOUNT_NUMBER=${KOREA_INVESTMENT_ACCOUNT_NUMBER} - - KOREA_INVESTMENT_API_BASE_URL=${KOREA_INVESTMENT_API_BASE_URL} + - KIS_MODE=${KIS_MODE:-virtual} + - KIS_VIRTUAL_APP_KEY=${KIS_VIRTUAL_APP_KEY} + - KIS_VIRTUAL_APP_SECRET=${KIS_VIRTUAL_APP_SECRET} + - KIS_VIRTUAL_ACCOUNT_NO=${KIS_VIRTUAL_ACCOUNT_NO} + - KIS_VIRTUAL_URL=${KIS_VIRTUAL_URL:-https://openapivts.koreainvestment.com:29443} + - KIS_REAL_APP_KEY=${KIS_REAL_APP_KEY} + - KIS_REAL_APP_SECRET=${KIS_REAL_APP_SECRET} + - KIS_REAL_ACCOUNT_NO=${KIS_REAL_ACCOUNT_NO} + - KIS_REAL_URL=${KIS_REAL_URL:-https://openapi.koreainvestment.com:9443} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - SECRET_KEY=${SECRET_KEY} - BACKEND_HOST=${BACKEND_HOST} - BACKEND_PORT=${BACKEND_PORT} - MCP_SERVER_URL=http://mcp-server:8001 - - NEWS_API_KEY=${NEWS_API_KEY} + - NEWS_API_KEY=${NEWS_API_KEY:-} - LOG_LEVEL=${LOG_LEVEL} ports: - "8000:8000" diff --git a/API_DOCS.md b/docs/API_DOCS.md similarity index 100% rename from API_DOCS.md rename to docs/API_DOCS.md diff --git a/API_SAFE_OPERATION.md b/docs/API_SAFE_OPERATION.md similarity index 100% rename from API_SAFE_OPERATION.md rename to docs/API_SAFE_OPERATION.md diff --git a/docs/BATCH_ANALYSIS_GUIDE.md b/docs/BATCH_ANALYSIS_GUIDE.md new file mode 100644 index 0000000..50750c8 --- /dev/null +++ b/docs/BATCH_ANALYSIS_GUIDE.md @@ -0,0 +1,116 @@ +# ๋ฐฐ์น˜ ๋ถ„์„ ๊ฐ€์ด๋“œ + +## ๊ฐœ์š” +๋งค์ผ ์•„์นจ ์žฅ ์‹œ์ž‘ ์ „์— ์ „์ฒด ์ข…๋ชฉ์„ ๋ถ„์„ํ•˜์—ฌ DB์— ์บ์‹œํ•ฉ๋‹ˆ๋‹ค. +์ด๋ฅผ ํ†ตํ•ด ๋Œ€์‹œ๋ณด๋“œ ์ฒซ ์ง„์ž… ์‹œ ๋น ๋ฅธ ๋กœ๋”ฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + +## ์ˆ˜๋™ ์‹คํ–‰ + +### ํ…Œ์ŠคํŠธ (10๊ฐœ ์ข…๋ชฉ๋งŒ) +```bash +cd backend +../.venv/bin/python batch_daily_analysis.py --test +``` + +### ์ œํ•œ๋œ ๊ฐœ์ˆ˜ ๋ถ„์„ +```bash +cd backend +../.venv/bin/python batch_daily_analysis.py --limit 30 +``` + +### ์ „์ฒด ๋ถ„์„ +```bash +cd backend +../.venv/bin/python batch_daily_analysis.py +``` + +## ์ž๋™ ์‹คํ–‰ (Cron) + +### Cron Job ์„ค์ • +```bash +./setup-cron.sh +``` + +์ด ์Šคํฌ๋ฆฝํŠธ๋Š”: +- ๋งค์ผ ํ‰์ผ(์›”~๊ธˆ) ์˜ค์ „ 8์‹œ 50๋ถ„์— ๋ฐฐ์น˜ ๋ถ„์„ ์‹คํ–‰ +- ์žฅ ์‹œ์ž‘ ์ „(9์‹œ) ๋ฐ์ดํ„ฐ๋ฅผ ์ค€๋น„ +- ๋กœ๊ทธ๋Š” `logs/batch_analysis.log`์— ์ €์žฅ + +### Cron Job ํ™•์ธ +```bash +crontab -l | grep batch_daily_analysis +``` + +### Cron Job ์ œ๊ฑฐ +```bash +crontab -e +# ํ•ด๋‹น ๋ผ์ธ ์‚ญ์ œ ํ›„ ์ €์žฅ +``` + +## ๋กœ๊ทธ ํ™•์ธ + +### ์‹ค์‹œ๊ฐ„ ๋กœ๊ทธ ๋ณด๊ธฐ +```bash +tail -f logs/batch_analysis.log +``` + +### ์ตœ๊ทผ ๋กœ๊ทธ ํ™•์ธ +```bash +tail -100 logs/batch_analysis.log +``` + +## ๋™์ž‘ ๋ฐฉ์‹ + +1. **8:50 AM**: Cron์ด ๋ฐฐ์น˜ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ +2. **์ข…๋ชฉ ์กฐํšŒ**: KOSPI + KOSDAQ ๊ฑฐ๋ž˜๋Ÿ‰ ์ƒ์œ„ ์ข…๋ชฉ +3. **๊ธฐ์ˆ ์  ๋ถ„์„**: ๊ฐ ์ข…๋ชฉ์˜ RSI, MA, ๋ณผ๋ฆฐ์ € ๋ฐด๋“œ ๋“ฑ ๊ณ„์‚ฐ +4. **DB ์ €์žฅ**: `stock_analysis` ํ…Œ์ด๋ธ”์— ์ €์žฅ +5. **9:00 AM**: ์žฅ ์‹œ์ž‘ +6. **์ฒซ ์ง„์ž…**: DB์—์„œ ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋น ๋ฅด๊ฒŒ ๋กœ๋“œ (1์ดˆ ์ด๋‚ด) + +## API ์ œํ•œ ๋Œ€์‘ + +ํ•œ๊ตญํˆฌ์ž์ฆ๊ถŒ API๋Š” ๋‹ค์Œ ์ œํ•œ์ด ์žˆ์Šต๋‹ˆ๋‹ค: +- ํ† ํฐ ๋ฐœ๊ธ‰: 1๋ถ„์— 1ํšŒ +- API ํ˜ธ์ถœ: ์ดˆ๋‹น 2ํšŒ ๊ถŒ์žฅ + +๋ฐฐ์น˜ ์Šคํฌ๋ฆฝํŠธ๋Š”: +- ๊ฐ ์ข…๋ชฉ ๋ถ„์„ ํ›„ 2์ดˆ ๋Œ€๊ธฐ +- API ์—๋Ÿฌ ์‹œ ์ž๋™ ์žฌ์‹œ๋„ (์ตœ๋Œ€ 3ํšŒ) +- ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„๋กœ ๋Œ€๊ธฐ ์‹œ๊ฐ„ ์ฆ๊ฐ€ + +## ์˜ˆ์ƒ ์†Œ์š” ์‹œ๊ฐ„ + +- 10๊ฐœ ์ข…๋ชฉ: ์•ฝ 30์ดˆ +- 30๊ฐœ ์ข…๋ชฉ: ์•ฝ 2๋ถ„ +- 60๊ฐœ ์ข…๋ชฉ (์ „์ฒด): ์•ฝ 4๋ถ„ + +## ๋ฌธ์ œ ํ•ด๊ฒฐ + +### Cron์ด ์‹คํ–‰๋˜์ง€ ์•Š์Œ +```bash +# Cron ๋ฐ๋ชฌ ์ƒํƒœ ํ™•์ธ (macOS) +sudo launchctl list | grep cron + +# Cron ๋ฐ๋ชฌ ์‹œ์ž‘ (macOS) +sudo launchctl start com.vix.cron +``` + +### API ํ† ํฐ ์—๋Ÿฌ +๋กœ๊ทธ์— "์ ‘๊ทผํ† ํฐ ๋ฐœ๊ธ‰ ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”" ๋ฉ”์‹œ์ง€๊ฐ€ ๋ฐ˜๋ณต๋˜๋ฉด: +- ๋ฐฐ์น˜ ์Šคํฌ๋ฆฝํŠธ๊ฐ€ ๋„ˆ๋ฌด ๋นจ๋ฆฌ ์‹คํ–‰๋จ +- `batch_daily_analysis.py`์˜ `await asyncio.sleep(2)` ๊ฐ’์„ 3~5์ดˆ๋กœ ์ฆ๊ฐ€ + +### DB ์—ฐ๊ฒฐ ์—๋Ÿฌ +```bash +# PostgreSQL ์‹คํ–‰ ํ™•์ธ +pg_isready -h localhost -p 5432 + +# ์„œ๋น„์Šค ์‹œ์ž‘ +./start-all.sh +``` + +## ์ฐธ๊ณ  +- ๋ฐฐ์น˜ ๋ถ„์„์€ ์„ ํƒ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค +- ์‹คํ–‰ํ•˜์ง€ ์•Š์•„๋„ ์ฒซ ์ง„์ž… ์‹œ ์‹ค์‹œ๊ฐ„ ๋ถ„์„์ด ์ž๋™์œผ๋กœ ์ˆ˜ํ–‰๋ฉ๋‹ˆ๋‹ค +- ๋‹ค๋งŒ ์‹ค์‹œ๊ฐ„ ๋ถ„์„์€ 10~30์ดˆ ์†Œ์š”๋ฉ๋‹ˆ๋‹ค diff --git a/BATCH_GUIDE.md b/docs/BATCH_GUIDE.md similarity index 100% rename from BATCH_GUIDE.md rename to docs/BATCH_GUIDE.md diff --git a/INSTALLATION.md b/docs/INSTALLATION.md similarity index 100% rename from INSTALLATION.md rename to docs/INSTALLATION.md diff --git a/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md similarity index 100% rename from PROJECT_STRUCTURE.md rename to docs/PROJECT_STRUCTURE.md diff --git a/USER_GUIDE.md b/docs/USER_GUIDE.md similarity index 100% rename from USER_GUIDE.md rename to docs/USER_GUIDE.md diff --git a/backend/README.md b/docs/backend_guide.md similarity index 100% rename from backend/README.md rename to docs/backend_guide.md diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3bee811..eff4633 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.6.2", "clsx": "^2.0.0", "date-fns": "^2.30.0", + "lightweight-charts": "^4.2.0", "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -21,8 +22,8 @@ }, "devDependencies": { "@types/node": "^24.10.0", - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", + "@types/react": "^18.3.26", + "@types/react-dom": "^18.3.7", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", @@ -32,8 +33,8 @@ "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.4.32", "tailwindcss": "^3.3.6", - "typescript": "^5.2.2", - "vite": "^5.0.8" + "typescript": "^5.9.3", + "vite": "^7.2.2" } }, "node_modules/@alloc/quick-lru": { @@ -50,13 +51,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -65,9 +66,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -75,21 +76,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -116,14 +117,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -133,13 +134,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -170,29 +171,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -202,9 +203,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -242,27 +243,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -304,42 +305,42 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -347,9 +348,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -361,9 +362,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -374,13 +375,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -391,13 +392,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -408,13 +409,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -425,13 +426,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -442,13 +443,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -459,13 +460,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -476,13 +477,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -493,13 +494,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -510,13 +511,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -527,13 +528,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -544,13 +545,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -561,13 +562,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -578,13 +579,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -595,13 +596,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -612,13 +613,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -629,13 +630,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -646,13 +647,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -663,13 +681,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -680,13 +715,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -697,13 +749,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -714,13 +766,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -731,13 +783,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -748,13 +800,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -900,53 +952,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1035,21 +1040,10 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -1063,9 +1057,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -1077,9 +1071,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -1091,9 +1085,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -1105,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -1119,9 +1113,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -1133,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -1147,9 +1141,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -1161,9 +1155,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -1175,9 +1169,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -1189,9 +1183,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -1203,9 +1197,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -1217,9 +1225,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -1231,9 +1253,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -1245,9 +1267,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -1259,9 +1281,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -1273,9 +1295,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", - "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -1287,9 +1309,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -1300,10 +1322,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -1315,9 +1351,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -1329,9 +1365,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -1343,9 +1379,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -1357,9 +1393,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -1371,9 +1407,9 @@ ] }, "node_modules/@tanstack/query-core": { - "version": "5.90.6", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.6.tgz", - "integrity": "sha512-AnZSLF26R8uX+tqb/ivdrwbVdGemdEDm1Q19qM6pry6eOZ6bEYiY7mWhzXT1YDIPTNEVcZ5kYP9nWjoxDLiIVw==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "license": "MIT", "funding": { "type": "github", @@ -1381,12 +1417,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", - "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.6" + "@tanstack/query-core": "5.90.20" }, "funding": { "type": "github", @@ -1484,9 +1520,9 @@ } }, "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -1519,9 +1555,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", - "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", "dependencies": { @@ -1536,14 +1572,14 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.26", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", - "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { @@ -1907,9 +1943,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", "dev": true, "funding": [ { @@ -1927,10 +1963,9 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, @@ -1945,13 +1980,13 @@ } }, "node_modules/axios": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", - "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -1963,9 +1998,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.23.tgz", - "integrity": "sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ==", + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2009,9 +2044,9 @@ } }, "node_modules/browserslist": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", - "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -2029,11 +2064,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.19", - "caniuse-lite": "^1.0.30001751", - "electron-to-chromium": "^1.5.238", - "node-releases": "^2.0.26", - "update-browserslist-db": "^1.1.4" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -2076,9 +2111,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001753", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", - "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", "dev": true, "funding": [ { @@ -2245,9 +2280,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/d3-array": { @@ -2281,9 +2316,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -2491,27 +2526,13 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { - "version": "1.5.244", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz", - "integrity": "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==", + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2558,9 +2579,9 @@ } }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2568,32 +2589,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escalade": { @@ -2690,9 +2714,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2772,9 +2796,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2823,6 +2847,12 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2831,9 +2861,9 @@ "license": "MIT" }, "node_modules/fast-equals": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz", - "integrity": "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2884,9 +2914,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -2978,27 +3008,10 @@ } } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -3012,16 +3025,16 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -3107,7 +3120,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -3371,16 +3384,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3421,22 +3424,6 @@ "dev": true, "license": "ISC" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3454,9 +3441,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3537,6 +3524,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lightweight-charts": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz", + "integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3574,9 +3570,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -3687,16 +3683,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3759,16 +3745,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3848,13 +3824,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3905,30 +3874,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -4248,12 +4193,12 @@ } }, "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -4263,13 +4208,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -4425,9 +4370,9 @@ } }, "node_modules/rollup": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -4441,28 +4386,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.5", - "@rollup/rollup-android-arm64": "4.52.5", - "@rollup/rollup-darwin-arm64": "4.52.5", - "@rollup/rollup-darwin-x64": "4.52.5", - "@rollup/rollup-freebsd-arm64": "4.52.5", - "@rollup/rollup-freebsd-x64": "4.52.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", - "@rollup/rollup-linux-arm-musleabihf": "4.52.5", - "@rollup/rollup-linux-arm64-gnu": "4.52.5", - "@rollup/rollup-linux-arm64-musl": "4.52.5", - "@rollup/rollup-linux-loong64-gnu": "4.52.5", - "@rollup/rollup-linux-ppc64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-musl": "4.52.5", - "@rollup/rollup-linux-s390x-gnu": "4.52.5", - "@rollup/rollup-linux-x64-gnu": "4.52.5", - "@rollup/rollup-linux-x64-musl": "4.52.5", - "@rollup/rollup-openharmony-arm64": "4.52.5", - "@rollup/rollup-win32-arm64-msvc": "4.52.5", - "@rollup/rollup-win32-ia32-msvc": "4.52.5", - "@rollup/rollup-win32-x64-gnu": "4.52.5", - "@rollup/rollup-win32-x64-msvc": "4.52.5", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -4500,9 +4448,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4535,19 +4483,6 @@ "node": ">=8" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4568,76 +4503,6 @@ "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4651,20 +4516,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4679,18 +4530,18 @@ } }, "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { @@ -4701,43 +4552,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4765,9 +4579,9 @@ } }, "node_modules/tailwind-merge": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", - "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", "license": "MIT", "funding": { "type": "github", @@ -4775,9 +4589,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", - "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4848,6 +4662,54 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4929,9 +4791,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -4999,21 +4861,24 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -5022,19 +4887,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -5055,9 +4926,46 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5084,107 +4992,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index e2e34ce..2e57040 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "axios": "^1.6.2", "clsx": "^2.0.0", "date-fns": "^2.30.0", + "lightweight-charts": "^4.2.0", "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -23,8 +24,8 @@ }, "devDependencies": { "@types/node": "^24.10.0", - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", + "@types/react": "^18.3.26", + "@types/react-dom": "^18.3.7", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", @@ -34,7 +35,7 @@ "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.4.32", "tailwindcss": "^3.3.6", - "typescript": "^5.2.2", - "vite": "^5.0.8" + "typescript": "^5.9.3", + "vite": "^7.2.2" } -} +} \ No newline at end of file diff --git a/frontend/src/components/CandlestickChart.tsx b/frontend/src/components/CandlestickChart.tsx deleted file mode 100644 index 943312c..0000000 --- a/frontend/src/components/CandlestickChart.tsx +++ /dev/null @@ -1,434 +0,0 @@ -import { useMemo } from 'react' - -interface CandleData { - date: string - dateLabel: string - open: number - high: number - low: number - close: number - volume: number -} - -interface CandlestickChartProps { - data: CandleData[] - width: number - height: number -} - -// ์ด๋™ํ‰๊ท  ๊ณ„์‚ฐ -const calculateMA = (data: CandleData[], period: number): (number | null)[] => { - return data.map((_, index) => { - if (index < period - 1) return null - const sum = data.slice(index - period + 1, index + 1).reduce((acc, d) => acc + d.close, 0) - return sum / period - }) -} - -// ๋ณผ๋ฆฐ์ €๋ฐด๋“œ ๊ณ„์‚ฐ -const calculateBollingerBands = (data: CandleData[], period: number = 20, stdDev: number = 2) => { - const ma = calculateMA(data, period) - const upper: (number | null)[] = [] - const lower: (number | null)[] = [] - - data.forEach((_, index) => { - if (index < period - 1 || ma[index] === null) { - upper.push(null) - lower.push(null) - return - } - - const slice = data.slice(index - period + 1, index + 1) - const mean = ma[index]! - const variance = slice.reduce((acc, d) => acc + Math.pow(d.close - mean, 2), 0) / period - const std = Math.sqrt(variance) - - upper.push(mean + stdDev * std) - lower.push(mean - stdDev * std) - }) - - return { middle: ma, upper, lower } -} - -const CandlestickChart = ({ data, width, height }: CandlestickChartProps) => { - const { candles, yScale, volumeScale, maxVolume, indicators } = useMemo(() => { - if (!data || data.length === 0) { - console.log('CandlestickChart: No data') - return { - candles: [], - yScale: { min: 0, max: 0, range: 0 }, - volumeScale: 0, - maxVolume: 0, - indicators: { ma5: [], ma20: [], ma60: [], bb: { middle: [], upper: [], lower: [] } } - } - } - - console.log('CandlestickChart: Rendering', data.length, 'candles') - console.log('First candle:', data[0]) - console.log('Last candle:', data[data.length - 1]) - - // ์ง€ํ‘œ ๊ณ„์‚ฐ - const ma5 = calculateMA(data, 5) - const ma20 = calculateMA(data, 20) - const ma60 = calculateMA(data, 60) - const bb = calculateBollingerBands(data, 20, 2) - - // ๊ฐ€๊ฒฉ ๋ฒ”์œ„ ๊ณ„์‚ฐ (์ง€ํ‘œ ํฌํ•จ) - const prices = data.flatMap(d => [d.open, d.high, d.low, d.close]).filter(p => p > 0) - const indicatorValues = [ - ...ma5.filter((v): v is number => v !== null && v > 0), - ...ma20.filter((v): v is number => v !== null && v > 0), - ...ma60.filter((v): v is number => v !== null && v > 0), - ...bb.upper.filter((v): v is number => v !== null && v > 0), - ...bb.lower.filter((v): v is number => v !== null && v > 0), - ] - const allValues = [...prices, ...indicatorValues] - - if (allValues.length === 0) { - console.error('โŒ No valid price data') - return { - candles: [], - yScale: { min: 0, max: 0, range: 0 }, - volumeScale: 0, - maxVolume: 0, - indicators: { ma5: [], ma20: [], ma60: [], bb: { middle: [], upper: [], lower: [] } } - } - } - - const min = Math.min(...allValues) - const max = Math.max(...allValues) - - // ์ด์ƒ์น˜ ๊ฒ€์ฆ: max๊ฐ€ min์˜ 10๋ฐฐ๋ฅผ ๋„˜์œผ๋ฉด ์ง€ํ‘œ ์ œ์™ธ - const priceRange = Math.max(...prices) - Math.min(...prices) - const totalRange = max - min - - if (totalRange > priceRange * 3) { - console.warn('โš ๏ธ Detected outlier in indicators, using price range only') - const priceMin = Math.min(...prices) - const priceMax = Math.max(...prices) - const range = priceMax - priceMin - const padding = range * 0.1 // ํŒจ๋”ฉ: 10% - - const chartHeight = height - 80 - const volumeHeight = 60 - const yMin = priceMin - padding - const yMax = priceMax + padding - const yRange = yMax - yMin - const leftMargin = 80 // ์™ผ์ชฝ ์—ฌ๋ฐฑ ์ฆ๊ฐ€: 60 โ†’ 80 - const rightMargin = 20 // ์˜ค๋ฅธ์ชฝ ์—ฌ๋ฐฑ ์ถ”๊ฐ€ - const chartWidth = width - leftMargin - rightMargin - const candleWidth = Math.max(5, Math.min(15, chartWidth / data.length * 0.7)) // ์บ”๋“ค ๋„ˆ๋น„ ์ฆ๊ฐ€ - const spacing = chartWidth / data.length - const maxVol = Math.max(...data.map(d => d.volume)) - - const candles = data.map((item, index) => { - const x = leftMargin + index * spacing + spacing / 2 - const isUp = item.close >= item.open - const openY = chartHeight - ((item.open - yMin) / yRange) * chartHeight + 10 - const closeY = chartHeight - ((item.close - yMin) / yRange) * chartHeight + 10 - const highY = chartHeight - ((item.high - yMin) / yRange) * chartHeight + 10 - const lowY = chartHeight - ((item.low - yMin) / yRange) * chartHeight + 10 - const bodyTop = Math.min(openY, closeY) - const bodyHeight = Math.abs(closeY - openY) || 1 - const volumeBarHeight = (item.volume / maxVol) * volumeHeight - - return { - x, highY, lowY, bodyTop, bodyHeight, isUp, - dateLabel: item.dateLabel, - open: item.open, high: item.high, low: item.low, close: item.close, - volume: item.volume, volumeBarHeight, candleWidth, - } - }) - - return { - candles, - yScale: { min: yMin, max: yMax, range: yRange }, - volumeScale: volumeHeight, - maxVolume: maxVol, - indicators: { ma5, ma20, ma60, bb } - } - } - - const range = max - min - const padding = range * 0.1 // ํŒจ๋”ฉ: 10% - - const chartHeight = height - 80 // ๊ฑฐ๋ž˜๋Ÿ‰ ์˜์—ญ ์ œ์™ธ - const volumeHeight = 60 - - const yMin = min - padding - const yMax = max + padding - const yRange = yMax - yMin - - const leftMargin = 80 // ์™ผ์ชฝ ์—ฌ๋ฐฑ ์ฆ๊ฐ€: 60 โ†’ 80 - const rightMargin = 20 // ์˜ค๋ฅธ์ชฝ ์—ฌ๋ฐฑ ์ถ”๊ฐ€ - const chartWidth = width - leftMargin - rightMargin - const candleWidth = Math.max(5, Math.min(15, chartWidth / data.length * 0.7)) // ์บ”๋“ค ๋„ˆ๋น„ ์ฆ๊ฐ€ - const spacing = chartWidth / data.length - - const maxVol = Math.max(...data.map(d => d.volume)) - - const candles = data.map((item, index) => { - const x = leftMargin + index * spacing + spacing / 2 - const isUp = item.close >= item.open - - const openY = chartHeight - ((item.open - yMin) / yRange) * chartHeight + 10 - const closeY = chartHeight - ((item.close - yMin) / yRange) * chartHeight + 10 - const highY = chartHeight - ((item.high - yMin) / yRange) * chartHeight + 10 - const lowY = chartHeight - ((item.low - yMin) / yRange) * chartHeight + 10 - - const bodyTop = Math.min(openY, closeY) - const bodyHeight = Math.abs(closeY - openY) || 1 - - const volumeBarHeight = (item.volume / maxVol) * volumeHeight - - return { - x, - highY, - lowY, - bodyTop, - bodyHeight, - isUp, - dateLabel: item.dateLabel, - open: item.open, - high: item.high, - low: item.low, - close: item.close, - volume: item.volume, - volumeBarHeight, - candleWidth, - } - }) - - return { - candles, - yScale: { min: yMin, max: yMax, range: yRange }, - volumeScale: volumeHeight, - maxVolume: maxVol, - indicators: { ma5, ma20, ma60, bb } - } - }, [data, width, height]) - - if (!data || data.length === 0) { - return ( -
- ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค -
- ) - } - - const chartHeight = height - 80 - const volumeTop = chartHeight + 20 - - // ์ง€ํ‘œ ๋ผ์ธ์„ ์œ„ํ•œ ์ขŒํ‘œ ๊ณ„์‚ฐ - const getLineY = (value: number) => { - return chartHeight - ((value - yScale.min) / yScale.range) * chartHeight + 10 - } - - const createLinePath = (values: (number | null)[]) => { - const segments: string[] = [] - let currentSegment: string[] = [] - - values.forEach((value, index) => { - if (value !== null && candles[index]) { - const x = candles[index].x - const y = getLineY(value) - if (currentSegment.length === 0) { - currentSegment.push(`M ${x} ${y}`) - } else { - currentSegment.push(`L ${x} ${y}`) - } - } else if (currentSegment.length > 0) { - // null ๊ฐ’์„ ๋งŒ๋‚˜๋ฉด ํ˜„์žฌ ์„ธ๊ทธ๋จผํŠธ๋ฅผ ์ข…๋ฃŒํ•˜๊ณ  ์ƒˆ๋กœ ์‹œ์ž‘ - segments.push(currentSegment.join(' ')) - currentSegment = [] - } - }) - - // ๋งˆ์ง€๋ง‰ ์„ธ๊ทธ๋จผํŠธ ์ถ”๊ฐ€ - if (currentSegment.length > 0) { - segments.push(currentSegment.join(' ')) - } - - return segments.join(' ') - } - - // ๋ณผ๋ฆฐ์ €๋ฐด๋“œ ์˜์—ญ path ์ƒ์„ฑ - const createBBAreaPath = () => { - const upperPoints: [number, number][] = [] - const lowerPoints: [number, number][] = [] - - indicators.bb.upper.forEach((value, index) => { - if (value !== null && indicators.bb.lower[index] !== null && candles[index]) { - upperPoints.push([candles[index].x, getLineY(value)]) - lowerPoints.push([candles[index].x, getLineY(indicators.bb.lower[index]!)]) - } - }) - - if (upperPoints.length === 0) return '' - - const pathParts: string[] = [] - pathParts.push(`M ${upperPoints[0][0]} ${upperPoints[0][1]}`) - upperPoints.slice(1).forEach(([x, y]) => pathParts.push(`L ${x} ${y}`)) - lowerPoints.reverse().forEach(([x, y]) => pathParts.push(`L ${x} ${y}`)) - pathParts.push('Z') - - return pathParts.join(' ') - } - - return ( - - {/* Y์ถ• ๊ทธ๋ฆฌ๋“œ */} - {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { - const y = chartHeight - ratio * chartHeight + 10 - const price = yScale.min + ratio * yScale.range - return ( - - - - {new Intl.NumberFormat('ko-KR', { maximumFractionDigits: 0 }).format(price)} - - - ) - })} - - {/* ๋ณผ๋ฆฐ์ €๋ฐด๋“œ ์˜์—ญ - ๊ทธ๋ฆฌ๋“œ ๋’ค, ์บ”๋“ค ์•ž */} - {indicators.bb.upper.length > 0 && ( - - )} - - {/* ์ด๋™ํ‰๊ท ์„  - ์บ”๋“ค ์•ž */} - - - - - {/* ๋ณผ๋ฆฐ์ €๋ฐด๋“œ ๋ผ์ธ - ์บ”๋“ค ์•ž */} - - - - - {/* ์บ”๋“ค์Šคํ‹ฑ */} - {candles.map((candle, index) => ( - - {/* ์‹ฌ์ง€ (High-Low ๋ผ์ธ) */} - - {/* ์บ”๋“ค ๋ชธํ†ต */} - - {/* X์ถ• ๋ผ๋ฒจ (์ผ๋ถ€๋งŒ ํ‘œ์‹œ) */} - {index % Math.max(1, Math.floor(candles.length / 10)) === 0 && ( - - {candle.dateLabel} - - )} - {/* ๊ฑฐ๋ž˜๋Ÿ‰ ๋ฐ” */} - - - ))} - - {/* ๊ฑฐ๋ž˜๋Ÿ‰ ๋ผ๋ฒจ */} - ๊ฑฐ๋ž˜๋Ÿ‰ - - {new Intl.NumberFormat('ko-KR', { notation: 'compact' }).format(maxVolume)} - - - {/* ๋ฒ”๋ก€ */} - - - MA5 - - - MA20 - - - MA60 - - - BB - - - ) -} - -export default CandlestickChart diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 56a561f..da00b18 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,8 @@ import { Link, useLocation } from 'react-router-dom' -import { LayoutDashboard, FileText, History, Cpu } from 'lucide-react' +import { LayoutDashboard, FileText, History, Cpu, Activity } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import api from '../lib/api' +import { useWebSocket } from '../hooks/useWebSocket' interface LayoutProps { children: React.ReactNode @@ -7,58 +10,78 @@ interface LayoutProps { const Layout = ({ children }: LayoutProps) => { const location = useLocation() + const { connected: wsConnected } = useWebSocket('dashboard') // Global WS status + + const { data: mcpStatus } = useQuery({ + queryKey: ['mcp-status'], + queryFn: async () => (await api.get('/mcp/status')).data, + refetchInterval: 5000, + }) const navItems = [ { path: '/', icon: LayoutDashboard, label: '๋Œ€์‹œ๋ณด๋“œ' }, - { path: '/rules', icon: FileText, label: '์ „๋žต ๋ฃฐ ์„ค์ •' }, + { path: '/rules', icon: FileText, label: '์ „๋žต ๋ฃฐ' }, { path: '/trades', icon: History, label: '๋งค๋งค ๋กœ๊ทธ' }, { path: '/mcp', icon: Cpu, label: 'MCP ์ƒํƒœ' }, ] return ( -
- {/* Header */} -
-
-

- Auto Stock Trading System -

-

- Claude AI & MCP ๊ธฐ๋ฐ˜ ์ž๋™ ๋งค๋งค ์‹œ์Šคํ…œ -

-
-
+
+ {/* Top Navigation Bar */} +
+
+ {/* Brand */} +
+
+ +
+
+

+ AutoStock +

+

GEN 3 ALGO

+
+
-
- {/* Sidebar */} - +
- {/* Main Content */} -
- {children} -
-
+ {/* Status Indicators */} +
+
+
+ {wsConnected ? 'LIVE' : 'OFFLINE'} +
+
+
+ MCP: {mcpStatus?.status || 'Unknown'} +
+
+
+ + {/* Main Content Area */} +
+ {children} +
) } diff --git a/frontend/src/components/PortfolioSummary.tsx b/frontend/src/components/PortfolioSummary.tsx new file mode 100644 index 0000000..196fa66 --- /dev/null +++ b/frontend/src/components/PortfolioSummary.tsx @@ -0,0 +1,90 @@ +import { ArrowUpRight, ArrowDownRight, AlertTriangle, Activity, TrendingUp, TrendingDown, DollarSign } from 'lucide-react'; + +interface PortfolioSummaryProps { + summary: any; + error: any; +} + +export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => { + if (error) { + return ( +
+ + ํฌํŠธํด๋ฆฌ์˜ค ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. +
+ ); + } + + if (!summary) { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ ); + } + + const { total_assets, total_value, total_investment, total_profit_loss, total_profit_loss_rate, available_cash } = summary; + const isProfit = total_profit_loss >= 0; + + return ( +
+ {/* ์ด ์ž์‚ฐ */} +
+
+

์ด ์ž์‚ฐ

+
+ +
+
+

+ {Math.round(total_assets || 0).toLocaleString()}์› +

+
+ ์˜ˆ์ˆ˜๊ธˆ: {Math.round(available_cash || 0).toLocaleString()}์› + ํ‰๊ฐ€๊ธˆ์•ก: {Math.round(total_value || 0).toLocaleString()}์› +
+
+ + {/* ์ด ๋งค์ž…๊ธˆ์•ก */} +
+
+

์ด ๋งค์ž…๊ธˆ์•ก

+
+ +
+
+

+ {Math.round(total_investment || 0).toLocaleString()}์› +

+
+ + {/* ์ด ํ‰๊ฐ€์†์ต */} +
+
+

์ด ํ‰๊ฐ€์†์ต

+
+ {isProfit ? : } +
+
+

+ {total_profit_loss > 0 ? '+' : ''}{Math.round(total_profit_loss || 0).toLocaleString()}์› +

+
+ + {/* ์ˆ˜์ต๋ฅ  */} +
+
+

์ˆ˜์ต๋ฅ 

+
+ {isProfit ? : } +
+
+

+ {total_profit_loss_rate > 0 ? '+' : ''}{total_profit_loss_rate?.toFixed(2)}% +

+
+
+ ); +}; diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx new file mode 100644 index 0000000..dd7fe02 --- /dev/null +++ b/frontend/src/components/TradingChart.tsx @@ -0,0 +1,512 @@ +// @ts-nocheck +import { useEffect, useRef, useMemo } from 'react'; +import { createChart, CrosshairMode, IChartApi, ISeriesApi, CandlestickData, LineData, HistogramData } from 'lightweight-charts'; +import { + calculateSMA, + calculateBollingerBands, + calculateRSI, + calculateMACD +} from '../utils/technicalIndicators'; + +interface TradingChartProps { + data: any[]; +} + +// ํ•œ๊ตญ ์‹œ์žฅ ์ƒ‰์ƒ ๊ทœ์น™ +const COLORS = { + candleUp: '#ef4444', // ์ƒ์Šน: ๋นจ๊ฐ• + candleDown: '#3b82f6', // ํ•˜๋ฝ: ํŒŒ๋ž‘ + ma5: '#fbbf24', // 5์ผ์„ : ๋…ธ๋ž€์ƒ‰ + ma20: '#f6ad55', // 20์ผ์„ : ์˜ค๋ Œ์ง€์ƒ‰ + ma60: '#10b981', // 60์ผ์„ : ์ดˆ๋ก์ƒ‰ + bbLine: '#4a5568', // ๋ณผ๋ฆฐ์ € ๋ฐด๋“œ: ํšŒ์ƒ‰ + rsi: '#a78bfa', // RSI: ๋ณด๋ผ์ƒ‰ + macd: '#3b82f6', // MACD: ํŒŒ๋ž€์ƒ‰ + signal: '#f97316', // Signal: ์ฃผํ™ฉ์ƒ‰ + background: '#0b0e14', + grid: '#1a1f2e', + text: '#9ca3af', + border: '#374151', +}; + +export const TradingChart = ({ data }: TradingChartProps) => { + // Refs for chart containers + const mainChartRef = useRef(null); + const volumeChartRef = useRef(null); + const rsiChartRef = useRef(null); + const macdChartRef = useRef(null); + + // Refs for chart instances + const mainChartInstanceRef = useRef(null); + const volumeChartInstanceRef = useRef(null); + const rsiChartInstanceRef = useRef(null); + const macdChartInstanceRef = useRef(null); + + // Refs for series + const candlestickSeriesRef = useRef | null>(null); + const volumeSeriesRef = useRef | null>(null); + + // ๋‚ ์งœ๋ฅผ ์ฐจํŠธ ์‹œ๊ฐ„ ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํ—ฌํผ ํ•จ์ˆ˜ + const toChartTime = (date: string | Date): string => { + if (typeof date === 'string') { + // YYYY-MM-DD ํ˜•์‹์ด๋ฉด ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ + if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return date; + } + // YYYYMMDD ํ˜•์‹์ด๋ฉด YYYY-MM-DD๋กœ ๋ณ€ํ™˜ + if (/^\d{8}$/.test(date)) { + return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`; + } + } + // ๊ทธ ์™ธ์˜ ๊ฒฝ์šฐ Date ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ + const d = new Date(date); + return d.toISOString().split('T')[0]; // "YYYY-MM-DD" + }; + + // ๋ฐ์ดํ„ฐ ๊ณ„์‚ฐ (๋ฉ”๋ชจ์ด์ œ์ด์…˜) + const processedData = useMemo(() => { + if (!data || data.length === 0) return null; + + // ๋ฐ์ดํ„ฐ ์ •๋ ฌ (์‹œ๊ฐ„ ์˜ค๋ฆ„์ฐจ์ˆœ) + const sortedData = [...data].sort((a, b) => { + const timeA = new Date(a.date).getTime(); + const timeB = new Date(b.date).getTime(); + return timeA - timeB; + }); + const closes = sortedData.map(d => d.close); + + // ๊ธฐ์ˆ ์  ์ง€ํ‘œ ๊ณ„์‚ฐ + const bollinger = calculateBollingerBands(closes, 20); + const sma5 = calculateSMA(closes, 5); + const sma60 = calculateSMA(closes, 60); + const rsi = calculateRSI(closes, 14); + const { macd, signal, histogram } = calculateMACD(closes); + + // ์บ”๋“ค์Šคํ‹ฑ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ (๋‚ ์งœ ๋ฌธ์ž์—ด์„ YYYY-MM-DD ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜) + const candles: CandlestickData[] = sortedData.map(item => ({ + time: toChartTime(item.date) as any, + open: item.open, + high: item.high, + low: item.low, + close: item.close, + })); + + // ๊ฑฐ๋ž˜๋Ÿ‰ ๋ฐ์ดํ„ฐ (ํ•œ๊ตญ ์‹œ์žฅ ์ƒ‰์ƒ ์ ์šฉ) + const volumes: HistogramData[] = sortedData.map(item => ({ + time: toChartTime(item.date) as any, + value: item.volume, + color: item.close >= item.open + ? 'rgba(239, 68, 68, 0.6)' // ์ƒ์Šน: ๋นจ๊ฐ• + : 'rgba(59, 130, 246, 0.6)' // ํ•˜๋ฝ: ํŒŒ๋ž‘ + })); + + // ๋ณผ๋ฆฐ์ € ๋ฐด๋“œ ๋ฐ์ดํ„ฐ + const bbUpperData: LineData[] = sortedData + .map((item, i) => ({ + time: toChartTime(item.date) as any, + value: bollinger.upper[i], + })) + .filter(d => !isNaN(d.value)); + + const bbMiddleData: LineData[] = sortedData + .map((item, i) => ({ + time: toChartTime(item.date) as any, + value: bollinger.middle[i], + })) + .filter(d => !isNaN(d.value)); + + const bbLowerData: LineData[] = sortedData + .map((item, i) => ({ + time: toChartTime(item.date) as any, + value: bollinger.lower[i], + })) + .filter(d => !isNaN(d.value)); + + // ์ด๋™ํ‰๊ท ์„  ๋ฐ์ดํ„ฐ + const sma5Data: LineData[] = sortedData + .map((item, i) => ({ + time: toChartTime(item.date) as any, + value: sma5[i], + })) + .filter(d => !isNaN(d.value)); + + const sma60Data: LineData[] = sortedData + .map((item, i) => ({ + time: toChartTime(item.date) as any, + value: sma60[i], + })) + .filter(d => !isNaN(d.value)); + + // RSI ๋ฐ์ดํ„ฐ + const rsiData: LineData[] = sortedData + .map((item, i) => ({ + time: toChartTime(item.date) as any, + value: rsi[i], + })) + .filter(d => !isNaN(d.value)); + + // RSI ๊ธฐ์ค€์„  (30, 50, 70) + const rsiLine30: LineData[] = sortedData.map(item => ({ + time: toChartTime(item.date) as any, + value: 30, + })); + const rsiLine50: LineData[] = sortedData.map(item => ({ + time: toChartTime(item.date) as any, + value: 50, + })); + const rsiLine70: LineData[] = sortedData.map(item => ({ + time: toChartTime(item.date) as any, + value: 70, + })); + + // MACD ๋ฐ์ดํ„ฐ + const macdHistogramData: HistogramData[] = sortedData + .map((item, i) => ({ + time: toChartTime(item.date) as any, + value: histogram[i], + color: histogram[i] >= 0 + ? 'rgba(239, 68, 68, 0.6)' // ์–‘์ˆ˜: ๋นจ๊ฐ• + : 'rgba(59, 130, 246, 0.6)' // ์Œ์ˆ˜: ํŒŒ๋ž‘ + })) + .filter(d => !isNaN(d.value)); + + const macdLineData: LineData[] = sortedData + .map((item, i) => ({ + time: toChartTime(item.date) as any, + value: macd[i], + })) + .filter(d => !isNaN(d.value)); + + const signalLineData: LineData[] = sortedData + .map((item, i) => ({ + time: toChartTime(item.date) as any, + value: signal[i], + })) + .filter(d => !isNaN(d.value)); + + return { + candles, + volumes, + bollinger: { upper: bbUpperData, middle: bbMiddleData, lower: bbLowerData }, + sma5: sma5Data, + sma60: sma60Data, + rsi: { data: rsiData, line30: rsiLine30, line50: rsiLine50, line70: rsiLine70 }, + macd: { histogram: macdHistogramData, line: macdLineData, signal: signalLineData }, + }; + }, [data]); + + // ๊ณตํ†ต ์ฐจํŠธ ์˜ต์…˜ + const getChartOptions = (height: number) => ({ + layout: { + background: { color: COLORS.background }, + textColor: COLORS.text, + fontSize: 11, + }, + grid: { + vertLines: { color: '#1a1f2e', style: 1 }, // ๋” ์–‡์€ ๊ทธ๋ฆฌ๋“œ + horzLines: { color: '#1a1f2e', style: 1 }, + }, + crosshair: { + mode: CrosshairMode.Normal, + vertLine: { + color: '#4a5568', + width: 1, + style: 2, // dashed + }, + horzLine: { + color: '#4a5568', + width: 1, + style: 2, + }, + }, + timeScale: { + borderColor: COLORS.border, + timeVisible: true, + secondsVisible: false, + borderVisible: false, // ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ + }, + rightPriceScale: { + borderColor: COLORS.border, + borderVisible: false, // ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ + }, + height, + }); + + // ์ฐจํŠธ ์ดˆ๊ธฐํ™” ๋ฐ ๋ฐ์ดํ„ฐ ์„ค์ • + useEffect(() => { + if (!processedData || + !mainChartRef.current || + !volumeChartRef.current || + !rsiChartRef.current || + !macdChartRef.current) { + return; + } + + const containerWidth = mainChartRef.current.clientWidth; + + try { + // === 1. ๋ฉ”์ธ ์ฐจํŠธ (์บ”๋“ค์Šคํ‹ฑ + ์ง€ํ‘œ) === + const mainChart = createChart(mainChartRef.current, { + ...getChartOptions(365), + width: containerWidth, + }); + + // ์บ”๋“ค์Šคํ‹ฑ ์‹œ๋ฆฌ์ฆˆ (ํ•œ๊ตญ ์‹œ์žฅ ์ƒ‰์ƒ) + const candlestickSeries = (mainChart ).addCandlestickSeries({ + upColor: COLORS.candleUp, + downColor: COLORS.candleDown, + borderVisible: false, + wickUpColor: COLORS.candleUp, + wickDownColor: COLORS.candleDown, + }); + candlestickSeries.setData(processedData.candles); + + // ๋ณผ๋ฆฐ์ € ๋ฐด๋“œ + const bbUpperSeries = (mainChart ).addLineSeries({ + color: COLORS.bbLine, + lineWidth: 1, + }); + bbUpperSeries.setData(processedData.bollinger.upper); + + const bbMiddleSeries = (mainChart ).addLineSeries({ + color: COLORS.ma20, + lineWidth: 1, + }); + bbMiddleSeries.setData(processedData.bollinger.middle); + + const bbLowerSeries = (mainChart ).addLineSeries({ + color: COLORS.bbLine, + lineWidth: 1, + }); + bbLowerSeries.setData(processedData.bollinger.lower); + + // 5์ผ ์ด๋™ํ‰๊ท ์„  + const sma5Series = (mainChart ).addLineSeries({ + color: COLORS.ma5, + lineWidth: 1.5, + }); + sma5Series.setData(processedData.sma5); + + // 60์ผ ์ด๋™ํ‰๊ท ์„  + const sma60Series = (mainChart ).addLineSeries({ + color: COLORS.ma60, + lineWidth: 1.5, + }); + sma60Series.setData(processedData.sma60); + + // === 2. ๊ฑฐ๋ž˜๋Ÿ‰ ์ฐจํŠธ === + const volumeChart = createChart(volumeChartRef.current, { + ...getChartOptions(67), + width: containerWidth, + }); + + const volumeSeries = (volumeChart ).addHistogramSeries({ + priceFormat: { + type: 'volume', + }, + }); + volumeSeries.setData(processedData.volumes); + + // === 3. RSI ์ฐจํŠธ === + const rsiChart = createChart(rsiChartRef.current, { + ...getChartOptions(100), + width: containerWidth, + }); + + // RSI ๋ผ์ธ + const rsiSeries = (rsiChart ).addLineSeries({ + color: COLORS.rsi, + lineWidth: 2, + }); + rsiSeries.setData(processedData.rsi.data); + + // RSI ๊ธฐ์ค€์„  (30, 50, 70) + const rsiLine30Series = (rsiChart ).addLineSeries({ + color: COLORS.border, + lineWidth: 1, + lineStyle: 2, // dashed + crosshairMarkerVisible: false, + }); + rsiLine30Series.setData(processedData.rsi.line30); + + const rsiLine50Series = (rsiChart ).addLineSeries({ + color: COLORS.border, + lineWidth: 1, + lineStyle: 2, + crosshairMarkerVisible: false, + }); + rsiLine50Series.setData(processedData.rsi.line50); + + const rsiLine70Series = (rsiChart ).addLineSeries({ + color: COLORS.border, + lineWidth: 1, + lineStyle: 2, + crosshairMarkerVisible: false, + }); + rsiLine70Series.setData(processedData.rsi.line70); + + // RSI Y์ถ• ๋ฒ”์œ„ ๊ณ ์ • (0-100) + (rsiChart ).priceScale('right').applyOptions({ + scaleMargins: { + top: 0.1, + bottom: 0.1, + }, + }); + + // === 4. MACD ์ฐจํŠธ === + const macdChart = createChart(macdChartRef.current, { + ...getChartOptions(100), + width: containerWidth, + }); + + // MACD ํžˆ์Šคํ† ๊ทธ๋žจ + const macdHistogramSeries = (macdChart ).addHistogramSeries(); + macdHistogramSeries.setData(processedData.macd.histogram); + + // MACD ๋ผ์ธ + const macdLineSeries = (macdChart ).addLineSeries({ + color: COLORS.macd, + lineWidth: 1.5, + }); + macdLineSeries.setData(processedData.macd.line); + + // Signal ๋ผ์ธ + const signalLineSeries = (macdChart ).addLineSeries({ + color: COLORS.signal, + lineWidth: 1.5, + }); + signalLineSeries.setData(processedData.macd.signal); + + // === ์‹œ๊ฐ„์ถ• ๋™๊ธฐํ™” === + // ์ดˆ๊ธฐ visible range ์„ค์ • (๋ชจ๋“  ์ฐจํŠธ๊ฐ€ ๋™์ผํ•œ ๋ฒ”์œ„๋ฅผ ๋ณด์ด๋„๋ก) + mainChart.timeScale().fitContent(); + + // ๋ฉ”์ธ ์ฐจํŠธ์˜ visible range๋ฅผ ๋‹ค๋ฅธ ์ฐจํŠธ๋“ค์— ์ ์šฉ + const initialRange = mainChart.timeScale().getVisibleRange(); + if (initialRange) { + volumeChart.timeScale().setVisibleRange(initialRange); + rsiChart.timeScale().setVisibleRange(initialRange); + macdChart.timeScale().setVisibleRange(initialRange); + } + + // ์‹ค์‹œ๊ฐ„ ๋™๊ธฐํ™” + mainChart.timeScale().subscribeVisibleTimeRangeChange((timeRange) => { + if (timeRange) { + volumeChart.timeScale().setVisibleRange(timeRange); + rsiChart.timeScale().setVisibleRange(timeRange); + macdChart.timeScale().setVisibleRange(timeRange); + } + }); + + // === ๋ชจ๋“  ์ฐจํŠธ ์„ค์ • ์™„๋ฃŒ ํ›„ refs์— ์ €์žฅ === + // (cleanup์ด ๋ถ€๋ถ„์ ์œผ๋กœ ๊ตฌ์„ฑ๋œ ์ฐจํŠธ๋ฅผ ์ œ๊ฑฐํ•˜์ง€ ์•Š๋„๋ก) + mainChartInstanceRef.current = mainChart; + volumeChartInstanceRef.current = volumeChart; + rsiChartInstanceRef.current = rsiChart; + macdChartInstanceRef.current = macdChart; + candlestickSeriesRef.current = candlestickSeries; + volumeSeriesRef.current = volumeSeries; + + // === ๋ฐ˜์‘ํ˜• ์ฒ˜๋ฆฌ === + const handleResize = () => { + const width = mainChartRef.current?.clientWidth || 0; + mainChart.applyOptions({ width }); + volumeChart.applyOptions({ width }); + rsiChart.applyOptions({ width }); + macdChart.applyOptions({ width }); + }; + + window.addEventListener('resize', handleResize); + + // Cleanup + return () => { + window.removeEventListener('resize', handleResize); + + // Safely remove chart instances + try { + if (mainChartInstanceRef.current) { + mainChartInstanceRef.current.remove(); + mainChartInstanceRef.current = null; + } + } catch (e) { + // Chart already disposed + } + + try { + if (volumeChartInstanceRef.current) { + volumeChartInstanceRef.current.remove(); + volumeChartInstanceRef.current = null; + } + } catch (e) { + // Chart already disposed + } + + try { + if (rsiChartInstanceRef.current) { + rsiChartInstanceRef.current.remove(); + rsiChartInstanceRef.current = null; + } + } catch (e) { + // Chart already disposed + } + + try { + if (macdChartInstanceRef.current) { + macdChartInstanceRef.current.remove(); + macdChartInstanceRef.current = null; + } + } catch (e) { + // Chart already disposed + } + }; + } catch (error: any) { + console.error('โŒ ์ฐจํŠธ ๋ Œ๋”๋ง ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:', error); + console.error('์˜ค๋ฅ˜ ์ƒ์„ธ:', { + message: error?.message || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜', + stack: error?.stack, + processedData: processedData, + }); + } + }, [processedData]); + + return ( +
+ {/* ๋ฉ”์ธ ์ฐจํŠธ (55%) - ๋” ํฌ๊ฒŒ */} +
+ + {/* ๊ฑฐ๋ž˜๋Ÿ‰ (10%) */} +
+ + {/* RSI (15%) */} +
+ + {/* MACD (15%) */} +
+ + {/* ๊ฐ„์†Œํ™”๋œ ๋ฒ”๋ก€ - ๋ฐฐ๊ฒฝ ํˆฌ๋ช…๋„ ๋†’์ž„ */} +
+
+ MA5 + MA20 + MA60 + | + BB +
+
+ + {/* ํ•œ๊ตญ ์‹œ์žฅ ํ‘œ์‹œ - ๋” ์ž‘๊ฒŒ */} +
+
+ +
+ ์ƒ์Šน +
+ +
+ ํ•˜๋ฝ +
+
+
+
+ ); +}; diff --git a/frontend/src/config.ts b/frontend/src/config.ts index da00c54..6d36665 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,5 +1,13 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' -const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000' + +// WebSocket URL: ํ˜„์žฌ ํŽ˜์ด์ง€ ๊ธฐ์ค€์œผ๋กœ ์ž๋™ ๊ฒฐ์ • (Docker/๋กœ์ปฌ ๋ชจ๋‘ ํ˜ธํ™˜) +const deriveWsUrl = () => { + const env = import.meta.env.VITE_WS_URL + if (env) return env + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${proto}//${window.location.host}` +} +const WS_BASE_URL = deriveWsUrl() export const config = { apiBaseUrl: API_BASE_URL, diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index b3cc1de..abb4608 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState, useCallback, useRef } from 'react' import { config } from '../config' interface WebSocketMessage { @@ -10,40 +10,91 @@ export const useWebSocket = (clientId: string) => { const [socket, setSocket] = useState(null) const [messages, setMessages] = useState([]) const [connected, setConnected] = useState(false) + const reconnectTimeoutRef = useRef(null) + const pingIntervalRef = useRef(null) + const reconnectAttemptsRef = useRef(0) + const mountedRef = useRef(true) + const baseDelay = 3000 - useEffect(() => { - const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`) + const connect = useCallback(() => { + if (!mountedRef.current) return - ws.onopen = () => { - console.log('WebSocket connected') - setConnected(true) - } + try { + const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`) - ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data) as WebSocketMessage - setMessages((prev: WebSocketMessage[]) => [...prev, message]) - } catch (error) { - console.error('Failed to parse WebSocket message:', error) + ws.onopen = () => { + console.log('๐Ÿ”— WebSocket connected') + setConnected(true) + reconnectAttemptsRef.current = 0 + + // 30์ดˆ๋งˆ๋‹ค ping ์ „์†ก (Nginx/ํ”„๋ก์‹œ ํƒ€์ž„์•„์›ƒ ๋ฐฉ์ง€) + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } + }, 30000) } - } - ws.onerror = (error) => { - console.error('WebSocket error:', error) - } + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as WebSocketMessage + if (message.type === 'pong') return + setMessages((prev: WebSocketMessage[]) => [...prev, message]) + } catch (error) { + console.error('Failed to parse WebSocket message:', error) + } + } - ws.onclose = () => { - console.log('WebSocket disconnected') - setConnected(false) - } + ws.onerror = () => { + // onclose๊ฐ€ ํ•ญ์ƒ ๋’ค๋”ฐ๋ฅด๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ๋Š” ๋ฌด์‹œ + } - setSocket(ws) + ws.onclose = () => { + console.log('๐Ÿ”Œ WebSocket disconnected') + setConnected(false) + setSocket(null) - return () => { - ws.close() + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + + // ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„ ์žฌ์—ฐ๊ฒฐ (์ตœ๋Œ€ 30์ดˆ ๊ฐ„๊ฒฉ, ๋ฌด์ œํ•œ ์žฌ์‹œ๋„) + if (mountedRef.current) { + const attempt = reconnectAttemptsRef.current + const delay = Math.min(baseDelay * Math.pow(1.5, attempt), 30000) + reconnectAttemptsRef.current += 1 + console.log(`๐Ÿ”„ Reconnecting WebSocket in ${(delay / 1000).toFixed(1)}s (attempt ${attempt + 1})...`) + reconnectTimeoutRef.current = setTimeout(() => { + connect() + }, delay) + } + } + + setSocket(ws) + } catch (error) { + console.error('Failed to create WebSocket:', error) } }, [clientId]) + useEffect(() => { + mountedRef.current = true + connect() + + return () => { + mountedRef.current = false + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + if (socket) { + socket.close() + } + } + }, [connect]) + const sendMessage = useCallback((message: string) => { if (socket && connected) { socket.send(message) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a1e2d38..545139f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -17,7 +17,12 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById('root')!).render( - + diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css index 784b135..73c9872 100644 --- a/frontend/src/pages/Dashboard.css +++ b/frontend/src/pages/Dashboard.css @@ -1,660 +1,18 @@ -/* HTS ๋Œ€์‹œ๋ณด๋“œ ์ปจํ…Œ์ด๋„ˆ */ -.hts-container { - height: 100%; - display: flex; - flex-direction: column; - background: #000000; - color: #e0e6ed; - font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; - overflow: hidden; -} - -/* ๋ฉ”์ธ ๋ ˆ์ด์•„์›ƒ */ -.hts-main-layout { - display: grid; - grid-template-columns: 300px 1fr 420px; - gap: 12px; - padding: 12px; - flex: 1; - overflow: hidden; -} - -/* ์™ผ์ชฝ ํŒจ๋„: ๊ณ„์ขŒ ์ •๋ณด & ๋ณด์œ ์ข…๋ชฉ */ -.left-panel { - background: #1a1a1a; - border: 1px solid #333; - border-radius: 4px; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.panel-header { - padding: 12px 15px; - background: #222; - border-bottom: 1px solid #333; - font-weight: 600; - font-size: 14px; -} - -.balance-info { - padding: 12px 15px; - background: #111; - border-bottom: 1px solid #333; -} - -.balance-row { - display: flex; - justify-content: space-between; - margin-bottom: 8px; - font-size: 13px; -} - -.balance-row span:first-child { - color: #999; -} - -.balance-row span.up { - color: #ff3b3b; - font-weight: 600; -} - -.balance-row span.down { - color: #2979ff; - font-weight: 600; -} - -.portfolio-list { - flex: 1; - overflow-y: auto; - padding: 8px; -} - -.portfolio-item { - padding: 10px; - margin-bottom: 6px; - background: #222; - border: 1px solid #333; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s; -} - -.portfolio-item:hover { - background: #2a2a2a; - border-color: #4fc3f7; -} - -.portfolio-item .stock-name { - font-weight: 600; - font-size: 13px; - margin-bottom: 6px; - display: block; - color: #fff; -} - -.portfolio-item .stock-info { - display: flex; - justify-content: space-between; - font-size: 11px; - color: #999; -} - -.portfolio-item .stock-info span.up { - color: #ff3b3b; - font-weight: 600; -} - -.portfolio-item .stock-info span.down { - color: #2979ff; - font-weight: 600; -} - -/* ์ค‘์•™ ํŒจ๋„ */ -.center-panel { - display: flex; - flex-direction: column; - gap: 8px; - overflow: hidden; -} - -/* ์ฐจํŠธ ์„น์…˜ */ -.chart-section { - background: #1a1a1a; - border: 1px solid #333; - border-radius: 4px; - padding: 15px; - display: flex; - flex-direction: column; - min-height: 420px; - flex: 1; -} - -.chart-toolbar { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; - gap: 12px; - flex-wrap: wrap; -} - -.chart-stocks { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.chart-stock { - background: #111; - border: 1px solid #333; - border-radius: 4px; - padding: 8px 12px; - display: flex; - flex-direction: column; - min-width: 120px; - cursor: pointer; - transition: all 0.2s; -} - -.chart-stock .name { - font-size: 13px; - font-weight: 600; - color: #fff; - margin-bottom: 4px; -} - -.chart-stock .rate { - font-size: 11px; - font-weight: 600; -} - -.chart-stock.active { - border-color: #00ff88; - box-shadow: 0 0 12px rgba(0, 255, 136, 0.2); -} - -.chart-actions { - display: flex; - align-items: center; - gap: 12px; -} - -.ws-status { - padding: 6px 12px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; - background: rgba(136, 146, 176, 0.2); - border: 1px solid rgba(136, 146, 176, 0.4); -} - -.ws-status.online { - background: rgba(0, 255, 136, 0.15); - border-color: rgba(0, 255, 136, 0.5); - color: #00ff88; -} - -.ws-status.offline { - color: #8892b0; -} - -.chart-period { - display: flex; - gap: 6px; -} - -.chart-period button { - padding: 6px 12px; - border-radius: 4px; - border: 1px solid #333; - background: #111; - color: #8892b0; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; -} - -.chart-period button.active, -.chart-period button:hover { - border-color: #00ff88; - color: #00ff88; -} - -.chart-container { - flex: 1; - min-height: 340px; - background: #0a0a0a; - border: 1px solid #222; - border-radius: 4px; - padding: 8px; - display: flex; - align-items: center; - justify-content: center; -} - -/* ๊ฑฐ๋ž˜ ์ด๋ ฅ ์„น์…˜ */ -.trades-section { - background: #1a1a1a; - border: 1px solid #333; - border-radius: 4px; - padding: 15px; - overflow: hidden; - display: flex; - flex-direction: column; - min-height: 280px; - max-height: 320px; -} - -.trades-table-container { - flex: 1; - overflow-y: auto; -} - -.trades-table { - width: 100%; - border-collapse: collapse; -} - -.trades-table thead { - position: sticky; - top: 0; - background: #1a1a1a; - z-index: 1; -} - -.trades-table th { - padding: 8px; - font-size: 11px; - color: #999; - font-weight: 600; - text-align: left; - border-bottom: 1px solid #333; -} - -.trades-table td { - padding: 8px; - font-size: 11px; - color: #ccc; - border-bottom: 1px solid #222; -} - -.trades-table tbody tr:hover { - background: #222; -} - -.trade-status { - padding: 2px 6px; - border-radius: 3px; - font-size: 10px; - font-weight: 600; -} - -.trade-status.executed { - background: rgba(76, 175, 80, 0.2); - color: #4caf50; -} - -.trade-status.pending { - background: rgba(255, 152, 0, 0.2); - color: #ff9800; -} - -.trade-status.failed { - background: rgba(244, 67, 54, 0.2); - color: #f44336; -} - -/* ์˜ค๋ฅธ์ชฝ ํŒจ๋„ */ -.right-panel { - display: flex; - flex-direction: column; - gap: 8px; -} - -.recommend-section, -.mcp-status-section, -.news-section, -.ai-analysis-section, -.risk-check-section { - background: #1a1a1a; - border: 1px solid #333; - border-radius: 4px; - padding: 15px; - display: flex; - flex-direction: column; -} - -.ai-analysis-section { - min-height: 180px; - max-height: 220px; -} - -.risk-check-section { - min-height: 150px; - max-height: 180px; -} - -.analysis-content, -.risk-content { - flex: 1; - overflow-y: auto; - padding: 12px; - background: #0a0a0a; - border: 1px solid #222; - border-radius: 4px; - font-size: 12px; - line-height: 1.6; - color: #ccc; -} - -.analysis-loading, -.risk-loading { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: #666; - font-size: 12px; -} - -.risk-item { - padding: 8px; - margin-bottom: 6px; - background: #111; - border: 1px solid #333; - border-radius: 4px; - font-size: 11px; -} - -.risk-item-header { - display: flex; - justify-content: space-between; - margin-bottom: 4px; - font-weight: 600; -} - -.risk-level { - padding: 2px 8px; - border-radius: 3px; - font-size: 10px; -} - -.risk-level.low { - background: rgba(76, 175, 80, 0.2); - color: #4caf50; -} - -.risk-level.medium { - background: rgba(255, 152, 0, 0.2); - color: #ff9800; -} - -.risk-level.high { - background: rgba(244, 67, 54, 0.2); - color: #f44336; -} - -.risk-description { - color: #999; - font-size: 10px; - line-height: 1.4; -} - -.recommend-section { - min-height: 300px; - max-height: 360px; -} - -.recommend-list { - display: flex; - flex-direction: column; - gap: 8px; - overflow-y: auto; - flex: 1; -} - -.recommend-item { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - background: #111; - border: 1px solid #333; - border-radius: 4px; - padding: 10px 12px; - cursor: pointer; - transition: all 0.2s; -} - -.recommend-item .info { - display: flex; - flex-direction: column; - gap: 4px; -} - -.recommend-item .info .name { - font-size: 13px; - font-weight: 600; - color: #fff; -} - -.recommend-item .info .code { - font-size: 10px; - color: #666; - letter-spacing: 0.5px; -} - -.recommend-item .figures { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; -} - -.recommend-item .figures .price { - font-size: 13px; - font-weight: 600; - color: #fff; -} - -.recommend-item .figures .rate { - font-size: 11px; - font-weight: 600; -} - -.recommend-item:hover, -.recommend-item.active { - border-color: #00ff88; - box-shadow: 0 0 12px rgba(0, 255, 136, 0.2); -} - -.btn-small { - background: #1a1a1a; - border: 1px solid #333; - color: #4fc3f7; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s; -} - -.btn-small:hover { - background: #222; - border-color: #4fc3f7; -} - -.btn-primary { - background: #4fc3f7; - color: #000; - border: none; - border-radius: 4px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; -} - -.btn-primary:hover { - background: #6fd4ff; -} - -.btn-primary:disabled { - background: #2a4a5a; - color: #666; - cursor: not-allowed; -} - -.mcp-status-section { - display: flex; - flex-direction: column; - gap: 12px; -} - -.mcp-status { - display: flex; - align-items: center; - gap: 10px; - padding: 10px; - background: #222; - border-radius: 4px; - margin-bottom: 10px; -} - -.status-indicator { - width: 12px; - height: 12px; - border-radius: 50%; - animation: pulse 2s infinite; -} - -.status-indicator.connected { - background: #4caf50; -} - -.status-indicator.disconnected { - background: #f44336; -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.5; - } -} - -.status-text { - flex: 1; -} - -.status-label { - font-size: 11px; - color: #999; -} - -.status-value { - font-size: 13px; - font-weight: 600; - color: #fff; -} - -.mcp-tools-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 6px; -} - -.mcp-tool-badge { - padding: 6px; - background: #111; - border: 1px solid #333; - border-radius: 3px; - font-size: 10px; - color: #999; - text-align: center; -} - -.news-section { - flex: 1; - min-height: 180px; - max-height: 220px; - overflow: hidden; -} - -.news-section .news-list { - max-height: 160px; - overflow-y: auto; -} - -.news-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.news-item { - padding: 10px; - background: #222; - border: 1px solid #333; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s; -} - -.news-item:hover { - border-color: #4fc3f7; - transform: translateX(2px); -} - -.news-title { - font-size: 12px; - font-weight: 600; - color: #fff; - margin-bottom: 4px; - line-height: 1.3; -} - -.news-meta { - font-size: 10px; - color: #666; -} - -/* ๊ณตํ†ต */ -.no-data { - padding: 40px 20px; - text-align: center; - color: #666; - font-size: 13px; -} - -/* ์ƒ‰์ƒ */ -.up { - color: #ff3b3b !important; -} - -.down { - color: #2979ff !important; -} - -/* ์Šคํฌ๋กค๋ฐ” */ -::-webkit-scrollbar { +/* Custom Scrollbar for Dashboard containers */ +.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { - background: #1a1a1a; +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; } -::-webkit-scrollbar-thumb { - background: #333; +.custom-scrollbar::-webkit-scrollbar-thumb { + background: #374151; /* gray-700 */ border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { - background: #444; -} \ No newline at end of file +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: #4b5563; /* gray-600 */ +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 56b7206..885aba3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,922 +1,784 @@ -import { useQuery } from '@tanstack/react-query' +import { useEffect, useState, useRef } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' import api from '../lib/api' import { useWebSocket } from '../hooks/useWebSocket' -import { useEffect, useMemo, useState } from 'react' -import HtsPage from '../components/HtsPage' -import CandlestickChart from '../components/CandlestickChart' +import { PortfolioSummary } from '../components/PortfolioSummary' +import { TradingChart } from '../components/TradingChart' +import { Activity, Brain, RefreshCw } from 'lucide-react' import './Dashboard.css' -const Dashboard = () => { - console.log('๐Ÿš€ Dashboard component mounted') +// ๋งˆ์ผ“ ์šด์˜ ์‹œ๊ฐ„ ์ฒดํฌ (ํ•œ๊ตญ์žฅ + ๋ฏธ๊ตญ์žฅ) +const checkMarketOpen = () => { + const now = new Date(); + const kstTime = new Date(now.toLocaleString("en-US", { timeZone: "Asia/Seoul" })); + const day = kstTime.getDay(); + const currentMinutes = kstTime.getHours() * 60 + kstTime.getMinutes(); - const { connected: wsConnected } = useWebSocket('dashboard') + // ์ฃผ๋ง ์ฒดํฌ (ํ† ์š”์ผ ์˜ค์ „ 7์‹œ ์ดํ›„ ~ ์ผ์š”์ผ์€ ํ์žฅ) + if (day === 0 || (day === 6 && currentMinutes >= 420)) return false; // ์ผ์š”์ผ or ํ† ์š”์ผ 7์‹œ ์ดํ›„ + + // ํ•œ๊ตญ์žฅ: ์›”~๊ธˆ 09:00 ~ 15:30 (540 ~ 930) + const isKoreaOpen = currentMinutes >= 540 && currentMinutes <= 930; + + // ๋ฏธ๊ตญ์žฅ (์„œ๋จธํƒ€์ž„ ๊ณ ๋ ค): + // - ์—ฌ๋ฆ„(3์›”~11์›”): 22:30 ~ ๋‹ค์Œ๋‚  05:00 (1350 ~ 1440 + 0 ~ 300) + // - ๊ฒจ์šธ(11์›”~3์›”): 23:30 ~ ๋‹ค์Œ๋‚  06:00 (1410 ~ 1440 + 0 ~ 360) + // ๊ฐ„๋‹จํžˆ: 22:30 ~ ๋‹ค์Œ๋‚  06:00 (1350 ~ 1440 + 0 ~ 360) + const isUSOpen = currentMinutes >= 1350 || currentMinutes <= 360; + + // ๊ธˆ์š”์ผ ๋ฐค ~ ํ† ์š”์ผ ์ƒˆ๋ฒฝ์€ ๋ฏธ๊ตญ์žฅ ๊ฐœ์žฅ (๊ธˆ์š”์ผ 22:30 ~ ํ† ์š”์ผ 06:00) + if (day === 5 && currentMinutes >= 1350) return true; // ๊ธˆ์š”์ผ ๋ฐค + if (day === 6 && currentMinutes <= 360) return true; // ํ† ์š”์ผ ์ƒˆ๋ฒฝ (๋ฏธ๊ตญ ๊ธˆ์š”์ผ) + + return isKoreaOpen || isUSOpen; +}; + +const Dashboard = () => { + const { connected: wsConnected, messages: wsMessages } = useWebSocket('dashboard') const [selectedStock, setSelectedStock] = useState('') const [chartPeriod, setChartPeriod] = useState<'D' | 'W' | 'M'>('D') - const [showIndicators, setShowIndicators] = useState(false) - const [indicators, setIndicators] = useState({ - use_macd: false, - use_rsi: true, - use_bollinger: false, - use_moving_average: true, - use_stochastic: false, - use_adx: false, - use_obv: false, - }) + const queryClient = useQueryClient() + const prevLatestTradeIdRef = useRef(null) - console.log('๐Ÿ”ง Setting up queries...') + const isMarketOpen = checkMarketOpen(); + + // --- Queries --- + // ์žฅ ๋งˆ๊ฐ ์‹œ์—๋Š” ์ž๋™ ํด๋ง ์™„์ „ ์ค‘๋‹จ (refetchInterval: false) const { data: portfolioSummary, error: portfolioSummaryError } = useQuery({ queryKey: ['portfolio-summary'], - queryFn: async () => { - console.log('๐Ÿ”ต Fetching portfolio summary...') - const response = await api.get('/portfolio/summary') - console.log('๐ŸŸข Portfolio summary received:', response.data) - return response.data - }, - refetchInterval: 5000, + queryFn: async () => (await api.get('/portfolio/summary')).data, + refetchInterval: isMarketOpen ? 10000 : false, }) - const { data: portfolio, error: portfolioError } = useQuery({ + const { data: portfolio } = useQuery({ queryKey: ['portfolio'], queryFn: async () => { - console.log('๐Ÿ”ต Fetching portfolio...') - try { - const response = await api.get('/portfolio') - console.log('๐ŸŸข Portfolio received:', response.data) - return response.data - } catch (error) { - console.error('โŒ Portfolio fetch error:', error) - throw error - } + const data = (await api.get('/portfolio')).data; + // ์ˆ˜๋Ÿ‰ 0์ธ ์ข…๋ชฉ ํ•„ํ„ฐ๋ง (์•ˆ์ „์žฅ์น˜) + return data.filter((item: any) => item.quantity > 0); }, - refetchInterval: 5000, - retry: 1, - }) - - // ์—๋Ÿฌ ๋กœ๊น… - useEffect(() => { - if (portfolioSummaryError) { - console.error('โŒ Portfolio Summary Error:', portfolioSummaryError) - } - if (portfolioError) { - console.error('โŒ Portfolio Error:', portfolioError) - } - }, [portfolioSummaryError, portfolioError]) - - const { data: recentTrades } = useQuery({ - queryKey: ['recent-trades'], - queryFn: async () => { - const response = await api.get('/trades', { params: { limit: 20 } }) - return response.data - }, - refetchInterval: 5000, + refetchInterval: isMarketOpen ? 10000 : false, }) const { data: mcpStatus } = useQuery({ queryKey: ['mcp-status'], - queryFn: async () => { - const response = await api.get('/mcp/status') - return response.data - }, - refetchInterval: 5000, + queryFn: async () => (await api.get('/mcp/status')).data, + refetchInterval: isMarketOpen ? 5000 : false, }) - const { data: news } = useQuery({ - queryKey: ['market-news'], - queryFn: async () => { - const response = await api.get('/market-data/news', { params: { limit: 5 } }) - return response.data - }, - refetchInterval: 30000, - }) - - // ๊ทœ์น™ ๊ธฐ๋ฐ˜ ์ž๋™ ์ถ”์ฒœ (์ž๋™ ๋กœ๋“œ) const { data: ruleBasedRecommendations } = useQuery({ queryKey: ['rule-based-recommendations'], queryFn: async () => { - console.log('๐Ÿ”ต Fetching rule-based recommendations...') - try { - const response = await api.get('/recommendations/rule-based') - console.log('๐ŸŸข Rule-based recommendations received:', response.data) - return response.data - } catch (error) { - console.error('๐Ÿ”ด Rule-based recommendations error:', error) - throw error + const response = await api.get('/recommendations/rule-based', { + params: { + realtime: false, + auto_trade: true // ์ž๋™๋งค๋งค ํ™œ์„ฑํ™” + } + }); + + const data = response.data; + + // ์‹ค์ œ๋กœ ๊ฑฐ๋ž˜๊ฐ€ ์ฒด๊ฒฐ๋œ ๊ฒฝ์šฐ์—๋งŒ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ๊ฐฑ์‹  + if (data.trades_executed && data.trades_executed.length > 0) { + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['trade-history'] }); + console.log('โœ… ์ž๋™๋งค๋งค ์ฒด๊ฒฐ:', data.trades_executed.length, '๊ฑด'); + }, 3000); } + + return data; }, - refetchInterval: 300000, // 5๋ถ„๋งˆ๋‹ค ์ž๋™ ๊ฐฑ์‹  + refetchInterval: isMarketOpen ? 10000 : false, + // ์žฅ์™ธ์—๋„ ์ดˆ๊ธฐ ๋กœ๋“œ๋Š” ์‹คํ–‰ (DB ์กฐํšŒ๋Š” ๋น ๋ฆ„) }) - // ์ˆ˜๋™ ์ง€ํ‘œ ๋ถ„์„ (AI ๋ถ„์„ ๋ฒ„ํŠผ์šฉ) - const { data: aiRecommendations, refetch: refetchRecommendations, isLoading: recommendationsLoading, error: recommendationsError } = useQuery({ - queryKey: ['ai-recommendations', indicators], - queryFn: async () => { - console.log('๐Ÿ”ต Fetching recommendations with indicators:', indicators) - try { - const response = await api.get('/recommendations', { params: indicators }) - console.log('๐ŸŸข Recommendations received:', response.data) - return response.data - } catch (error) { - console.error('๐Ÿ”ด Recommendations fetch error:', error) - throw error - } - }, - enabled: false, // ์ˆ˜๋™์œผ๋กœ๋งŒ ํ˜ธ์ถœ - }) - - // ์ถ”์ฒœ ์ข…๋ชฉ ์—๋Ÿฌ ๋กœ๊น… - if (recommendationsError) { - console.error('โŒ Recommendations query error:', recommendationsError) - } - - // ๊ทœ์น™ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ ๋กœ๊น… - if (ruleBasedRecommendations) { - console.log('โœ… Rule-based Recommendations:', { - total: ruleBasedRecommendations.total, - activeRules: ruleBasedRecommendations.active_rules, - found: ruleBasedRecommendations.recommendations?.length || 0 - }) - } - - // ์ˆ˜๋™ ์ถ”์ฒœ ๊ฒฐ๊ณผ ๋กœ๊น… - if (aiRecommendations) { - console.log('โœ… AI Recommendations:', { - total: aiRecommendations.total, - analyzed: aiRecommendations.analyzed_stocks, - found: aiRecommendations.recommendations?.length || 0 - }) - } - - const { data: chartData, isLoading: chartLoading, error: chartError } = useQuery({ + const { data: chartData, isLoading: chartLoading } = useQuery({ queryKey: ['chart-data', selectedStock, chartPeriod], queryFn: async () => { - if (!selectedStock) { - console.log('โš ๏ธ No stock selected, skipping chart fetch') - return null - } - console.log('๐Ÿ”ต Fetching chart data for:', selectedStock, 'period:', chartPeriod) - try { - const response = await api.get(`/market-data/chart/${selectedStock}`, { - params: { period: chartPeriod }, - }) - console.log('๐ŸŸข Chart data received:', { - stock_code: response.data.stock_code, - selectedStock: selectedStock, - dataLength: response.data.data?.length, - firstPrice: response.data.data?.[0]?.close, - match: response.data.stock_code === selectedStock - }) - return response.data - } catch (error) { - console.error('๐Ÿ”ด Chart data fetch error:', error) - throw error - } - }, - enabled: Boolean(selectedStock), - refetchInterval: 60000, - }) - - // ์ฐจํŠธ ์—๋Ÿฌ ๋กœ๊น… - if (chartError) { - console.error('โŒ Chart query error:', chartError) - } - - // AI ์ข…ํ•ฉ ๋ถ„์„ (์„ ํƒ๋œ ์ข…๋ชฉ์— ๋Œ€ํ•ด) - const { data: aiAnalysis, isLoading: aiAnalysisLoading } = useQuery({ - queryKey: ['ai-analysis', selectedStock, chartData], - queryFn: async () => { - if (!selectedStock || !chartData?.data?.[0]) return null - console.log('๐Ÿ”ต Fetching AI analysis for:', selectedStock) - try { - const response = await fetch('http://localhost:8001/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - analysis_type: 'MARKET_ANALYSIS', - data: { - stock_code: selectedStock, - market_data: { - stock_name: chartData?.stock_name || selectedStock, - current_price: Number(chartData.data[0].close), - change_rate: Number(chartData.data[0].change_rate || 0), - volume: Number(chartData.data[0].volume || 0), - high: Number(chartData.data[0].high), - low: Number(chartData.data[0].low) - }, - news: [] - } - }) - }) - const result = await response.json() - console.log('๐ŸŸข AI analysis received:', result) - return result.result || result.analysis - } catch (error) { - console.error('๐Ÿ”ด AI analysis error:', error) - return 'โš ๏ธ AI ๋ถ„์„ ์„œ๋น„์Šค์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' - } - }, - enabled: Boolean(selectedStock && chartData?.data?.[0]), - refetchInterval: 300000, // 5๋ถ„๋งˆ๋‹ค - }) - - // ๋ฆฌ์Šคํฌ ์ฒดํฌ - const { data: riskCheck, isLoading: riskCheckLoading } = useQuery({ - queryKey: ['risk-check', selectedStock, portfolio, chartData], - queryFn: async () => { - if (!selectedStock || !chartData?.data?.[0]) return null - console.log('๐Ÿ”ต Fetching risk check for:', selectedStock) - try { - const response = await fetch('http://localhost:8001/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - analysis_type: 'RISK_ASSESSMENT', - data: { - portfolio: { - total_value: portfolioSummary?.total_value || 0, - total_stocks: portfolioSummary?.total_stocks || 0, - holdings: portfolio || [] - }, - trade_plan: { - stock_code: selectedStock, - stock_name: chartData?.stock_name || selectedStock, - current_price: Number(chartData.data[0].close), - action: 'BUY', - quantity: 1 - } - } - }) - }) - const result = await response.json() - console.log('๐ŸŸข Risk check received:', result) - return result.result || result.analysis - } catch (error) { - console.error('๐Ÿ”ด Risk check error:', error) - return 'โš ๏ธ ๋ฆฌ์Šคํฌ ๋ถ„์„ ์„œ๋น„์Šค์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' - } - }, - enabled: Boolean(selectedStock && chartData?.data?.[0]), - refetchInterval: 300000, // 5๋ถ„๋งˆ๋‹ค - }) - - // ์ฐจํŠธ ์ƒ๋‹จ์— ํ‘œ์‹œํ•  ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ ์ƒ์„ฑ - const displayStocks = useMemo(() => { - // 1. ๊ทœ์น™ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ์ด ์žˆ์œผ๋ฉด ์šฐ์„  ์‚ฌ์šฉ - if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) { - const stocks: any[] = [] - ruleBasedRecommendations.recommendations_by_rule.forEach((ruleGroup: any) => { - if (ruleGroup.stocks && stocks.length < 4) { - ruleGroup.stocks.slice(0, 4 - stocks.length).forEach((stock: any) => { - stocks.push(stock) - }) - } + if (!selectedStock) return null + const response = await api.get(`/market-data/chart/${selectedStock}`, { + params: { period: chartPeriod, realtime: true, limit: 200 }, }) - return stocks.slice(0, 4) - } - // 2. ํฌํŠธํด๋ฆฌ์˜ค๊ฐ€ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ - if (portfolio && portfolio.length > 0) { - return [...portfolio] - .sort((a: any, b: any) => (b.profit_loss_rate ?? 0) - (a.profit_loss_rate ?? 0)) - .slice(0, 4) - } + const rawData = response.data.data || []; - // 3. ๋‘˜ ๋‹ค ์—†์œผ๋ฉด ๋นˆ ๋ฐฐ์—ด (ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ œ๊ฑฐ) - return [] - }, [ruleBasedRecommendations, portfolio]) - - // ํ‘œ์‹œํ•  ์ข…๋ชฉ๋“ค์˜ ์ตœ์‹  ๊ฐ€๊ฒฉ ๊ฐ€์ ธ์˜ค๊ธฐ - const stockCodesToFetch = displayStocks.map((item: any) => item.stock_code) - - const { data: stockPrices = {} } = useQuery>({ - queryKey: ['all-stock-prices', stockCodesToFetch.join(',')], - queryFn: async () => { - if (stockCodesToFetch.length === 0) { - return {} - } - const promises = stockCodesToFetch.map(async (code: string) => { - try { - const response = await api.get(`/market-data/chart/${code}`, { - params: { period: 'D' }, - }) - // KIS API๋Š” ์ตœ์‹  ๋ฐ์ดํ„ฐ๊ฐ€ ์ฒซ๋ฒˆ์งธ (index 0) - const latestPrice = response.data?.data?.[0]?.close || 0 - return { code, price: Number(latestPrice) } - } catch (error) { - console.error(`Failed to fetch price for ${code}:`, error) - return { code, price: 0 } - } - }) - const results = await Promise.all(promises) - return results.reduce((acc: Record, item) => { - acc[item.code] = item.price - return acc - }, {}) - }, - enabled: stockCodesToFetch.length > 0, - refetchInterval: 60000, - }) - - const recommendedStocks = useMemo(() => { - console.log('๐Ÿ’ก Building recommendedStocks:', { - displayStocksCount: displayStocks.length, - stockPricesCount: Object.keys(stockPrices).length, - stockPrices - }) - return displayStocks.map((item: any) => { - const priceFromQuery = stockPrices[item.stock_code] - const finalPrice = priceFromQuery || item.current_price || 0 - console.log(` - ${item.stock_code}: query=${priceFromQuery}, original=${item.current_price}, final=${finalPrice}`) - return { - ...item, - current_price: finalPrice, - } - }) - }, [displayStocks, stockPrices]) - - const currentStockPrice = useMemo(() => { - if (!chartData?.data || chartData.data.length === 0) { - console.log('โŒ currentStockPrice: No data') - return null - } - // KIS API๋Š” ์ตœ์‹  ๋ฐ์ดํ„ฐ๊ฐ€ ์ฒซ๋ฒˆ์งธ (index 0) - const price = Number(chartData.data[0].close) - console.log('โœ… currentStockPrice:', price, 'from', chartData.data[0].date) - return price - }, [chartData]) - - // ์ดˆ๊ธฐ ์ข…๋ชฉ ์„ ํƒ (ํ•œ ๋ฒˆ๋งŒ ์‹คํ–‰) - useEffect(() => { - console.log('๐Ÿ“Š Initial stock selection check') - - // ์ด๋ฏธ ์ข…๋ชฉ์ด ์„ ํƒ๋˜์–ด ์žˆ์œผ๋ฉด ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์Œ - if (selectedStock && selectedStock !== '') { - console.log('โœ… Stock already selected:', selectedStock) - return - } - - // ๊ทœ์น™ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ์ด ์žˆ์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์ข…๋ชฉ ์„ ํƒ - if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) { - const firstRule = ruleBasedRecommendations.recommendations_by_rule[0] - if (firstRule.stocks?.length > 0) { - const firstStock = firstRule.stocks[0] - console.log('๐ŸŽฏ Auto-selecting first rule-based recommendation:', firstStock.stock_code) - setSelectedStock(firstStock.stock_code) - return - } - } - - // ํฌํŠธํด๋ฆฌ์˜ค๊ฐ€ ์žˆ์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์ข…๋ชฉ ์„ ํƒ - if (portfolio && portfolio.length > 0) { - console.log('๐ŸŽฏ Auto-selecting first portfolio stock:', portfolio[0].stock_code) - setSelectedStock(portfolio[0].stock_code) - return - } - - // ์•„๋ฌด๊ฒƒ๋„ ์—†์œผ๋ฉด ์ข…๋ชฉ ์„ ํƒ ์—†์Œ (ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ œ๊ฑฐ) - console.log('โš ๏ธ No stocks available') - }, [ruleBasedRecommendations, portfolio]) // selectedStock์€ ์˜์กด์„ฑ์—์„œ ์ œ๊ฑฐ - - const chartSeries = useMemo(() => { - if (!selectedStock) { - console.log('โš ๏ธ chartSeries: No stock selected') - return [] - } - - if (!chartData?.data || chartData.data.length === 0) { - console.log('โŒ chartSeries: No data', chartData) - return [] - } - - // ์ฐจํŠธ ๋ฐ์ดํ„ฐ๊ฐ€ ์„ ํƒ๋œ ์ข…๋ชฉ๊ณผ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธ - if (chartData.stock_code !== selectedStock) { - console.error('๐Ÿ”ด CRITICAL: Chart data mismatch!', { - chartStock: chartData.stock_code, - selectedStock: selectedStock, - chartName: chartData.stock_name, - dataLength: chartData.data.length - }) - // ๋ฐ์ดํ„ฐ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด ๋นˆ ๋ฐฐ์—ด ๋ฐ˜ํ™˜ (์ƒˆ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ๋Œ€๊ธฐ) - return [] - } - - console.log('โœ… Chart data matches selected stock:', selectedStock) - - console.log('โœ… Raw chartData:', { - stock: chartData.stock_code, - dataLength: chartData.data.length, - first: chartData.data[0], - last: chartData.data[chartData.data.length - 1] - }) - - // KIS API๋Š” ์ตœ์‹  ๋ฐ์ดํ„ฐ๊ฐ€ ์ฒซ๋ฒˆ์งธ (20251104, 20251103, ...) - // ์ฐจํŠธ๋Š” ์˜ค๋ž˜๋œ ๊ฒƒ๋ถ€ํ„ฐ ์ตœ์‹  ์ˆœ์„œ๋กœ ํ‘œ์‹œํ•ด์•ผ ํ•˜๋ฏ€๋กœ reverse ํ•„์š” - const sortedData = [...chartData.data] - .reverse() // ์˜ค๋ž˜๋œ ๋‚ ์งœ๋ถ€ํ„ฐ ์ •๋ ฌ - // KIS API๊ฐ€ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋ชจ๋“  ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ (slice ์ œ๊ฑฐ) - .filter((item: any) => { - // ์œ ํšจํ•œ ๋ฐ์ดํ„ฐ๋งŒ ํ•„ํ„ฐ๋ง (0์ด ์•„๋‹Œ ๊ฐ€๊ฒฉ) - return item.open > 0 && item.high > 0 && item.low > 0 && item.close > 0 - }) - .map((item: any) => ({ + return rawData.map((item: any) => ({ date: item.date, - dateLabel: item.date ? `${item.date.slice(4, 6)}/${item.date.slice(6, 8)}` : '', open: Number(item.open), high: Number(item.high), low: Number(item.low), close: Number(item.close), - volume: Number(item.volume), - })) + volume: Number(item.volume) + })).reverse(); + }, + enabled: Boolean(selectedStock), + refetchInterval: isMarketOpen ? 10000 : false, + }) - console.log('โœ… Chart series processed:', { - length: sortedData.length, - first: sortedData[0], - last: sortedData[sortedData.length - 1] - }) + const { data: orderbook } = useQuery({ + queryKey: ['orderbook', selectedStock], + queryFn: async () => { + if (!selectedStock) return null; + try { + const response = await api.get(`/market-data/orderbook/${selectedStock}`); + return response.data; + } catch (error: any) { + console.error(`โŒ ํ˜ธ๊ฐ€ ์กฐํšŒ ์‹คํŒจ (${selectedStock}):`, error.response?.data?.detail || error.message); + return null; + } + }, + enabled: Boolean(selectedStock), + refetchInterval: isMarketOpen ? 5000 : false, // 5์ดˆ๋งˆ๋‹ค ๊ฐฑ์‹  (API ๋ถ€ํ•˜ ๊ฐ์†Œ) + retry: 1, // ์žฌ์‹œ๋„ 1ํšŒ๋กœ ์ œํ•œ + }) - if (sortedData.length === 0) { - console.log('โŒ All data filtered out (invalid prices)') + const { data: tradeHistory } = useQuery({ + queryKey: ['trade-history'], + queryFn: async () => { + const data = (await api.get('/trades', { params: { limit: 50 } })).data; + + // ID ๊ธฐ๋ฐ˜ ์ค‘๋ณต ์ œ๊ฑฐ (DB์—์„œ ์ด๋ฏธ unique) + const seen = new Set(); + return data.filter((trade: any) => { + if (seen.has(trade.id)) return false; + seen.add(trade.id); + return true; + }).slice(0, 20); + }, + staleTime: 0, + refetchInterval: isMarketOpen ? 30000 : false, + refetchOnWindowFocus: true, + }) + + const { data: journals } = useQuery({ + queryKey: ['journals'], + queryFn: async () => (await api.get('/journals', { params: { limit: 20 } })).data, + staleTime: 0, + refetchOnWindowFocus: true, + }) + + // ๊ฑฐ๋ž˜ ์ด๋ ฅ์ด ๋ณ€๊ฒฝ๋˜๋ฉด ํฌํŠธํด๋ฆฌ์˜ค ์ฆ‰์‹œ ๊ฐฑ์‹  (๋งค๋งค ๋ฐœ์ƒ ์‹œ์—๋งŒ ์กฐํšŒ) + useEffect(() => { + if (tradeHistory && tradeHistory.length > 0) { + const latestTrade = tradeHistory[0]; + const latestId = latestTrade.id; + + // ์ƒˆ๋กœ์šด ๊ฑฐ๋ž˜ ID๊ฐ€ ๊ฐ์ง€๋˜๋ฉด ํฌํŠธํด๋ฆฌ์˜ค ๊ฐ•์ œ ๊ฐฑ์‹  + if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) { + console.log('โœ… ์ƒˆ ๋งค๋งค ๊ฐ์ง€:', latestTrade.stock_name, latestTrade.order_type, 'โ†’ ํฌํŠธํด๋ฆฌ์˜ค ๊ฐฑ์‹ '); + + // ํฌํŠธํด๋ฆฌ์˜ค ์ฆ‰์‹œ ๊ฐฑ์‹  + queryClient.invalidateQueries({ queryKey: ['portfolio'] }); + queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); + + // 1์ดˆ, 3์ดˆ ํ›„ ์ถ”๊ฐ€ ๊ฐฑ์‹  (๋ฐฑ์—”๋“œ ๋™๊ธฐํ™” ๋Œ€๊ธฐ) + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['portfolio'] }); + queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); + }, 1000); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['portfolio'] }); + queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); + }, 3000); + } + prevLatestTradeIdRef.current = latestId; } + }, [tradeHistory, queryClient]) - return sortedData - }, [chartData, selectedStock]) - - const formatCurrency = (value: number) => { - if (!value) { - return '0์›' + // WebSocket journal_created ์ด๋ฒคํŠธ โ†’ ๋งค๋งค์ผ์ง€ ์ฆ‰์‹œ ๊ฐฑ์‹  + useEffect(() => { + if (wsMessages.length === 0) return + const latest = wsMessages[wsMessages.length - 1] + if (latest.type === 'journal_created') { + queryClient.invalidateQueries({ queryKey: ['journals'] }) } - return new Intl.NumberFormat('ko-KR').format(value) + '์›' - } + }, [wsMessages, queryClient]) - const formatPercent = (value: number) => { - if (!value) { - return '+0.00%' + // ๋ณด์œ  ์ข…๋ชฉ ์ฝ”๋“œ Set (์ถ”์ฒœ ๋ชฉ๋ก ํ•„ํ„ฐ๋ง์šฉ) + const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code)) + + // ๋งค์ˆ˜ ์ง„ํ–‰ ์ค‘์ธ ์ข…๋ชฉ ์ฝ”๋“œ Set (PENDING BUY ์ฃผ๋ฌธ) + const pendingBuyStockCodes = new Set( + (tradeHistory || []) + .filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY') + .map((t: any) => t.stock_code) + ) + + // ๋ฏธ์ฒด๊ฒฐ ์ฃผ๋ฌธ ์ •๋ฆฌ ํ•จ์ˆ˜ + const resetPendingTrades = async () => { + try { + const response = await api.delete('/trades/pending'); + queryClient.invalidateQueries({ queryKey: ['trade-history'] }); + console.log('โœ… ๋ฏธ์ฒด๊ฒฐ ์ฃผ๋ฌธ ์ •๋ฆฌ ์™„๋ฃŒ:', response.data); + // alert ์ œ๊ฑฐ - ์ฝ˜์†” ๋กœ๊ทธ๋กœ๋งŒ ํ™•์ธ + } catch (error: any) { + console.error('๋ฏธ์ฒด๊ฒฐ ์ฃผ๋ฌธ ์ •๋ฆฌ ์‹คํŒจ:', error); + // alert ์ œ๊ฑฐ - ์ฝ˜์†” ๋กœ๊ทธ๋กœ๋งŒ ํ™•์ธ } - return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%` - } + }; - const formatDate = (dateStr: string) => { - if (!dateStr || dateStr.length !== 8) return '' - // YYYYMMDD โ†’ MM/DD - return `${dateStr.slice(4, 6)}/${dateStr.slice(6, 8)}` - } + // ์ฃผ๊ธฐ์  ๊ฑฐ๋ž˜ ๋‚ด์—ญ ๊ฐฑ์‹  ์ œ๊ฑฐ - ์‹ค์ œ ๋งค๋งค ๋ฐœ์ƒ ์‹œ์—๋งŒ ๊ฐฑ์‹  (138-164๋ฒˆ ์ค„ ๋กœ์ง ์‚ฌ์šฉ) + // useEffect(() => { + // if (!isMarketOpen) { + // return; + // } + // const interval = setInterval(() => { + // queryClient.invalidateQueries({ queryKey: ['trade-history'] }); + // }, 60000); + // return () => clearInterval(interval); + // }, [isMarketOpen, queryClient]) - const getTradeStatusClass = (status: string) => { - switch (status) { - case 'EXECUTED': - return 'executed' - case 'PENDING': - return 'pending' - case 'FAILED': - return 'failed' - default: - return '' + const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({ + queryKey: ['ai-analysis', selectedStock], + queryFn: async () => { + if (!selectedStock) return null; + console.log('๐Ÿค– AI ๋ถ„์„ ์š”์ฒญ:', selectedStock); + + try { + // AI ๋ถ„์„ API ํ˜ธ์ถœ + const response = await api.get(`/market-data/ai-analysis/${selectedStock}`); + const data = response.data; + + // ๋ถ„์„ ํ…์ŠคํŠธ ์ƒ์„ฑ + let analysis = `๐Ÿ“ˆ ${data.stock_name} (${data.stock_code})\n\n`; + + // ํ˜„์žฌ๊ฐ€ & ๊ฑฐ๋ž˜๋Ÿ‰ + analysis += `๐Ÿ’ฐ ํ˜„์žฌ๊ฐ€: ${data.current_price.toLocaleString()}์›\n`; + analysis += `๐Ÿ“Š ๊ณ ๊ฐ€/์ €๊ฐ€: ${data.high_price.toLocaleString()} / ${data.low_price.toLocaleString()}์›\n`; + analysis += `๐Ÿ“ฆ ๊ฑฐ๋ž˜๋Ÿ‰: ${(data.volume / 1000).toFixed(0)}K์ฃผ\n`; + + // ๊ธฐ์ˆ ์  ์ง€ํ‘œ (ํ•ญ์ƒ ํ‘œ์‹œ) + analysis += `\n๐Ÿ“Š ๊ธฐ์ˆ ์  ์ง€ํ‘œ:\n`; + const hasIndicators = data.indicators && Object.values(data.indicators).some(v => v !== null && v !== undefined); + + if (hasIndicators) { + if (data.indicators.rsi !== null && data.indicators.rsi !== undefined) { + analysis += ` RSI(14): ${data.indicators.rsi.toFixed(1)}\n`; + } + if (data.indicators.ma5 !== null) { + analysis += ` 5์ผ ์ดํ‰: ${data.indicators.ma5.toFixed(0)}์›\n`; + } + if (data.indicators.ma20 !== null) { + analysis += ` 20์ผ ์ดํ‰: ${data.indicators.ma20.toFixed(0)}์›\n`; + } + if (data.indicators.ma60 !== null) { + analysis += ` 60์ผ ์ดํ‰: ${data.indicators.ma60.toFixed(0)}์›\n`; + } + if (data.indicators.stochastic_k !== null) { + analysis += ` ์Šคํ† ์บ์Šคํ‹ฑ K: ${data.indicators.stochastic_k.toFixed(1)}\n`; + } + } else { + analysis += ` (DB์— ์ €์žฅ๋œ ์ง€ํ‘œ ์—†์Œ - ๋ฐฐ์น˜ ์ž‘์—… ๋Œ€๊ธฐ์ค‘)\n`; + } + + // ์ถ”์ฒœ ๋ฐ ์ถ”์„ธ + analysis += `\n๐Ÿค– ๋ถ„์„ ๊ฒฐ๊ณผ:\n`; + const recommendMap: any = { + 'BUY': '๐Ÿ’š ๋งค์ˆ˜', + 'SELL': 'โค๏ธ ๋งค๋„', + 'HOLD': '๐Ÿ’› ๊ด€๋ง' + }; + const trendMap: any = { + 'UP': '๐Ÿ“ˆ ์ƒ์Šน', + 'DOWN': '๐Ÿ“‰ ํ•˜๋ฝ', + 'SIDEWAYS': 'โžก๏ธ ํšก๋ณด' + }; + analysis += ` ์ถ”์ฒœ: ${recommendMap[data.recommendation] || data.recommendation}\n`; + analysis += ` ์‹ ๋ขฐ๋„: ${(data.confidence * 100).toFixed(0)}%\n`; + analysis += ` ์ถ”์„ธ: ${trendMap[data.trend] || data.trend}\n`; + + // ๋ถ„์„ ๊ทผ๊ฑฐ (์˜๋ฏธ์žˆ๋Š” ๋‚ด์šฉ๋งŒ ํ‘œ์‹œ) + const skipMessages = [ + '๊ธฐ์ˆ ์  ์ง€ํ‘œ ๋ฐ์ดํ„ฐ ์—†์Œ', + '๊ธฐ์ˆ ์  ์ง€ํ‘œ ๋ฐ์ดํ„ฐ ๋ถ€์กฑ', + 'AI ๋ถ„์„์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + '์ถฉ๋ถ„ํ•œ ์‹ ํ˜ธ ์—†์Œ' + ]; + + if (data.ai_analysis && !skipMessages.includes(data.ai_analysis)) { + analysis += `\n๐Ÿ’ก ๋ถ„์„ ๊ทผ๊ฑฐ:\n${data.ai_analysis}\n`; + } + + return { analysis, data }; + } catch (error: any) { + console.error('โŒ AI ๋ถ„์„ ์‹คํŒจ:', error); + return { + analysis: `โŒ AI ๋ถ„์„์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.\n${error.response?.data?.detail || error.message}`, + data: null + }; + } + }, + enabled: !!selectedStock, + staleTime: 30000, + }); + + // Extract analysis text from the query result + const aiAnalysisText = aiAnalysisData?.analysis || null; + + // ์ดˆ๊ธฐ ์ข…๋ชฉ ์„ ํƒ (ํฌํŠธํด๋ฆฌ์˜ค๊ฐ€ ์žˆ์œผ๋ฉด ์ฒซ๋ฒˆ์งธ, ์•„๋‹ˆ๋ฉด ์ถ”์ฒœ ์ข…๋ชฉ ์ฒซ๋ฒˆ์งธ) + useEffect(() => { + if (!selectedStock) { + if (portfolio && portfolio.length > 0) { + setSelectedStock(portfolio[0].stock_code) + } else if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) { + const firstRule = ruleBasedRecommendations.recommendations_by_rule[0]; + if (firstRule.stocks && firstRule.stocks.length > 0) { + setSelectedStock(firstRule.stocks[0].stock_code) + } + } } - } - - const handleStockSelect = (stockCode: string) => { - setSelectedStock(stockCode) - } + }, [portfolio, ruleBasedRecommendations, selectedStock]) return ( - -
-
-
-
๊ณ„์ขŒ ์ •๋ณด
-
-
- ์˜ˆ์ˆ˜๊ธˆ - 10,000,000์› -
-
- ์ด ์ž์‚ฐ - {formatCurrency((portfolioSummary?.total_value || 0) + 10000000)} -
-
- ์ด ์†์ต - = 0 ? 'up' : 'down'}> - {formatCurrency(portfolioSummary?.total_profit_loss || 0)} - -
-
- ์ˆ˜์ต๋ฅ  - = 0 ? 'up' : 'down'}> - {formatPercent(portfolioSummary?.total_profit_loss_rate || 0)} - -
-
- ๋ณด์œ  ์ข…๋ชฉ - {portfolioSummary?.total_stocks || 0}๊ฐœ -
-
+
-
๋ณด์œ  ์ข…๋ชฉ
-
- {portfolio && portfolio.length > 0 ? ( - portfolio.map((item: any) => ( -
handleStockSelect(item.stock_code)} - style={{ cursor: 'pointer' }} - > -
{item.stock_name} ({item.stock_code})
-
- {item.quantity}์ฃผ - {formatCurrency(item.current_price)} - = 0 ? 'up' : 'down'}> - {formatPercent(item.profit_loss_rate || 0)} - -
+
+
+
+ +
+
+

+ AutoStock Trader +

+

Gen 3 AI Algorithm

+
+
+
+
+
+ {wsConnected ? '์‹ค์‹œ๊ฐ„ ์—ฐ๊ฒฐ๋จ' : '์—ฐ๊ฒฐ ๋Š๊น€'} +
+
+
+ MCP: {mcpStatus?.status || 'Unknown'} +
+
+
+ +
+
+ +
+ +
+ + {/* [Left Column] ๋ณด์œ  ์ข…๋ชฉ (Holding Stocks) */} +
+
+
+

+ + ๋ณด์œ  ์ข…๋ชฉ +

+ + {portfolio?.length || 0} + +
+
+ {portfolio && portfolio.length > 0 ? ( + portfolio.map((stock: any) => { + const currentPrice = Number(stock.current_price || 0); + const avgPrice = Number(stock.average_price || 0); + const quantity = Number(stock.quantity || 0); + const totalValue = currentPrice * quantity; + const profitLossRate = Number(stock.profit_loss_rate || 0); + + const handleSell = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!confirm(`${stock.stock_name} ์ „๋Ÿ‰(${quantity}์ฃผ)์„ ์‹œ์žฅ๊ฐ€๋กœ ๋งค๋„ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?`)) { + return; + } + try { + await api.post('/portfolio/sell', { + stock_code: stock.stock_code, + quantity: quantity, + price: null // ์‹œ์žฅ๊ฐ€ + }); + queryClient.invalidateQueries({ queryKey: ['portfolio'] }); + queryClient.invalidateQueries({ queryKey: ['trade-history'] }); + console.log(`โœ… ${stock.stock_name} ๋งค๋„ ์ฃผ๋ฌธ ์™„๋ฃŒ`); + } catch (error: any) { + console.error('๋งค๋„ ์‹คํŒจ:', error); + alert(`๋งค๋„ ์‹คํŒจ: ${error.response?.data?.detail || error.message}`); + } + }; + + return ( +
+
{ + setSelectedStock(stock.stock_code); + refetchAiAnalysis(); + }} + className="cursor-pointer" + > +
+ + {stock.stock_name} + + 0 ? 'text-red-400' : 'text-blue-400'}`}> + {profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}% + +
+
+ {stock.stock_code} + {quantity}์ฃผ +
+
+ {avgPrice.toLocaleString()}์› + {currentPrice.toLocaleString()}์› +
+
+ ํ‰๊ฐ€๊ธˆ์•ก + 0 ? 'text-red-400' : 'text-blue-400'}`}> + {totalValue.toLocaleString()}์› + +
+
+ {/* ์ฒญ์‚ฐ ๋ฒ„ํŠผ */} + +
+ ); + }) + ) : ( +
+

๋ณด์œ  ์ค‘์ธ ์ข…๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.

- )) - ) : ( -
- ๋ณด์œ  ์ข…๋ชฉ ์—†์Œ -
- )} + )} +
-
-
-
๋Œ€ํ‘œ ์ข…๋ชฉ ์ฐจํŠธ
-
-
- {recommendedStocks.length > 0 ? ( - recommendedStocks.map((item: any) => { - const isSelected = selectedStock === item.stock_code - // stockPrices์—์„œ ์ตœ์‹  ๊ฐ€๊ฒฉ ๊ฐ€์ ธ์˜ค๊ธฐ (์ฐจํŠธ ์ƒ๋‹จ๊ณผ ์ถ”์ฒœ ์ข…๋ชฉ ๋™์ผํ•œ ๊ฐ€๊ฒฉ) - const displayPrice = stockPrices[item.stock_code] || item.current_price || 0 - return ( - - ) - }) + {/* [Center Column] ์ฐจํŠธ & ํŠธ๋ ˆ์ด๋”ฉ ์ •๋ณด (Chart, AI, Trade) */} +
+ {/* ์ฐจํŠธ ์„น์…˜ (Top) */} +
+
+
+

+ {portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name || + ruleBasedRecommendations?.recommendations_by_rule?.flatMap((r: any) => r.stocks).find((s: any) => s.stock_code === selectedStock)?.stock_name || + selectedStock || '์ข…๋ชฉ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”'} + {selectedStock && {selectedStock}} +

+
+
+ {['D', 'W', 'M'].map((p) => ( + + ))} +
+
+ +
+ {chartLoading ? ( +
+
+ + ์ฐจํŠธ ๋กœ๋”ฉ์ค‘... +
+
+ ) : chartData && chartData.length > 0 ? ( + + ) : ( +
+ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. +
+ )} +
+
+ + {/* ํ•˜๋‹จ: AI ํˆฌ์ž ๋ถ„์„ & ๊ด€๋ จ ๋‰ด์Šค (Bottom Split) */} +
+ {/* AI ํˆฌ์ž ๋ถ„์„ */} +
+

+ + AI ํˆฌ์ž ๋ถ„์„ +

+
+ {aiAnalysisText ? ( + typeof aiAnalysisText === 'string' + ? aiAnalysisText + : JSON.stringify(aiAnalysisText, null, 2) ) : ( -
์ถ”์ฒœ ์ข…๋ชฉ ๋กœ๋”ฉ ์ค‘...
+
+

์ข…๋ชฉ์„ ์„ ํƒํ•˜๋ฉด
AI ํˆฌ์ž ๋ถ„์„์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.

+
)}
-
-
- {/* MCP ์ƒํƒœ UI ์ œ๊ฑฐ๋จ */} - {/* ์‹ค์‹œ๊ฐ„ ์—ฐ๊ฒฐ UI ์ œ๊ฑฐ๋จ */} -
-
- {(['D', 'W', 'M'] as const).map((period) => ( - - ))} -
-
-
- {chartLoading &&
์ฐจํŠธ ๋กœ๋”ฉ ์ค‘...
} - {!chartLoading && chartSeries.length === 0 && ( -
์ฐจํŠธ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
- )} - {!chartLoading && chartSeries.length > 0 && ( -
- -
- )} -
-
-
-
์ตœ๊ทผ ๊ฑฐ๋ž˜ ๋‚ด์—ญ
-
- - - - - - - - - - - - - {recentTrades && recentTrades.length > 0 ? ( - recentTrades.map((trade: any) => ( - - - - - - - - - )) - ) : ( - - - - )} - -
์‹œ๊ฐ„์ข…๋ชฉ๊ตฌ๋ถ„์ˆ˜๋Ÿ‰๊ฐ€๊ฒฉ์ƒํƒœ
{new Date(trade.created_at).toLocaleTimeString('ko-KR')}{trade.stock_name} - {trade.order_type} - {trade.order_quantity}{formatCurrency(trade.order_price || 0)} - - {trade.status} - -
๊ฑฐ๋ž˜ ๋‚ด์—ญ ์—†์Œ
+ {/* ํ˜ธ๊ฐ€์ฐฝ - ์ฆ๊ถŒ์‚ฌ ์Šคํƒ€์ผ (2์ปฌ๋Ÿผ) */} +
+

+ + ์‹ค์‹œ๊ฐ„ ํ˜ธ๊ฐ€ +

+
+ {orderbook ? (() => { + // ํ˜„์žฌ๊ฐ€ ์ฃผ๋ณ€ ยฑ10๋‹จ๊ณ„๋ฅผ 10์› ๋‹จ์œ„๋กœ ์—ฐ์† ์ƒ์„ฑ + const currentPrice = orderbook.current_price; + const priceUnit = 10; + const steps = 10; + + // ๋งค๋„ ํ˜ธ๊ฐ€: ํ˜„์žฌ๊ฐ€ + 10 ~ ํ˜„์žฌ๊ฐ€ + 100 (๋†’์€ ๊ฐ€๊ฒฉ๋ถ€ํ„ฐ) + const sequentialAsks = []; + for (let i = steps; i >= 1; i--) { + const price = currentPrice + (priceUnit * i); + const existingAsk = orderbook.asks?.find((ask: any) => ask.price === price); + sequentialAsks.push({ + price: price, + volume: existingAsk ? existingAsk.volume : 0 + }); + } + + // ๋งค์ˆ˜ ํ˜ธ๊ฐ€: ํ˜„์žฌ๊ฐ€ - 10 ~ ํ˜„์žฌ๊ฐ€ - 100 (๋†’์€ ๊ฐ€๊ฒฉ๋ถ€ํ„ฐ) + const sequentialBids = []; + for (let i = 1; i <= steps; i++) { + const price = currentPrice - (priceUnit * i); + const existingBid = orderbook.bids?.find((bid: any) => bid.price === price); + sequentialBids.push({ + price: price, + volume: existingBid ? existingBid.volume : 0 + }); + } + + return ( +
+ {/* ๋งค๋„ ์˜์—ญ (์ƒ๋‹จ 10๋‹จ๊ณ„) */} +
+ {sequentialAsks.map((ask: any, i: number) => ( +
+ {/* ๋งค๋„์ž”๋Ÿ‰ (์ขŒ์ธก, ์šฐ์ธก์ •๋ ฌ) */} +
+ {ask.volume > 0 ? ask.volume.toLocaleString() : '-'} +
+ {/* ๋งค๋„ํ˜ธ๊ฐ€ (์ค‘์•™, ๋นจ๊ฐ„์ƒ‰) */} +
+ {ask.price.toLocaleString()} +
+ {/* ๋นˆ ๊ณต๊ฐ„ */} +
+
+ ))} +
+ + {/* ํ˜„์žฌ๊ฐ€ (์ค‘์•™ ๊ฐ•์กฐ) */} +
+
+
+ {currentPrice?.toLocaleString()} +
+ {orderbook.current_volume > 0 && ( +
+ ({orderbook.current_volume.toLocaleString()}) +
+ )} +
+
+ ์—ฐ์† ํ˜ธ๊ฐ€์ฐฝ +
+
+ + {/* ๋งค์ˆ˜ ์˜์—ญ (ํ•˜๋‹จ 10๋‹จ๊ณ„) */} +
+ {sequentialBids.map((bid: any, i: number) => ( +
+ {/* ๋นˆ ๊ณต๊ฐ„ */} +
+ {/* ๋งค์ˆ˜ํ˜ธ๊ฐ€ (์ค‘์•™, ํŒŒ๋ž€์ƒ‰) */} +
+ {bid.price.toLocaleString()} +
+ {/* ๋งค์ˆ˜์ž”๋Ÿ‰ (์šฐ์ธก, ์ขŒ์ธก์ •๋ ฌ) */} +
+ {bid.volume > 0 ? bid.volume.toLocaleString() : '-'} +
+
+ ))} +
+
+ ); + })() : ( +
+ ์ข…๋ชฉ์„ ์„ ํƒํ•˜๋ฉด ํ˜ธ๊ฐ€ ์ •๋ณด๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. +
+ )} +
-
-
-
- ์ถ”์ฒœ ์ข…๋ชฉ - + {/* [Right Column] ๋ถ„์„ ์ข…๋ชฉ & ๋งค์ˆ˜/์ฒด๊ฒฐ */} +
+ {/* ๋ถ„์„ ์ข…๋ชฉ (Top) */} +
+
+

+ + ๋ถ„์„ ์ข…๋ชฉ +

+ + {ruleBasedRecommendations?.analyzed_stocks || 0} +
- -
- {(() => { - console.log('๐ŸŽฏ Rendering recommend-list:', { - hasRule: !!ruleBasedRecommendations, - ruleLength: ruleBasedRecommendations?.recommendations?.length || 0, - hasAI: !!aiRecommendations, - aiLength: aiRecommendations?.recommendations?.length || 0, - portfolioLength: recommendedStocks.length - }) - return null - })()} - {/* 1. ๊ทœ์น™ ๊ธฐ๋ฐ˜ ์ถ”์ฒœ (๊ทœ์น™๋ณ„ ๊ทธ๋ฃนํ•‘) */} - {ruleBasedRecommendations && ruleBasedRecommendations.recommendations_by_rule?.length > 0 ? ( - ruleBasedRecommendations.recommendations_by_rule.map((ruleGroup: any) => ( -
- {/* ๊ทœ์น™ ํ—ค๋” */} -
- - ๊ทœ์น™ {ruleGroup.priority} - - - {ruleGroup.rule_name} - - - ({ruleGroup.matched_count}๊ฐœ ์ข…๋ชฉ ๋งค์นญ) +
+ {ruleBasedRecommendations === undefined ? ( +
+ +

๋ถ„์„ ์ข…๋ชฉ ๋กœ๋”ฉ ์ค‘...

+

์ตœ์ดˆ ๋กœ๋”ฉ ์‹œ 10์ดˆ ์ •๋„ ์†Œ์š”๋ฉ๋‹ˆ๋‹ค

+
+ ) : (ruleBasedRecommendations?.analyzed_stock_list || []).length > 0 ? ( + (ruleBasedRecommendations.analyzed_stock_list as any[]).map((stock: any, idx: number) => { + const isRecommended = (ruleBasedRecommendations?.recommendations || []).some((r: any) => r.stock_code === stock.stock_code) + const isHeld = heldStockCodes.has(stock.stock_code) + const isPending = pendingBuyStockCodes.has(stock.stock_code) + const changeRate = stock.change_rate || 0 + return ( + + ) + }) + ) : ( +
+

๋ฐฐ์น˜ ๋ถ„์„ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+

make batch-test ์‹คํ–‰ ํ•„์š”

+
+ )} +
+
+ + {/* ๋งค๋งค์ผ์ง€ (์ „์ฒด ๋„ˆ๋น„) */} +
+
+
+
+

+ + ๋งค๋งค์ผ์ง€ +

+ +
+
+ {journals && journals.length > 0 ? ( + journals.map((journal: any) => { + const journalDate = new Date(journal.journal_date || journal.created_at); + const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }); + const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }); + const isGoodMood = journal.mood === '์ข‹์Œ' || journal.mood === 'good' || journal.mood === 'positive'; + const isBadMood = journal.mood === '๋‚˜์จ' || journal.mood === 'bad' || journal.mood === 'negative'; + const tags: string[] = Array.isArray(journal.tags) ? journal.tags : []; + const hasProfitTag = tags.some((t: string) => t.includes('์ต์ ˆ') || t.includes('profit')); + const hasLossTag = tags.some((t: string) => t.includes('์†์ ˆ') || t.includes('loss')); - {/* ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ */} - {ruleGroup.stocks.map((item: any) => { - // stockPrices์—์„œ ์ตœ์‹  ๊ฐ€๊ฒฉ ๊ฐ€์ ธ์˜ค๊ธฐ (์ˆซ์ž ๋ณ€ํ™˜) - const latestPrice = Number(stockPrices[item.stock_code] || item.current_price || 0) return ( - - ) - })} -
- )) - ) : aiRecommendations && aiRecommendations.recommendations?.length > 0 ? ( - /* 2. ์ˆ˜๋™ ์ง€ํ‘œ ๋ถ„์„ ์ถ”์ฒœ */ - aiRecommendations.recommendations.map((item: any) => { - // stockPrices์—์„œ ์ตœ์‹  ๊ฐ€๊ฒฉ ๊ฐ€์ ธ์˜ค๊ธฐ (์ˆซ์ž ๋ณ€ํ™˜) - const latestPrice = Number(stockPrices[item.stock_code] || item.current_price || 0) - return ( - - ) - }) - ) : recommendedStocks.length > 0 ? ( - recommendedStocks.map((item: any) => { - const displayPrice = Number(selectedStock === item.stock_code && currentStockPrice - ? currentStockPrice - : item.current_price) - return ( - - ) - }) - ) : ( -
๋ณด์œ  ์ข…๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค
- )} -
-
- - {/* AI ๋ถ„์„ ์„น์…˜ */} -
-
๐Ÿค– AI ์ข…ํ•ฉ ๋ถ„์„
-
- {aiAnalysisLoading ? ( -
๋ถ„์„ ์ค‘...
- ) : aiAnalysis ? ( - typeof aiAnalysis === 'string' ? ( -
{aiAnalysis}
- ) : typeof aiAnalysis === 'object' && aiAnalysis !== null ? ( -
- {aiAnalysis.analysis || aiAnalysis.recommendation || JSON.stringify(aiAnalysis, null, 2)} -
- ) : ( -
๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค
- ) - ) : ( -
์ข…๋ชฉ์„ ์„ ํƒํ•˜์„ธ์š”
- )} -
-
- - {/* ๋ฆฌ์Šคํฌ ์ฒดํฌ ์„น์…˜ */} -
-
โš ๏ธ ๋ฆฌ์Šคํฌ ์ฒดํฌ
-
- {riskCheckLoading ? ( -
๋ถ„์„ ์ค‘...
- ) : riskCheck ? ( - typeof riskCheck === 'string' ? ( -
{riskCheck}
- ) : typeof riskCheck === 'object' && riskCheck !== null ? ( -
- {riskCheck.analysis || riskCheck.recommendation || JSON.stringify(riskCheck, null, 2)} -
- ) : ( -
๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค
- ) - ) : ( -
์ข…๋ชฉ์„ ์„ ํƒํ•˜์„ธ์š”
- )} -
-
- -
-
์‹œ์žฅ ๋‰ด์Šค
-
- {news && news.length > 0 ? ( - // ์„ ํƒ๋œ ์ข…๋ชฉ์— ํ•ด๋‹นํ•˜๋Š” ๋‰ด์Šค๋งŒ ํ•„ํ„ฐ๋ง - news.filter((item: any) => !selectedStock || item.stock_code === selectedStock) - .slice(0, 5) - .map((item: any) => ( -
window.open(item.url, '_blank')}> -
{item.title}
-
- {new Date(item.published_at).toLocaleString('ko-KR', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - })} -
+ ); + }) + ) : ( +
+ ๋งค๋งค์ผ์ง€ ์—†์Œ + ๋งค๋„ ์ฒด๊ฒฐ ํ›„ ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค
- )) - ) : ( -
๋‰ด์Šค ์—†์Œ
- )} + )} +
+
-
- - ) +
+
+ ); } -export default Dashboard +export default Dashboard; + diff --git a/frontend/src/pages/TradeHistory.tsx b/frontend/src/pages/TradeHistory.tsx index d3154b1..b86391d 100644 --- a/frontend/src/pages/TradeHistory.tsx +++ b/frontend/src/pages/TradeHistory.tsx @@ -8,6 +8,7 @@ import api from '../lib/api' interface Trade { id: string created_at: string + executed_at?: string status: 'EXECUTED' | 'FAILED' | 'PENDING' | string stock_code: string stock_name?: string @@ -16,7 +17,13 @@ interface Trade { order_quantity: number executed_price: number | null executed_quantity: number | null + total_amount?: number | null message?: string | null + entry_reason?: string | null + exit_reason?: string | null + signals?: string[] | null + profit_loss?: number | null + profit_loss_rate?: number | null } const TradeHistory = () => { @@ -82,11 +89,11 @@ const TradeHistory = () => { ์ƒํƒœ ์ข…๋ชฉ ๊ตฌ๋ถ„ - ์ฃผ๋ฌธ๊ฐ€ + ๊ฐ€๊ฒฉ ์ˆ˜๋Ÿ‰ - ์ฒด๊ฒฐ๊ฐ€ - ์ฒด๊ฒฐ์ˆ˜๋Ÿ‰ - ๋ฉ”์‹œ์ง€ + ์ด์•ก + ์†์ต + ์ด์œ /์‹ ํ˜ธ @@ -130,19 +137,55 @@ const TradeHistory = () => { - {formatCurrency(trade.order_price)} + {formatCurrency(trade.executed_price || trade.order_price)} - {trade.order_quantity.toLocaleString()} + {(trade.executed_quantity || trade.order_quantity).toLocaleString()}์ฃผ - - {trade.executed_price != null ? formatCurrency(trade.executed_price) : '-'} + + {trade.total_amount != null ? formatCurrency(trade.total_amount) : '-'} - - {trade.executed_quantity != null ? trade.executed_quantity.toLocaleString() : '-'} + + {trade.order_type === 'SELL' && trade.profit_loss != null ? ( +
+
= 0 ? 'text-primary' : 'text-danger' + }`}> + {Number(trade.profit_loss) >= 0 ? '+' : ''}{formatCurrency(Number(trade.profit_loss))} +
+ {trade.profit_loss_rate != null && ( +
= 0 ? 'text-primary' : 'text-danger' + }`}> + ({Number(trade.profit_loss_rate) >= 0 ? '+' : ''}{Number(trade.profit_loss_rate).toFixed(2)}%) +
+ )} +
+ ) : ( + - + )} - - {trade.message || '-'} + + {trade.order_type === 'BUY' && trade.entry_reason && ( +
+
๐Ÿ“ˆ ์ง„์ž… ์ด์œ 
+
{trade.entry_reason}
+ {trade.signals && trade.signals.length > 0 && ( +
+ {trade.signals.slice(0, 3).map((signal, idx) => ( + + {signal} + + ))} +
+ )} +
+ )} + {trade.order_type === 'SELL' && trade.exit_reason && ( +
+
๐Ÿ“‰ ๋งค๋„ ์ด์œ 
+
{trade.exit_reason}
+
+ )} + {!trade.entry_reason && !trade.exit_reason && (trade.message || '-')} ))} diff --git a/frontend/src/pages/TradingRules.tsx b/frontend/src/pages/TradingRules.tsx index 91a216d..7e3787b 100644 --- a/frontend/src/pages/TradingRules.tsx +++ b/frontend/src/pages/TradingRules.tsx @@ -11,9 +11,10 @@ const TradingRules = () => { name: '', description: '', rule_type: 'BUY', - priority: 1, - conditions: '{\n "rsi_threshold": 40,\n "sma_short": 5,\n "sma_long": 20\n}', - actions: '{\n "take_profit": 5.0,\n "stop_loss": 3.0,\n "position_sizing": {\n "target_ratio": 0.15\n }\n}' + risk_level: 'MEDIUM', + stop_loss: 3.0, + take_profit: 5.0, + max_quantity: 1 }) // ํŠธ๋ ˆ์ด๋”ฉ ๊ทœ์น™ ์กฐํšŒ @@ -28,36 +29,27 @@ const TradingRules = () => { // ๊ทœ์น™ ์ƒ์„ฑ const createMutation = useMutation({ mutationFn: async (data: any) => { - // MCP๋กœ ๊ทœ์น™ ๊ฒ€์ฆ - try { - const validateResponse = await fetch('http://localhost:8001/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - analysis_type: 'RULE_VALIDATION', - data: { - name: data.name, - conditions: JSON.parse(data.conditions), - actions: JSON.parse(data.actions) - } - }) - }); - const validation = await validateResponse.json(); + // AI ์ž๋™ ๋งค๋งค๋ฅผ ์œ„ํ•œ ๊ธฐ๋ณธ ํ…œํ”Œ๋ฆฟ ๊ตฌ์„ฑ + const finalConditions = { + type: "AI_AUTO_OPTIMIZATION", + risk_level: data.risk_level, + description: "AI๊ฐ€ ์‹œ์žฅ ์ƒํ™ฉ์— ๋”ฐ๋ผ ์ง„์ž… ์กฐ๊ฑด์„ ๋™์ ์œผ๋กœ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค." + }; - if (!confirm(`๐Ÿค– AI ๊ทœ์น™ ๊ฒ€์ฆ ๊ฒฐ๊ณผ:\n\n${validation.analysis || validation.result}\n\n๊ณ„์† ์ง„ํ–‰ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?`)) { - throw new Error('์‚ฌ์šฉ์ž๊ฐ€ ์ทจ์†Œํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } - } catch (error: any) { - if (error.message === '์‚ฌ์šฉ์ž๊ฐ€ ์ทจ์†Œํ–ˆ์Šต๋‹ˆ๋‹ค.') { - throw error; - } - console.warn('MCP ๊ฒ€์ฆ ์‹คํŒจ, ๊ทธ๋Œ€๋กœ ์ง„ํ–‰:', error); - } + const finalActions = { + stop_loss: parseFloat(data.stop_loss), + take_profit: parseFloat(data.take_profit), + max_quantity: parseInt(data.max_quantity), + trailing_stop: true + }; return api.post('/rules', { - ...data, - conditions: JSON.parse(data.conditions), - actions: JSON.parse(data.actions) + name: data.name, + description: data.description, + rule_type: data.rule_type, + priority: 1, + conditions: finalConditions, + actions: finalActions }) }, onSuccess: () => { @@ -67,16 +59,15 @@ const TradingRules = () => { name: '', description: '', rule_type: 'BUY', - priority: 1, - conditions: '{\n "rsi_threshold": 40,\n "sma_short": 5,\n "sma_long": 20\n}', - actions: '{\n "take_profit": 5.0,\n "stop_loss": 3.0,\n "position_sizing": {\n "target_ratio": 0.15\n }\n}' + risk_level: 'MEDIUM', + stop_loss: 3.0, + take_profit: 5.0, + max_quantity: 1 }) - alert('โœ… ๊ทœ์น™์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค!') + alert('โœ… AI ์ž๋™๋งค๋งค ๊ทœ์น™์ด ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค!') }, onError: (error: any) => { - if (error.message !== '์‚ฌ์šฉ์ž๊ฐ€ ์ทจ์†Œํ–ˆ์Šต๋‹ˆ๋‹ค.') { - alert(`โŒ ๊ทœ์น™ ์ถ”๊ฐ€ ์‹คํŒจ: ${error.response?.data?.detail || error.message}`) - } + alert(`โŒ ๊ทœ์น™ ์ถ”๊ฐ€ ์‹คํŒจ: ${error.response?.data?.detail || error.message}`) }, }) @@ -158,35 +149,50 @@ const TradingRules = () => {
- + + +
+
+
+
+ setFormData({ ...formData, priority: parseInt(e.target.value) })} + value={formData.stop_loss} + onChange={(e) => setFormData({ ...formData, stop_loss: parseFloat(e.target.value) })} className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white" - min="1" + step="0.1" + /> +
+
+ + setFormData({ ...formData, take_profit: parseFloat(e.target.value) })} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white" + step="0.1" />
- -