diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4f1e850..239de7f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -62,16 +62,123 @@ "Bash(echo \"$f OK\")", "Bash(done)", "Bash(find /Users/commylife/myproject/java/workspace/auto-stock -type f \\\\\\(-name *.tmp -o -name *.bak -o -name *.swap \\\\\\))", - "Bash(source /Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/activate)", + "Bash(source /Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/activate:*)", + "Bash(/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python:*)", + "Bash(/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/python3:*)", + "Bash(/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/pip:*)", + "Bash(/Users/commylife/myproject/java/workspace/auto-stock/.venv/bin/pytest:*)", + "Bash(.venv/bin/python:*)", + "Bash(.venv/bin/python3:*)", + "Bash(.venv/bin/pip:*)", + "Bash(.venv/bin/pytest:*)", "Bash(ssh:*)", "Bash(find /Users/commylife/myproject/java/workspace/auto-stock/frontend/src -type f \\\\\\(-name *.tsx -o -name *.ts \\\\\\))", "Bash(git push:*)", "Bash(git fetch:*)", - "Bash(sqlite3 /Users/commylife/myproject/java/workspace/auto-stock/data/trading_db.sqlite \"SELECT sql FROM sqlite_master WHERE type=''table'' AND name=''trade_history'';\")" + "Bash(sqlite3 /Users/commylife/myproject/java/workspace/auto-stock/data/trading_db.sqlite \"SELECT sql FROM sqlite_master WHERE type=''table'' AND name=''trade_history'';\")", + "Bash(git rev-list *)", + "Bash(/usr/sbin/ab -n 100 -c 10 -k -s 20 \"https://dev-fo.wegolpg.com/login.do\")", + "Bash(echo *)", + "Bash(mkdir -p /tmp/scan)", + "Bash(nohup nikto -h https://dev-fo.wegolpg.com -maxtime 300 -Tuning 123457890abc -o /tmp/scan/nikto.txt -Format txt)", + "Bash(nmap -Pn -T4 --host-timeout 120s -p 80,443,8011,8080,8443,22,3306,5432,6379,5672 -sV dev-fo.wegolpg.com)", + "Bash(sslscan --no-colour dev-fo.wegolpg.com)", + "Bash(ab -n 10000 -c 50 -k -s 30 \"https://dev-fo.wegolpg.com/login.do\")", + "Bash(ab -n 10000 -c 50 -s 30 \"https://dev-fo.wegolpg.com/login.do\")", + "Bash(break)", + "Bash(/usr/bin/curl -sS -c cookies.txt --max-time 15 \"https://dev-fo.wegolpg.com/login.do\")", + "Bash(awk '{print $6, $7}')", + "Bash(/usr/bin/curl -sS --max-time 15 \"https://dev-fo.wegolpg.com/resources/js/sas-981bf3b8ad0f002d8bb7b6c6ac55a5b2.js\")", + "Bash(docker info *)", + "Bash(.venv/bin/pip list *)", + "Bash(open -a Docker)", + "Bash(awk 'NR>=1')", + "Bash(awk 'NR>=340 && NR<=430 { print NR\": \"$0 }' app/main.py)", + "Bash(timeout 75 docker logs -f --since 1s autostock-backend)", + "Bash(wait)", + "Bash(xargs -I{} sh -c 'sed -n \"$\\(\\({}-20\\)\\),$\\(\\({}+5\\)\\)p\" app/services/trade_executor.py')", + "Bash(ping -c 3 -b 192.168.50.255)", + "Bash(ping -c 3 192.168.50.255)", + "Bash(arp -an)", + "Bash(sort -t. -k4 -n)", + "Bash(command -v curl)", + "Bash(command -v head)", + "Bash(nmap -Pn -p- -T4 --max-retries 1 --host-timeout 300s 192.168.50.1)", + "Bash(/usr/bin/head -20)", + "Bash(/usr/bin/curl -sk -D - -o /tmp/b188.html --max-time 10 \"https://192.168.50.188:15023/\")", + "Bash(/usr/bin/head -12)", + "Bash(/usr/bin/head -c 600 /tmp/b188.html)", + "Bash(/usr/bin/grep -ioE \"location\\\\.href[^;]*|block|denied|forbidden|not allow|WAN|Main_Login|cloud_sync\" /tmp/b188.html)", + "Bash(/usr/bin/head -10)", + "Bash(/usr/bin/curl -sk --max-time 10 \"https://192.168.50.188:15023/message.htm\" -o /tmp/msg188.html)", + "Bash(/usr/bin/sed -e 's/<[^>]*>//g' /tmp/msg188.html)", + "Bash(/usr/bin/grep -vE \"^\\\\s*$\")", + "Bash(/usr/bin/grep -oE \"desc[^;]{0,200}|You have no .{0,120}|not authorized.{0,80}|LAN.{0,80}\" /tmp/msg188.html)", + "Bash(/usr/bin/head -6)", + "Bash(bash -n /private/tmp/claude-501/-Users-commylife-myproject-java-workspace-auto-stock/93c4883b-04d6-4a82-ab88-849275eb0a32/scratchpad/sniff-nas.sh)", + "Bash(/usr/bin/head -4)", + "Bash(/usr/bin/head -8 /tmp/nas-sniff.txt)", + "Bash(/usr/bin/awk '{print $2}' /tmp/nas-sniff.txt)", + "Bash(/usr/bin/head -25)", + "Bash(networksetup -listallhardwareports)", + "Bash(/usr/bin/grep -A1 \"Hardware Port\")", + "Bash(/usr/bin/grep -vE \"^--\")", + "Bash(/usr/bin/xargs echo \" 고유 MAC 수:\")", + "Bash(/usr/bin/grep -v \"^192\\\\.168\\\\.50\\\\.\")", + "Bash(/usr/bin/head)", + "Bash(/usr/bin/head -5)", + "Bash(/usr/bin/curl -sI --max-time 8 http://192.168.50.197:5000/)", + "Bash(/usr/bin/grep -i \"^date:\")", + "Bash(openssl s_client -connect 192.168.50.197:5001 -servername nas.shium.net)", + "Bash(openssl s_client -connect 192.168.50.197:443 -servername nas.shium.net)", + "Bash(openssl x509 *)", + "Bash(/usr/bin/curl -skI --max-time 8 https://192.168.50.197/)", + "Bash(/usr/bin/curl -sI --max-time 8 http://192.168.50.197/)", + "Bash(openssl s_client -connect 192.168.50.197:6082 -servername nas.shium.net)", + "Bash(/usr/bin/curl -o /dev/null -w \" 검증 결과: %{http_code}\\\\n\" --max-time 8 https://192.168.50.197:6082/)", + "Bash(/usr/bin/head -3)", + "Bash(openssl s_client -connect 192.168.50.197:6082)", + "Bash(/usr/bin/curl -sk -o /dev/null -w \" https → HTTP %{http_code}\\\\n\" --max-time 8 https://192.168.50.197:6082/)", + "Bash(/usr/bin/curl -s -o /dev/null -w \" http → HTTP %{http_code}\\\\n\" --max-time 8 http://192.168.50.197:6082/)", + "Bash(openssl s_client -connect 192.168.50.198:443)", + "Bash(traceroute -I -m 5 -w 2 -q 3 8.8.8.8)", + "Bash(/usr/bin/head -8)", + "Bash(traceroute -m 5 -w 2 -q 3 1.1.1.1)", + "Bash(/usr/bin/curl -s --max-time 8 https://api.ipify.org)", + "Bash(/usr/bin/curl -s --max-time 8 http://192.168.75.1/dzs/login.asp)", + "Read(//tmp/**)", + "Bash(/usr/bin/grep -ioE \"model[^<>\\\\\"]{0,40}|DASAN[^<>\\\\\"]{0,30}|SK[ _-]?\\(broadband|telecom\\)[^<>\\\\\"]{0,20}|H[0-9]{3}[A-Z]?|GW-[A-Z0-9-]+|[^<]*\" /tmp/dzs.html)", + "Bash(/usr/bin/head -15)", + "Bash(/usr/bin/sed -e 's/<[^>]*>/ /g' /tmp/dzs.html)", + "Bash(/usr/bin/tr -s ' \\\\n' ' \\\\n')", + "Bash(/usr/bin/curl -s --max-time 8 http://192.168.50.1:55522/rootDesc.xml)", + "Bash(/usr/bin/grep -oE \"<\\(UDN|serialNumber|modelName|friendlyName\\)>[^<]*\" /tmp/root.xml)", + "Bash(/usr/bin/grep -oiE \"\\([0-9a-f]{2}[:-]\\){5}[0-9a-f]{2}|[0-9a-f]{12}\" /tmp/root.xml)", + "Bash(/usr/bin/grep \"192.168.50.1\\)\")", + "Bash(dig +short A nas.shium.net @1.1.1.1)", + "Bash(/usr/bin/tail -1)", + "Bash(/usr/bin/grep -nE \"^ [a-z0-9_-]+:\" docker-compose.yml)", + "Bash(/usr/bin/grep -nE \"^#{1,3} \" deploy/NAS-SETUP-GUIDE.md)", + "Bash(/usr/bin/head -30)", + "Bash(/usr/bin/grep -nE \"stage\\\\\\(|DEPLOY_DIR|sh '|sh \\\\\"\" Jenkinsfile)" ] }, "enableAllProjectMcpServers": true, "enabledMcpjsonServers": [ "auto-stock" - ] + ], + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-orchestrate.py\"", + "timeout": 10, + "statusMessage": "오케스트레이션 모드 판정 중..." + } + ] + } + ] + } } diff --git a/CLAUDE.md b/CLAUDE.md index 1fac869..f4c69e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,263 +1,122 @@ -# 자동 주식 매매 시스템 — Claude 컨텍스트 가이드 +# 자동 주식 매매 시스템 — Claude 가이드 -> **핵심 지침 (Core Directive)**: 이 문서는 시스템의 전략, 규칙 및 아키텍처를 정의합니다. 모든 코드 수정 및 분석은 이 가이드라인을 엄격하게 준수해야 합니다. +> 모든 코드 수정·분석은 이 문서의 규칙을 엄격히 준수할 것. ---- +## 1. 스택 & 환경 +- **백엔드**: Python 3.11 / FastAPI / SQLAlchemy 2.0(Async) / PostgreSQL 15 / Redis 7 / RabbitMQ 3 +- **프론트**: React 18 / TypeScript / Vite / Recharts / Lightweight Charts +- **인프라·ML**: Docker Compose, Nginx, Alembic / PyTorch, LightGBM, scikit-learn +- **연동**: 한국투자증권(KIS) REST·WebSocket, MCP 서버(stdio) / AI 계층: `AI_PROVIDER`=claude|gemini +- **주요 .env**: `KIS_MODE`(virtual|real), `TOKEN_SAVE_MODE`(JSON 압축+차트 30일 제한), `AUTO_TRADE_ENABLED`, `US_TRADING_ENABLED` -## 1. 프로젝트 개요 +## 2. 구조 (`backend/app/`) +- `api/endpoints/` — 20개 모듈 (portfolio, market_data, trades, day_trading, backtest 등) +- `core/` — config(설정), database, security(require_api_key), json_helpers(_dumps), market_schedule +- `models/models.py` — Portfolio, TradeHistory, ActiveTrade, StockAnalysis, StockBan 등 +- `realtime/` — websocket_client, event_handler / `strategy/` — base_strategy, strategy_manager +- `services/` 전략: mean_reversion_strategy(§6), short_term_strategy(§7.2), day_trading_strategy(§7.3), kosdaq_scanner(§7.1), us_stock_strategy +- `services/daytrading/`·`services/swing/` — entry_service, exit_service, position_manager, scheduler +- `services/` 핵심: trade_executor(매매엔진: 매수/매도/체결확인/슬리피지), db_worker(비동기 DB워커), mdd_manager·loss_tracker·correlation_filter(리스크), korea_investment_api·kis_realtime_stream·naver_finance(데이터), feature_engineering·ml_ensemble·technical_indicators(분석) +- `mcp-server/` — stdio_server, auto_trade_claude, claude_client, gemini_client, trade_monitor, risk_validator, mcp_tools +- 배치(`backend/`) — batch_daily_analysis, batch_intraday_analysis, train_ensemble, collect_krx_data -### 1.1 기술 스택 -- **백엔드**: Python 3.11, FastAPI, SQLAlchemy 2.0 (Async), PostgreSQL 15, Redis 7, RabbitMQ 3 -- **프론트엔드**: React 18, TypeScript, Vite, Recharts, Lightweight Charts -- **인프라**: Docker Compose, Nginx, Alembic (DB 마이그레이션) -- **AI 계층**: Claude 3.5 Sonnet / Gemini 2.0 Flash (`.env`의 `AI_PROVIDER`로 전환) -- **ML**: PyTorch, LightGBM, scikit-learn (앙상블 예측) -- **연동 (Integration)**: 한국투자증권(KIS) REST/WebSocket API, MCP Server (stdio) +## 3. 아키텍처 원칙 (필수) +1. **실시간 계층 DB I/O 절대 금지** — API 콜백·웹소켓·실시간 스케줄러에서 `AsyncSessionLocal()`/`commit()` 호출 금지. +2. **모든 DB 쓰기는 `db_worker.py`의 `db_task_queue` 경유** (실패 시 지수 백오프 1→2→4초, 최대 3회 재시도). 예외: `sync_portfolio()`만 단일 트랜잭션 필요로 엔드포인트 직접 DB 접근 허용. +3. 지원 task: insert_trade_history, update_trade_history(+_by_order_id), upsert_stock_analysis, bulk_upsert_stock_analysis, upsert_stock_ban, upsert_portfolio, upsert_active_trade, close_active_trade, insert_trade_journal, log_mcp_deny +4. **동시성**: trade_executor `_order_semaphores`(종목별 동시 주문 방지) / Redis 분산 락(워커 간 동시 매도 방지) / loss_tracker·mdd_manager `asyncio.Lock`. 동시 접근 경로에서는 `is_banned_safe()`, `record_trade_result_safe()` 사용. -### 1.2 주요 환경 변수 (`.env`) -- **AI_PROVIDER**: `gemini` 또는 `claude` -- **KIS_MODE**: `virtual` (모의투자) 또는 `real` (실전투자) -- **TOKEN_SAVE_MODE**: `true` (JSON을 압축하고, 차트 데이터를 30일치로 제한함) -- **AUTO_TRADE_ENABLED**: 자동 매매 활성화 여부 -- **US_TRADING_ENABLED**: 미국 주식 매매 활성화 여부 +## 4. 코딩 규칙 +- **기존 함수/클래스 시그니처 변경 금지** → 새 전략은 `_v2`/`_v3`, 동기→비동기 확장은 `_safe` 접미사의 새 메서드. +- JSON은 `json.dumps` 금지, `core.json_helpers._dumps()` 사용. +- 쓰기/삭제 엔드포인트에 `Depends(require_api_key)` 필수. 외부 API 호출 `verify=True` 유지(KIS 포함). `SECRET_KEY` 64자+ 랜덤. +- 프롬프트는 토큰 최소화, 컴팩트 포맷. ---- +## 5. 리스크 규칙 +### 5.1 사이징·필터 +- 종목당 최대 **6%** (`MAX_POSITION_SIZE=0.06`), 최대 **20종목**(`MAX_HOLDINGS_COUNT`), 레버리지/인버스 ETF 최대 **20%**(`LEVERAGE_ETF_MAX_RATIO`). +- 가격 필터: 국내 >5,000원, 미국 >$4 (ETF·리츠 예외) (`MIN_PRICE_KRW`, `MIN_PRICE_USD`). +- 상관관계: 섹터당 최대 3종목, 기존 포트폴리오와 상관계수 >0.7 진입 거부 (`correlation_filter`). +- 연속 손실: 동일 종목 **3연패 → 42캘린더일(≈30거래일) 진입 금지** (`loss_tracker`). `record_trade_result()` 후 반드시 `save_to_db()`(db_worker upsert_stock_ban 경유). 재시작 시 `load_from_db()` 복원. -## 2. 디렉토리 구조 +### 5.2 SL/TP (일봉/스윙) +- 기본 SL: **-7%**(안정) / -10%(변동성). ATR SL: `진입가 − 2.5×ATR` (`ATR_STOP_MULTIPLIER=2.5`). +- **SL은 기본/ATR 중 타이트한 쪽 — 항상 `min()`, `max()` 금지.** +- TP: +2R 도달 시 50% 분할 매도, 잔량 트레일링 스탑 `고점 − 1.5×ATR`. -### 2.1 백엔드 (`backend/app/`) -- **API**: `api/endpoints/` — 20개 엔드포인트 모듈 (portfolio, market_data, trades, day_trading, backtest 등) -- **코어 (Core)**: `core/config.py` (전역 설정), `core/database.py`, `core/security.py` (API 키 인증), `core/json_helpers.py`, `core/market_schedule.py` -- **모델 (Models)**: `models/models.py` — SQLAlchemy ORM (Portfolio, TradeHistory, ActiveTrade, StockAnalysis, StockBan 등) -- **실시간 (Realtime)**: `realtime/websocket_client.py`, `realtime/event_handler.py` -- **전략 추상화 (Strategy)**: `strategy/base_strategy.py`, `strategy/strategy_manager.py` +### 5.3 MDD (mdd_manager — portfolio.py·swing/scheduler.py에서 자동 호출, 중복 실행 방지 내장) +- **-10%**: 신규 진입 중단 + 포지션 30% 축소 + 단기·데이트레이딩 중단 (REDUCE_AND_PAUSE) +- **-20%**: 전 포지션 50% 강제 축소, 레버리지/인버스 ETF 우선 청산 (FORCE_REDUCE) +- 잔고가 peak 갱신 시 모든 MDD 플래그 리셋. -### 2.2 서비스 (`backend/app/services/`) -- **전략 (Strategies)**: - - `mean_reversion_strategy.py` — RSI+BB 평균 회귀 v2 (섹션 6) - - `short_term_strategy.py` — 15분봉 단기 전략 (섹션 7.2) - - `day_trading_strategy.py` — 5분봉 데이트레이딩 ORB/VWAP/RSI_BB (섹션 7.3) - - `kosdaq_scanner.py` — 코스닥 스윙 스캐너 (섹션 7.1) - - `us_stock_strategy.py` — 미국 주식 전략 -- **데이트레이딩 모듈 (Modular)**: `daytrading/entry_service.py`, `daytrading/exit_service.py`, `daytrading/position_manager.py`, `daytrading/scheduler.py` -- **스윙 모듈 (Modular)**: `swing/entry_service.py`, `swing/exit_service.py`, `swing/position_manager.py`, `swing/scheduler.py` -- **매매 실행**: `trade_executor.py` — 핵심 매매 엔진 (매수/매도/체결확인/슬리피지) -- **분석 (Analysis)**: `feature_engineering.py`, `ml_ensemble.py`, `tensorflow_predictor.py`, `technical_indicators.py` -- **리스크**: `mdd_manager.py` (MDD 추적 + 자동 축소 실행), `loss_tracker.py` (연속 손실 ban), `correlation_filter.py` (상관관계 필터) -- **아키텍처**: `db_worker.py` (비동기 DB 처리 워커, 재시도 3회) -- **데이터**: `korea_investment_api.py` (KIS REST), `kis_realtime_stream.py` (KIS WebSocket), `naver_finance.py` -- **유틸**: `websocket_manager.py`, `order_dispatcher.py`, `signal_types.py`, `watchlist.py` +## 6. 전략 A: RSI+BB 평균 회귀 v2 (`mean_reversion_strategy`, `swing/`) — 스윙 자본 75% +- **시장 국면(일봉)**: BULL = 코스피200>MA200 & MA200 기울기>0 / BEAR = 코스피20020MA / BEAR: RSI<35 + 가격1.5×20MA + 반전캔들 필수 / 선제: 일봉 RSI<46 & 60분봉 RSI<30 & MA5 상향돌파 +- **청산**: 50% 부분매도: RSI≥55 or 가격>BB중앙 or 수익>2×ATR / 전량: RSI≥70 or 가격>BB상단(99%) or 트레일링(고점−1.5×ATR) -### 2.3 MCP (`mcp-server/`) -- `stdio_server.py`, `auto_trade_claude.py`, `claude_client.py`, `gemini_client.py`, `trade_monitor.py`, `risk_validator.py`, `mcp_tools.py` +## 7. 전략 B: 공격적 성장 +### 7.1 코스닥 스캐너 (`kosdaq_scanner`) — 자본 25% +코스닥 전 종목(~1,600) / 가격>5,000원 + 거래량 상위 20% / 점수 = `0.5×RSI + 0.3×BB + 0.2×Vol`, ≥0.15 중 상위 20개 선정. -### 2.4 배치 스크립트 (`backend/`) -- `batch_daily_analysis.py` — 일일 분석 배치 -- `batch_intraday_analysis.py` — 장중 분석 배치 -- `train_ensemble.py` — ML 모델 학습 -- `collect_krx_data.py` — KRX 데이터 수집 +### 7.2 단기 15분봉 (`short_term_strategy`) — 자본 최대 25%, **MDD -10% 시 비활성** +- 타겟: 일봉 RSI<40 / 진입: 15분 RSI<30 + BB하단 부근 + 종가>MA5(상향돌파) + 거래량>1.3×20MA(`SHORT_TERM_VOLUME_MULTIPLIER`) + ATR/close≥0.8%(`SHORT_TERM_MIN_ATR_RATIO`, 거래비용 커버) +- 청산: 15분 RSI>55 or 수익>1.5×ATR(15분) / SL: `min(진입가−1.0×ATR, -2% 하드스탑)` — 타이트한(stop가 높은) 쪽. *§5.2 ATR 2.5×는 일봉/스윙 전용* ---- +### 7.3 데이트레이딩 5분봉 (`day_trading_strategy`, `daytrading/`) — 자본 최대 25%(`DAY_TRADE_MAX_RATIO_DT`), **MDD -10% 시 비활성** -## 3. 아키텍처 원칙 (필수 준수 사항) +| 전략 | 진입 | SL | +|------|------|-----| +| ORB | 시초 레인지 돌파 + 거래량≥1.5×평균분당 + 직전 5분봉 양봉 | `max(ORB저점, 현재가×(1-ORB_SL%))` + ATR 하한, -2.5% 클램프 | +| VWAP | VWAP 지지 반등 + RSI(14)<40 + 거래량≥1.3× + 양봉 | `min(ATR×1.5, -2.5% 하드)` + 최소 1원 | +| RSI_BB | RSI≤30 + 종가≤BB하단 + 거래량≥1.3× + 양봉 | `min(ATR×1.5, -2.5% 하드)` + 최소 1원 | -### 3.1 관심사 분리 (Separation of Concerns) -- **실시간 계층 (Real-time Layer)**: API 콜백, 웹소켓 브로드캐스트 및 메모리 캐싱을 처리합니다. **DB I/O(데이터베이스 입출력)는 절대 허용되지 않습니다.** -- **영속성 계층 (Persistence Layer)**: 백그라운드 워커를 통해 DB INSERT/UPDATE 작업을 처리합니다. -- **예외**: `sync_portfolio()`는 SELECT→UPDATE/DELETE→commit이 같은 트랜잭션에서 이뤄져야 하므로 API 엔드포인트 경로에서 직접 DB 접근을 허용합니다. +- 사이징: 종목당 4%(`DAY_TRADE_MAX_CAPITAL_RATIO`), 동시 3종목(`DAY_TRADE_MAX_POSITIONS`), confidence 조정 `예산×(confidence/0.7)` +- 청산: +2R 시 50% 분할→잔량 트레일링(ATR×1.0) / 진입 30분(6봉)간 +0.3% 미달 시 시간손절 / 5분 RSI≥70 & 수수료 이상 수익 시 / **15:20 EOD 전량 청산**(`DAY_TRADE_EOD_FLAT_*`) +- 제한: SIDEWAYS 진입 차단 / 점심(11:30~13:00) VWAP·RSI_BB 제한 / **14:30 이후 신규 마감** / 일일 -3% 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()` -### 3.2 비동기 DB 워커 규칙 -- **직접적인 DB 접근 금지**: API 콜백이나 실시간 스케줄러 내부에서 `AsyncSessionLocal()` 또는 `commit()`을 절대 호출하지 마십시오. -- **큐(Queue) 사용**: 모든 데이터 저장(매매 내역, 분석 결과 등)은 `db_worker.py`의 `db_task_queue`로 보내야 합니다. -- **워커 (Worker)**: 백그라운드 워커가 큐를 처리하고 안전한 DB 트랜잭션을 순차적으로 수행합니다. -- **재시도**: 실패한 task는 최대 3회까지 지수 백오프(1→2→4초)로 재시도합니다. -- **지원 task 타입**: `insert_trade_history`, `update_trade_history`, `update_trade_history_by_order_id`, `upsert_stock_analysis`, `bulk_upsert_stock_analysis`, `upsert_stock_ban`, `upsert_portfolio`, `upsert_active_trade`, `close_active_trade`, `insert_trade_journal`, `log_mcp_deny` +## 8. Claude 지침 +### 8.1 체크포인트 +1. DB 쓰기 = db_worker 경유 (예외: sync_portfolio) +2. 모든 매매에 §5 리스크 규칙 적용 +3. SIDEWAYS 국면 진입 제안 금지 +4. 백테스트·Walk-forward 없이 실전 투입 금지 +5. **SL 선택 = 항상 `min()`(타이트한 쪽). `max()` 절대 금지** +6. `record_trade_result()` 후 `save_to_db()` 호출 확인 +7. 쓰기/삭제 API에 `require_api_key` 필수 +8. JSON 출력 시 `_dumps()` / 새 기능은 새 파일 / 기존 시그니처 수정 금지 -### 3.3 동시성 안전 -- **종목별 세마포어**: `trade_executor.py`의 `_order_semaphores`로 동일 종목 동시 주문 방지. -- **Redis 분산 락**: 여러 워커 프로세스간 동일 종목 동시 매도 방지. -- **loss_tracker 락**: `asyncio.Lock`으로 `_tracker` 딕셔너리 보호. 동시 접근 경로에서는 `is_banned_safe()`, `record_trade_result_safe()` 사용. -- **MDD 매니저 락**: `asyncio.Lock`으로 MDD 업데이트 및 축소 실행 보호. +### 8.2 config.py 주요값 (백테스트 검증 2023-03~2026-03 완료) ---- +| 설정 | 값 | 비고 | +|------|-----|------| +| `MAX_POSITION_SIZE` | 0.06 | 0.04→0.06 | +| `MAX_HOLDINGS_COUNT` | 20 | | +| `LEVERAGE_ETF_MAX_RATIO` | 0.20 | | +| `ATR_STOP_MULTIPLIER` | 2.5 | 스윙 ATR SL 배수 | +| `STOP_LOSS_PERCENT` | 7.0 | 6.0→7.0 | +| `MEAN_REVERSION_RSI_THRESHOLD` | 46.0 | 40→46 | +| `MEAN_REVERSION_BEAR_RSI_THRESHOLD` | 35.0 | 30→35 | +| `MEAN_REVERSION_RSI_EXIT_THRESHOLD` | 55.0 | 1차 익절, 50→55 | +| `MEAN_REVERSION_RSI_FULL_EXIT` | 70.0 | 전량 청산, 65→70 | +| `DAY_TRADE_MAX_POSITIONS` | 3 | | +| `DAY_TRADE_ENTRY_CUTOFF` | 14:30 | 15:00→14:30 | +| `CONSECUTIVE_LOSS_BAN_COUNT` | 3 | | -## 4. 코딩 가이드라인 +### 8.3 스윙 듀얼 전략 (3종 병행) -### 4.1 안정성 -- **기존 코드 파괴 금지 (No Breaking Changes)**: 기존 함수나 클래스의 시그니처를 절대 변경하지 마십시오. -- **버전 관리**: 새로운 전략 구현 시 `_v2`, `_v3` 등의 접미사를 사용하십시오. -- **안전 래퍼**: 기존 동기 API를 비동기로 확장할 때 `_safe` 접미사의 새 메서드를 추가하십시오 (예: `is_banned_safe()`). +| 전략 | 진입 | 청산 | +|------|------|------| +| 평균회귀 | RSI 과매도 + BB하단 | RSI 55 부분 → RSI 70 전량 | +| 모멘텀 돌파 | 20일 신고가 + 거래량 2× + RSI 50~70 | +10% 30%매도 → +20% 30%매도 → 트레일링 | +| 눌림목 매수 | MA20>MA60 + MA20 근처 조정 + RSI 반등 | 평균회귀와 동일 | -### 4.2 최적화 -- **JSON 처리**: `json.dumps` 대신 항상 `core.json_helpers._dumps()`를 사용하십시오. -- **프롬프트 엔지니어링**: 토큰 사용을 최소화하고 컴팩트한 포맷을 사용하십시오. +### 8.4 적응형 모드 (`adaptive_strategy.py` 자동 전환) -### 4.3 보안 -- **API 인증**: 쓰기/삭제 엔드포인트에는 반드시 `Depends(require_api_key)`를 적용하십시오. -- **SSL 검증**: 외부 API 호출 시 `verify=True` 유지 (KIS API 포함). -- **비밀키**: `.env`의 `SECRET_KEY`는 64자 이상 랜덤 문자열 사용. +| 모드 | RSI 진입 | 포지션 | SL | 트리거 | +|------|---------|--------|-----|--------| +| Aggressive | 50 | 7% | -9% | ATR백분위<30 + ADX>25 + 5일수익>+2% | +| Normal | 46 | 6% | -7% | 기본 | +| Defensive | 38 | 4% | -6% | ATR백분위>70 or 5일수익<-3% | +| Crisis | 25 | 1.5% | -3% | ATR백분위>90 or 5일수익<-5% | ---- - -## 5. 리스크 관리 규칙 - -### 5.1 포지션 사이징 (Position Sizing) -- **최대 비중**: 종목당 총 자본의 **6%** (`config.py: MAX_POSITION_SIZE = 0.06`, 백테스트 검증 반영). -- **최대 보유 종목 수**: **20개** (`config.py: MAX_HOLDINGS_COUNT = 20`). -- **레버리지**: 인버스/레버리지 ETF의 경우 총 자본의 최대 **20%**로 제한 (`config.py: LEVERAGE_ETF_MAX_RATIO = 0.20`). - -### 5.2 손절(SL) 및 익절(TP) — 일봉/스윙 기준 -- **기본 손절 (SL Base)**: -7% (안정적 종목), -10% (변동성 종목). (백테스트 검증: 기존 -6% → -7%) -- **ATR 손절**: `진입가 - (2.5 * ATR)` (`config.py: ATR_STOP_MULTIPLIER = 2.5`). **기본 손절과 ATR 손절 중 타이트한(더 작은) 쪽 적용** — 코드에서 `min(stop_loss, atr_stop_pct)` 사용. -- **익절 (TP)**: +2R 수익(리스크 대비 2배 수익) 도달 시 50% 분할 매도. -- **트레일링 스탑 (Trailing Stop)**: 나머지 50% 물량에 대해 `고점 - (1.5 * ATR)` 적용. - -### 5.3 MDD(최대 낙폭) 관리 -- **MDD -10%**: 신규 진입을 중단합니다. 포지션 규모를 30% 축소합니다. **단기 매매 전략 및 데이트레이딩을 중단합니다.** (`mdd_manager.py: REDUCE_AND_PAUSE`) -- **MDD -20%**: 모든 포지션을 강제로 50% 축소합니다. 레버리지/인버스 ETF부터 우선 청산합니다. (`mdd_manager.py: FORCE_REDUCE`) -- **자동 실행**: `mdd_manager.execute_reduction()`이 `portfolio.py`와 `swing/scheduler.py`에서 자동 호출됩니다. 중복 실행 방지 플래그가 내장되어 있습니다. -- **복구**: 잔고가 peak를 갱신하면 모든 MDD 플래그가 리셋됩니다. - -### 5.4 필터 (Filters) -- **가격**: 5,000원 초과 (국내), $4 초과 (미국). (예외: ETF, 리츠). (`config.py: MIN_PRICE_KRW = 5000, MIN_PRICE_USD = 4.0`) -- **상관관계 (Correlation)**: 섹터당 최대 3종목 (`correlation_filter.py: max_per_sector = 3`). 기존 포트폴리오와의 상관계수가 0.7을 초과하면 진입을 거부합니다. - -### 5.5 연속 손실 관리 -- 동일 종목에서 **3회 연속 손실 시 42캘린더일(≈30거래일) 진입 금지** (`loss_tracker.py`). -- DB 영속화: `save_to_db()` → `db_worker`의 `upsert_stock_ban` 경유. -- 앱 재시작 시 `load_from_db()`로 ban 상태 복원. - ---- - -## 6. 전략 A: RSI + 볼린저 밴드 평균 회귀 v2 - -> **구현 파일**: `mean_reversion_strategy.py`, `swing/` 모듈 -> **자산 배분**: 스윙 포트폴리오 (코스닥 스캐너 25% 외 나머지) - -### 6.1 시장 국면 (일봉 기준) -- **상승장 (BULL)**: 코스피200 > 200일 이동평균선(MA200) AND MA200 기울기 > 0. -- **하락장 (BEAR)**: 코스피200 < MA200 AND MA200 기울기 < 0. -- **횡보장 (SIDEWAYS)**: ADX < 20 OR 기울기 ≈ 0. **(이 국면에서는 모든 진입 신호를 무시합니다)** - - `market_analyzer.py`에서 `position_multiplier=0.0`, `max_trades=0` 반환. - -### 6.2 진입 조건 -- **상승장**: RSI(14) < 40 (기본값, `.env`에서 `MEAN_REVERSION_RSI_THRESHOLD`로 조정 가능), 가격이 볼린저 밴드(BB) 하단 부근 (-3% ~ +1.5%), 거래량 > 20일 평균 거래량(20MA). -- **하락장**: RSI(14) < 30, 가격 < 볼린저 밴드 하단, 거래량 > 1.5배 × 20MA, 반전 캔들(Reversal candle) 필수. -- **선제 진입 (Pre-emptive)**: 일봉 RSI < 40 AND 60분봉 RSI < 30 AND 5일 이동평균선(MA5) 상향 돌파. - -### 6.3 청산 조건 -- **부분 매도 (50%)**: RSI >= 50 OR 가격 > 볼린저 밴드 중앙선 OR 수익 > 2×ATR. -- **전량 매도**: RSI >= 65 OR 가격 > 볼린저 밴드 상단 (99%) OR 트레일링 스탑 도달 (`고점 - 1.5×ATR`). - ---- - -## 7. 전략 B: 공격적 성장 (Aggressive Growth) - -### 7.1 코스닥 스캐너 (스윙 매매) -> **구현 파일**: `kosdaq_scanner.py` - -- **대상 (Scope)**: 코스닥 전 종목 (약 1,600개). -- **필터 (Filter)**: 가격 > 5,000원, 거래량 상위 20%. -- **점수 (Score)**: `0.5×RSI_score + 0.3×BB_score + 0.2×Vol_score`. 최소 점수 0.15 이상인 종목 중 상위 20개 선정. -- **자산 배분 (Allocation)**: 전체 자본의 25%. - -### 7.2 단기 전략 (15분봉) -> **구현 파일**: `short_term_strategy.py` - -- **타겟 (Target)**: 일봉 RSI < 40 인 종목. -- **진입 (Entry)**: 15분봉 RSI < 30, 가격이 볼린저 밴드 하단 부근, 종가 > MA5 (상향 돌파 확인), 거래량 > 1.3배 × 20MA (`config.py: SHORT_TERM_VOLUME_MULTIPLIER = 1.3`). -- **최소 변동성**: ATR/close ≥ 0.8% (`config.py: SHORT_TERM_MIN_ATR_RATIO = 0.008`) — 거래비용 커버 보장. -- **청산 (Exit)**: 15분봉 RSI > 55 OR 수익 > 1.5 × ATR(15분봉). -- **손절 (SL)**: `진입가 - 1.0 × ATR(15분봉)` 또는 `-2% 하드 스탑` 중 **타이트한(더 높은 stop_price) 쪽** — 코드에서 `min(atr_stop, hard_stop)` 사용. *(15분봉 단기전략 전용 — 섹션 5.2 ATR 2.5×는 일봉/스윙 기준)* -- **자산 배분 (Allocation)**: 전체 자본의 최대 25%. **MDD가 -10% 미만이면 비활성화합니다.** - -### 7.3 데이트레이딩 (5분봉, ORB/VWAP/RSI_BB) -> **구현 파일**: `day_trading_strategy.py`, `daytrading/` 모듈 - -#### 전략 3종 - -| 전략 | 진입 조건 | 손절 기준 | -|------|----------|----------| -| **ORB** | 시초가 레인지 돌파 + 거래량 ≥ 1.5× 평균분당거래량 + 직전 5분봉 양봉 | `max(ORB 저점, 현재가×(1-ORB_SL%))` + ATR 하한, 상한 -2.5% 클램프 | -| **VWAP** | VWAP 지지 반등 + RSI(14) < 40 + 거래량 ≥ 1.3× + 양봉 | `min(ATR×1.5 기반, 하드스탑 -2.5%)` + 최소 1원 보장 | -| **RSI_BB** | RSI(14) ≤ 30 + 종가 ≤ BB 하단 + 거래량 ≥ 1.3× + 양봉 | `min(ATR×1.5 기반, 하드스탑 -2.5%)` + 최소 1원 보장 | - -#### 포지션 사이징 -- 종목당 최대 4% (섹션 5.1 준수, `config.py: DAY_TRADE_MAX_CAPITAL_RATIO = 0.04`). -- 동시 최대 3종목 (`config.py: DAY_TRADE_MAX_POSITIONS = 3`). -- confidence 기반 동적 조정: `예산 × (signal.confidence / 0.7)`. - -#### 자산 배분 -- 전체 자본의 최대 25% (`config.py: DAY_TRADE_MAX_RATIO_DT = 0.25`). - -#### 청산 규칙 -- **분할 매도**: 목표가(+2R) 달성 시 50% 분할 매도, 잔량은 트레일링 스탑(ATR × 1.0) 적용. -- **시간 손절**: 진입 후 30분(6봉)간 +0.3% 미달 시 청산 (기회비용 관리). -- **과매수 청산**: 5분봉 RSI ≥ 70 AND 수수료 이상 수익 시 청산. -- **EOD 강제 청산**: 15:20 이후 전량 청산 (`config.py: DAY_TRADE_EOD_FLAT_HOUR/MIN`). - -#### 필터 및 제한 -- **시장 국면**: SIDEWAYS 국면에서 진입 차단 (섹션 6.1 준수). -- **시간대 제한**: 점심시간(11:30~13:00) VWAP/RSI_BB 진입 제한, **14:30 이후 신규 진입 마감**. -- **일일 손실 한도**: -3% 초과 시 당일 신규 진입 중단 (`config.py: DAY_TRADE_DAILY_LOSS_LIMIT = -0.03`). -- **쿨다운**: 당일 3연패 시 신규 진입 차단 (`daytrading/position_manager.py`). -- **loss_tracker 연동**: 전량 매도 시 `loss_tracker.record_trade_result()` + `save_to_db()` 호출. -- **MDD -10% 미만이면 비활성화합니다.** - ---- - -## 8. Claude에 대한 지침 - -### 8.1 주요 점검 사항 (Critical Checkpoints) -1. **아키텍처**: 모든 DB 쓰기 작업이 `db_worker.py`를 사용하는지 확인하십시오. 예외: `sync_portfolio()`. -2. **안전성 (Safety)**: 모든 매매에 리스크 규칙(섹션 5)을 항상 적용하십시오. -3. **시장 국면 (Regime)**: 횡보장(SIDEWAYS) 국면에서는 진입을 제안하지 마십시오. -4. **테스트**: 백테스트 및 전진 분석(Walk-forward validation) 없이는 실전 투자를 하지 마십시오. -5. **손절 방향**: 손절가 선택 시 항상 `min()` (타이트한 쪽)을 사용하십시오. `max()`는 절대 사용하지 마십시오. -6. **loss_tracker 영속화**: `record_trade_result()` 호출 후 반드시 `save_to_db()` 호출을 확인하십시오. -7. **인증**: 쓰기/삭제 API에는 반드시 `require_api_key` 의존성을 적용하십시오. - -### 8.2 응답 형식 (Response Format) -- JSON 출력 시 `_dumps()`를 사용하십시오. -- 새로운 기능 개발 시 새 파일을 생성하십시오. -- 기존 시그니처를 수정하지 마십시오. - -### 8.3 주요 설정값 참조 (`config.py`) — 백테스트 검증 완료 - -| 설정 | 값 | 설명 | 백테스트 변경 | -|------|---|------|------------| -| `MAX_POSITION_SIZE` | 0.06 | 종목당 최대 6% | 기존 0.04 → 0.06 | -| `MAX_HOLDINGS_COUNT` | 20 | 동시 보유 최대 | 유지 | -| `LEVERAGE_ETF_MAX_RATIO` | 0.20 | 레버리지 ETF 최대 | 유지 | -| `ATR_STOP_MULTIPLIER` | 2.5 | 스윙 ATR 손절 배수 | 유지 | -| `STOP_LOSS_PERCENT` | 7.0 | 기본 손절 7% | 기존 6.0 → 7.0 | -| `MEAN_REVERSION_RSI_THRESHOLD` | 46.0 | 평균회귀 RSI 임계값 | 기존 40.0 → 46.0 | -| `MEAN_REVERSION_BEAR_RSI_THRESHOLD` | 35.0 | BEAR RSI 임계값 | 기존 30.0 → 35.0 | -| `MEAN_REVERSION_RSI_EXIT_THRESHOLD` | 55.0 | 1차 익절 RSI | 기존 50.0 → 55.0 | -| `MEAN_REVERSION_RSI_FULL_EXIT` | 70.0 | 전량 청산 RSI | 기존 65.0 → 70.0 | -| `DAY_TRADE_MAX_POSITIONS` | 3 | 데이트레이딩 동시 최대 | 유지 | -| `DAY_TRADE_ENTRY_CUTOFF` | 14:30 | 데이트레이딩 마감 | 기존 15:00 → 14:30 | -| `CONSECUTIVE_LOSS_BAN_COUNT` | 3 | 연속 손실 ban 횟수 | 유지 | - -### 8.4 듀얼 전략 (백테스트 검증: 2023-03 ~ 2026-03) - -스윙 전략은 3가지 진입 전략을 병행합니다: - -| 전략 | 조건 | 청산 방식 | 기여도 | -|------|------|----------|--------| -| **평균회귀** | RSI 과매도 + BB 하단 | RSI 55 부분익절 → RSI 70 전량 | 베이스 수익 | -| **모멘텀 돌파** | 20일 신고가 + 거래량 2x + RSI 50~70 | +10% 30%매도 → +20% 30%매도 → 트레일링 | 큰 이익 | -| **눌림목 매수** | MA20>MA60 + MA20 근처 조정 + RSI 반등 | 평균회귀와 동일 | 안정 수익 | - -### 8.5 적응형 모드 (4단계) - -시장 상황에 따라 파라미터가 자동 전환됩니다 (`adaptive_strategy.py`): - -| 모드 | RSI 진입 | 포지션 | 손절 | 트리거 | -|------|---------|--------|------|--------| -| **Aggressive** | 50 | 7% | -9% | ATR백분위<30 + ADX>25 + 5일수익>+2% | -| **Normal** | 46 | 6% | -7% | 기본 | -| **Defensive** | 38 | 4% | -6% | ATR백분위>70 또는 5일수익<-3% | -| **Crisis** | 25 | 1.5% | -3% | ATR백분위>90 또는 5일수익<-5% | - -*마지막 업데이트: 2026-03-25 (백테스트 검증 반영)* +*마지막 업데이트: 2026-09-06 (문서 압축 최적화, 규칙 변경 없음)* diff --git a/backend/_sweep_single_axis.py b/backend/_sweep_single_axis.py new file mode 100644 index 0000000..04f7086 --- /dev/null +++ b/backend/_sweep_single_axis.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""단일 파라미터 축 민감도 스윕 (run_backtest.py의 'normal' 모드 필드 1개만 오버라이드). + +run_backtest_sweep.py와 동일한 텍스트 치환 방식을 사용하되, ADAPTIVE_MODES 전체가 +아니라 normal 모드의 지정된 키 하나만 바꿔서 단일 축 민감도를 측정한다. +""" +import subprocess, json, sys, os, time, argparse + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +BACKTEST_SCRIPT = os.path.join(BASE_DIR, "run_backtest.py") + +BASELINE_MODES = { + "aggressive": {"rsi_entry": 48.0, "bear_rsi_entry": 38.0, "bb_pct_b_scale": 0.45, "position_size": 0.07, + "max_holdings": 15, "stop_loss": -8.0, "stop_loss_vol": -12.0, "atr_stop_mult": 3.0, + "atr_trail_mult": 1.8, "rsi_partial": 58.0, "rsi_full": 72.0}, + "normal": {"rsi_entry": 43.0, "bear_rsi_entry": 32.0, "bb_pct_b_scale": 0.35, "position_size": 0.05, + "max_holdings": 12, "stop_loss": -6.0, "stop_loss_vol": -10.0, "atr_stop_mult": 2.5, + "atr_trail_mult": 1.5, "rsi_partial": 52.0, "rsi_full": 67.0}, + "defensive": {"rsi_entry": 35.0, "bear_rsi_entry": 25.0, "bb_pct_b_scale": 0.20, "position_size": 0.03, + "max_holdings": 8, "stop_loss": -5.0, "stop_loss_vol": -8.0, "atr_stop_mult": 2.0, + "atr_trail_mult": 1.2, "rsi_partial": 48.0, "rsi_full": 60.0}, + "crisis": {"rsi_entry": 28.0, "bear_rsi_entry": 20.0, "bb_pct_b_scale": 0.10, "position_size": 0.0, + "max_holdings": 3, "stop_loss": -4.0, "stop_loss_vol": -6.0, "atr_stop_mult": 1.5, + "atr_trail_mult": 1.0, "rsi_partial": 45.0, "rsi_full": 55.0}, +} + + +def patch_and_run(modes, start, end, timeout=600): + with open(BACKTEST_SCRIPT, "r") as f: + content = f.read() + start_marker = "ADAPTIVE_MODES = {" + end_marker = "\n\ndef determine_adaptive_mode(" + s = content.find(start_marker) + e = content.find(end_marker) + if s == -1 or e == -1: + print("[ERROR] ADAPTIVE_MODES 블록을 찾을 수 없습니다") + return None + new_block = "ADAPTIVE_MODES = " + json.dumps(modes, indent=4, ensure_ascii=False) + patched = content[:s] + new_block + content[e:] + + tmp_script = os.path.join(BASE_DIR, f"_tmp_sweep_{os.getpid()}.py") + with open(tmp_script, "w") as f: + f.write(patched) + try: + result = subprocess.run( + [sys.executable, tmp_script, "--start", start, "--end", end], + capture_output=True, text=True, timeout=timeout, + ) + output = result.stdout + result.stderr + finally: + try: + os.remove(tmp_script) + except OSError: + pass + + metrics = {} + for line in output.split("\n"): + line = line.strip() + if "총 수익률:" in line: + metrics["return"] = line.split(":")[-1].strip() + elif "CAGR:" in line: + metrics["cagr"] = line.split(":")[-1].strip() + elif "Max Drawdown:" in line: + metrics["mdd"] = line.split(":")[-1].strip() + elif "승률:" in line: + metrics["winrate"] = line.split(":")[-1].strip() + elif "총 매매 횟수:" in line: + metrics["trades"] = line.split(":")[-1].strip() + elif "Sharpe Ratio:" in line: + metrics["sharpe"] = line.split(":")[-1].strip() + elif "Profit Factor:" in line: + metrics["pf"] = line.split(":")[-1].strip() + elif "최대 연패:" in line: + metrics["max_loss_streak"] = line.split(":")[-1].strip() + if not metrics: + print("---- RAW OUTPUT (no metrics parsed) ----") + print(output[-3000:]) + return metrics + + +AXIS_KEY_MAP = { + "rsi_entry": "rsi_entry", + "stop_loss": "stop_loss", + "rsi_partial": "rsi_partial", + "atr_stop_mult": "atr_stop_mult", +} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--axis", required=True, choices=list(AXIS_KEY_MAP.keys())) + ap.add_argument("--values", required=True, help="comma separated values, e.g. 42,46,50") + ap.add_argument("--start", default="2024-09-01") + ap.add_argument("--end", default="2026-09-05") + args = ap.parse_args() + + key = AXIS_KEY_MAP[args.axis] + values = [float(v) for v in args.values.split(",")] + + print(f"=== Axis: {args.axis} ({key}) | period {args.start}~{args.end} ===") + for v in values: + import copy + modes = copy.deepcopy(BASELINE_MODES) + modes["normal"][key] = v + print(f"\n--- {args.axis}={v} (normal mode only) ---") + t0 = time.time() + m = patch_and_run(modes, args.start, args.end) + elapsed = time.time() - t0 + print(f" elapsed={elapsed:.1f}s {m}") + + +if __name__ == "__main__": + main() diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 0459cfc..bf54784 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -14,7 +14,7 @@ from app.api.endpoints import ( journals, day_trading, strategy_config, backtest, analysis, swing_portfolio, daytrading_v2, us_trading, kr_backtest, market_intelligence, market_playbook, news, - adaptive_strategy, + adaptive_strategy, manual_buy, ) api_router = APIRouter() @@ -28,6 +28,10 @@ api_router.include_router( portfolio.router, prefix="/portfolio", tags=["portfolio"], dependencies=[Depends(require_api_key)], ) +api_router.include_router( + manual_buy.router, prefix="/portfolio", tags=["portfolio"], + dependencies=[Depends(require_api_key)], +) api_router.include_router( recommendations.router, prefix="/recommendations", tags=["recommendations"], dependencies=[Depends(require_api_key)], diff --git a/backend/app/api/endpoints/manual_buy.py b/backend/app/api/endpoints/manual_buy.py new file mode 100644 index 0000000..0c982d9 --- /dev/null +++ b/backend/app/api/endpoints/manual_buy.py @@ -0,0 +1,183 @@ +"""추천 종목 수동 매수 엔드포인트 (프론트 "매수" 버튼 — 청산 버튼의 매수 대응). + +POST /api/v1/portfolio/buy + - quantity 생략 시: 총자산 × MAX_POSITION_SIZE 예산으로 시장가 수량 자동 산출 + - 리스크 가드(§5): 가격 필터·연속손실 ban·MDD 신규진입 차단·최대 보유수·TRADING_HALTED + - 주문은 trade_executor.safe_place_order (종목별 세마포어 + Redis 분산락) 경유 + - DB 쓰기는 db_worker 큐 경유(insert_trade_history / upsert_portfolio), 이후 KIS 잔고 재동기화 +""" + +import asyncio +import time +import logging +from datetime import datetime +from typing import Optional + +import pytz +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.core.security import require_api_key +from app.models.models import Portfolio +from app.services.korea_investment_api import korea_investment_api +from app.services.loss_tracker import loss_tracker +from app.services.mdd_manager import mdd_manager +from app.services.utils import _is_domestic + +logger = logging.getLogger(__name__) +router = APIRouter() +_KST = pytz.timezone("Asia/Seoul") + + +class BuyRequest(BaseModel): + stock_code: str + stock_name: Optional[str] = None + quantity: Optional[int] = Field(None, gt=0, description="매수 수량 (None이면 예산 기반 자동 산출)") + price: Optional[int] = Field(None, gt=0, description="지정가 (None이면 시장가)") + + +def _kst_now() -> datetime: + return datetime.now(_KST).replace(tzinfo=None) + + +# 종목별 수동 매수 중복 방지: 진행 중(in-flight) 또는 최근 완료(쿨다운) 시 거부. +# KIS 지연으로 주문이 30~60초 걸릴 때 화면 타임아웃 → 재클릭 → 같은 종목 3회 매수된 사고 방지. +_inflight: set[str] = set() +_last_done: dict[str, float] = {} +_DUP_COOLDOWN_SEC = 120.0 + + +@router.post("/buy", dependencies=[Depends(require_api_key)]) +async def buy_stock(request: BuyRequest, db: AsyncSession = Depends(get_db)): + """추천 종목 수동 매수 (매수 버튼).""" + stock_code = request.stock_code.strip() + if not _is_domestic(stock_code): + raise HTTPException(status_code=400, detail="국내 종목(6자리 코드)만 수동 매수를 지원합니다") + if settings.TRADING_HALTED: + raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태") + + if stock_code in _inflight: + raise HTTPException(status_code=409, detail="같은 종목 매수 주문이 아직 처리 중입니다 (KIS 응답 대기). 잠시 후 포트폴리오를 확인하세요") + _since = time.monotonic() - _last_done.get(stock_code, -1e9) + if _since < _DUP_COOLDOWN_SEC: + raise HTTPException( + status_code=409, + detail=f"{int(_DUP_COOLDOWN_SEC - _since)}초 전 같은 종목을 매수했습니다. 중복 주문 방지 쿨다운 중", + ) + _inflight.add(stock_code) + try: + return await _buy_stock_impl(request, stock_code, db) + finally: + _inflight.discard(stock_code) + + +async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession): + + # ── 현재가 ────────────────────────────────────────────────────────── + try: + price_data = await korea_investment_api.get_current_price(stock_code) + output = (price_data or {}).get("output") or {} + current_price = float(output.get("stck_prpr") or 0) + stock_name = request.stock_name or output.get("hts_kor_isnm") or stock_code + except Exception as e: + raise HTTPException(status_code=502, detail=f"현재가 조회 실패: {e}") + if current_price <= 0: + raise HTTPException(status_code=404, detail=f"현재가를 가져올 수 없습니다: {stock_code}") + + # ── 리스크 가드 (CLAUDE.md §5) ─────────────────────────────────────── + if current_price <= settings.MIN_PRICE_KRW: + raise HTTPException(status_code=400, detail=f"가격 필터: {settings.MIN_PRICE_KRW:,}원 이하 종목 매수 금지") + if await loss_tracker.is_banned_safe(stock_code): + raise HTTPException(status_code=400, detail="연속 손실 진입 금지 종목입니다 (loss_tracker ban)") + if not await mdd_manager.can_open_new_position(): + raise HTTPException(status_code=400, detail="MDD 신규 진입 중단 상태입니다") + + held = await db.execute(select(Portfolio).where(Portfolio.stock_code == stock_code)) + holding = held.scalar_one_or_none() + if holding is None: + cnt = (await db.execute( + select(func.count()).select_from(Portfolio).where(Portfolio.quantity > 0) + )).scalar() or 0 + if cnt >= settings.MAX_HOLDINGS_COUNT: + raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과") + + # ── 수량 산출 (예산 = 총자산 × MAX_POSITION_SIZE, 예수금 한도) ──────────── + quantity = request.quantity + order_price = float(request.price) if request.price else current_price + if quantity is None: + try: + balance = await korea_investment_api.get_balance() + output2 = balance.get("output2") or [{}] + b = output2[0] if isinstance(output2, list) else output2 + available_cash = float(b.get("dnca_tot_amt") or 0) + total_assets = float(b.get("tot_evlu_amt") or 0) + except Exception as e: + raise HTTPException(status_code=502, detail=f"잔고 조회 실패: {e}") + budget = min(total_assets * settings.MAX_POSITION_SIZE, available_cash) + quantity = int(budget // order_price) + if quantity < 1: + raise HTTPException( + status_code=400, + detail=f"예산 부족: 예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 (예수금 {available_cash:,.0f}원)", + ) + + logger.info(f"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}") + + # ── 주문 (세마포어 + 분산락) ───────────────────────────────────────── + from app.services.trade_executor import safe_place_order, _delayed_sync_portfolio + order_result = await safe_place_order( + stock_code=stock_code, order_type="BUY", quantity=quantity, + price=request.price, is_domestic=True, + ) + if not order_result or order_result.get("rt_cd") != "0": + msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패" + raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}") + order_id = (order_result.get("output") or {}).get("ODNO") + + # ── DB 기록 (db_worker 큐) ──────────────────────────────────────────── + from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert + now = _kst_now() + await enqueue_trade_history({ + "stock_code": stock_code, + "stock_name": stock_name, + "order_type": "BUY", + "order_price": float(request.price) if request.price else None, + "order_quantity": quantity, + "executed_price": float(order_price), + "executed_quantity": quantity, + "total_amount": float(order_price * quantity), + "status": "COMPLETED", + "order_id": order_id, + "message": "수동 매수 (추천 패널)", + "strategy_type": "manual", + "created_at": now, + "executed_at": now, + }) + if holding is None: + await enqueue_portfolio_upsert({ + "stock_code": stock_code, + "stock_name": stock_name, + "quantity": quantity, + "average_price": order_price, + "current_price": current_price, + "market": "KRX", + "strategy_type": "manual", + "currency_code": "KRW", + "exchange_code": "KRX", + }) + # 체결 후 KIS 잔고 기준 재동기화 (평균단가·수량 정확화) + asyncio.create_task(_delayed_sync_portfolio(3)) + + _last_done[stock_code] = time.monotonic() + logger.info(f"✅ 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원 (주문번호 {order_id})") + return { + "success": True, + "message": f"{stock_name} {quantity}주 매수 주문 완료", + "order_id": order_id, + "quantity": quantity, + "price": order_price, + } diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py index b8a6d08..7d1d6c8 100644 --- a/backend/app/api/endpoints/market_data.py +++ b/backend/app/api/endpoints/market_data.py @@ -212,6 +212,7 @@ def _generate_sample_chart(stock_code: str, period: str, limit: int = 100) -> di chart_cache = {} _CHART_CACHE_MAX_SIZE = 100 _CHART_CACHE_TTL = 600 # 10분 +_CHART_STALE_DAYS = 7 # DB 마지막 봉이 이보다 오래되면 네이버 차트 폴백 def _aggregate_weekly_monthly(daily_data: list, period: str) -> list: @@ -328,13 +329,23 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis db_limit = limit if period == "D" else limit * 7 if period == "W" else limit * 31 db_data = await _fetch_chart_from_db(stock_code, min(db_limit, 1200)) - if not db_data: - # DB에 데이터 없으면 네이버 차트 폴백 + # DB 데이터가 없거나 오래됐으면(마지막 봉이 _CHART_STALE_DAYS일 이상 전) 네이버 차트 폴백 + # — KRX 일괄 수집(DATAGOKR)이 멈춘 종목은 DB가 수개월 전에서 끊겨 차트가 현재가와 어긋난다. + _stale = False + if db_data: + try: + _last = datetime.strptime(str(db_data[-1].get("date", ""))[:8], "%Y%m%d") + _stale = (current_time - _last).days > _CHART_STALE_DAYS + except ValueError: + _stale = False + if not db_data or _stale: + if _stale: + logger.info(f"차트 DB 데이터 오래됨 ({stock_code}, 마지막 {db_data[-1].get('date')}) → 네이버 폴백") try: from app.services.naver_finance import get_naver_daily_chart naver_data = await get_naver_daily_chart(stock_code, days=limit * 2) if naver_data: - db_data = [] + naver_rows = [] for item in naver_data: o = float(item.get("stck_oprc") or 0) h = float(item.get("stck_hgpr") or 0) @@ -343,7 +354,7 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis d = item.get("stck_bsop_date", "") if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0: continue - db_data.append({ + naver_rows.append({ "date": d, "open": o, "high": h, @@ -351,7 +362,8 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis "close": c, "volume": int(item.get("acml_vol") or 0), }) - db_data = db_data[-limit:] + if naver_rows: + db_data = naver_rows[-limit:] logger.info(f"차트 네이버 폴백: {stock_code} ({len(db_data)}행)") except Exception as e: logger.warning(f"네이버 차트 폴백 실패 ({stock_code}): {e}") diff --git a/backend/app/api/endpoints/mcp.py b/backend/app/api/endpoints/mcp.py index 5bf489f..a332388 100644 --- a/backend/app/api/endpoints/mcp.py +++ b/backend/app/api/endpoints/mcp.py @@ -14,6 +14,7 @@ from typing import Dict, Any, Optional, Tuple from pydantic import BaseModel import httpx import logging +import time from app.core.config import settings @@ -66,6 +67,20 @@ def get_http_client() -> httpx.AsyncClient: _fail_open_count: int = 0 _FAIL_OPEN_WARN_THRESHOLD: int = 5 # 연속 N회 초과 시 경고 로그 +# ─── RISK_ASSESSMENT 결과 캐시 (MCP_RISK_CACHE_TTL>0 일 때만) ──────────── +# 스케줄러가 매 사이클(60s/30s) 같은 종목셋으로 재요청하는 AI 호출을 줄인다. +_risk_cache: Dict[str, Tuple[float, bool, str]] = {} # key → (expires_at, approve, reason) + + +def _risk_cache_key(trade_plan: Dict[str, Any]) -> str: + stocks = trade_plan.get("top_stocks") or [] + codes = sorted( + str(s.get("stock_code", s) if isinstance(s, dict) else s) for s in stocks + ) + if not codes and trade_plan.get("stock_code"): + codes = [str(trade_plan["stock_code"])] + return f"{trade_plan.get('strategy', '')}:{trade_plan.get('action', '')}:{','.join(codes)}" + # ─── 공통 리스크 평가 함수 ──────────────────────────────────────────── async def request_mcp_risk_assessment( @@ -91,6 +106,13 @@ async def request_mcp_risk_assessment( if not settings.MCP_VALIDATION_ENABLED: return True, "mcp_disabled" + _ttl = settings.MCP_RISK_CACHE_TTL + _ckey = _risk_cache_key(trade_plan) if _ttl > 0 else None + if _ckey: + _hit = _risk_cache.get(_ckey) + if _hit and _hit[0] > time.monotonic(): + return _hit[1], f"{_hit[2]} (cached)" + try: client = get_http_client() except RuntimeError: @@ -116,7 +138,7 @@ async def request_mcp_risk_assessment( "signals": signals, }, }, - timeout=15.0, + timeout=settings.MCP_REQUEST_TIMEOUT, ) if resp.status_code == 200: result = resp.json() @@ -124,11 +146,11 @@ async def request_mcp_risk_assessment( payload = result.get("result", result) decision = str(payload.get("decision", "approve")).lower() reason = str(payload.get("reason", "")) - if "deny" in decision or "reject" in decision: - _fail_open_count = 0 # 정상 응답 수신 시 카운터 리셋 - return False, reason _fail_open_count = 0 # 정상 응답 수신 시 카운터 리셋 - return True, reason + approve = not ("deny" in decision or "reject" in decision) + if _ckey: + _risk_cache[_ckey] = (time.monotonic() + _ttl, approve, reason) + return approve, reason else: _fail_open_count += 1 logger.warning( diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 46e6ba2..a5e2318 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -178,7 +178,9 @@ async def get_rule_based_recommendations( else: result = await get_rule_based_recommendations_from_db(db) - if auto_trade and result.get("recommendations_by_rule"): + # 대시보드 폴링(GET)이 매매를 트리거하면 스윙 스케줄러와 동시 실행되어 일일 한도가 무력화됨 + # (2026-09-17). 기본적으로 조회 전용이며, API_TRIGGERED_AUTO_TRADE=true 일 때만 실행. + if auto_trade and settings.API_TRIGGERED_AUTO_TRADE and result.get("recommendations_by_rule"): rec_snapshot = result async def _bg_trade(): try: diff --git a/backend/app/api/endpoints/risk_management.py b/backend/app/api/endpoints/risk_management.py index d68f394..d4ad9f0 100644 --- a/backend/app/api/endpoints/risk_management.py +++ b/backend/app/api/endpoints/risk_management.py @@ -291,7 +291,15 @@ async def check_risk_limits( session = result.scalar_one_or_none() if not session: - session = TradingSession(session_date=today, starting_balance=Decimal(str(account_balance))) + session = TradingSession( + session_date=today, + starting_balance=Decimal(str(account_balance)), + 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) @@ -386,9 +394,16 @@ async def update_trading_session( session = result.scalar_one_or_none() if not session: - session = TradingSession(session_date=today) + 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) - + # 손익 누적 current_pl = float(session.daily_profit_loss) if session.daily_profit_loss else 0 session.daily_profit_loss = Decimal(str(current_pl + profit_loss)) diff --git a/backend/app/api/endpoints/swing_portfolio.py b/backend/app/api/endpoints/swing_portfolio.py index f3b4a8d..4b4e2c5 100644 --- a/backend/app/api/endpoints/swing_portfolio.py +++ b/backend/app/api/endpoints/swing_portfolio.py @@ -128,8 +128,7 @@ async def manual_swing_sell( stock_code=req.stock_code, order_type="SELL", quantity=quantity, - price=0, - order_price_type="01", + price=None, # 시장가 (order_dvsn="01"은 price=None일 때 자동 적용) ) if not order_resp or order_resp.get("rt_cd") != "0": err = order_resp.get("msg1", "unknown") if order_resp else "no response" diff --git a/backend/app/api/endpoints/us_trading.py b/backend/app/api/endpoints/us_trading.py index 684206d..91bf3ee 100644 --- a/backend/app/api/endpoints/us_trading.py +++ b/backend/app/api/endpoints/us_trading.py @@ -69,7 +69,7 @@ async def execute_us_trade(req: UsOrderRequest): if not req.price: # Get current price for limit order price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange) - req.price = float(price_data.get("last", 0)) + req.price = float(price_data.get("output", {}).get("last", 0)) if not req.price or req.price <= 0: raise HTTPException( status_code=400, @@ -77,12 +77,12 @@ async def execute_us_trade(req: UsOrderRequest): ) result = await korea_investment_api.place_overseas_order( - req.ticker, "buy", req.quantity, req.price, exchange + req.ticker, req.order_type.upper(), req.quantity, req.price, exchange ) elif req.order_type.lower() == "sell": if not req.price: price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange) - req.price = float(price_data.get("last", 0)) + req.price = float(price_data.get("output", {}).get("last", 0)) if not req.price or req.price <= 0: raise HTTPException( status_code=400, @@ -90,7 +90,7 @@ async def execute_us_trade(req: UsOrderRequest): ) result = await korea_investment_api.place_overseas_order( - req.ticker, "sell", req.quantity, req.price, exchange + req.ticker, req.order_type.upper(), req.quantity, req.price, exchange ) else: raise HTTPException(status_code=400, detail="order_type must be 'buy' or 'sell'") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 78a65fe..d92fbcf 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -69,6 +69,8 @@ class Settings(BaseSettings): KIS_WS_VIRTUAL_URL: str = "wss://openapivts.koreainvestment.com:29443/websocket" KIS_WS_ENABLED: bool = True # WebSocket 실시간 스트림 활성화 KIS_SYNC_INTERVAL: int = 10 # 구독 목록 동기화 주기 (초) + KIS_QUOTES_VIA_REAL: bool = True # 모의투자 모드에서 시세·차트·호가 조회를 실계좌 시세 서버(초당 20건)로 라우팅. 주문·잔고는 모의서버 유지 + KIS_REAL_MIN_REQUEST_INTERVAL: float = 0.1 # 실계좌 시세 인스턴스 최소 요청 간격(초). 한도 20/s 대비 여유 10/s KIS_RECONNECT_WAIT: int = 5 # 재연결 대기 시간 (초) KIS_APPROVAL_KEY_TTL: int = 82800 # approval_key 캐시 유효시간 (초, 23시간) @@ -85,6 +87,9 @@ class Settings(BaseSettings): MCP_SERVER_URL: str = "http://localhost:8001" MCP_API_KEY: str = "" # MCP 서버 인증 키 (backend -> MCP 통신 시 X-API-Key 헤더) MCP_VALIDATION_ENABLED: bool = True # 기본 ON — AI 검증 없이 자동매매 차단 + MCP_REQUEST_TIMEOUT: float = 15.0 # MCP /analyze 호출 타임아웃(초) — claude_code CLI 모드는 60 권장 + MCP_RISK_CACHE_TTL: int = 0 # 동일 종목셋 RISK_ASSESSMENT 결과 재사용(초). 0=매번 호출, 3600=1시간 + BATCH_ANALYSIS_INTERVAL: int = 600 # 장중 배치 분석 주기(초). 3600=1시간 MCP_CONFIDENCE_THRESHOLD: float = 0.5 # AI 신뢰도 최소값 (0.0~1.0) MCP_ANALYSIS_MIN_SCORE: int = 70 # 기본 70점 — .env에서 MCP_ANALYSIS_MIN_SCORE=N 으로 조정 NEWS_API_KEY: str = "" @@ -268,6 +273,7 @@ class Settings(BaseSettings): OVERHEAT_10DAY_MAX: float = 30.0 OVERHEAT_DISPARITY_MAX: float = 108.0 BEAR_MARKET_FILTER_ENABLED: bool = True # 하락장 방어 필터 + SWING_INDEX_BREAKER_ENABLED: bool = False # 지수 서킷브레이커(KODEX200= target_price: + # [FIX] target_price<=0 방어 — 0이면 즉시 조건 충족되어 오매도되는 것을 방지 + if target_price > 0 and current_price >= target_price: gain_pct = (current_price - entry_price) / entry_price * 100 return DayTradeExit( action="SELL_PARTIAL", diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index 3629b84..a9c262b 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -124,8 +124,10 @@ async def _check_single_position(stock_code: str) -> dict | None: return None entry_price = pos.get("entry_price", 0) - stop_loss = pos.get("stop_loss", entry_price * 0.94) - target_price = pos.get("target_price", entry_price * 1.06) + # [FIX] 0 값이 저장된 경우 dict.get() 기본값이 적용되지 않으므로 `or`로 방어 + # (target_price=0이면 day_trading_strategy.py의 목표가 도달 조건이 항상 참이 되어 즉시 오매도됨) + stop_loss = pos.get("stop_loss") or (entry_price * 0.94) + target_price = pos.get("target_price") or (entry_price * 1.06) quantity = pos.get("quantity", 0) highest_price = pos.get("highest_price", entry_price) entry_time = pos.get("entry_time") diff --git a/backend/app/services/daytrading/position_manager.py b/backend/app/services/daytrading/position_manager.py index 349a462..fbac0ea 100644 --- a/backend/app/services/daytrading/position_manager.py +++ b/backend/app/services/daytrading/position_manager.py @@ -267,6 +267,7 @@ class DayTradingState: int: 복원된 포지션 수 """ from app.models.models import ActiveTrade + from app.core.config import settings today = _today_kst() restored = 0 @@ -293,12 +294,32 @@ class DayTradingState: if trade.stock_code in self.positions: continue + entry_price = float(trade.entry_price) if trade.entry_price else 0 + stop_loss = float(trade.stop_loss) if trade.stop_loss else 0 + target_price = float(trade.take_profit) if trade.take_profit else 0 + + # [FIX] stop_loss/target_price가 0/None으로 복원되면 entry_price 기반 + # 기본값으로 대체 — day_trading_strategy.py의 target_price<=0 즉시매도 방지 + if entry_price > 0 and stop_loss <= 0: + stop_loss = entry_price * (1 - settings.DAY_TRADE_STOP_LOSS_PCT) + logger.warning( + f"[DayTrading] {trade.stock_code} stop_loss 복원값 없음 → " + f"기본값 적용: {stop_loss:.0f}" + ) + if entry_price > 0 and target_price <= 0: + risk = entry_price - stop_loss + target_price = entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER + logger.warning( + f"[DayTrading] {trade.stock_code} target_price 복원값 없음 → " + f"기본값 적용: {target_price:.0f}" + ) + self.positions[trade.stock_code] = { "stock_name": trade.stock_name or trade.stock_code, "strategy": trade.strategy_name or "day_trade", - "entry_price": float(trade.entry_price) if trade.entry_price else 0, - "stop_loss": float(trade.stop_loss) if trade.stop_loss else 0, - "target_price": float(trade.take_profit) if trade.take_profit else 0, + "entry_price": entry_price, + "stop_loss": stop_loss, + "target_price": target_price, "quantity": trade.quantity or 0, "highest_price": float(trade.highest_since_entry) if trade.highest_since_entry else ( float(trade.entry_price) if trade.entry_price else 0 diff --git a/backend/app/services/db_worker.py b/backend/app/services/db_worker.py index 3fba126..f009aef 100644 --- a/backend/app/services/db_worker.py +++ b/backend/app/services/db_worker.py @@ -462,12 +462,14 @@ async def db_writer_worker() -> None: else: # remaining_pct_multiply: 기존 remaining_pct에 비율을 곱함 # (MDD 축소 시 기존 분할매도 이력 반영) - multiply_ratio = data.pop("remaining_pct_multiply", None) + # data.get() 사용 — pop()으로 task dict를 파괴적으로 변경하면 + # 재시도 시 이 키가 사라져 no-op으로 성공 처리되는 버그 방지 + multiply_ratio = data.get("remaining_pct_multiply") if multiply_ratio is not None: current_remaining = getattr(record, "remaining_pct", 1.0) or 1.0 - record.remaining_pct = current_remaining * float(multiply_ratio) + record.remaining_pct = float(current_remaining) * float(multiply_ratio) for key, val in data.items(): - if key != "stock_code" and val is not None: + if key not in ("stock_code", "remaining_pct_multiply") and val is not None: setattr(record, key, val) await session.commit() @@ -525,6 +527,10 @@ async def db_writer_worker() -> None: while not queue.empty(): try: drain_task = queue.get_nowait() + except asyncio.QueueEmpty: + # get_nowait()이 실패해 아이템을 꺼내지 못했으므로 task_done() 호출 금지 + break + try: drain_type = drain_task.get("type", "unknown") drain_data = drain_task.get("data") async with AsyncSessionLocal() as drain_session: @@ -548,8 +554,6 @@ async def db_writer_worker() -> None: else: await _save_to_dlq(drain_task, "shutdown_drain_unknown_type") remaining += 1 - except asyncio.QueueEmpty: - break except Exception as drain_err: logger.error(f"[DBWorker] Drain 실패: {drain_err}") try: diff --git a/backend/app/services/kis_realtime_stream.py b/backend/app/services/kis_realtime_stream.py index fe38a89..5047da1 100644 --- a/backend/app/services/kis_realtime_stream.py +++ b/backend/app/services/kis_realtime_stream.py @@ -214,18 +214,20 @@ def _parse_realtime_message(raw: str) -> Optional[dict]: _enc, tr_id, _cnt, body = parts[0], parts[1], parts[2], parts[3] fields = body.split("^") - if tr_id == TR_TICK and len(fields) >= 10: + if tr_id == TR_TICK and len(fields) >= 15: # H0STCNT0 필드 순서 (KIS 개발가이드 기준) - # [0]=종목코드, [1]=체결시간, [2]=현재가, [4]=전일대비율, - # [7]=누적거래량, [9]=누적거래대금 + # [0]=종목코드 [1]=체결시간 [2]=현재가 [3]=전일대비부호 [4]=전일대비(원) [5]=전일대비율(%) + # [6]=가중평균가 [7]=시가 [8]=고가 [9]=저가 [10]=매도호가1 [11]=매수호가1 + # [12]=체결거래량 [13]=누적거래량 [14]=누적거래대금 + # (2026-09-18 수정: 기존 [4]/[7]/[9] 는 전일대비원/시가/저가를 잘못 읽고 있었음) return { "_type": "tick", "tr_id": tr_id, "stock_code": fields[0], "price": _safe_float(fields[2]), - "change_rate": _safe_float(fields[4]), - "volume": _safe_int(fields[7]), - "trade_amount":_safe_int(fields[9]), + "change_rate": _safe_float(fields[5]), + "volume": _safe_int(fields[13]), + "trade_amount":_safe_int(fields[14]), "timestamp": fields[1] if len(fields) > 1 else "", } diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 55d8e85..729cbf0 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -199,7 +199,8 @@ class KoreaInvestmentAPI: instance._http_client = None instance._http_client_lock = asyncio.Lock() instance.TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache_real.json" - instance.min_request_interval = settings.API_MIN_REQUEST_INTERVAL + # 실서버는 초당 20건 허용 — 모의서버용 간격(API_MIN_REQUEST_INTERVAL)과 분리 + instance.min_request_interval = getattr(settings, "KIS_REAL_MIN_REQUEST_INTERVAL", 0.1) instance.max_retries = settings.API_MAX_RETRIES instance.retry_delay = settings.API_RETRY_DELAY instance.retry_backoff = settings.API_RETRY_BACKOFF @@ -549,8 +550,24 @@ class KoreaInvestmentAPI: raise last_exception raise Exception("API 요청 실패 (알 수 없는 오류)") + # ── 시세 조회 실서버 라우팅 ────────────────────────────────────────────── + # 모의투자 서버(openapivts)는 앱키당 초당 2건 제한 + 응답 지연이 잦아(EGW00201) + # 시세·차트·호가 조회를 감당하지 못한다. KIS_QUOTES_VIA_REAL=True 이고 실계좌 + # 시세 인스턴스(real_market_api)가 있으면 조회성 호출만 실서버로 위임한다. + # 주문·잔고는 그대로 모의서버. (real_market_api 자신은 위임하지 않음 → 재귀 방지) + def _quote_delegate(self) -> "KoreaInvestmentAPI | None": + if not getattr(settings, "KIS_QUOTES_VIA_REAL", False): + return None + _real = globals().get("real_market_api") + if _real is None or _real is self: + return None + return _real + async def get_current_price(self, stock_code: str) -> Dict[str, Any]: """현재가 조회""" + _d = self._quote_delegate() + if _d is not None: + return await _d.get_current_price(stock_code) try: result, _ = await self._make_request( "GET", @@ -572,6 +589,9 @@ class KoreaInvestmentAPI: 캐시 키: chart:daily:{stock_code}:{period}:{limit} TTL: 5분 (장중 업데이트 주기 고려) """ + _d = self._quote_delegate() + if _d is not None: + return await _d.get_daily_chart(stock_code, period, limit) cache_key = f"chart:daily:{stock_code}:{period}:{limit}" # ── 1. 캐시 히트 확인 ─────────────────────────────────────────────── @@ -771,6 +791,9 @@ class KoreaInvestmentAPI: ] } """ + _d = self._quote_delegate() + if _d is not None: + return await _d.get_minute_chart(stock_code, ref_time, include_past) try: if not ref_time: from datetime import datetime @@ -829,6 +852,9 @@ class KoreaInvestmentAPI: Returns: List[dict] — output2 레코드 목록 (오래된 것 → 최신 순) """ + _d = self._quote_delegate() + if _d is not None: + return await _d.get_minute_chart_full_day(stock_code, market_open) from datetime import datetime import pytz kst = pytz.timezone("Asia/Seoul") @@ -1244,6 +1270,9 @@ class KoreaInvestmentAPI: async def get_orderbook(self, stock_code: str) -> Dict[str, Any]: """호가 정보 조회 (매수/매도 호가 10단계)""" + _d = self._quote_delegate() + if _d is not None: + return await _d.get_orderbook(stock_code) try: result, _ = await self._make_request( "GET", diff --git a/backend/app/services/market_analyzer.py b/backend/app/services/market_analyzer.py index 20e1e2b..5494c1a 100644 --- a/backend/app/services/market_analyzer.py +++ b/backend/app/services/market_analyzer.py @@ -118,8 +118,10 @@ async def check_market_regime() -> dict: chart_data = None try: from app.services.naver_finance import get_naver_daily_chart - naver_data = await get_naver_daily_chart("069500", days=240) # oldest-first - if naver_data and len(naver_data) >= 60: + # MA200+기울기(5봉) 판별에 205봉 이상 필요 — 캘린더일 기준이므로 400일 요청(≈270거래일) + naver_data = await get_naver_daily_chart("069500", days=400) # oldest-first + # 205봉 미만이면 MA200/SIDEWAYS 판별 불가 → KIS 폴백(240봉 페이지네이션)으로 넘김 + if naver_data and len(naver_data) >= 205: chart_data = list(reversed(naver_data)) # → newest-first except Exception: pass @@ -128,14 +130,17 @@ async def check_market_regime() -> dict: _api = _real_market_api or korea_investment_api kospi_chart = await _api.get_daily_chart("069500", "D", limit=240) if "output2" not in kospi_chart or not kospi_chart["output2"]: - return {"regime": "neutral", "message": "시장 지수 확인 불가", "position_multiplier": 0.5} + # 국면 판별 불가 시 신규 진입 차단 (기존 0.5배 매매 지속 → 무국면 매매가 실손실 원인) + return {"regime": "neutral", "regime_200d": "bear", "message": "시장 지수 확인 불가 — 신규 진입 차단", + "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} chart_data = kospi_chart["output2"] # newest-first # closes: newest-first (closes[0]=최신, closes[-1]=가장 오래된) closes = [float(item.get("stck_clpr", 0)) for item in chart_data] if len(closes) < 60: - return {"regime": "neutral", "message": "데이터 부족", "position_multiplier": 0.5} + return {"regime": "neutral", "regime_200d": "bear", "message": "데이터 부족 — 신규 진입 차단", + "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} def _cache_and_return(result: dict) -> dict: _market_regime_cache["result"] = result @@ -173,9 +178,17 @@ async def check_market_regime() -> dict: is_sideways_200 = regime_enum == MarketRegime.SIDEWAYS ma200 = sum(closes[:200]) / 200 else: - is_bull_200 = current_price > ma20 - is_sideways_200 = False - ma200 = ma60 + # 205봉 미만 → MA200/ADX 국면 판별 불가. 기존에는 MA20 프록시로 상승장 판정 후 + # 매매를 지속했으나(실계좌 손실 원인), 판별 불가 시 신규 진입을 차단한다. + logger.warning(f"국면 판별 불가: 일봉 {len(closes)}개 < 205 — 신규 진입 차단") + return _cache_and_return({ + "regime": "neutral", + "regime_200d": "bear", + "message": f"국면 판별 불가 (일봉 {len(closes)}개 < 205) — 신규 진입 차단", + "position_multiplier": 0.0, + "min_score": 9999, + "max_trades": 0, + }) # ── HMM 앙상블 (optional) ──────────────────────────────────── # 규칙 기반 결과를 먼저 결정한 뒤, HMM 결과와 앙상블 diff --git a/backend/app/services/naver_finance.py b/backend/app/services/naver_finance.py index 2eef2ef..f5f3b73 100644 --- a/backend/app/services/naver_finance.py +++ b/backend/app/services/naver_finance.py @@ -10,7 +10,7 @@ KIS API 호출량을 절감한다. - market_analyzer.get_all_tradable_stocks() 대체 - market_analyzer.analyze_stock() 배치 분석용 일봉 데이터 제공 -URL (종목 목록): https://finance.naver.com/sise/sise_market_sum.naver?sosok=0&page=N +URL (종목 목록): https://m.stock.naver.com/api/stocks/marketValue/{KOSPI|KOSDAQ}?page=N&pageSize=50 URL (일봉 차트): https://fchart.stock.naver.com/siseJson.naver?symbol=CODE&requestType=1&startTime=YYYYMMDD&endTime=YYYYMMDD&timeframe=day """ @@ -65,16 +65,43 @@ def _parse_page(html: bytes) -> List[Tuple[str, str, int]]: return result +_LIST_API_URL = "https://m.stock.naver.com/api/stocks/marketValue/{market}" +_LIST_PAGE_SIZE = 50 # 구 HTML 페이지(~50종목/페이지)와 동일한 페이지 의미 유지 +_SOSOK_TO_MARKET = {0: "KOSPI", 1: "KOSDAQ"} + + +def _parse_json_page(data: Dict) -> List[Tuple[str, str, int]]: + """m.stock.naver.com JSON → [(종목코드, 종목명, 현재가), ...]""" + result = [] + for item in data.get("stocks") or []: + code = item.get("itemCode") or "" + name = (item.get("stockName") or "").strip() + if not code or not name: + continue + try: + price = int(str(item.get("closePrice") or "0").replace(",", "")) + except ValueError: + price = 0 + result.append((code, name, price)) + return result + + async def _fetch_page(client: httpx.AsyncClient, sosok: int, page: int) -> List[Tuple[str, str, int]]: + """시가총액 순위 1페이지 조회. + + 2026-09 네이버가 finance.naver.com/sise/sise_market_sum.naver(HTML)를 폐기하고 + stock.naver.com으로 302 리다이렉트 → m.stock.naver.com JSON API로 교체. + """ + market = _SOSOK_TO_MARKET.get(sosok, "KOSPI") try: resp = await client.get( - _BASE_URL, - params={"sosok": sosok, "page": page}, + _LIST_API_URL.format(market=market), + params={"page": page, "pageSize": _LIST_PAGE_SIZE}, headers=_HEADERS, timeout=10, ) resp.raise_for_status() - return _parse_page(resp.content) + return _parse_json_page(resp.json()) except Exception as exc: logger.warning("네이버 파이낸스 조회 실패 sosok=%d page=%d: %s", sosok, page, exc) return [] diff --git a/backend/app/services/swing/entry_service.py b/backend/app/services/swing/entry_service.py index 9421eda..db90d95 100644 --- a/backend/app/services/swing/entry_service.py +++ b/backend/app/services/swing/entry_service.py @@ -115,7 +115,8 @@ async def execute_swing_entries(recommendations_response: dict, db: AsyncSession # KODEX200(069500) 일봉 종가 < MA20 → 신규 스윙 진입 전면 중단 (기존 보유 청산/관리는 정상 동작). # check_market_regime()이 이미 계산한 drop_from_ma20(=(현재가-MA20)/MA20*100)을 재사용하여 # 지수 데이터 중복 조회를 피한다. - if isinstance(regime, dict): + # 2026-09-17: 사용자 결정으로 기본 OFF (SWING_INDEX_BREAKER_ENABLED=true 로 재활성화 가능). + if settings.SWING_INDEX_BREAKER_ENABLED and isinstance(regime, dict): _drop_from_ma20 = regime.get("drop_from_ma20") if _drop_from_ma20 is not None and float(_drop_from_ma20) < 0: logger.info( diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index b5ab662..04974de 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -1220,6 +1220,11 @@ async def _execute_sell( await _release_order_lock(stock_code, "SELL") +# execute_auto_trade_background 동시 실행 방지 — 스윙 스케줄러·API 트리거가 겹치면 +# 일일 거래 한도(MAX_DAILY_TRADES)가 호출 단위로만 적용돼 합산 초과됨 (2026-09-17 11종목 동시 매수 사고). +_auto_trade_bg_lock = asyncio.Lock() + + async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): """recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행. @@ -1234,6 +1239,14 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response) return + if _auto_trade_bg_lock.locked(): + logger.info("[AUTO_TRADE_BG] 이전 실행이 진행 중 — 이번 호출 스킵 (중복 실행 방지)") + return + async with _auto_trade_bg_lock: + await _execute_auto_trade_background_locked(recommendations_response, db) + + +async def _execute_auto_trade_background_locked(recommendations_response: dict, db: AsyncSession): try: total_processed = 0 trade_results = [] @@ -1245,7 +1258,31 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn 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) + max_trades = min(settings.MAX_DAILY_TRADES, regime_max_trades) + + # 일일 한도는 "오늘 이미 나간 BUY 종목 수"를 차감한 전역 기준으로 적용 + # (호출 단위 슬라이스만으로는 여러 사이클에 걸쳐 한도 초과됨) + try: + _today_buys = await db.execute( + select(TradeHistory.stock_code) + .where( + TradeHistory.order_type == "BUY", + TradeHistory.status.in_(["COMPLETED", "PENDING"]), + TradeHistory.created_at >= get_daily_cutoff_utc(), + ) + .distinct() + ) + _bought_today = len(_today_buys.scalars().all()) + except Exception as _e: + logger.warning(f"[AUTO_TRADE_BG] 당일 매수 건수 조회 실패 (한도 미차감): {_e}") + _bought_today = 0 + max_trades = max(0, max_trades - _bought_today) + if max_trades == 0: + logger.info( + f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 {_bought_today}종목 / 한도 " + f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵" + ) + return result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) held_stocks = {p.stock_code for p in result.scalars().all()} # 현재 보유 종목 집합 diff --git a/docker-compose.yml b/docker-compose.yml index 25269d7..1703933 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - postgres_data:/var/lib/postgresql/data - ./docker/init-db.sql:/docker-entrypoint-initdb.d/init.sql ports: - - "127.0.0.1:15432:5432" + - "127.0.0.1:15433:5432" networks: - autostock-network healthcheck: @@ -167,7 +167,7 @@ services: environment: - TZ=Asia/Seoul ports: - - "127.0.0.1:3000:80" + - "127.0.0.1:3001:80" networks: - autostock-network depends_on: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4cba010..84114a7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,6 @@ "react-chartjs-2": "^5.3.1", "react-dom": "^18.2.0", "react-router-dom": "^6.20.0", - "recharts": "^2.10.3", "tailwind-merge": "^2.1.0" }, "devDependencies": { @@ -1485,69 +1484,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "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": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2303,129 +2239,9 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "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" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -2460,12 +2276,6 @@ } } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2522,16 +2332,6 @@ "node": ">=6.0.0" } }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2861,12 +2661,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "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", @@ -2880,15 +2674,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-equals": { - "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" - } - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3356,15 +3141,6 @@ "dev": true, "license": "ISC" }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3589,12 +3365,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "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": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3769,6 +3539,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4117,23 +3888,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4206,12 +3960,6 @@ "react": "^18.3.1" } }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4254,37 +4002,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-smooth": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", - "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", - "license": "MIT", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4308,38 +4025,6 @@ "node": ">=8.10.0" } }, - "node_modules/recharts": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", - "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", - "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", - "license": "MIT", - "dependencies": { - "decimal.js-light": "^2.4.1" - } - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4686,12 +4371,6 @@ "node": ">=0.8" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "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", @@ -4868,28 +4547,6 @@ "dev": true, "license": "MIT" }, - "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/frontend/src/components/JournalPanel.tsx b/frontend/src/components/JournalPanel.tsx index ff9ccd2..6f68809 100644 --- a/frontend/src/components/JournalPanel.tsx +++ b/frontend/src/components/JournalPanel.tsx @@ -131,7 +131,8 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending }: Jou
- {Number(trade.order_quantity || 0)}주 × {Number(trade.order_price || 0).toLocaleString()}원 + {/* 시장가 주문은 order_price가 null → 체결가(executed_price) 우선 표시 */} + {Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price || trade.order_price || 0)).toLocaleString()}원
{isSell && statusOk && pnl !== 0 && (
diff --git a/frontend/src/components/OrderbookPanel.tsx b/frontend/src/components/OrderbookPanel.tsx index 20b2693..d55e486 100644 --- a/frontend/src/components/OrderbookPanel.tsx +++ b/frontend/src/components/OrderbookPanel.tsx @@ -48,10 +48,12 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
{orderbook ? (() => { const currentPrice = orderbook.current_price - // 매도호가: 높은 가격순 정렬 (상단에 높은 가격), 최대 8단계 + // 매도호가: 현재가에 가까운(낮은) 8단계를 취한 뒤 높은 가격이 위로 오도록 정렬 + // (높은 순으로 먼저 자르면 1·2호가가 잘려 스프레드에 빈 칸이 생김) const asks: { price: number; volume: number }[] = [...(orderbook.asks || [])] - .sort((a, b) => b.price - a.price) + .sort((a, b) => a.price - b.price) .slice(0, 8) + .reverse() // 매수호가: 높은 가격순 정렬 (상단에 현재가에 가까운 가격), 최대 8단계 const bids: { price: number; volume: number }[] = [...(orderbook.bids || [])] .sort((a, b) => b.price - a.price) diff --git a/frontend/src/components/RecommendationPanel.tsx b/frontend/src/components/RecommendationPanel.tsx index 275d3ca..b2ff40c 100644 --- a/frontend/src/components/RecommendationPanel.tsx +++ b/frontend/src/components/RecommendationPanel.tsx @@ -15,7 +15,7 @@ * 최적화: * - RecommendationItem, RecommendationPanel 모두 React.memo 적용 */ -import React from 'react' +import React, { useState } from 'react' import { RefreshCw } from 'lucide-react' import { RecommendationsData, RecommendedStock, Recommendation } from '../types/trading' import { formatTime } from '../utils/formatUtils' @@ -31,6 +31,7 @@ interface RecommendationItemProps { isHeld: boolean isPending: boolean livePrices: Record | undefined + onBuy: (stockCode: string, stockName: string) => Promise } const RecommendationItem = React.memo(({ @@ -43,24 +44,43 @@ const RecommendationItem = React.memo(({ isHeld, isPending, livePrices, + onBuy, }: RecommendationItemProps) => { const changeRate = stock.change_rate || 0 const isSelected = selectedStock === stock.stock_code const isUp = changeRate >= 0 + const [buying, setBuying] = useState(false) + + const handleBuyClick = async (e: React.MouseEvent) => { + e.stopPropagation() + if (buying) return + if (!window.confirm(`${stock.stock_name} 시장가 매수할까요?\n(수량: 총자산 × 종목당 최대비중으로 자동 산출)`)) return + setBuying(true) + try { + await onBuy(stock.stock_code, stock.stock_name) + } catch (error: unknown) { + const err = error as any + alert(`매수 실패: ${err.response?.data?.detail || err.message}`) + } finally { + setBuying(false) + } + } return ( - + + {/* Buy button — 보유/매수중 종목은 숨김 (PortfolioPanel 청산 버튼과 동일 스타일) */} + {!isHeld && !isPending && ( +
+ +
+ )} +
) }) @@ -135,6 +168,8 @@ interface RecommendationPanelProps { pendingBuyStockCodes: Set /** 실시간 가격 맵 */ livePrices: Record | undefined + /** 수동 매수 콜백 */ + onBuy: (stockCode: string, stockName: string) => Promise } const RecommendationPanel = React.memo(({ @@ -147,6 +182,7 @@ const RecommendationPanel = React.memo(({ heldStockCodes, pendingBuyStockCodes, livePrices, + onBuy, }: RecommendationPanelProps) => { return (
@@ -205,6 +241,7 @@ const RecommendationPanel = React.memo(({ isHeld={isHeld} isPending={isPending} livePrices={livePrices} + onBuy={onBuy} /> ) }) diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index e7ce9f6..80d0c6a 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -284,9 +284,12 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad try { const allCandles = pd.candles; const totalBars = allCandles.length; - const visibleRange = { - from: allCandles[Math.max(0, totalBars - 100)].time, - to: allCandles[totalBars - 1].time, + if (totalBars === 0) return; + // 시간 기반 setVisibleRange는 차트가 아직 레이아웃되기 전(폭 0)이나 재마운트 직후 + // "Value is null"을 던짐 → 인덱스 기반 logical range로 교체 + 개별 try/catch + const logicalRange = { from: Math.max(0, totalBars - 100), to: totalBars - 1 }; + const setRange = (chart: IChartApi | null) => { + try { chart?.timeScale().setVisibleLogicalRange(logicalRange); } catch (_) { /* not laid out yet */ } }; // 메인 차트 데이터 교체 @@ -299,11 +302,11 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad mainLines[3].setData(pd.sma5); mainLines[4].setData(pd.sma60); } - mainChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange); + setRange(mainChartInstanceRef.current); // 거래량 데이터 교체 volumeSeriesRef.current?.setData(pd.volumes); - volumeChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange); + setRange(volumeChartInstanceRef.current); // RSI 데이터 교체 const rsiLines = rsiLineSeriesRef.current; @@ -313,13 +316,13 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad rsiLines[2].setData(pd.rsi.line50); rsiLines[3].setData(pd.rsi.line70); } - rsiChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange); + setRange(rsiChartInstanceRef.current); // MACD 데이터 교체 macdSeriesRef.current.histogram?.setData(pd.macd.histogram); macdSeriesRef.current.line?.setData(pd.macd.line); macdSeriesRef.current.signal?.setData(pd.macd.signal); - macdChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange); + setRange(macdChartInstanceRef.current); } catch (error: unknown) { const err = error as Error | null; console.error('차트 데이터 업데이트 오류:', err?.message); @@ -402,11 +405,12 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad macdSeriesRef.current = { histogram: macdHistSeries, line: macdLineSeries, signal: macdSignalSeries }; // 시간축 동기화 - mainChart.timeScale().subscribeVisibleTimeRangeChange((timeRange) => { - if (timeRange) { - volumeChart.timeScale().setVisibleRange(timeRange); - rsiChart.timeScale().setVisibleRange(timeRange); - macdChart.timeScale().setVisibleRange(timeRange); + // logical range 동기화: 하위 차트 시리즈에 해당 time이 아직 없을 때(setData 직전) + // setVisibleRange가 "Value is null"을 던지므로 인덱스 기반으로 맞춘다 + mainChart.timeScale().subscribeVisibleLogicalRangeChange((range) => { + if (!range) return; + for (const c of [volumeChart, rsiChart, macdChart]) { + try { c.timeScale().setVisibleLogicalRange(range); } catch (_) { /* series not ready */ } } }); diff --git a/frontend/src/hooks/useTradeActions.ts b/frontend/src/hooks/useTradeActions.ts index fc6e56e..b8d5227 100644 --- a/frontend/src/hooks/useTradeActions.ts +++ b/frontend/src/hooks/useTradeActions.ts @@ -12,11 +12,13 @@ export const useTradeActions = () => { quantity: number ): Promise => { try { + // KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면 + // 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함 await api.post('/portfolio/sell', { stock_code: stockCode, quantity, price: null, - }) + }, { timeout: 90000 }) // Invalidate queries to refresh portfolio queryClient.invalidateQueries({ @@ -38,6 +40,30 @@ export const useTradeActions = () => { [queryClient] ) + /** 추천 종목 수동 매수 (시장가, 수량은 서버가 예산 기반 산출) */ + const handleBuy = useCallback( + async (stockCode: string, stockName: string): Promise => { + try { + await api.post('/portfolio/buy', { + stock_code: stockCode, + stock_name: stockName, + quantity: null, + price: null, + }, { timeout: 90000 }) + queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] }) + queryClient.invalidateQueries({ queryKey: ['trade-history'] }) + console.log(`✅ ${stockName} buy order completed`) + } catch (error: any) { + console.error( + `❌ Buy order failed for ${stockName}:`, + error.response?.data?.detail || error.message + ) + throw error + } + }, + [queryClient] + ) + const resetPendingTrades = useCallback(async (): Promise => { try { const response = await api.delete('/trades/pending') @@ -62,6 +88,7 @@ export const useTradeActions = () => { return { handleSell, + handleBuy, resetPendingTrades, invalidateTradeHistory, } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1081675..3a19f1a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -210,7 +210,7 @@ const Dashboard = () => { }, [processChartData]) // Trade actions hook - const { handleSell, resetPendingTrades } = useTradeActions() + const { handleSell, handleBuy, resetPendingTrades } = useTradeActions() // Subscribe to market data via WebSocket // reconnect 감지: wsConnected가 false→true 전환 시 강제 재구독 @@ -770,6 +770,7 @@ const Dashboard = () => { heldStockCodes={heldStockCodes} pendingBuyStockCodes={pendingBuyStockCodes} livePrices={livePrices} + onBuy={handleBuy} /> # 컨테이너에서 실행 시 필수 + +제약: + - API tool_use(함수 호출) 미지원 → tools 인자는 무시하고 텍스트(JSON) 응답만 사용. + - 프롬프트 캐시 usage 는 CLI가 보고하는 값을 그대로 전달. +""" + +import asyncio +import json +import logging +import os +import shutil +import tempfile +from typing import Any, Dict, List + +from config import settings +from claude_client import ClaudeClient, _CACHED_CONTEXT + +logger = logging.getLogger(__name__) + +_CLI_TIMEOUT = float(os.getenv("CLAUDE_CODE_TIMEOUT", "120")) +_CLI_BIN = os.getenv("CLAUDE_CODE_BIN", "claude") +_MAX_CONCURRENT = int(os.getenv("CLAUDE_CODE_MAX_CONCURRENT", "2")) +# 프로젝트 .claude/ 설정(훅·CLAUDE.md)이 끼어들지 않도록 중립 디렉터리에서 실행 +_CLI_CWD = os.getenv("CLAUDE_CODE_CWD") or os.path.join(tempfile.gettempdir(), "autostock-claude-code") + + +class ClaudeCodeClient(ClaudeClient): + """`claude -p` 서브프로세스로 ClaudeClient.analyze() 를 대체.""" + + def __init__(self): + # 부모 __init__ 은 AsyncAnthropic(api_key) 를 만들지만 여기서는 SDK 미사용 + self.client = None + self.model = settings.CLAUDE_MODEL + self.haiku_model = settings.CLAUDE_HAIKU_MODEL + self._sem = asyncio.Semaphore(_MAX_CONCURRENT) + os.makedirs(_CLI_CWD, exist_ok=True) + if shutil.which(_CLI_BIN) is None: + logger.error("[ClaudeCode] '%s' 실행 파일을 찾을 수 없음 — 분석 호출은 모두 실패합니다", _CLI_BIN) + if not os.getenv("CLAUDE_CODE_OAUTH_TOKEN") and not os.getenv("ANTHROPIC_API_KEY"): + logger.warning("[ClaudeCode] CLAUDE_CODE_OAUTH_TOKEN 미설정 — 호스트 로그인 세션이 없으면 인증 실패") + + async def analyze( + self, + system_prompt: str, + user_message: str, + tools: List[Dict[str, Any]] = None, + max_tokens: int = None, + model_override: str = None, + ) -> Dict[str, Any]: + use_model = model_override or self.model + if tools: + logger.debug("[ClaudeCode] tools %d개 무시 (CLI 모드는 tool_use 미지원)", len(tools)) + + full_system = f"{_CACHED_CONTEXT}\n\n{system_prompt}" + cmd = [ + _CLI_BIN, "-p", user_message, + "--output-format", "json", + "--model", use_model, + "--system-prompt", full_system, + "--tools", "", # 파일/셸 도구 비활성 — 순수 텍스트 분석 + "--max-turns", "1", + ] + + env = os.environ.copy() + env.pop("ANTHROPIC_API_KEY", None) # 크레딧 없는 API 키가 구독 인증을 가리지 않도록 + + try: + async with self._sem: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + cwd=_CLI_CWD, + ) + try: + out, err = await asyncio.wait_for(proc.communicate(), timeout=_CLI_TIMEOUT) + except asyncio.TimeoutError: + proc.kill() + raise RuntimeError(f"claude CLI timeout ({_CLI_TIMEOUT}s)") + + if proc.returncode != 0: + raise RuntimeError( + f"claude CLI exit={proc.returncode}: {err.decode(errors='ignore')[-500:]}" + ) + + data = json.loads(out.decode()) + if data.get("is_error"): + raise RuntimeError(f"claude CLI error: {data.get('result')}") + + usage = data.get("usage") or {} + result = { + "content": [{"type": "text", "text": data.get("result", "")}], + "stop_reason": data.get("stop_reason", "end_turn"), + "model_used": use_model, + "usage": { + "input_tokens": usage.get("input_tokens", 0), + "output_tokens": usage.get("output_tokens", 0), + "cache_read_tokens": usage.get("cache_read_input_tokens", 0), + "cache_write_tokens": usage.get("cache_creation_input_tokens", 0), + }, + } + logger.debug( + "[ClaudeCode] model=%s in=%s out=%s cost=$%s", + use_model, usage.get("input_tokens"), usage.get("output_tokens"), + data.get("total_cost_usd"), + ) + return result + + except Exception as e: + logger.error(f"Claude Code analysis error: {e}") + raise + + +claude_code_client = ClaudeCodeClient() diff --git a/mcp-server/config.py b/mcp-server/config.py index bcf7f37..f8492c5 100644 --- a/mcp-server/config.py +++ b/mcp-server/config.py @@ -42,7 +42,7 @@ class Settings: LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") # Claude Settings - CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6-20250514") + CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6") CLAUDE_HAIKU_MODEL = os.getenv("CLAUDE_HAIKU_MODEL", "claude-haiku-4-5-20251001") # Gemini Settings @@ -69,7 +69,7 @@ class Settings: if self.AI_PROVIDER == "gemini": self.MAX_TOKENS = int(os.getenv("GEMINI_MAX_TOKENS", "1024")) self.TEMPERATURE = float(os.getenv("GEMINI_TEMPERATURE", "0.3")) - else: + else: # claude | claude_code self.MAX_TOKENS = int(os.getenv("CLAUDE_MAX_TOKENS", "1024")) self.TEMPERATURE = float(os.getenv("CLAUDE_TEMPERATURE", "0.3")) diff --git a/mcp-server/gemini_client.py b/mcp-server/gemini_client.py index 4f37a0e..4ab6124 100644 --- a/mcp-server/gemini_client.py +++ b/mcp-server/gemini_client.py @@ -59,8 +59,8 @@ class GeminiClient: temperature=self.config.temperature, ) - # 비동기 호출 - response = await self.client.models.generate_content( + # 비동기 호출 (google-genai SDK: models.generate_content는 동기 함수 — aio 네임스페이스 사용) + response = await self.client.aio.models.generate_content( model=self.model_id, contents=user_message, config=config_with_system diff --git a/mcp-server/main.py b/mcp-server/main.py index ae90739..5389623 100644 --- a/mcp-server/main.py +++ b/mcp-server/main.py @@ -35,6 +35,10 @@ from mcp_tools import mcp_tools if settings.AI_PROVIDER == "gemini": from gemini_client import gemini_client ai_client = gemini_client +elif settings.AI_PROVIDER == "claude_code": + # Claude Code 구독(`claude -p`)으로 실행 — API 크레딧 불필요 (테스트용) + from claude_code_client import claude_code_client + ai_client = claude_code_client else: ai_client = claude_client @@ -118,7 +122,7 @@ async def health_check(): return { "status": "healthy", "ai_provider": settings.AI_PROVIDER, - "hybrid_ai_enabled": settings.HYBRID_AI_ENABLED if settings.AI_PROVIDER == "claude" else False, + "hybrid_ai_enabled": settings.HYBRID_AI_ENABLED if settings.AI_PROVIDER in ("claude", "claude_code") else False, "claude_model": settings.CLAUDE_MODEL, "claude_haiku_model": settings.CLAUDE_HAIKU_MODEL, "claude_configured": bool(settings.ANTHROPIC_API_KEY), diff --git a/mcp-server/risk_validator.py b/mcp-server/risk_validator.py index 51d52bb..2c86661 100644 --- a/mcp-server/risk_validator.py +++ b/mcp-server/risk_validator.py @@ -35,7 +35,7 @@ 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, ActiveTrade, TradeHistory +from app.models.models import Portfolio, ActiveTrade, TradeHistory, StockBan from app.services.korea_investment_api import korea_investment_api logger = logging.getLogger(__name__) @@ -234,6 +234,16 @@ class RiskValidator: # Import MDD manager from app.services.mdd_manager import mdd_manager + # MCP는 백엔드와 별도 프로세스 — 인메모리 mdd_manager는 항상 초기 상태이므로 + # 판정 전에 백엔드가 Redis에 저장한 최신 상태를 복원한다. + # 복원 실패 시 fail-open 유지 (기존 인메모리 상태로 계속 진행 + 경고 로그). + try: + restored = await mdd_manager.load_from_redis() + if not restored: + logger.warning("MDD state restore from Redis returned no data (fail-open, using in-memory state)") + except Exception as redis_err: + logger.warning(f"MDD state restore from Redis failed (fail-open): {redis_err}") + # Check if new positions are paused due to MDD can_open = await mdd_manager.can_open_new_position() diff --git a/mcp-server/trade_monitor.py b/mcp-server/trade_monitor.py index 9057795..c7895b0 100644 --- a/mcp-server/trade_monitor.py +++ b/mcp-server/trade_monitor.py @@ -30,7 +30,14 @@ ACTIVE_TRADES_FILE = os.path.join(os.path.dirname(__file__), 'active_trades.json MONITORING_INTERVAL_SECONDS = 60 # 60초마다 확인 BACKEND_API_BASE = os.getenv('BACKEND_API_BASE', 'http://localhost:8000') -async def update_active_trade_state_via_api(stock_code: str, new_status: str, reason: str = "") -> bool: +# 백엔드 require_api_key와 동일한 소스 (app/core/security.py: API_KEY 우선, 없으면 SECRET_KEY 폴백) +API_KEY = settings.API_KEY if settings.API_KEY else settings.SECRET_KEY +_API_HEADERS = {"X-API-Key": API_KEY} + + +async def update_active_trade_state_via_api( + stock_code: str, new_status: str, reason: str = "", remaining_pct: float = None +) -> bool: """ CLAUDE.md Section 3.2: 직접 DB 접근 금지. 백엔드 API를 통해 상태 업데이트. @@ -41,20 +48,25 @@ async def update_active_trade_state_via_api(stock_code: str, new_status: str, re stock_code: 종목 코드 new_status: 목표 상태 ('active', 'pending_sell', 'closed') reason: 상태 변경 이유 + remaining_pct: 부분 매도 후 잔여 비율 (제공 시 ActiveTrade.remaining_pct 갱신) Returns: 성공 여부 """ try: async with aiohttp.ClientSession() as session: - url = f"{BACKEND_API_BASE}/api/portfolio/active-trades/{stock_code}/status" + url = f"{BACKEND_API_BASE}/api/v1/portfolio/active-trades/{stock_code}/status" payload = { "status": new_status, "reason": reason, "updated_at": datetime.utcnow().isoformat() } + if remaining_pct is not None: + payload["remaining_pct"] = remaining_pct - async with session.patch(url, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as resp: + async with session.patch( + url, json=payload, headers=_API_HEADERS, timeout=aiohttp.ClientTimeout(total=5) + ) as resp: if resp.status == 200: logging.info(f"✅ [{stock_code}] API를 통해 상태 변경: {new_status}") return True @@ -240,6 +252,7 @@ async def check_and_execute_trades(db: AsyncSession): should_sell = False sell_reason = "" sell_quantity = 0 + new_remaining_pct = 0.0 # 매도 후 잔여 비율 (0.0 = 전량 매도 → closed) try: # 현재가 조회 @@ -269,6 +282,7 @@ async def check_and_execute_trades(db: AsyncSession): should_sell = True sell_quantity = int(quantity * remaining_pct) sell_reason = f"손절가 도달 (현재가: {current_price} <= 손절가: {stop_loss})" + new_remaining_pct = 0.0 # 전량 매도 → closed # ───────────────────────────────────────────────────────────── # 2. 기본 익절 조건 확인 @@ -277,6 +291,7 @@ async def check_and_execute_trades(db: AsyncSession): should_sell = True sell_quantity = int(quantity * remaining_pct) sell_reason = f"목표가 도달 (현재가: {current_price} >= 목표가: {take_profit})" + new_remaining_pct = 0.0 # 전량 매도 → closed # ───────────────────────────────────────────────────────────── # 3. 부분 익절 + 트레일링 스탑 조건 (CLAUDE.md 5.2) @@ -296,11 +311,13 @@ async def check_and_execute_trades(db: AsyncSession): if should_tp: should_sell = True if remaining_pct == 1.0: - # 첫 익절: 50% 분할 매도 + # 첫 익절: 50% 분할 매도 — 잔량 50%는 'active'로 유지 (CLAUDE.md 5.2) sell_quantity = int(quantity * 0.5) + new_remaining_pct = remaining_pct * 0.5 else: - # 트레일링 스탑: 남은 물량 전부 + # 트레일링 스탑: 남은 물량 전부 → closed sell_quantity = int(quantity * remaining_pct) + new_remaining_pct = 0.0 sell_reason = tp_reason else: @@ -313,7 +330,7 @@ async def check_and_execute_trades(db: AsyncSession): if should_sell and sell_quantity > 0: await _execute_sell_with_pending_state( - stock_code, quantity, sell_quantity, entry_price, sell_reason + stock_code, quantity, sell_quantity, entry_price, sell_reason, new_remaining_pct ) @@ -322,7 +339,8 @@ async def _execute_sell_with_pending_state( total_quantity: int, sell_quantity: int, entry_price: float, - sell_reason: str + sell_reason: str, + new_remaining_pct: float = 0.0 ) -> None: """ CLAUDE.md Section 5.2: 매도 주문 실행 및 상태 전이 @@ -330,7 +348,9 @@ async def _execute_sell_with_pending_state( 단계: 1. 상태를 'pending_sell'로 변경 (즉시) 2. 매도 주문 실행 - 3. 주문 성공 시 'closed'로 변경 (1-5초 대기) + 3. 주문 성공 시 상태 전이 (1-5초 대기) + - new_remaining_pct > 0 (부분 매도): 'active' 유지 + remaining_pct 갱신 + - new_remaining_pct == 0 (전량 매도): 'closed'로 전이 4. 실패 시 'active'로 롤백 Args: @@ -339,6 +359,7 @@ async def _execute_sell_with_pending_state( sell_quantity: 매도할 수량 entry_price: 진입가 sell_reason: 매도 이유 + new_remaining_pct: 매도 후 잔여 비율 (0.0이면 전량 매도 → closed) """ try: logging.warning( @@ -374,24 +395,41 @@ async def _execute_sell_with_pending_state( logging.info(f"[{stock_code}] 매도 주문 결과: {sell_result}") if sell_result and sell_result.get('rt_cd') == '0': - order_id = sell_result.get('output', {}).get('order_id', '') + order_id = sell_result.get('output', {}).get('ODNO', '') logging.info(f"✅ [{stock_code}] 매도 주문 성공 (주문ID: {order_id})") # ───────────────────────────────────────────────────────── - # Step 3: 1-5초 대기 후 체결 확인 및 상태 'closed'로 변경 + # Step 3: 1-5초 대기 후 체결 확인 및 상태 전이 + # CLAUDE.md 5.2: 부분 매도(+2R 50%)는 'active' 유지 + remaining_pct 갱신, + # 전량 매도만 'closed'로 전이 (잔량이 감시에서 이탈하지 않도록) # ───────────────────────────────────────────────────────── await asyncio.sleep(2) # 체결 대기 - success = await update_active_trade_state_via_api( - stock_code, - "closed", - f"매도 완료: {sell_reason} (주문ID: {order_id})" - ) - - if success: - logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed") + if new_remaining_pct > 0: + success = await update_active_trade_state_via_api( + stock_code, + "active", + f"부분 매도 완료: {sell_reason} (주문ID: {order_id})", + remaining_pct=new_remaining_pct + ) + if success: + logging.info( + f"✅ [{stock_code}] 상태: pending_sell → active " + f"(잔여비율: {new_remaining_pct*100:.1f}%)" + ) + else: + logging.warning(f"⚠️ [{stock_code}] 부분매도 후 active 전이 실패 (order_id: {order_id})") else: - logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})") + success = await update_active_trade_state_via_api( + stock_code, + "closed", + f"매도 완료: {sell_reason} (주문ID: {order_id})" + ) + + if success: + logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed") + else: + logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})") else: error_msg = sell_result.get('msg1', '알 수 없는 오류') if sell_result else "응답 없음" logging.error(f"❌ [{stock_code}] 매도 주문 실패: {error_msg}")