매매전략 수정중

This commit is contained in:
2026-03-06 20:46:08 +09:00
parent 2b9a90a03f
commit 12460b7c9e
15 changed files with 752 additions and 105 deletions

View File

@@ -1,3 +1,8 @@
"""
디버그/진단 API 엔드포인트 (개발/운영 내부 확인용)
- GET /debug/autotrade-status: 자동매매 설정, 장 개장 여부, 현재 KST 시각 반환
"""
from fastapi import APIRouter from fastapi import APIRouter
from app.core.config import settings from app.core.config import settings
from app.core.market_schedule import is_market_open from app.core.market_schedule import is_market_open

View File

@@ -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 fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Optional from typing import List, Optional
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta

View File

@@ -1,3 +1,10 @@
"""
TensorFlow 예측 API 엔드포인트
- GET /predict/{stock_code}: 단일 종목 주가 방향 예측 (LSTM)
- GET /predict/batch: 복수 종목 일괄 예측
- GET /status: TF 모델 로드 상태 확인
"""
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import List from typing import List
import logging import logging

View File

@@ -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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete from sqlalchemy import select, update, delete

View File

@@ -1,3 +1,13 @@
"""
한국투자증권(KIS) REST API 클라이언트
- OAuth2 토큰 자동 발급 및 파일 캐시 (~/.cache/auto-stock/)
- 국내/해외 주식 현재가, 일봉/분봉 차트 조회
- 매수/매도 주문 및 체결 확인
- CircuitBreaker: 연속 N회 실패 시 일정 시간 요청 차단
- APICallMonitor: 호출 성공률·지연시간 통계
- KIS_MODE(virtual/real)에 따라 엔드포인트 자동 전환
"""
import httpx import httpx
from typing import Dict, Any, Optional, Deque from typing import Dict, Any, Optional, Deque
from datetime import datetime, timedelta from datetime import datetime, timedelta

View File

@@ -1,3 +1,11 @@
"""
시장 분석 서비스
- KIS API를 통해 관심 종목(watchlist)의 기술적 지표 일괄 분석
- 시장 국면(bull/bear/sideways) 판별 및 추천 종목 선별
- OVERHEAT(급등 과열) 필터, 추세 필터 적용
- 분석 결과를 StockAnalysis DB에 upsert하고 Redis 캐시에 저장
"""
import asyncio import asyncio
import logging import logging
import time import time

View File

@@ -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 import logging
from typing import Optional 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]: def _ma(values: list, window: int) -> Optional[float]:
"""단순 이동평균(SMA) 계산. 데이터 부족 시 None 반환."""
try: try:
if len(values) < window: if len(values) < window:
return None 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]: 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): if not closes or len(closes) < max(ma_short, ma_long):
return None return None
ma_s = _ma(closes, ma_short) ma_s = _ma(closes, ma_short)
@@ -139,6 +154,11 @@ def analyze_custom_signals(chart_data):
def analyze_macd(chart_data: list) -> dict: 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: if len(chart_data) < 26:
return {"signal": None, "description": "데이터 부족"} return {"signal": None, "description": "데이터 부족"}
@@ -273,6 +293,11 @@ def analyze_rsi(chart_data: list) -> dict:
def analyze_bollinger(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_period = settings.BOLLINGER_PERIOD # 기본값 20
bb_stddev = settings.BOLLINGER_STDDEV # 기본값 2.0 bb_stddev = settings.BOLLINGER_STDDEV # 기본값 2.0
if len(chart_data) < bb_period: 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: 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_long = settings.MA_LONG_PERIOD # 기본값 60
ma_short = settings.MA_MEDIUM_PERIOD # 기본값 20 ma_short = settings.MA_MEDIUM_PERIOD # 기본값 20
ma_quick = settings.MA_SHORT_PERIOD # 기본값 5 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: 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: if len(chart_data) < 14:
return {"signal": None, "description": "데이터 부족"} 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: 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: if len(chart_data) < 2:
return {"signal": None, "description": "데이터 부족"} 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: 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: if len(chart_data) < 29:
return {"signal": None, "description": "데이터 부족", "adx": None} return {"signal": None, "description": "데이터 부족", "adx": None}
@@ -390,6 +436,12 @@ def analyze_adx(chart_data: list) -> dict:
def analyze_obv(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: if len(chart_data) < 2:
return {"signal": None, "description": "데이터 부족"} return {"signal": None, "description": "데이터 부족"}

View File

@@ -1,3 +1,11 @@
"""
TensorFlow/Keras 기반 주가 예측 서비스
- LSTM 모델로 단기 주가 방향(상승/하락) 예측
- KIS API 차트 데이터에서 피처 추출 → 정규화 → 모델 추론
- 모델 파일 없으면 더미 예측(0.5) 반환 (서비스 무중단)
- ML_TF_ENABLED=false 시 비활성화 가능
"""
import asyncio import asyncio
import functools import functools
import logging import logging

View File

@@ -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 asyncio
import logging import logging
import time import time
@@ -23,11 +32,35 @@ logger = logging.getLogger(__name__)
# ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ──────────────────────────────── # ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ────────────────────────────────
_order_semaphores: dict = {} _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: def _get_order_semaphore(stock_code: str) -> asyncio.Semaphore:
"""종목별 세마포어 반환. 없으면 생성.""" """종목별 세마포어 반환. 없으면 생성. 크기 초과 시 미사용 항목 정리.
[동작] 동일 종목에 대해 동시에 2개 이상의 주문이 발생하지 않도록
asyncio.Semaphore(1) 으로 상호 배제. 코드 진입 시 lock → 완료 시 release.
"""
if stock_code not in _order_semaphores: if stock_code not in _order_semaphores:
_cleanup_semaphores() # 200개 초과 시 미사용 세마포어 정리
_order_semaphores[stock_code] = asyncio.Semaphore(1) _order_semaphores[stock_code] = asyncio.Semaphore(1)
return _order_semaphores[stock_code] 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: 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: try:
from app.core.redis import get_redis from app.core.redis import get_redis
client = get_redis() client = get_redis()
if not client: if not client:
return True # Redis 미연결 → 락 없이 진행 return True # Redis 미연결 → 락 없이 진행 (세마포어가 대신 보호)
key = f"order_lock:{stock_code}:{order_type}" key = f"order_lock:{stock_code}:{order_type}"
result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL) 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: except Exception as e:
logger.debug(f"[order_lock] 획득 오류 (패스): {e}") logger.debug(f"[order_lock] 획득 오류 (패스): {e}")
return True return True
async def _release_order_lock(stock_code: str, order_type: str) -> None: async def _release_order_lock(stock_code: str, order_type: str) -> None:
"""Redis 분산락 해제.""" """Redis 분산락 해제.
[동작] finally 블록에서 반드시 호출 — 주문 성공/실패 관계없이 락 해제.
"""
try: try:
from app.core.redis import delete_key from app.core.redis import delete_key
await delete_key(f"order_lock:{stock_code}:{order_type}") await delete_key(f"order_lock:{stock_code}:{order_type}")
except Exception: except Exception:
pass pass # 해제 실패 시 TTL(60초) 만료 후 자동 해제됨
# ── 주문 재시도 — Exponential backoff (1→2→4초, 최대 3회) ─────────────────── # ── 주문 재시도 — Exponential backoff (1→2→4초, 최대 3회) ───────────────────
@@ -70,28 +111,39 @@ async def _place_order_with_retry(
exchange_code: str = "NASD", exchange_code: str = "NASD",
max_retries: int = 3, max_retries: int = 3,
) -> dict: ) -> 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 = {} last_result: dict = {}
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
if is_domestic: if is_domestic:
# 국내 주식 주문 (KIS TTTC0012U / VTTC0012U)
result = await korea_investment_api.place_order(stock_code, order_type, quantity, price) result = await korea_investment_api.place_order(stock_code, order_type, quantity, price)
else: else:
# 해외 주식 주문 (KIS TTTT1002U 등)
result = await korea_investment_api.place_overseas_order( result = await korea_investment_api.place_overseas_order(
stock_code, order_type, quantity, float(price), exchange_code stock_code, order_type, quantity, float(price), exchange_code
) )
# rt_cd "0"=성공, "1"=PENDING → 재시도 불필요 # rt_cd "0"=성공, "1"=PENDING → 재시도 불필요
if result.get("rt_cd") in ("0", "1"): if result.get("rt_cd") in ("0", "1"):
return result return result
# rt_cd 오류 → 재시도 대상
last_result = result last_result = result
if attempt < max_retries - 1: if attempt < max_retries - 1:
wait = 1 * (2 ** attempt) # 1s → 2s → 4s wait = 1 * (2 ** attempt) # 1s → 2s → 4s (exponential backoff)
logger.warning( logger.warning(
f"[ORDER_RETRY] {stock_code} {order_type} rt_cd={result.get('rt_cd')} " f"[ORDER_RETRY] {stock_code} {order_type} rt_cd={result.get('rt_cd')} "
f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries})" f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries})"
) )
await asyncio.sleep(wait) await asyncio.sleep(wait)
except Exception as e: except Exception as e:
# 네트워크 타임아웃 등 예외 → 재시도 대상
last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None} last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None}
if attempt < max_retries - 1: if attempt < max_retries - 1:
wait = 1 * (2 ** attempt) wait = 1 * (2 ** attempt)
@@ -104,13 +156,19 @@ async def _place_order_with_retry(
# ── 미체결 주문 1분 후 자동 취소 ───────────────────────────────────────────── # ── 미체결 주문 1분 후 자동 취소 ─────────────────────────────────────────────
async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None: async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
"""delay초 후에도 PENDING 상태면 자동 취소.""" """delay초 후에도 PENDING 상태면 자동 취소.
await asyncio.sleep(delay)
[동작] 주문 후 delay(기본 60초)를 기다린 뒤 KIS 취소 API 호출.
이미 체결됐다면 취소 실패(msg1에 오류 메시지) → DEBUG 로그만 남기고 무시.
호출 측에서 asyncio.create_task() 로 백그라운드 실행.
"""
await asyncio.sleep(delay) # 60초 대기 후 취소 시도
try: try:
result = await korea_investment_api.cancel_order(order_id, quantity) result = await korea_investment_api.cancel_order(order_id, quantity)
if result.get("rt_cd") == "0": if result.get("rt_cd") == "0":
logger.info(f"[AUTO_CANCEL] 미체결 자동 취소 완료: {stock_code} order_id={order_id}") logger.info(f"[AUTO_CANCEL] 미체결 자동 취소 완료: {stock_code} order_id={order_id}")
else: else:
# 이미 체결됐거나 취소 불가 상태 → 무시
logger.debug(f"[AUTO_CANCEL] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)") logger.debug(f"[AUTO_CANCEL] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)")
except Exception as e: except Exception as e:
logger.debug(f"[AUTO_CANCEL] {stock_code} — {type(e).__name__}: {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: 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: if _account_cache["data"] and time.monotonic() - _account_cache["ts"] < _ACCOUNT_CACHE_TTL:
return _account_cache["data"] return _account_cache["data"]
return None return None
def _set_account_cache(available_cash: float, total_assets: float) -> None: def _set_account_cache(available_cash: float, total_assets: float) -> None:
"""계좌 캐시 갱신. 현재 시각(monotonic)을 타임스탬프로 저장."""
_account_cache["data"] = (available_cash, total_assets) _account_cache["data"] = (available_cash, total_assets)
_account_cache["ts"] = time.monotonic() _account_cache["ts"] = time.monotonic()
@@ -162,9 +225,14 @@ async def _calculate_kelly_position(
"""Kelly Criterion 기반 최적 포지션 사이징 (Half-Kelly 적용). """Kelly Criterion 기반 최적 포지션 사이징 (Half-Kelly 적용).
f* = (p*b - (1-p)) / b f* = (p*b - (1-p)) / b
- p: 최근 N거래 승률 - p: 최근 N거래 승률 (이긴 거래 수 / 전체 거래 수)
- b: 평균이익률 / 평균손실률 - b: 평균이익률 / 평균손실률 (Payoff Ratio)
데이터 부족(< 5개)이면 0.0 반환 → 호출측에서 v2 fallback. - Half-Kelly: f* / 2 → 변동성 감소 목적
[동작]
1. DB에서 최근 lookback회 매도 체결 기록 조회
2. 5거래 미만이면 통계 신뢰 부족 → 0.0 반환 (호출측에서 v2 fallback)
3. 반환값 = min(total_assets×clamped, available_cash, max_per_position 한도)
""" """
try: try:
recent = await db.execute( recent = await db.execute(
@@ -180,19 +248,19 @@ async def _calculate_kelly_position(
) )
trades = recent.scalars().all() trades = recent.scalars().all()
if len(trades) < 5: if len(trades) < 5:
return 0.0 return 0.0 # 데이터 부족 → v2 fallback
rates = [float(t.profit_loss_rate) for t in trades] rates = [float(t.profit_loss_rate) for t in trades]
wins = [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] losses = [abs(r) for r in rates if r < 0] # 손실 거래 절대값 목록
if not wins or not losses: if not wins or not losses:
return 0.0 return 0.0 # 전승 또는 전패 → Kelly 계산 불가
p = len(wins) / len(rates) p = len(wins) / len(rates) # 승률
b = (sum(wins) / len(wins)) / (sum(losses) / len(losses)) b = (sum(wins) / len(wins)) / (sum(losses) / len(losses)) # Payoff Ratio
kelly_f = (p * b - (1 - p)) / b kelly_f = (p * b - (1 - p)) / b # Kelly 공식
half_kelly = max(0.0, kelly_f / 2) half_kelly = max(0.0, kelly_f / 2) # Half-Kelly (보수적)
clamped = min(half_kelly, max_per_position) clamped = min(half_kelly, max_per_position) # 종목당 최대 4% 상한 적용
return min(total_assets * clamped, available_cash, total_assets * max_per_position) return min(total_assets * clamped, available_cash, total_assets * max_per_position)
except Exception as e: except Exception as e:
logger.debug(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {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, db_trade_id: int,
delay: int = 3, delay: int = 3,
) -> None: ) -> None:
"""주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문.""" """주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문.
await asyncio.sleep(delay)
[동작 흐름]
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: if not order_id:
# order_id 없으면 체결 조회 불가 → 포트폴리오 동기화만 수행
await _delayed_sync_portfolio(0) await _delayed_sync_portfolio(0)
return return
try: try:
# KIS 체결 내역 조회 (TR: TTTC8001R / VTTC8001R)
exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic) exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic)
filled_qty = int(exec_result.get("filled_qty", 0)) filled_qty = int(exec_result.get("filled_qty", 0))
filled_price = float(exec_result.get("filled_price", order_price) or order_price) filled_price = float(exec_result.get("filled_price", order_price) or order_price)
# ── 슬리피지 계산 및 로깅 ───────────────────────────────────────────── # ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ─────────────────
slippage_pct = 0.0 slippage_pct = 0.0
if order_price > 0 and filled_price > 0: if order_price > 0 and filled_price > 0:
slippage_pct = ((filled_price - order_price) / order_price) * 100 slippage_pct = ((filled_price - order_price) / order_price) * 100
@@ -234,7 +316,7 @@ async def _verify_and_sync_execution(
f"슬리피지={slippage_pct:+.3f}%" f"슬리피지={slippage_pct:+.3f}%"
) )
# ── DB 업데이트 (별도 세션) ─────────────────────────────────────────── # ── DB 업데이트 (별도 세션으로 호출측 세션과 격리) ───────────────────
if filled_qty > 0: if filled_qty > 0:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
row = await session.execute( row = await session.execute(
@@ -242,7 +324,7 @@ async def _verify_and_sync_execution(
) )
trade = row.scalar_one_or_none() trade = row.scalar_one_or_none()
if trade: if trade:
partial = filled_qty < ordered_quantity partial = filled_qty < ordered_quantity # 부분 체결 여부
trade.executed_quantity = filled_qty trade.executed_quantity = filled_qty
trade.executed_price = Decimal(str(filled_price)) trade.executed_price = Decimal(str(filled_price))
trade.status = "PARTIAL" if partial else "COMPLETED" 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"[PARTIAL_FILL] {stock_code} {order_type} "
f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문" f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문"
) )
# 재주문은 백그라운드 태스크로 분리 (현재 흐름 블록 방지)
asyncio.create_task( asyncio.create_task(
_reorder_unfilled( _reorder_unfilled(
stock_code=stock_code, 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: except Exception as e:
logger.warning( logger.warning(
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 확인 실패 — " f"[VERIFY_EXEC] {stock_code} {order_type} 체결 확인 실패 — "
f"{type(e).__name__}: {e} (포트폴리오 동기화만 실행)" 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"} return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"}
# ── 3. 현금·자산 조회 (30초 캐시 활용) ────────────────────────────────── # ── 3. 현금·자산 조회 (30초 캐시 활용) ──────────────────────────────────
# KIS API 호출을 줄이기 위해 인메모리 캐시(TTL 30초) 우선 사용
cached = _get_cached_account() cached = _get_cached_account()
if cached: if cached:
available_cash, total_assets = cached available_cash, total_assets = cached
@@ -422,9 +506,10 @@ async def _execute_buy(
except Exception: except Exception:
available_cash, total_assets = 0.0, 0.0 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 포함 오늘 중복 주문 차단 ────────────────────────────────── # ── 4. PENDING 포함 오늘 중복 주문 차단 ──────────────────────────────────
# 오늘 이미 BUY 주문이 있으면(COMPLETED 또는 PENDING 상태) 재진입 방지
pending_check = await db.execute( pending_check = await db.execute(
select(TradeHistory).where( select(TradeHistory).where(
TradeHistory.stock_code == stock_code, TradeHistory.stock_code == stock_code,
@@ -436,7 +521,8 @@ async def _execute_buy(
if pending_check.scalars().first(): if pending_check.scalars().first():
return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"} 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( daily_buy_check = await db.execute(
select(TradeHistory).where( select(TradeHistory).where(
TradeHistory.stock_code == stock_code, TradeHistory.stock_code == stock_code,
@@ -449,10 +535,12 @@ async def _execute_buy(
return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"} return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"}
# ── 6. 쿨다운 체크 (ATR 기반 동적) ────────────────────────────────────── # ── 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) atr_val = float(stock.get("atr", 0) or 0)
if atr_val > 0 and current_price > 0: if atr_val > 0 and current_price > 0:
atr_pct = atr_val / current_price * 100 atr_pct = atr_val / current_price * 100
# 고변동(ATR% > 3%) → 쿨다운 단축, 저변동 → 기본 30분
cooldown_min = max(15, int(30 - atr_pct * 3)) cooldown_min = max(15, int(30 - atr_pct * 3))
else: else:
cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) 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.status.in_(["EXECUTED", "COMPLETED"]),
TradeHistory.created_at >= daily_cutoff, TradeHistory.created_at >= daily_cutoff,
) )
.order_by(desc(TradeHistory.created_at)) .order_by(desc(TradeHistory.created_at)) # 가장 최근 매수부터
) )
recent_buys = cooldown_check.scalars().all() recent_buys = cooldown_check.scalars().all()
# 마지막 매수 이후 쿨다운 시간이 지나지 않았으면 HOLD
if recent_buys and (_get_kst_now() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60: 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"} return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"}
# ── 7. 포지션 사이징 (Kelly 우선, v2 fallback) ──────────────────────────── # ── 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 current_value = float(holding.quantity * current_price) if holding else 0.0
regime_str = stock.get("_regime_str", "bear") regime_str = stock.get("_regime_str", "bear")
regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.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 db, stock_code, total_assets, available_cash
) )
if invest_amount <= 0: if invest_amount <= 0:
# Kelly 데이터 부족 → v2 fallback # Kelly 데이터 부족(5거래 미만) → 전략 v2 fallback
invest_amount = calculate_position_size_v2( invest_amount = calculate_position_size_v2(
total_assets=total_assets, total_assets=total_assets,
available_cash=available_cash, available_cash=available_cash,
@@ -489,20 +579,25 @@ async def _execute_buy(
rsi=stock.get("rsi"), rsi=stock.get("rsi"),
adx=stock.get("adx_14"), 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 invest_amount = available_cash * 0.04
# 투자금액 → 주수 환산 (Decimal 사용으로 부동소수점 오차 방지)
quantity = int(Decimal(str(invest_amount)) / Decimal(str(current_price))) quantity = int(Decimal(str(invest_amount)) / Decimal(str(current_price)))
if quantity <= 0: if quantity <= 0:
return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name} return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name}
# ── 8. Redis 분산락 획득 ────────────────────────────────────────────────── # ── 8. Redis 분산락 획득 ──────────────────────────────────────────────────
# 여러 워커/프로세스가 동시에 같은 종목을 매수하는 것을 방지
if not await _acquire_order_lock(stock_code, "BUY"): if not await _acquire_order_lock(stock_code, "BUY"):
return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"} return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"}
# ── 9. 주문 실행 (종목별 세마포어 + 재시도) ─────────────────────────────── # ── 9. 주문 실행 (종목별 세마포어 + 재시도) ───────────────────────────────
# 세마포어: 단일 프로세스 내 동일 종목 동시 주문 방지
# 재시도: exponential backoff (1→2→4초, 최대 3회)
try: 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): async with _get_order_semaphore(stock_code):
order_result = await _place_order_with_retry( order_result = await _place_order_with_retry(
is_domestic=is_domestic, is_domestic=is_domestic,
@@ -515,10 +610,11 @@ async def _execute_buy(
total_amount = Decimal(str(current_price)) * quantity total_amount = Decimal(str(current_price)) * quantity
entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점" entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점"
success = order_result.get("rt_cd") == "0" success = order_result.get("rt_cd") == "0" # KIS 성공 코드
is_pending = order_result.get("rt_cd") == "1" is_pending = order_result.get("rt_cd") == "1" # 주문접수(체결 미확인)
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED") status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")
# TradeHistory DB 기록 (flush로 id 확보 후 commit)
trade = TradeHistory( trade = TradeHistory(
rule_id=stock.get("rule_id"), rule_id=stock.get("rule_id"),
stock_code=stock_code, stock_code=stock_code,
@@ -538,16 +634,18 @@ async def _execute_buy(
executed_at=_get_kst_now() if success else None, executed_at=_get_kst_now() if success else None,
) )
db.add(trade) db.add(trade)
await db.flush() # id 확보 await db.flush() # auto-increment id 확보 (commit 전에 id 필요)
trade_id = trade.id trade_id = trade.id
await db.commit() await db.commit()
if success: if success:
# 인메모리 포트폴리오 캐시 즉시 반영 (UI 실시간 업데이트)
try: try:
from app.api.endpoints.portfolio import portfolio_cache from app.api.endpoints.portfolio import portfolio_cache
portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price) portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price)
except Exception: except Exception:
pass pass
# 3초 후 KIS 체결 확인 → 슬리피지 기록 + 부분체결 재주문 (백그라운드)
asyncio.create_task( asyncio.create_task(
_verify_and_sync_execution( _verify_and_sync_execution(
order_id=order_result.get("odno", ""), order_id=order_result.get("odno", ""),
@@ -563,6 +661,7 @@ async def _execute_buy(
) )
) )
elif is_pending: elif is_pending:
# PENDING(주문접수) 상태이면 60초 후 미체결 자동 취소 시도
order_id_val = order_result.get("odno") order_id_val = order_result.get("odno")
if order_id_val: if order_id_val:
asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity)) 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_code: str,
stock_name: str, stock_name: str,
) -> dict: ) -> 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) _avg = float(holding.average_price or 0)
# 손익률: (현재가 - 평균매입가) / 평균매입가 × 100
profit_loss_rate = ( profit_loss_rate = (
((current_price - _avg) / _avg) * 100 if _avg > 0 ((current_price - _avg) / _avg) * 100 if _avg > 0
else float(holding.profit_loss_rate or 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) take_profit = stock.get("take_profit", 3.0)
stop_loss = stock.get("stop_loss", -1.5) 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) atr = float(stock.get("atr", 0) or 0)
_avg_price = float(holding.average_price or 0) _avg_price = float(holding.average_price or 0)
exit_reason_detail = f"손익률 {profit_loss_rate:.2f}%" exit_reason_detail = f"손익률 {profit_loss_rate:.2f}%"
if atr > 0 and _avg_price > 0: 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 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) 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: 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}" 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"): if not await _acquire_order_lock(stock_code, "SELL"):
return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"} return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"}
try: try:
# ── 1. 중복 주문 체크 (10초 이내) ──────────────────────────────────── # ── 1. 중복 주문 체크 (30초 이내, FAILED 제외) ───────────────────────
recent_cutoff = _get_kst_now() - timedelta(seconds=10) # FAILED 주문은 제외하여 오류 이후 재매도 허용
recent_cutoff = _get_kst_now() - timedelta(seconds=30)
duplicate_check = await db.execute( duplicate_check = await db.execute(
select(TradeHistory).where( select(TradeHistory).where(
TradeHistory.stock_code == stock_code, TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL", TradeHistory.order_type == "SELL",
TradeHistory.status.in_(["COMPLETED", "PARTIAL", "PENDING"]),
TradeHistory.created_at >= recent_cutoff, TradeHistory.created_at >= recent_cutoff,
) )
) )
@@ -639,7 +753,7 @@ async def _execute_sell(
return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name}
# ── 2. 주문 실행 (종목별 세마포어 + 재시도) ────────────────────────── # ── 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): async with _get_order_semaphore(stock_code):
order_result = await _place_order_with_retry( order_result = await _place_order_with_retry(
is_domestic=is_domestic, is_domestic=is_domestic,
@@ -651,11 +765,12 @@ async def _execute_sell(
) )
total_amount = Decimal(str(current_price)) * quantity 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" success = order_result.get("rt_cd") == "0"
is_pending = order_result.get("rt_cd") == "1" is_pending = order_result.get("rt_cd") == "1"
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED") status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")
# TradeHistory DB 기록
trade = TradeHistory( trade = TradeHistory(
rule_id=stock.get("rule_id"), rule_id=stock.get("rule_id"),
stock_code=stock_code, stock_code=stock_code,
@@ -677,11 +792,12 @@ async def _execute_sell(
executed_at=_get_kst_now() if success else None, executed_at=_get_kst_now() if success else None,
) )
db.add(trade) db.add(trade)
await db.flush() # id 확보 await db.flush() # auto-increment id 확보
trade_id = trade.id trade_id = trade.id
await db.commit() await db.commit()
if success: if success:
# 인메모리 포트폴리오 캐시에서 해당 종목 즉시 제거 (UI 실시간 반영)
try: try:
from app.api.endpoints.portfolio import portfolio_cache from app.api.endpoints.portfolio import portfolio_cache
avg = float(holding.average_price or current_price) avg = float(holding.average_price or current_price)
@@ -691,6 +807,7 @@ async def _execute_sell(
] ]
except Exception: except Exception:
pass pass
# 3초 후 KIS 체결 확인 (슬리피지 기록 포함)
asyncio.create_task( asyncio.create_task(
_verify_and_sync_execution( _verify_and_sync_execution(
order_id=order_result.get("odno", ""), order_id=order_result.get("odno", ""),
@@ -706,6 +823,7 @@ async def _execute_sell(
) )
) )
elif is_pending: elif is_pending:
# PENDING 상태 → 60초 후 미체결 자동 취소 시도
order_id_val = order_result.get("odno") order_id_val = order_result.get("odno")
if order_id_val: if order_id_val:
asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity)) 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): 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): if not isinstance(recommendations_response, dict):
logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response) logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response)
return return
@@ -739,6 +865,7 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn
total_processed = 0 total_processed = 0
trade_results = [] trade_results = []
# 시장 국면 조회 (BEAR 시 max_trades 축소, position_multiplier 감소)
market_regime = None market_regime = None
if settings.BEAR_MARKET_FILTER_ENABLED: if settings.BEAR_MARKET_FILTER_ENABLED:
market_regime = await check_market_regime() 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) max_trades = min(20, regime_max_trades)
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) 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", []) rec_groups = recommendations_response.get("recommendations_by_rule", [])
if isinstance(rec_groups, dict): if isinstance(rec_groups, dict):
normalized_list = [] 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}) normalized_list.append({"rule_name": name, "stocks": group})
rec_groups = normalized_list rec_groups = normalized_list
# 종목 코드 기준으로 중복 제거 (rule_priority 낮은 쪽 = 더 중요한 규칙 우선)
unique_stocks: dict = {} unique_stocks: dict = {}
for rule_group in rec_groups: for rule_group in rec_groups:
if not isinstance(rule_group, dict): 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: if stock_code not in unique_stocks:
unique_stocks[stock_code] = stock unique_stocks[stock_code] = stock
elif stock.get("rule_priority", 999) < unique_stocks[stock_code].get("rule_priority", 999): 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( sorted_stocks = sorted(
unique_stocks.values(), unique_stocks.values(),
key=lambda s: (s.get("rule_priority", 999), -s.get("score", 0)), 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) logger.warning("[AUTO_TRADE_BG] Skipping invalid stock entry: %r", stock)
continue continue
try: try:
stock["_position_multiplier"] = position_multiplier stock["_position_multiplier"] = position_multiplier # 국면별 배율 주입
stock["_regime_str"] = ( stock["_regime_str"] = (
market_regime.get("regime_200d", "bear") if market_regime else "bear" 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"]: if result["action"] in ["BUY", "SELL"]:
trade_results.append(result) trade_results.append(result)
await asyncio.sleep(0.5) await asyncio.sleep(0.5) # KIS API 레이트 리밋: 초당 2회 이하 권장
except Exception as e: except Exception as e:
logger.error(f"[AUTO_TRADE_BG] {stock.get('stock_code')} 처리 오류: {type(e).__name__}: {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·손실추적·보유수·당일매도쿨다운·신호분석)를 통과한 후 공통 필터(가격·MDD·손실추적·보유수·당일매도쿨다운·신호분석)를 통과한 후
_execute_buy / _execute_sell 에 위임한다. _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: try:
stock_code = stock.get("stock_code") 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: if not stock_code or current_price <= 0:
return {"action": "SKIP", "reason": "invalid_data"} return {"action": "SKIP", "reason": "invalid_data"}
# 국내(6자리 숫자) vs 해외 판별
is_domestic = stock_code.isdigit() and len(stock_code) == 6 is_domestic = stock_code.isdigit() and len(stock_code) == 6
exchange_code = stock.get("exchange_code", "NASD") exchange_code = stock.get("exchange_code", "NASD")
@@ -862,22 +1006,26 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
# ── MDD / 손실추적 / 보유수 ────────────────────────────────────────── # ── MDD / 손실추적 / 보유수 ──────────────────────────────────────────
if not await mdd_manager.can_open_new_position(): if not await mdd_manager.can_open_new_position():
# MDD -10% 이상 낙폭 → 신규 진입 중단
return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name} return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name}
if loss_tracker.is_banned(stock_code): if loss_tracker.is_banned(stock_code):
# 3회 연속 손실 종목 → 30거래일 진입 금지
return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name} return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name}
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
if len(held_result.scalars().all()) >= settings.MAX_HOLDINGS_COUNT: if len(held_result.scalars().all()) >= settings.MAX_HOLDINGS_COUNT:
# 동시 보유 20종목 한도 초과
return {"action": "SKIP", "reason": "max_holdings_reached", "stock": stock_name} 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) cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
recent_sell = await db.execute( recent_sell = await db.execute(
select(TradeHistory).where( select(TradeHistory).where(
TradeHistory.stock_code == stock_code, TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL", TradeHistory.order_type == "SELL",
TradeHistory.status == "EXECUTED", TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
TradeHistory.executed_at >= cutoff_time, TradeHistory.executed_at >= cutoff_time,
) )
) )
@@ -891,7 +1039,7 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
Portfolio.quantity > 0, Portfolio.quantity > 0,
) )
) )
holding = result.scalar_one_or_none() holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단
# ── 신호 분석 (Enum 기반) ───────────────────────────────────────────── # ── 신호 분석 (Enum 기반) ─────────────────────────────────────────────
text_signals = " ".join(signals) text_signals = " ".join(signals)
@@ -900,16 +1048,21 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
except Exception: except Exception:
score_val = 0.0 score_val = 0.0
# "거래량부족" 신호 제거 후 매수 판단 (거래량 부족 단독으로는 매수 차단)
signals_without_volume_weak = [s for s in signals if "거래량부족" not in s] signals_without_volume_weak = [s for s in signals if "거래량부족" not in s]
text_signals_filtered = " ".join(signals_without_volume_weak) text_signals_filtered = " ".join(signals_without_volume_weak)
should_buy = any(k in text_signals_filtered for k in BUY_KEYWORDS) should_buy = any(k in text_signals_filtered for k in BUY_KEYWORDS)
if not signals_without_volume_weak and signals: 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) 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_score_trade = stock.get("ml_score")
ml_confidence_trade = stock.get("ml_confidence") ml_confidence_trade = stock.get("ml_confidence")
ml_signal_trade = stock.get("ml_signal") ml_signal_trade = stock.get("ml_signal")
@@ -932,12 +1085,13 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
ml_signal_trade == "HOLD" ml_signal_trade == "HOLD"
and weighted_score < settings.ML_ENSEMBLE_BUY_THRESHOLD * 0.7 and weighted_score < settings.ML_ENSEMBLE_BUY_THRESHOLD * 0.7
): ):
should_buy = False should_buy = False # ML이 HOLD 강하게 권고 시 매수 억제
except Exception as e: except Exception as e:
logger.debug(f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}") logger.debug(f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}")
has_rule = stock.get("rule_id") is not None has_rule = stock.get("rule_id") is not None
if has_rule: if has_rule:
# 규칙 기반 종목: BLOCK 신호만 없으면 should_buy = True (규칙 자체가 매수 신호)
if any(b in text_signals for b in BLOCK_KEYWORDS): if any(b in text_signals for b in BLOCK_KEYWORDS):
return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"} 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: if not ml_hold_active:
should_buy = True should_buy = True
else: else:
# 비규칙 종목: BLOCK 신호 있으면 과열 HOLD
if any(b in text_signals for b in BLOCK_KEYWORDS): if any(b in text_signals for b in BLOCK_KEYWORDS):
return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"} return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"}
if should_buy and not has_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) 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) 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): if not has_strong_signal and (matched_count < 2 or score_val < 2):
should_buy = False should_buy = False
# ── ATR 기반 강제 손절 (신호 없어도 발동) ──────────────────────────── # ── ATR 기반 강제 손절 (신호 없어도 발동) ────────────────────────────
# 현재가가 평균매입가 - 2.5×ATR 이하이면 매도 신호 없어도 강제 손절
if holding and not should_sell: if holding and not should_sell:
atr = float(stock.get("atr", 0) or 0) atr = float(stock.get("atr", 0) or 0)
avg_price = float(holding.average_price or 0) avg_price = float(holding.average_price or 0)

View File

@@ -1,3 +1,11 @@
"""
WebSocket 연결 관리 모듈
- 클라이언트 연결/해제 및 인증 관리
- 종목별 구독(subscribe/unsubscribe/set_subscriptions) 관리
- 개별 클라이언트, 전체, 종목 구독자 타겟 메시지 전송
- 싱글톤 websocket_manager 인스턴스 제공 (main.py 에서 import)
"""
from typing import Dict, Set from typing import Dict, Set
from fastapi import WebSocket from fastapi import WebSocket
from app.core.json_helpers import _dumps from app.core.json_helpers import _dumps

View File

@@ -1,17 +1,20 @@
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from 'react-router-dom'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import TradingRules from './pages/TradingRules'
import TradeHistory from './pages/TradeHistory' import TradeHistory from './pages/TradeHistory'
import MCPStatus from './pages/MCPStatus' import MCPStatus from './pages/MCPStatus'
import StrategySettings from './pages/StrategySettings'
import Layout from './components/Layout'
function App() { function App() {
return ( return (
<Routes> <Layout>
<Route path="/" element={<Dashboard />} /> <Routes>
<Route path="/rules" element={<TradingRules />} /> <Route path="/" element={<Dashboard />} />
<Route path="/trades" element={<TradeHistory />} /> <Route path="/trades" element={<TradeHistory />} />
<Route path="/mcp" element={<MCPStatus />} /> <Route path="/mcp" element={<MCPStatus />} />
</Routes> <Route path="/strategy" element={<StrategySettings />} />
</Routes>
</Layout>
) )
} }

View File

@@ -1,6 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import HtsHeader from './HtsHeader'
import './HtsPage.css' import './HtsPage.css'
interface HtsPageProps { interface HtsPageProps {
@@ -12,7 +11,6 @@ interface HtsPageProps {
const HtsPage = ({ children, className, contentClassName }: HtsPageProps) => { const HtsPage = ({ children, className, contentClassName }: HtsPageProps) => {
return ( return (
<div className={clsx('hts-page', className)}> <div className={clsx('hts-page', className)}>
<HtsHeader />
<div className={clsx('hts-content', contentClassName)}> <div className={clsx('hts-content', contentClassName)}>
{children} {children}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Link, useLocation } from 'react-router-dom' 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 { useQuery } from '@tanstack/react-query'
import api from '../lib/api' import api from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket' import { useWebSocket } from '../hooks/useWebSocket'
@@ -20,7 +20,7 @@ const Layout = ({ children }: LayoutProps) => {
const navItems = [ const navItems = [
{ path: '/', icon: LayoutDashboard, label: '대시보드' }, { path: '/', icon: LayoutDashboard, label: '대시보드' },
{ path: '/rules', icon: FileText, label: '전략 룰' }, { path: '/strategy', icon: SlidersHorizontal, label: '전략 설정' },
{ path: '/trades', icon: History, label: '매매 로그' }, { path: '/trades', icon: History, label: '매매 로그' },
{ path: '/mcp', icon: Cpu, label: 'MCP 상태' }, { path: '/mcp', icon: Cpu, label: 'MCP 상태' },
] ]

View File

@@ -8,7 +8,6 @@ import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
import OrderbookPanel from '../components/OrderbookPanel' import OrderbookPanel from '../components/OrderbookPanel'
import RecommendationPanel from '../components/RecommendationPanel' import RecommendationPanel from '../components/RecommendationPanel'
import JournalPanel from '../components/JournalPanel' import JournalPanel from '../components/JournalPanel'
import { Activity } from 'lucide-react'
import './Dashboard.css' import './Dashboard.css'
// 마켓 운영 시간 체크 (한국장 + 미국장) // 마켓 운영 시간 체크 (한국장 + 미국장)
@@ -112,7 +111,7 @@ const Dashboard = () => {
const portfolioSummary = portfolioCombined?.summary const portfolioSummary = portfolioCombined?.summary
// MCP 상태 - 30초 (UI 정보용, 느려도 무방) // MCP 상태 - 30초 (UI 정보용, 느려도 무방)
const { data: mcpStatus } = useQuery({ useQuery({
queryKey: ['mcp-status'], queryKey: ['mcp-status'],
queryFn: async () => (await api.get('/mcp/status')).data, queryFn: async () => (await api.get('/mcp/status')).data,
refetchInterval: isMarketOpen ? 30000 : false, refetchInterval: isMarketOpen ? 30000 : false,
@@ -242,11 +241,11 @@ const Dashboard = () => {
if (!portfolio) return if (!portfolio) return
setLivePrices(prev => { setLivePrices(prev => {
const next = { ...prev } const next = { ...prev }
;(portfolio as any[]).forEach((s: any) => { ; (portfolio as any[]).forEach((s: any) => {
if (s.current_price && !next[s.stock_code]) { if (s.current_price && !next[s.stock_code]) {
next[s.stock_code] = Number(s.current_price) next[s.stock_code] = Number(s.current_price)
} }
}) })
return next return next
}) })
}, [portfolio]) }, [portfolio])
@@ -502,31 +501,7 @@ const Dashboard = () => {
}, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock]) }, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock])
return ( return (
<div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col h-screen overflow-hidden"> <div className="bg-[#0f1115] text-gray-100 font-sans flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
<header className="border-b border-gray-800 bg-[#161b22] px-6 py-4 flex justify-between items-center z-50 backdrop-blur-md bg-opacity-90 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="bg-blue-600 p-2 rounded-lg shadow-lg shadow-blue-500/20">
<Activity className="text-white" size={20} />
</div>
<div>
<h1 className="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
AutoStock Trader
</h1>
<p className="text-xs text-gray-500">Gen 3 AI Algorithm</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800 rounded-full border border-gray-700">
<div className={`w-2 h-2 rounded-full ${wsConnected ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]' : 'bg-red-500'}`} />
<span className="text-xs text-gray-400">{wsConnected ? '실시간 연결됨' : '연결 끊김'}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800 rounded-full border border-gray-700">
<div className={`w-2 h-2 rounded-full ${mcpStatus?.status === 'connected' ? 'bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]' : 'bg-yellow-500'}`} />
<span className="text-xs text-gray-400">MCP: {mcpStatus?.status || 'Unknown'}</span>
</div>
</div>
</header>
<main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-4 overflow-hidden"> <main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-4 overflow-hidden">
<div className="flex-shrink-0"> <div className="flex-shrink-0">

View File

@@ -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<string, string> = {
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 (
<div className={`relative bg-slate-900 border rounded-lg p-3 transition-all ${
!cfg.is_active
? 'border-slate-800 opacity-40'
: isModified
? 'border-yellow-500/40 bg-yellow-500/5'
: dirty
? 'border-blue-500/40 bg-blue-500/5'
: 'border-slate-800 hover:border-slate-700'
}`}>
{/* 상태 뱃지 */}
{(isModified || dirty) && (
<span className={`absolute top-1.5 right-1.5 text-[9px] px-1 py-0.5 rounded font-medium ${
dirty ? 'bg-blue-500/20 text-blue-400' : 'bg-yellow-500/20 text-yellow-400'
}`}>
{dirty ? '미저장' : '수정됨'}
</span>
)}
{/* 레이블 + 설명 */}
<p className="text-xs font-medium text-slate-200 pr-8 leading-snug">{cfg.label}</p>
{cfg.description && (
<p className="text-[10px] text-slate-600 mt-0.5 mb-2 line-clamp-1">{cfg.description}</p>
)}
{/* 컨트롤 영역 */}
<div className="flex items-center justify-between mt-2">
{isBool ? (
<button
onClick={() => onSave(cfg.key, isTrue ? 'false' : 'true')}
disabled={!cfg.is_active}
className={`relative w-9 h-5 rounded-full transition-colors ${isTrue ? 'bg-blue-500' : 'bg-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${isTrue ? 'translate-x-4' : 'translate-x-0.5'}`} />
</button>
) : (
<div className="flex items-center gap-1.5">
<input
type={cfg.value_type === 'int' || cfg.value_type === 'float' ? 'number' : 'text'}
value={editValue}
onChange={(e) => 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"
/>
<span className="text-[10px] text-slate-600">/ {cfg.default_value}</span>
</div>
)}
{/* 액션 버튼 */}
<div className="flex items-center gap-0.5">
{!isBool && (
<button
onClick={handleSave}
disabled={!dirty || isSaving}
title="저장"
className="p-1 rounded text-blue-400 hover:bg-blue-500/10 disabled:opacity-20 disabled:cursor-not-allowed transition-colors"
>
<Save size={11} />
</button>
)}
<button
onClick={() => onToggle(cfg.key)}
title={cfg.is_active ? '비활성화' : '활성화'}
className={`p-1 rounded transition-colors ${cfg.is_active ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-700'}`}
>
{cfg.is_active ? <Power size={11} /> : <PowerOff size={11} />}
</button>
<button
onClick={handleReset}
title="기본값으로 초기화"
className="p-1 rounded text-slate-500 hover:text-yellow-400 hover:bg-yellow-500/10 transition-colors"
>
<RotateCcw size={10} />
</button>
</div>
</div>
</div>
)
}
// ── 그룹 섹션 컴포넌트 ────────────────────────────────────────────────────────
const GROUP_ICONS: Record<string, string> = {
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 (
<div>
{/* 그룹 헤더 */}
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center justify-between py-2 mb-3 group"
>
<div className="flex items-center gap-2">
<span>{GROUP_ICONS[groupKey] || '📁'}</span>
<span className="font-semibold text-slate-200 text-sm">
{GROUP_LABELS[groupKey] || groupKey}
</span>
<span className="text-xs text-slate-600">{configs.length}개</span>
{modifiedCount > 0 && (
<span className="text-xs px-2 py-0.5 bg-yellow-500/15 text-yellow-400 rounded-full">
{modifiedCount}개 수정됨
</span>
)}
</div>
{collapsed
? <ChevronRight size={15} className="text-slate-500" />
: <ChevronDown size={15} className="text-slate-500" />
}
</button>
{/* 파라미터 카드 그리드 */}
{!collapsed && (
<div className="grid grid-cols-3 gap-2 mb-4">
{configs.map((cfg) => (
<ConfigRow
key={cfg.key}
cfg={cfg}
onSave={onSave}
onToggle={onToggle}
onReset={onReset}
isSaving={isSaving}
/>
))}
</div>
)}
</div>
)
}
// ── 메인 페이지 ───────────────────────────────────────────────────────────────
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<StrategyConfig[]>({
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<string, StrategyConfig[]> = {}
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 (
<HtsPage>
<div className="flex items-center justify-center h-64 text-slate-400">
로딩 중...
</div>
</HtsPage>
)
}
return (
<HtsPage>
<div className="space-y-5">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white">전략 설정</h2>
<p className="text-sm text-slate-500 mt-1">
변경 즉시 캐시 무효화 → 30초 이내 전략 코드에 반영됩니다.
비활성화 시 config.py 기본값이 사용됩니다.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => refetch()}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-400 hover:text-white border border-slate-700 rounded-lg hover:border-slate-500 transition-colors"
>
<RefreshCw size={14} />
새로고침
</button>
<button
onClick={() => {
if (confirm('모든 파라미터를 config.py 기본값으로 초기화하시겠습니까?')) {
resetAllMutation.mutate()
}
}}
disabled={resetAllMutation.isPending || totalModified === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-yellow-400 border border-yellow-500/40 rounded-lg hover:bg-yellow-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={14} />
전체 초기화 {totalModified > 0 && `(${totalModified})`}
</button>
</div>
</div>
{/* 알림 배너 */}
{notification && (
<div className={`px-4 py-2.5 rounded-lg text-sm font-medium ${
notification.type === 'success'
? 'bg-green-500/15 text-green-400 border border-green-500/30'
: 'bg-red-500/15 text-red-400 border border-red-500/30'
}`}>
{notification.msg}
</div>
)}
{/* 파라미터 없음 */}
{configs.length === 0 && (
<div className="text-center py-16 text-slate-500">
<p>전략 파라미터가 없습니다.</p>
<p className="text-xs mt-2">DB 마이그레이션 후 서버를 재시작하면 기본값이 자동 생성됩니다.</p>
</div>
)}
{/* 그룹별 섹션 — 2열 그리드 */}
<div className="grid grid-cols-3 gap-5 items-start">
{[
...GROUP_ORDER.filter(g => grouped[g]?.length),
...Object.keys(grouped).filter(g => !GROUP_ORDER.includes(g) && grouped[g]?.length),
].map(groupKey => (
<div key={groupKey} className="bg-slate-900/40 border border-slate-800 rounded-xl p-4">
<GroupSection
groupKey={groupKey}
configs={grouped[groupKey]}
onSave={(key, value) => updateMutation.mutate({ key, value })}
onToggle={(key) => toggleMutation.mutate(key)}
onReset={(key) => resetOneMutation.mutate(key)}
isSaving={updateMutation.isPending}
/>
</div>
))}
</div>
</div>
</HtsPage>
)
}
export default StrategySettings