diff --git a/backend/app/api/endpoints/debug.py b/backend/app/api/endpoints/debug.py index 3a61fa1..f24e188 100644 --- a/backend/app/api/endpoints/debug.py +++ b/backend/app/api/endpoints/debug.py @@ -1,3 +1,8 @@ +""" +디버그/진단 API 엔드포인트 (개발/운영 내부 확인용) + +- GET /debug/autotrade-status: 자동매매 설정, 장 개장 여부, 현재 KST 시각 반환 +""" from fastapi import APIRouter from app.core.config import settings from app.core.market_schedule import is_market_open diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 5ff7b32..81f04bc 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -1,3 +1,11 @@ +""" +추천 종목 및 자동매매 API 엔드포인트 + +- GET /rule-based: 룰 기반 추천 종목 조회 (DB) +- GET /rule-based/realtime: 실시간 분석 후 DB upsert + 결과 반환 +- POST /execute-trade: 추천 기반 자동매매 즉시 실행 (수동 트리거) +- 내부 유틸: execute_auto_trade_background() — auto_trade_scheduler 에서 호출 +""" from fastapi import APIRouter, HTTPException, Query, Depends from typing import List, Optional from datetime import datetime, date, timedelta diff --git a/backend/app/api/endpoints/tensorflow_predictions.py b/backend/app/api/endpoints/tensorflow_predictions.py index 21c083f..688334a 100644 --- a/backend/app/api/endpoints/tensorflow_predictions.py +++ b/backend/app/api/endpoints/tensorflow_predictions.py @@ -1,3 +1,10 @@ +""" +TensorFlow 예측 API 엔드포인트 + +- GET /predict/{stock_code}: 단일 종목 주가 방향 예측 (LSTM) +- GET /predict/batch: 복수 종목 일괄 예측 +- GET /status: TF 모델 로드 상태 확인 +""" from fastapi import APIRouter, HTTPException, Query from typing import List import logging diff --git a/backend/app/api/endpoints/trading_rules.py b/backend/app/api/endpoints/trading_rules.py index f0f2088..5b887ac 100644 --- a/backend/app/api/endpoints/trading_rules.py +++ b/backend/app/api/endpoints/trading_rules.py @@ -1,3 +1,13 @@ +""" +매매 규칙(TradingRule) 관리 API 엔드포인트 + +- GET /: 전체 매매 규칙 목록 조회 +- GET /{rule_id}: 특정 규칙 조회 +- POST /: 새 규칙 생성 +- PUT /{rule_id}: 규칙 수정 +- DELETE /{rule_id}: 규칙 삭제 +- PATCH /{rule_id}/toggle: 규칙 활성화/비활성화 토글 +""" from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update, delete diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 31b0590..c2430cd 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -1,3 +1,13 @@ +""" +한국투자증권(KIS) REST API 클라이언트 + +- OAuth2 토큰 자동 발급 및 파일 캐시 (~/.cache/auto-stock/) +- 국내/해외 주식 현재가, 일봉/분봉 차트 조회 +- 매수/매도 주문 및 체결 확인 +- CircuitBreaker: 연속 N회 실패 시 일정 시간 요청 차단 +- APICallMonitor: 호출 성공률·지연시간 통계 +- KIS_MODE(virtual/real)에 따라 엔드포인트 자동 전환 +""" import httpx from typing import Dict, Any, Optional, Deque from datetime import datetime, timedelta diff --git a/backend/app/services/market_analyzer.py b/backend/app/services/market_analyzer.py index ef8532f..710604c 100644 --- a/backend/app/services/market_analyzer.py +++ b/backend/app/services/market_analyzer.py @@ -1,3 +1,11 @@ +""" +시장 분석 서비스 + +- KIS API를 통해 관심 종목(watchlist)의 기술적 지표 일괄 분석 +- 시장 국면(bull/bear/sideways) 판별 및 추천 종목 선별 +- OVERHEAT(급등 과열) 필터, 추세 필터 적용 +- 분석 결과를 StockAnalysis DB에 upsert하고 Redis 캐시에 저장 +""" import asyncio import logging import time diff --git a/backend/app/services/technical_indicators.py b/backend/app/services/technical_indicators.py index 7436fca..1e41cbe 100644 --- a/backend/app/services/technical_indicators.py +++ b/backend/app/services/technical_indicators.py @@ -1,3 +1,12 @@ +""" +기술적 지표 분석 모듈 + +- 이동평균(MA/EMA), 볼린저 밴드, RSI, MACD, 스토캐스틱, ATR, ADX, OBV 등 계산 +- 급등 과열 필터(analyze_overheat): 단기 급등 종목 매수 차단 +- 추세 필터(_passes_uptrend): MA 정배열 기반 상승 추세 확인 +- 커스텀 신호(analyze_custom_signals): 이격도 필터 포함 이동평균 기반 신호 +- 모든 함수는 chart_data(KIS API 응답 리스트) 또는 순수 수치 리스트를 입력으로 받음 +""" import logging from typing import Optional @@ -21,6 +30,7 @@ OVERHEAT_DISPARITY_MAX = float(getattr(settings, "OVERHEAT_DISPARITY_MAX", 108.0 def _ma(values: list, window: int) -> Optional[float]: + """단순 이동평균(SMA) 계산. 데이터 부족 시 None 반환.""" try: if len(values) < window: return None @@ -48,6 +58,11 @@ def _ema(values: list, window: int) -> Optional[float]: def _passes_uptrend(closes: list, ma_short: int = TREND_MA_SHORT, ma_long: int = TREND_MA_LONG, tol: float = TREND_TOLERANCE_PCT) -> Optional[bool]: + """MA 정배열 기반 상승 추세 확인. + + MA_SHORT >= MA_LONG AND 현재가 >= MA_SHORT (허용 오차 tol 적용). + 데이터 부족 시 None 반환. + """ if not closes or len(closes) < max(ma_short, ma_long): return None ma_s = _ma(closes, ma_short) @@ -139,6 +154,11 @@ def analyze_custom_signals(chart_data): def analyze_macd(chart_data: list) -> dict: + """MACD 골든크로스/데드크로스 신호 분석. + + EMA12, EMA26으로 MACD 라인 계산 후 Signal(EMA9)과 교차 여부 판단. + Returns: {"signal": "buy"/"sell"/None, "description": str} + """ if len(chart_data) < 26: return {"signal": None, "description": "데이터 부족"} @@ -273,6 +293,11 @@ def analyze_rsi(chart_data: list) -> dict: def analyze_bollinger(chart_data: list) -> dict: + """볼린저 밴드 신호 분석. + + 현재가가 하한선 이하 → buy, 상한선 이상 → sell. + Returns: {"signal": "buy"/"sell"/None, "description": str} + """ bb_period = settings.BOLLINGER_PERIOD # 기본값 20 bb_stddev = settings.BOLLINGER_STDDEV # 기본값 2.0 if len(chart_data) < bb_period: @@ -296,6 +321,11 @@ def analyze_bollinger(chart_data: list) -> dict: def analyze_moving_average(chart_data: list) -> dict: + """이동평균선(5/20/60일) 정배열/역배열 신호 분석. + + MA5 > MA20 정배열 → buy, MA5 < MA20 < MA60 역배열 → sell. + Returns: {"signal": "buy"/"sell"/None, "description": str} + """ ma_long = settings.MA_LONG_PERIOD # 기본값 60 ma_short = settings.MA_MEDIUM_PERIOD # 기본값 20 ma_quick = settings.MA_SHORT_PERIOD # 기본값 5 @@ -320,6 +350,11 @@ def analyze_moving_average(chart_data: list) -> dict: def analyze_stochastic(chart_data: list) -> dict: + """스토캐스틱 %K 과매도/과매수 신호 분석. + + %K < 30 → buy, %K > 70 → sell (14봉 기준 Fast Stochastic). + Returns: {"signal": "buy"/"sell"/None, "description": str} + """ if len(chart_data) < 14: return {"signal": None, "description": "데이터 부족"} @@ -346,6 +381,12 @@ def analyze_stochastic(chart_data: list) -> dict: def analyze_volatility_breakout(chart_data: list, k: float = 0.5) -> dict: + """변동성 돌파 전략 신호 분석. + + 목표가 = 당일 시가 + 전일 변동폭 × k (기본 0.5). + 현재가 >= 목표가 → buy. + Returns: {"signal": "buy"/None, "description": str} + """ if len(chart_data) < 2: return {"signal": None, "description": "데이터 부족"} @@ -371,6 +412,11 @@ def analyze_volatility_breakout(chart_data: list, k: float = 0.5) -> dict: def analyze_adx(chart_data: list) -> dict: + """ADX(Average Directional Index) 추세 강도 신호 분석. + + ADX < 20 → 횡보장(평균회귀 유리) buy, ADX > 30 → 강추세(평균회귀 위험) sell. + Returns: {"signal": "buy"/"sell"/None, "description": str, "adx": float} + """ if len(chart_data) < 29: return {"signal": None, "description": "데이터 부족", "adx": None} @@ -390,6 +436,12 @@ def analyze_adx(chart_data: list) -> dict: def analyze_obv(chart_data: list) -> dict: + """OBV(On-Balance Volume) 기반 거래량 신호 분석. + + 최근 5봉 거래량 추세 확인: 거래량 1.5배 증가 + 상승 → buy, + 거래량 0.5배 감소 + 하락 → sell. + Returns: {"signal": "buy"/"sell"/None, "description": str} + """ if len(chart_data) < 2: return {"signal": None, "description": "데이터 부족"} diff --git a/backend/app/services/tensorflow_predictor.py b/backend/app/services/tensorflow_predictor.py index 3a25acf..1a58d68 100644 --- a/backend/app/services/tensorflow_predictor.py +++ b/backend/app/services/tensorflow_predictor.py @@ -1,3 +1,11 @@ +""" +TensorFlow/Keras 기반 주가 예측 서비스 + +- LSTM 모델로 단기 주가 방향(상승/하락) 예측 +- KIS API 차트 데이터에서 피처 추출 → 정규화 → 모델 추론 +- 모델 파일 없으면 더미 예측(0.5) 반환 (서비스 무중단) +- ML_TF_ENABLED=false 시 비활성화 가능 +""" import asyncio import functools import logging diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 8d6855f..420404f 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -1,3 +1,12 @@ +"""자동매매 실행 엔진 (trade_executor.py) + +흐름 요약: + execute_auto_trade_background() ← 추천 목록 수신 + └─ execute_auto_trade() ← 종목별 공통 필터 (가격·MDD·신호 등) + ├─ _execute_buy() ← 매수 로직 (급등체크→트렌드→포지션사이징→주문) + └─ _execute_sell() ← 매도 로직 (중복체크→주문→DB기록) + └─ _verify_and_sync_execution() ← 체결 확인·슬리피지 기록·부분체결 재주문 +""" import asyncio import logging import time @@ -23,11 +32,35 @@ logger = logging.getLogger(__name__) # ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ──────────────────────────────── _order_semaphores: dict = {} +_SEMAPHORE_MAX_SIZE = 200 + + +def _cleanup_semaphores() -> None: + """세마포어 딕셔너리 크기가 임계값 초과 시 미사용 항목 제거. + + [동작] sem._value == 1 이면 현재 아무도 잡고 있지 않은 상태(unlock). + 장기 운영 시 거래된 적 있는 모든 종목 코드가 누적되는 것을 방지. + """ + if len(_order_semaphores) <= _SEMAPHORE_MAX_SIZE: + return # 임계값 이하 → 정리 불필요 + to_delete = [ + code for code, sem in _order_semaphores.items() + if sem._value == 1 # unlocked = 현재 사용 중 아님 + ] + for code in to_delete: + _order_semaphores.pop(code, None) + if to_delete: + logger.debug(f"[SEMAPHORE_CLEANUP] {len(to_delete)}개 세마포어 정리 (잔여: {len(_order_semaphores)})") def _get_order_semaphore(stock_code: str) -> asyncio.Semaphore: - """종목별 세마포어 반환. 없으면 생성.""" + """종목별 세마포어 반환. 없으면 생성. 크기 초과 시 미사용 항목 정리. + + [동작] 동일 종목에 대해 동시에 2개 이상의 주문이 발생하지 않도록 + asyncio.Semaphore(1) 으로 상호 배제. 코드 진입 시 lock → 완료 시 release. + """ if stock_code not in _order_semaphores: + _cleanup_semaphores() # 200개 초과 시 미사용 세마포어 정리 _order_semaphores[stock_code] = asyncio.Semaphore(1) return _order_semaphores[stock_code] @@ -37,27 +70,35 @@ _ORDER_LOCK_TTL = 60 # seconds async def _acquire_order_lock(stock_code: str, order_type: str) -> bool: - """Redis SETNX 기반 분산락 획득. 성공 시 True.""" + """Redis SETNX 기반 분산락 획득. 성공 시 True. + + [동작] Redis SET NX(NOT EXISTS) + EX(TTL 60초)로 원자적 락 설정. + - 동일 종목·방향의 주문이 이미 처리 중이면 False → 호출측에서 SKIP. + - Redis 미연결 시 True(락 없이 진행) — 이 경우 세마포어가 2차 방어. + """ try: from app.core.redis import get_redis client = get_redis() if not client: - return True # Redis 미연결 → 락 없이 진행 + return True # Redis 미연결 → 락 없이 진행 (세마포어가 대신 보호) key = f"order_lock:{stock_code}:{order_type}" result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL) - return result is True + return result is True # None이면 이미 키 존재 → 락 실패 except Exception as e: logger.debug(f"[order_lock] 획득 오류 (패스): {e}") return True async def _release_order_lock(stock_code: str, order_type: str) -> None: - """Redis 분산락 해제.""" + """Redis 분산락 해제. + + [동작] finally 블록에서 반드시 호출 — 주문 성공/실패 관계없이 락 해제. + """ try: from app.core.redis import delete_key await delete_key(f"order_lock:{stock_code}:{order_type}") except Exception: - pass + pass # 해제 실패 시 TTL(60초) 만료 후 자동 해제됨 # ── 주문 재시도 — Exponential backoff (1→2→4초, 최대 3회) ─────────────────── @@ -70,28 +111,39 @@ async def _place_order_with_retry( exchange_code: str = "NASD", max_retries: int = 3, ) -> dict: - """주문 실행 with exponential backoff. 시스템 오류에만 재시도.""" + """주문 실행 with exponential backoff. 시스템 오류에만 재시도. + + [동작] + 1. 국내/해외 분기 후 KIS REST API 호출 + 2. rt_cd "0"(성공) 또는 "1"(PENDING) → 즉시 반환 (재시도 불필요) + 3. 그 외 오류: 1초→2초→4초 대기 후 최대 max_retries회 재시도 + 4. 최대 재시도 소진 시 last_result(마지막 응답) 반환 + """ last_result: dict = {} for attempt in range(max_retries): try: if is_domestic: + # 국내 주식 주문 (KIS TTTC0012U / VTTC0012U) result = await korea_investment_api.place_order(stock_code, order_type, quantity, price) else: + # 해외 주식 주문 (KIS TTTT1002U 등) result = await korea_investment_api.place_overseas_order( stock_code, order_type, quantity, float(price), exchange_code ) # rt_cd "0"=성공, "1"=PENDING → 재시도 불필요 if result.get("rt_cd") in ("0", "1"): return result + # rt_cd 오류 → 재시도 대상 last_result = result if attempt < max_retries - 1: - wait = 1 * (2 ** attempt) # 1s → 2s → 4s + wait = 1 * (2 ** attempt) # 1s → 2s → 4s (exponential backoff) logger.warning( f"[ORDER_RETRY] {stock_code} {order_type} rt_cd={result.get('rt_cd')} " f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries})" ) await asyncio.sleep(wait) except Exception as e: + # 네트워크 타임아웃 등 예외 → 재시도 대상 last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None} if attempt < max_retries - 1: wait = 1 * (2 ** attempt) @@ -104,13 +156,19 @@ async def _place_order_with_retry( # ── 미체결 주문 1분 후 자동 취소 ───────────────────────────────────────────── async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None: - """delay초 후에도 PENDING 상태면 자동 취소.""" - await asyncio.sleep(delay) + """delay초 후에도 PENDING 상태면 자동 취소. + + [동작] 주문 후 delay(기본 60초)를 기다린 뒤 KIS 취소 API 호출. + 이미 체결됐다면 취소 실패(msg1에 오류 메시지) → DEBUG 로그만 남기고 무시. + 호출 측에서 asyncio.create_task() 로 백그라운드 실행. + """ + await asyncio.sleep(delay) # 60초 대기 후 취소 시도 try: result = await korea_investment_api.cancel_order(order_id, quantity) if result.get("rt_cd") == "0": logger.info(f"[AUTO_CANCEL] 미체결 자동 취소 완료: {stock_code} order_id={order_id}") else: + # 이미 체결됐거나 취소 불가 상태 → 무시 logger.debug(f"[AUTO_CANCEL] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)") except Exception as e: logger.debug(f"[AUTO_CANCEL] {stock_code} — {type(e).__name__}: {e}") @@ -122,13 +180,18 @@ _ACCOUNT_CACHE_TTL = 30 # seconds def _get_cached_account() -> tuple | None: - """(available_cash, total_assets) 30초 캐시. 만료 시 None 반환.""" + """(available_cash, total_assets) 30초 캐시. 만료 시 None 반환. + + [동작] monotonic clock으로 TTL 검사. 만료되지 않았으면 캐시 반환, + 만료됐거나 데이터가 없으면 None → 호출측에서 portfolio_cache 재조회. + """ if _account_cache["data"] and time.monotonic() - _account_cache["ts"] < _ACCOUNT_CACHE_TTL: return _account_cache["data"] return None def _set_account_cache(available_cash: float, total_assets: float) -> None: + """계좌 캐시 갱신. 현재 시각(monotonic)을 타임스탬프로 저장.""" _account_cache["data"] = (available_cash, total_assets) _account_cache["ts"] = time.monotonic() @@ -162,9 +225,14 @@ async def _calculate_kelly_position( """Kelly Criterion 기반 최적 포지션 사이징 (Half-Kelly 적용). f* = (p*b - (1-p)) / b - - p: 최근 N거래 승률 - - b: 평균이익률 / 평균손실률 - 데이터 부족(< 5개)이면 0.0 반환 → 호출측에서 v2 fallback. + - p: 최근 N거래 승률 (이긴 거래 수 / 전체 거래 수) + - b: 평균이익률 / 평균손실률 (Payoff Ratio) + - Half-Kelly: f* / 2 → 변동성 감소 목적 + + [동작] + 1. DB에서 최근 lookback회 매도 체결 기록 조회 + 2. 5거래 미만이면 통계 신뢰 부족 → 0.0 반환 (호출측에서 v2 fallback) + 3. 반환값 = min(total_assets×clamped, available_cash, max_per_position 한도) """ try: recent = await db.execute( @@ -180,19 +248,19 @@ async def _calculate_kelly_position( ) trades = recent.scalars().all() if len(trades) < 5: - return 0.0 + return 0.0 # 데이터 부족 → v2 fallback rates = [float(t.profit_loss_rate) for t in trades] - wins = [r for r in rates if r > 0] - losses = [abs(r) for r in rates if r < 0] + wins = [r for r in rates if r > 0] # 수익 거래 목록 + losses = [abs(r) for r in rates if r < 0] # 손실 거래 절대값 목록 if not wins or not losses: - return 0.0 + return 0.0 # 전승 또는 전패 → Kelly 계산 불가 - p = len(wins) / len(rates) - b = (sum(wins) / len(wins)) / (sum(losses) / len(losses)) - kelly_f = (p * b - (1 - p)) / b - half_kelly = max(0.0, kelly_f / 2) - clamped = min(half_kelly, max_per_position) + p = len(wins) / len(rates) # 승률 + b = (sum(wins) / len(wins)) / (sum(losses) / len(losses)) # Payoff Ratio + kelly_f = (p * b - (1 - p)) / b # Kelly 공식 + half_kelly = max(0.0, kelly_f / 2) # Half-Kelly (보수적) + clamped = min(half_kelly, max_per_position) # 종목당 최대 4% 상한 적용 return min(total_assets * clamped, available_cash, total_assets * max_per_position) except Exception as e: logger.debug(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {e}") @@ -212,19 +280,33 @@ async def _verify_and_sync_execution( db_trade_id: int, delay: int = 3, ) -> None: - """주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문.""" - await asyncio.sleep(delay) + """주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문. + + [동작 흐름] + 1. delay초 대기 (체결 확인 전 KIS 서버 처리 시간 확보) + 2. KIS 체결 조회 API 호출 → filled_qty, filled_price 추출 + 3. 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 + 4. 별도 AsyncSessionLocal 세션으로 TradeHistory 업데이트 + - 완전 체결(filled_qty == ordered_quantity) → status = "COMPLETED" + - 부분 체결(filled_qty < ordered_quantity) → status = "PARTIAL" + 잔여 재주문 + 5. 포트폴리오 동기화 트리거 + + 주의: 호출측 db 세션과 분리된 별도 세션을 사용 (cross-session 충돌 방지). + """ + await asyncio.sleep(delay) # KIS 서버 체결 처리 대기 if not order_id: + # order_id 없으면 체결 조회 불가 → 포트폴리오 동기화만 수행 await _delayed_sync_portfolio(0) return try: + # KIS 체결 내역 조회 (TR: TTTC8001R / VTTC8001R) exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic) filled_qty = int(exec_result.get("filled_qty", 0)) filled_price = float(exec_result.get("filled_price", order_price) or order_price) - # ── 슬리피지 계산 및 로깅 ───────────────────────────────────────────── + # ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ───────────────── slippage_pct = 0.0 if order_price > 0 and filled_price > 0: slippage_pct = ((filled_price - order_price) / order_price) * 100 @@ -234,7 +316,7 @@ async def _verify_and_sync_execution( f"슬리피지={slippage_pct:+.3f}%" ) - # ── DB 업데이트 (별도 세션) ─────────────────────────────────────────── + # ── DB 업데이트 (별도 세션으로 호출측 세션과 격리) ─────────────────── if filled_qty > 0: async with AsyncSessionLocal() as session: row = await session.execute( @@ -242,7 +324,7 @@ async def _verify_and_sync_execution( ) trade = row.scalar_one_or_none() if trade: - partial = filled_qty < ordered_quantity + partial = filled_qty < ordered_quantity # 부분 체결 여부 trade.executed_quantity = filled_qty trade.executed_price = Decimal(str(filled_price)) trade.status = "PARTIAL" if partial else "COMPLETED" @@ -257,6 +339,7 @@ async def _verify_and_sync_execution( f"[PARTIAL_FILL] {stock_code} {order_type} " f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문" ) + # 재주문은 백그라운드 태스크로 분리 (현재 흐름 블록 방지) asyncio.create_task( _reorder_unfilled( stock_code=stock_code, @@ -269,14 +352,14 @@ async def _verify_and_sync_execution( ) ) - await _delayed_sync_portfolio(0) + await _delayed_sync_portfolio(0) # KIS 잔고 기준으로 포트폴리오 재동기화 except Exception as e: logger.warning( f"[VERIFY_EXEC] {stock_code} {order_type} 체결 확인 실패 — " f"{type(e).__name__}: {e} (포트폴리오 동기화만 실행)" ) - await _delayed_sync_portfolio(0) + await _delayed_sync_portfolio(0) # 체결 확인 실패해도 포트폴리오는 동기화 # ── 부분 체결 후 미체결 수량 재주문 ────────────────────────────────────────── @@ -410,6 +493,7 @@ async def _execute_buy( return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"} # ── 3. 현금·자산 조회 (30초 캐시 활용) ────────────────────────────────── + # KIS API 호출을 줄이기 위해 인메모리 캐시(TTL 30초) 우선 사용 cached = _get_cached_account() if cached: available_cash, total_assets = cached @@ -422,9 +506,10 @@ async def _execute_buy( except Exception: available_cash, total_assets = 0.0, 0.0 - daily_cutoff = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) + daily_cutoff = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) # 오늘 00:00:00 KST # ── 4. PENDING 포함 오늘 중복 주문 차단 ────────────────────────────────── + # 오늘 이미 BUY 주문이 있으면(COMPLETED 또는 PENDING 상태) 재진입 방지 pending_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, @@ -436,7 +521,8 @@ async def _execute_buy( if pending_check.scalars().first(): return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"} - # ── 5. 일 매수 횟수 제한 (settings 기반) ───────────────────────────────── + # ── 5. 일 매수 횟수 제한 (settings.MAX_DAILY_BUYS_PER_STOCK) ───────────── + # 종목당 당일 최대 매수 횟수를 초과하면 추가 매수 차단 daily_buy_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, @@ -449,10 +535,12 @@ async def _execute_buy( return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"} # ── 6. 쿨다운 체크 (ATR 기반 동적) ────────────────────────────────────── + # ATR%가 높을수록(고변동) 쿨다운 단축 → max(15, 30 - atr_pct×3)분 + # 예: ATR% = 5% → 쿨다운 = max(15, 30-15) = 15분 + # ATR% = 1% → 쿨다운 = max(15, 30-3) = 27분 atr_val = float(stock.get("atr", 0) or 0) if atr_val > 0 and current_price > 0: atr_pct = atr_val / current_price * 100 - # 고변동(ATR% > 3%) → 쿨다운 단축, 저변동 → 기본 30분 cooldown_min = max(15, int(30 - atr_pct * 3)) else: cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) @@ -465,13 +553,15 @@ async def _execute_buy( TradeHistory.status.in_(["EXECUTED", "COMPLETED"]), TradeHistory.created_at >= daily_cutoff, ) - .order_by(desc(TradeHistory.created_at)) + .order_by(desc(TradeHistory.created_at)) # 가장 최근 매수부터 ) recent_buys = cooldown_check.scalars().all() + # 마지막 매수 이후 쿨다운 시간이 지나지 않았으면 HOLD if recent_buys and (_get_kst_now() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60: return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"} # ── 7. 포지션 사이징 (Kelly 우선, v2 fallback) ──────────────────────────── + # 우선순위: Half-Kelly → calculate_position_size_v2 → available_cash×4% current_value = float(holding.quantity * current_price) if holding else 0.0 regime_str = stock.get("_regime_str", "bear") regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR @@ -480,7 +570,7 @@ async def _execute_buy( db, stock_code, total_assets, available_cash ) if invest_amount <= 0: - # Kelly 데이터 부족 → v2 fallback + # Kelly 데이터 부족(5거래 미만) → 전략 v2 fallback invest_amount = calculate_position_size_v2( total_assets=total_assets, available_cash=available_cash, @@ -489,20 +579,25 @@ async def _execute_buy( rsi=stock.get("rsi"), adx=stock.get("adx_14"), ) - if invest_amount <= 0 and total_assets <= 0: + if invest_amount <= 0: + # v2도 0 반환 시(현금 부족 등) → 가용현금의 4% 최소 배분 invest_amount = available_cash * 0.04 + # 투자금액 → 주수 환산 (Decimal 사용으로 부동소수점 오차 방지) quantity = int(Decimal(str(invest_amount)) / Decimal(str(current_price))) if quantity <= 0: return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name} # ── 8. Redis 분산락 획득 ────────────────────────────────────────────────── + # 여러 워커/프로세스가 동시에 같은 종목을 매수하는 것을 방지 if not await _acquire_order_lock(stock_code, "BUY"): return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"} # ── 9. 주문 실행 (종목별 세마포어 + 재시도) ─────────────────────────────── + # 세마포어: 단일 프로세스 내 동일 종목 동시 주문 방지 + # 재시도: exponential backoff (1→2→4초, 최대 3회) try: - order_price = _adjust_order_price(current_price, "BUY", is_domestic) + order_price = _adjust_order_price(current_price, "BUY", is_domestic) # +0.5% 체결률 향상 async with _get_order_semaphore(stock_code): order_result = await _place_order_with_retry( is_domestic=is_domestic, @@ -515,10 +610,11 @@ async def _execute_buy( total_amount = Decimal(str(current_price)) * quantity entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점" - success = order_result.get("rt_cd") == "0" - is_pending = order_result.get("rt_cd") == "1" + success = order_result.get("rt_cd") == "0" # KIS 성공 코드 + is_pending = order_result.get("rt_cd") == "1" # 주문접수(체결 미확인) status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED") + # TradeHistory DB 기록 (flush로 id 확보 후 commit) trade = TradeHistory( rule_id=stock.get("rule_id"), stock_code=stock_code, @@ -538,16 +634,18 @@ async def _execute_buy( executed_at=_get_kst_now() if success else None, ) db.add(trade) - await db.flush() # id 확보 + await db.flush() # auto-increment id 확보 (commit 전에 id 필요) trade_id = trade.id await db.commit() if success: + # 인메모리 포트폴리오 캐시 즉시 반영 (UI 실시간 업데이트) try: from app.api.endpoints.portfolio import portfolio_cache portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price) except Exception: pass + # 3초 후 KIS 체결 확인 → 슬리피지 기록 + 부분체결 재주문 (백그라운드) asyncio.create_task( _verify_and_sync_execution( order_id=order_result.get("odno", ""), @@ -563,6 +661,7 @@ async def _execute_buy( ) ) elif is_pending: + # PENDING(주문접수) 상태이면 60초 후 미체결 자동 취소 시도 order_id_val = order_result.get("odno") if order_id_val: asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity)) @@ -596,10 +695,21 @@ async def _execute_sell( stock_code: str, stock_name: str, ) -> dict: - """SELL 주문 실행 로직 — 중복체크 → semaphore → 주문 → TradeHistory → 체결 확인.""" + """SELL 주문 실행 로직 — 중복체크 → semaphore → 주문 → TradeHistory → 체결 확인. - quantity = holding.quantity + [동작 흐름] + 1. 현재 손익률 계산 (현재가 vs 평균매입가) + 2. ATR 기반 동적 손절선·트레일링스탑 재계산 + 3. Redis 분산락 획득 → 중복 매도 방지 + 4. 30초 이내 동일 종목 SELL 기록 확인 (FAILED 제외) + 5. 지정가 -0.5% 조정 후 KIS SELL 주문 + 6. TradeHistory DB 기록 (flush → id 확보 → commit) + 7. 성공 시: 포트폴리오 캐시 즉시 제거 + 체결 확인 백그라운드 태스크 + """ + + quantity = holding.quantity # 보유 전량 매도 _avg = float(holding.average_price or 0) + # 손익률: (현재가 - 평균매입가) / 평균매입가 × 100 profit_loss_rate = ( ((current_price - _avg) / _avg) * 100 if _avg > 0 else float(holding.profit_loss_rate or 0) @@ -607,31 +717,35 @@ async def _execute_sell( take_profit = stock.get("take_profit", 3.0) stop_loss = stock.get("stop_loss", -1.5) - # ATR 기반 동적 손절/트레일링 스탑 조정 + # ── ATR 기반 동적 손절/트레일링 스탑 조정 ──────────────────────────────── + # ATR 손절선 = 평균매입가 - 2.5×ATR (기본 손절보다 유리한 쪽 사용) + # 트레일링스탑 = 고점 - 1.5×ATR (수익 실현 구간에서만 적용) atr = float(stock.get("atr", 0) or 0) _avg_price = float(holding.average_price or 0) exit_reason_detail = f"손익률 {profit_loss_rate:.2f}%" if atr > 0 and _avg_price > 0: - atr_stop_price = _avg_price - 2.5 * atr + atr_stop_price = _avg_price - 2.5 * atr # ATR 기반 손절가 atr_stop_pct = ((atr_stop_price / _avg_price) - 1) * 100 - stop_loss = max(stop_loss, atr_stop_pct) + stop_loss = max(stop_loss, atr_stop_pct) # 기본 손절 vs ATR 손절 중 유리한 쪽 high_price = float(stock.get("high_price") or current_price) - trailing_stop_price = high_price - 1.5 * atr + trailing_stop_price = high_price - 1.5 * atr # 고점 기준 트레일링스탑 if current_price <= trailing_stop_price and profit_loss_rate > 0: exit_reason_detail = f"ATR 트레일링 스탑: {current_price:.0f} ≤ {trailing_stop_price:.0f} | {exit_reason_detail}" - # Redis 분산락 획득 + # Redis 분산락 획득 — 여러 워커가 동시에 같은 종목을 매도하는 것을 방지 if not await _acquire_order_lock(stock_code, "SELL"): return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"} try: - # ── 1. 중복 주문 체크 (10초 이내) ──────────────────────────────────── - recent_cutoff = _get_kst_now() - timedelta(seconds=10) + # ── 1. 중복 주문 체크 (30초 이내, FAILED 제외) ─────────────────────── + # FAILED 주문은 제외하여 오류 이후 재매도 허용 + recent_cutoff = _get_kst_now() - timedelta(seconds=30) duplicate_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "SELL", + TradeHistory.status.in_(["COMPLETED", "PARTIAL", "PENDING"]), TradeHistory.created_at >= recent_cutoff, ) ) @@ -639,7 +753,7 @@ async def _execute_sell( return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} # ── 2. 주문 실행 (종목별 세마포어 + 재시도) ────────────────────────── - order_price = _adjust_order_price(current_price, "SELL", is_domestic) + order_price = _adjust_order_price(current_price, "SELL", is_domestic) # -0.5% 체결률 향상 async with _get_order_semaphore(stock_code): order_result = await _place_order_with_retry( is_domestic=is_domestic, @@ -651,11 +765,12 @@ async def _execute_sell( ) total_amount = Decimal(str(current_price)) * quantity - profit_loss = (current_price - float(holding.average_price or current_price)) * quantity + profit_loss = (current_price - float(holding.average_price or current_price)) * quantity # 절대 손익금액 success = order_result.get("rt_cd") == "0" is_pending = order_result.get("rt_cd") == "1" status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED") + # TradeHistory DB 기록 trade = TradeHistory( rule_id=stock.get("rule_id"), stock_code=stock_code, @@ -677,11 +792,12 @@ async def _execute_sell( executed_at=_get_kst_now() if success else None, ) db.add(trade) - await db.flush() # id 확보 + await db.flush() # auto-increment id 확보 trade_id = trade.id await db.commit() if success: + # 인메모리 포트폴리오 캐시에서 해당 종목 즉시 제거 (UI 실시간 반영) try: from app.api.endpoints.portfolio import portfolio_cache avg = float(holding.average_price or current_price) @@ -691,6 +807,7 @@ async def _execute_sell( ] except Exception: pass + # 3초 후 KIS 체결 확인 (슬리피지 기록 포함) asyncio.create_task( _verify_and_sync_execution( order_id=order_result.get("odno", ""), @@ -706,6 +823,7 @@ async def _execute_sell( ) ) elif is_pending: + # PENDING 상태 → 60초 후 미체결 자동 취소 시도 order_id_val = order_result.get("odno") if order_id_val: asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity)) @@ -730,7 +848,15 @@ async def _execute_sell( async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): - """recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행.""" + """recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행. + + [동작 흐름] + 1. 시장 국면(BEAR_MARKET_FILTER) 조회 → 최대 거래 수·포지션 배율 결정 + 2. recommendations_by_rule 그룹을 단일 unique_stocks dict로 정규화 + - 동일 종목이 여러 룰에 걸리면 우선순위(rule_priority) 낮은 쪽 유지 + 3. (rule_priority ASC, score DESC)로 정렬 → 상위 max_trades개 처리 + 4. 종목별 execute_auto_trade() 호출 (0.5초 간격 → KIS API 레이트 리밋 준수) + """ if not isinstance(recommendations_response, dict): logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response) return @@ -739,6 +865,7 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn total_processed = 0 trade_results = [] + # 시장 국면 조회 (BEAR 시 max_trades 축소, position_multiplier 감소) market_regime = None if settings.BEAR_MARKET_FILTER_ENABLED: market_regime = await check_market_regime() @@ -748,8 +875,9 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn max_trades = min(20, regime_max_trades) result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) - held_stocks = {p.stock_code for p in result.scalars().all()} + held_stocks = {p.stock_code for p in result.scalars().all()} # 현재 보유 종목 집합 + # recommendations_by_rule 정규화 (list 또는 dict 모두 처리) rec_groups = recommendations_response.get("recommendations_by_rule", []) if isinstance(rec_groups, dict): normalized_list = [] @@ -760,6 +888,7 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn normalized_list.append({"rule_name": name, "stocks": group}) rec_groups = normalized_list + # 종목 코드 기준으로 중복 제거 (rule_priority 낮은 쪽 = 더 중요한 규칙 우선) unique_stocks: dict = {} for rule_group in rec_groups: if not isinstance(rule_group, dict): @@ -777,8 +906,9 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn if stock_code not in unique_stocks: unique_stocks[stock_code] = stock elif stock.get("rule_priority", 999) < unique_stocks[stock_code].get("rule_priority", 999): - unique_stocks[stock_code] = stock + unique_stocks[stock_code] = stock # 더 높은 우선순위 룰로 교체 + # 우선순위↑(숫자↓), 점수↓ 순으로 정렬 후 max_trades개만 처리 sorted_stocks = sorted( unique_stocks.values(), key=lambda s: (s.get("rule_priority", 999), -s.get("score", 0)), @@ -789,7 +919,7 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn logger.warning("[AUTO_TRADE_BG] Skipping invalid stock entry: %r", stock) continue try: - stock["_position_multiplier"] = position_multiplier + stock["_position_multiplier"] = position_multiplier # 국면별 배율 주입 stock["_regime_str"] = ( market_regime.get("regime_200d", "bear") if market_regime else "bear" ) @@ -799,7 +929,7 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn if result["action"] in ["BUY", "SELL"]: trade_results.append(result) - await asyncio.sleep(0.5) + await asyncio.sleep(0.5) # KIS API 레이트 리밋: 초당 2회 이하 권장 except Exception as e: logger.error(f"[AUTO_TRADE_BG] {stock.get('stock_code')} 처리 오류: {type(e).__name__}: {e}") @@ -839,6 +969,19 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): 공통 필터(가격·MDD·손실추적·보유수·당일매도쿨다운·신호분석)를 통과한 후 _execute_buy / _execute_sell 에 위임한다. + + [필터 체크 순서] + 1. 유효성 검사 (stock_code, current_price) + 2. 국내/해외 구분 (stock_code 6자리 숫자 → 국내) + 3. 가격 필터 (국내 5,000원↑ / 미국 $4↑) + 4. MDD 일시정지 여부 (MDD < -10% 시 신규진입 중단) + 5. 연속손실 밴 여부 (3회 연속 손실 → 30일 진입 금지) + 6. 최대 보유 종목 수 초과 여부 (settings.MAX_HOLDINGS_COUNT) + 7. 당일 매도 후 재매수 쿨다운 (당일 SELL COMPLETED 기록 존재 시 재매수 금지) + 8. 신호 분석: BUY_KEYWORDS / SELL_KEYWORDS / BLOCK_KEYWORDS 매칭 + 9. ML 가중치 통합 (ML_ENSEMBLE_ENABLED 시 rule 40% + ML 60% 합산) + 10. ATR 강제 손절 (신호 없어도 현재가 ≤ avg - 2.5×ATR 이면 강제 매도) + 11. _execute_buy 또는 _execute_sell 위임 """ try: stock_code = stock.get("stock_code") @@ -849,6 +992,7 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): if not stock_code or current_price <= 0: return {"action": "SKIP", "reason": "invalid_data"} + # 국내(6자리 숫자) vs 해외 판별 is_domestic = stock_code.isdigit() and len(stock_code) == 6 exchange_code = stock.get("exchange_code", "NASD") @@ -862,22 +1006,26 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): # ── MDD / 손실추적 / 보유수 ────────────────────────────────────────── if not await mdd_manager.can_open_new_position(): + # MDD -10% 이상 낙폭 → 신규 진입 중단 return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name} if loss_tracker.is_banned(stock_code): + # 3회 연속 손실 종목 → 30거래일 진입 금지 return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name} held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) if len(held_result.scalars().all()) >= settings.MAX_HOLDINGS_COUNT: + # 동시 보유 20종목 한도 초과 return {"action": "SKIP", "reason": "max_holdings_reached", "stock": stock_name} # ── 당일 매도 후 재매수 쿨다운 ─────────────────────────────────────── + # 당일 이미 SELL 체결된 종목은 당일 재매수 금지 (섹션 4.3) cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) recent_sell = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "SELL", - TradeHistory.status == "EXECUTED", + TradeHistory.status.in_(["COMPLETED", "PARTIAL"]), TradeHistory.executed_at >= cutoff_time, ) ) @@ -891,7 +1039,7 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): Portfolio.quantity > 0, ) ) - holding = result.scalar_one_or_none() + holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단 # ── 신호 분석 (Enum 기반) ───────────────────────────────────────────── text_signals = " ".join(signals) @@ -900,16 +1048,21 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): except Exception: score_val = 0.0 + # "거래량부족" 신호 제거 후 매수 판단 (거래량 부족 단독으로는 매수 차단) signals_without_volume_weak = [s for s in signals if "거래량부족" not in s] text_signals_filtered = " ".join(signals_without_volume_weak) should_buy = any(k in text_signals_filtered for k in BUY_KEYWORDS) if not signals_without_volume_weak and signals: - should_buy = False + should_buy = False # 거래량부족 신호만 있으면 매수 금지 should_sell = any(k in text_signals for k in SELL_KEYWORDS) - # ── ML 가중치 통합 (60% ML + 40% 규칙) ────────────────────────────── + # ── ML 가중치 통합 (기본: 규칙 30% + ML 70%) ───────────────────────── + # weighted_score = score × RULE_WEIGHT + ml_score × ML_WEIGHT + # ML BUY: weighted_score ≥ threshold AND confidence ≥ min → should_buy = True + # ML SELL: confidence ≥ 0.5 → should_sell = True + # ML HOLD: weighted_score 낮으면 should_buy = False 강제 ml_score_trade = stock.get("ml_score") ml_confidence_trade = stock.get("ml_confidence") ml_signal_trade = stock.get("ml_signal") @@ -932,12 +1085,13 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): ml_signal_trade == "HOLD" and weighted_score < settings.ML_ENSEMBLE_BUY_THRESHOLD * 0.7 ): - should_buy = False + should_buy = False # ML이 HOLD 강하게 권고 시 매수 억제 except Exception as e: logger.debug(f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}") has_rule = stock.get("rule_id") is not None if has_rule: + # 규칙 기반 종목: BLOCK 신호만 없으면 should_buy = True (규칙 자체가 매수 신호) if any(b in text_signals for b in BLOCK_KEYWORDS): return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"} @@ -945,16 +1099,19 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): if not ml_hold_active: should_buy = True else: + # 비규칙 종목: BLOCK 신호 있으면 과열 HOLD if any(b in text_signals for b in BLOCK_KEYWORDS): return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"} if should_buy and not has_rule: + # 비규칙 종목: 강한 신호 OR (신호 2개 이상 AND 점수 2점 이상) 필수 has_strong_signal = any(k in text_signals_filtered for k in STRONG_BUY_KEYWORDS) matched_count = sum(1 for k in BUY_KEYWORDS if k in text_signals_filtered) if not has_strong_signal and (matched_count < 2 or score_val < 2): should_buy = False # ── ATR 기반 강제 손절 (신호 없어도 발동) ──────────────────────────── + # 현재가가 평균매입가 - 2.5×ATR 이하이면 매도 신호 없어도 강제 손절 if holding and not should_sell: atr = float(stock.get("atr", 0) or 0) avg_price = float(holding.average_price or 0) diff --git a/backend/app/services/websocket_manager.py b/backend/app/services/websocket_manager.py index 92673eb..67b13cb 100644 --- a/backend/app/services/websocket_manager.py +++ b/backend/app/services/websocket_manager.py @@ -1,3 +1,11 @@ +""" +WebSocket 연결 관리 모듈 + +- 클라이언트 연결/해제 및 인증 관리 +- 종목별 구독(subscribe/unsubscribe/set_subscriptions) 관리 +- 개별 클라이언트, 전체, 종목 구독자 타겟 메시지 전송 +- 싱글톤 websocket_manager 인스턴스 제공 (main.py 에서 import) +""" from typing import Dict, Set from fastapi import WebSocket from app.core.json_helpers import _dumps diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff3664c..0796df9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,20 @@ import { Routes, Route } from 'react-router-dom' import Dashboard from './pages/Dashboard' -import TradingRules from './pages/TradingRules' import TradeHistory from './pages/TradeHistory' import MCPStatus from './pages/MCPStatus' +import StrategySettings from './pages/StrategySettings' +import Layout from './components/Layout' function App() { return ( - - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + + ) } diff --git a/frontend/src/components/HtsPage.tsx b/frontend/src/components/HtsPage.tsx index 7195ed1..f20083f 100644 --- a/frontend/src/components/HtsPage.tsx +++ b/frontend/src/components/HtsPage.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react' import clsx from 'clsx' -import HtsHeader from './HtsHeader' import './HtsPage.css' interface HtsPageProps { @@ -12,7 +11,6 @@ interface HtsPageProps { const HtsPage = ({ children, className, contentClassName }: HtsPageProps) => { return (
-
{children}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index da00b18..0df52a0 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from 'react-router-dom' -import { LayoutDashboard, FileText, History, Cpu, Activity } from 'lucide-react' +import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import api from '../lib/api' import { useWebSocket } from '../hooks/useWebSocket' @@ -20,7 +20,7 @@ const Layout = ({ children }: LayoutProps) => { const navItems = [ { path: '/', icon: LayoutDashboard, label: '대시보드' }, - { path: '/rules', icon: FileText, label: '전략 룰' }, + { path: '/strategy', icon: SlidersHorizontal, label: '전략 설정' }, { path: '/trades', icon: History, label: '매매 로그' }, { path: '/mcp', icon: Cpu, label: 'MCP 상태' }, ] diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f2a757b..b40ba84 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -8,7 +8,6 @@ import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel' import OrderbookPanel from '../components/OrderbookPanel' import RecommendationPanel from '../components/RecommendationPanel' import JournalPanel from '../components/JournalPanel' -import { Activity } from 'lucide-react' import './Dashboard.css' // 마켓 운영 시간 체크 (한국장 + 미국장) @@ -112,7 +111,7 @@ const Dashboard = () => { const portfolioSummary = portfolioCombined?.summary // MCP 상태 - 30초 (UI 정보용, 느려도 무방) - const { data: mcpStatus } = useQuery({ + useQuery({ queryKey: ['mcp-status'], queryFn: async () => (await api.get('/mcp/status')).data, refetchInterval: isMarketOpen ? 30000 : false, @@ -242,11 +241,11 @@ const Dashboard = () => { if (!portfolio) return setLivePrices(prev => { const next = { ...prev } - ;(portfolio as any[]).forEach((s: any) => { - if (s.current_price && !next[s.stock_code]) { - next[s.stock_code] = Number(s.current_price) - } - }) + ; (portfolio as any[]).forEach((s: any) => { + if (s.current_price && !next[s.stock_code]) { + next[s.stock_code] = Number(s.current_price) + } + }) return next }) }, [portfolio]) @@ -502,31 +501,7 @@ const Dashboard = () => { }, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock]) return ( -
- -
-
-
- -
-
-

- AutoStock Trader -

-

Gen 3 AI Algorithm

-
-
-
-
-
- {wsConnected ? '실시간 연결됨' : '연결 끊김'} -
-
-
- MCP: {mcpStatus?.status || 'Unknown'} -
-
-
+
diff --git a/frontend/src/pages/StrategySettings.tsx b/frontend/src/pages/StrategySettings.tsx new file mode 100644 index 0000000..7d477ec --- /dev/null +++ b/frontend/src/pages/StrategySettings.tsx @@ -0,0 +1,398 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '../lib/api' +import { useState, useMemo } from 'react' +import HtsPage from '../components/HtsPage' +import { RotateCcw, Save, Power, PowerOff, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react' + +// ── 타입 ───────────────────────────────────────────────────────────────────── + +interface StrategyConfig { + id: number + key: string + group: string + label: string + description: string | null + value_type: string // 'float' | 'int' | 'bool' | 'str' + value: string + default_value: string + min_value: string | null + max_value: string | null + is_active: boolean + updated_by: string | null +} + +// ── 그룹 한글 레이블 ────────────────────────────────────────────────────────── + +const GROUP_LABELS: Record = { + INDICATOR: '기술 지표', + RISK: '리스크 관리', + FILTER: '진입 필터', + DAY_TRADE: '데이트레이딩', + MEAN_REVERSION: '평균회귀 전략', + GENERAL: '일반 설정', +} + +const GROUP_ORDER = ['GENERAL', 'RISK', 'FILTER', 'INDICATOR', 'MEAN_REVERSION', 'DAY_TRADE'] + +// ── 개별 파라미터 행 컴포넌트 ───────────────────────────────────────────────── + +interface ConfigRowProps { + cfg: StrategyConfig + onSave: (key: string, value: string) => void + onToggle: (key: string) => void + onReset: (key: string) => void + isSaving: boolean +} + +function ConfigRow({ cfg, onSave, onToggle, onReset, isSaving }: ConfigRowProps) { + const [editValue, setEditValue] = useState(cfg.value) + const [dirty, setDirty] = useState(false) + + const isDefault = cfg.value === cfg.default_value + const isModified = !isDefault && !dirty + + const handleChange = (val: string) => { + setEditValue(val) + setDirty(val !== cfg.value) + } + + const handleSave = () => { + onSave(cfg.key, editValue) + setDirty(false) + } + + const handleReset = () => { + setEditValue(cfg.default_value) + setDirty(cfg.default_value !== cfg.value) + onReset(cfg.key) + } + + const isBool = cfg.value_type === 'bool' + const isTrue = cfg.value === 'true' || cfg.value === 'True' || cfg.value === '1' + + return ( +
+ {/* 상태 뱃지 */} + {(isModified || dirty) && ( + + {dirty ? '미저장' : '수정됨'} + + )} + + {/* 레이블 + 설명 */} +

{cfg.label}

+ {cfg.description && ( +

{cfg.description}

+ )} + + {/* 컨트롤 영역 */} +
+ {isBool ? ( + + ) : ( +
+ handleChange(e.target.value)} + disabled={!cfg.is_active} + step={cfg.value_type === 'float' ? '0.001' : '1'} + min={cfg.min_value ?? undefined} + max={cfg.max_value ?? undefined} + className="w-20 px-2 py-1 text-xs bg-slate-800 border border-slate-700 rounded text-white text-right + focus:outline-none focus:border-blue-500 disabled:opacity-40" + /> + / {cfg.default_value} +
+ )} + + {/* 액션 버튼 */} +
+ {!isBool && ( + + )} + + +
+
+
+ ) +} + +// ── 그룹 섹션 컴포넌트 ──────────────────────────────────────────────────────── + +const GROUP_ICONS: Record = { + GENERAL: '⚙️', + RISK: '🛡️', + FILTER: '🔍', + INDICATOR: '📊', + MEAN_REVERSION: '↩️', + DAY_TRADE: '⚡', +} + +interface GroupSectionProps { + groupKey: string + configs: StrategyConfig[] + onSave: (key: string, value: string) => void + onToggle: (key: string) => void + onReset: (key: string) => void + isSaving: boolean +} + +function GroupSection({ groupKey, configs, onSave, onToggle, onReset, isSaving }: GroupSectionProps) { + const [collapsed, setCollapsed] = useState(false) + const modifiedCount = configs.filter(c => c.value !== c.default_value).length + + return ( +
+ {/* 그룹 헤더 */} + + + {/* 파라미터 카드 그리드 */} + {!collapsed && ( +
+ {configs.map((cfg) => ( + + ))} +
+ )} +
+ ) +} + +// ── 메인 페이지 ─────────────────────────────────────────────────────────────── + +const StrategySettings = () => { + const queryClient = useQueryClient() + const [notification, setNotification] = useState<{ type: 'success' | 'error'; msg: string } | null>(null) + + const showNotification = (type: 'success' | 'error', msg: string) => { + setNotification({ type, msg }) + setTimeout(() => setNotification(null), 3000) + } + + // 전략 파라미터 목록 조회 + const { data: configs = [], isLoading, refetch } = useQuery({ + queryKey: ['strategy-config'], + queryFn: async () => { + const res = await api.get('/strategy-config') + return res.data + }, + staleTime: 10_000, + }) + + // 단일 파라미터 수정 + const updateMutation = useMutation({ + mutationFn: async ({ key, value }: { key: string; value: string }) => { + return api.put(`/strategy-config/${key}`, { value, updated_by: 'ui' }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['strategy-config'] }) + showNotification('success', '저장 완료') + }, + onError: (err: any) => { + showNotification('error', `저장 실패: ${err.response?.data?.detail || err.message}`) + }, + }) + + // 활성화 토글 + const toggleMutation = useMutation({ + mutationFn: async (key: string) => { + return api.patch(`/strategy-config/${key}/toggle`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['strategy-config'] }) + }, + onError: (err: any) => { + showNotification('error', `토글 실패: ${err.message}`) + }, + }) + + // 단일 기본값 리셋 + const resetOneMutation = useMutation({ + mutationFn: async (key: string) => { + return api.post(`/strategy-config/${key}/reset`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['strategy-config'] }) + showNotification('success', '기본값으로 초기화 완료') + }, + }) + + // 전체 기본값 리셋 + const resetAllMutation = useMutation({ + mutationFn: async () => { + return api.post('/strategy-config/reset-all') + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['strategy-config'] }) + showNotification('success', '전체 파라미터가 기본값으로 초기화되었습니다.') + }, + onError: (err: any) => { + showNotification('error', `초기화 실패: ${err.message}`) + }, + }) + + // 그룹별 분류 + const grouped = useMemo(() => { + const map: Record = {} + for (const cfg of configs) { + if (!map[cfg.group]) map[cfg.group] = [] + map[cfg.group].push(cfg) + } + return map + }, [configs]) + + // 수정된 파라미터 수 + const totalModified = configs.filter(c => c.value !== c.default_value).length + + if (isLoading) { + return ( + +
+ 로딩 중... +
+
+ ) + } + + return ( + +
+ {/* 헤더 */} +
+
+

전략 설정

+

+ 변경 즉시 캐시 무효화 → 30초 이내 전략 코드에 반영됩니다. + 비활성화 시 config.py 기본값이 사용됩니다. +

+
+
+ + +
+
+ + {/* 알림 배너 */} + {notification && ( +
+ {notification.msg} +
+ )} + + {/* 파라미터 없음 */} + {configs.length === 0 && ( +
+

전략 파라미터가 없습니다.

+

DB 마이그레이션 후 서버를 재시작하면 기본값이 자동 생성됩니다.

+
+ )} + + {/* 그룹별 섹션 — 2열 그리드 */} +
+ {[ + ...GROUP_ORDER.filter(g => grouped[g]?.length), + ...Object.keys(grouped).filter(g => !GROUP_ORDER.includes(g) && grouped[g]?.length), + ].map(groupKey => ( +
+ updateMutation.mutate({ key, value })} + onToggle={(key) => toggleMutation.mutate(key)} + onReset={(key) => resetOneMutation.mutate(key)} + isSaving={updateMutation.isPending} + /> +
+ ))} +
+
+
+ ) +} + +export default StrategySettings