From 096e087a9ad2e82e6e0ceaaca1f89dc866929082 Mon Sep 17 00:00:00 2001 From: shuim Date: Tue, 10 Mar 2026 09:49:03 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/worktrees/naughty-carson | 1 + backend/app/api/endpoints/backtest.py | 2 + backend/app/services/backtest_engine.py | 11 +- backend/app/services/market_analyzer.py | 32 +- .../app/services/mean_reversion_strategy.py | 18 +- backend/app/services/naver_finance.py | 121 +++ backend/batch_daily_analysis.py | 73 +- frontend/src/pages/StrategySettings.tsx | 962 +++++++++++++----- 8 files changed, 930 insertions(+), 290 deletions(-) create mode 160000 .claude/worktrees/naughty-carson create mode 100644 backend/app/services/naver_finance.py diff --git a/.claude/worktrees/naughty-carson b/.claude/worktrees/naughty-carson new file mode 160000 index 0000000..3a08cd8 --- /dev/null +++ b/.claude/worktrees/naughty-carson @@ -0,0 +1 @@ +Subproject commit 3a08cd86093af1b844408a4cef5e5715a0506344 diff --git a/backend/app/api/endpoints/backtest.py b/backend/app/api/endpoints/backtest.py index fc8b9bd..3f5f7a2 100644 --- a/backend/app/api/endpoints/backtest.py +++ b/backend/app/api/endpoints/backtest.py @@ -74,6 +74,8 @@ async def _get_preset(preset_key: str) -> Optional[Dict[str, Any]]: "MEAN_REVERSION_TAKE_PROFIT_PCT": strategy_cfg.get("TAKE_PROFIT_PERCENT", 12.0), "MAX_TOTAL_POSITIONS": strategy_cfg.get("MAX_HOLDINGS_COUNT", 5), "MEAN_REVERSION_RSI_THRESHOLD": strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", 30.0), + "RSI_OVERBOUGHT_LEVEL": strategy_cfg.get("RSI_OVERBOUGHT_LEVEL", 70.0), + "ATR_STOP_MULTIPLIER": strategy_cfg.get("ATR_STOP_MULTIPLIER", 2.5), } return {"name": "현재 설정", "params": params} return _STRATEGY_PRESETS.get(preset_key) diff --git a/backend/app/services/backtest_engine.py b/backend/app/services/backtest_engine.py index 3f5d830..36b1e80 100644 --- a/backend/app/services/backtest_engine.py +++ b/backend/app/services/backtest_engine.py @@ -268,9 +268,10 @@ class BacktestEngine: rsi_threshold = params.get("MEAN_REVERSION_RSI_THRESHOLD", 30) stop_loss = params.get("MEAN_REVERSION_STOP_LOSS_PCT", -6.0) / 100 take_profit = params.get("MEAN_REVERSION_TAKE_PROFIT_PCT", 12.0) / 100 - + rsi_overbought = params.get("RSI_OVERBOUGHT_LEVEL", 70.0) + current_price = df.loc[current_idx, "close"] - + # 매수 신호 (과매도 + BB 하단) if rsi < rsi_threshold and bb_pos < 0.2: if stock_code not in self.positions: @@ -279,12 +280,12 @@ class BacktestEngine: "price": current_price, "reason": f"RSI={rsi:.1f} BB하단" } - + # 매도 신호 (손절/익절) if stock_code in self.positions: pos = self.positions[stock_code] pnl_pct = (current_price - pos.avg_price) / pos.avg_price - + if pnl_pct <= stop_loss: return { "action": "SELL", @@ -297,7 +298,7 @@ class BacktestEngine: "price": current_price, "reason": f"익절 {pnl_pct*100:.1f}%" } - elif rsi > 70 and bb_pos > 0.8: + elif rsi > rsi_overbought and bb_pos > 0.8: return { "action": "SELL", "price": current_price, diff --git a/backend/app/services/market_analyzer.py b/backend/app/services/market_analyzer.py index 710604c..f2be348 100644 --- a/backend/app/services/market_analyzer.py +++ b/backend/app/services/market_analyzer.py @@ -534,31 +534,15 @@ async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Opt return await analyze_stock(stock_code, stock_name, criteria) async def get_all_tradable_stocks() -> List[tuple]: - SKIP_KEYWORDS = [ - "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", - "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE", - "선물", "스팩", "리츠", "SPAC", - ] + """네이버 파이낸스에서 KOSPI+KOSDAQ 시가총액 순위 종목 조회. + + KIS volume-rank API 대신 네이버 파이낸스 크롤링을 사용한다. + 실서버 API 키 불필요. + """ try: - _list_api = _real_market_api or korea_investment_api - await asyncio.sleep(3.0) - kospi_result = await _list_api.get_stock_list("J") - await asyncio.sleep(3.0) - kosdaq_result = await _list_api.get_stock_list("Q") - - stocks = [] - for result in (kospi_result, kosdaq_result): - if "output" in result: - for item in result["output"]: - stock_code = item.get("mksc_shrn_iscd", "") - stock_name = item.get("hts_kor_isnm", "") - if not stock_code or not stock_name: - continue - if any(kw in stock_name for kw in SKIP_KEYWORDS): - continue - stocks.append((stock_code, stock_name)) - + from app.services.naver_finance import get_naver_stock_list + stocks = await get_naver_stock_list(max_pages_per_market=10) return stocks except Exception as e: - logger.error(f"Error fetching stock list from KIS API: {e}", exc_info=True) + logger.error(f"네이버 파이낸스 종목 조회 실패: {e}", exc_info=True) return [] \ No newline at end of file diff --git a/backend/app/services/mean_reversion_strategy.py b/backend/app/services/mean_reversion_strategy.py index 03aac24..2d37580 100644 --- a/backend/app/services/mean_reversion_strategy.py +++ b/backend/app/services/mean_reversion_strategy.py @@ -25,7 +25,7 @@ from typing import Optional, Dict, List, Tuple from dataclasses import dataclass from enum import Enum -from app.core.config import settings +from app.services.strategy_config_service import strategy_cfg logger = logging.getLogger(__name__) @@ -225,7 +225,7 @@ def check_buy_signal( return None if regime == MarketRegime.BULL: - rsi_ok = rsi <= settings.MEAN_REVERSION_RSI_THRESHOLD # ← 기본값 30 + rsi_ok = rsi <= strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", 30.0) if rsi_ok: # [2026-03] RSI 반등 확인: rsi_prev 제공 시 RSI 하락 중이면 진입 금지 @@ -303,8 +303,10 @@ def check_exit_signal( # ── 1. 손절 (최우선) ────────────────────────────────────────── # [2026-03] ATR 손절 기준: lowest_since_entry → entry_price # 이유: 매수 직후 저점이 낮으면 ATR 손절가가 현재가보다 훨씬 아래 → 손절 사실상 무력화 - atr_stop = entry_price - 2.5 * atr - pct_stop = entry_price * 0.94 # -6% + atr_stop_mult = strategy_cfg.get("ATR_STOP_MULTIPLIER", 2.5) + stop_loss_pct = strategy_cfg.get("STOP_LOSS_PERCENT", -6.0) # e.g. -6.0 + atr_stop = entry_price - atr_stop_mult * atr + pct_stop = entry_price * (1 + stop_loss_pct / 100) stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절 if close <= stop_price: @@ -392,13 +394,13 @@ def calculate_position_size( if rsi is not None: if rsi <= 20: - multiplier = settings.MEAN_REVERSION_MULTIPLIER_STRONG + multiplier = strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_STRONG", 1.50) elif rsi <= 25: - multiplier = settings.MEAN_REVERSION_MULTIPLIER_NORMAL + multiplier = strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_NORMAL", 1.25) else: - multiplier = settings.MEAN_REVERSION_MULTIPLIER_WEAK + multiplier = strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_WEAK", 1.00) else: - multiplier = settings.MEAN_REVERSION_MULTIPLIER_WEAK + multiplier = strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_WEAK", 1.00) if adx is not None and adx < 15: multiplier = min(multiplier + 0.25, 2.0) diff --git a/backend/app/services/naver_finance.py b/backend/app/services/naver_finance.py new file mode 100644 index 0000000..89fb75e --- /dev/null +++ b/backend/app/services/naver_finance.py @@ -0,0 +1,121 @@ +"""네이버 파이낸스 종목 목록 크롤러 + +KIS volume-rank API 대신 네이버 시가총액 순위 페이지에서 +KOSPI/KOSDAQ 종목 목록을 가져온다. + +사용처: + - batch_daily_analysis.get_all_tradable_stocks() DB 폴백 이전 1차 시도 + - market_analyzer.get_all_tradable_stocks() 대체 + +URL: https://finance.naver.com/sise/sise_market_sum.naver?sosok=0&page=N + sosok=0 : KOSPI + sosok=1 : KOSDAQ +""" + +import asyncio +import logging +from typing import List, Tuple + +import httpx +from bs4 import BeautifulSoup + +logger = logging.getLogger(__name__) + +_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"} +_BASE_URL = "https://finance.naver.com/sise/sise_market_sum.naver" + +SKIP_KEYWORDS = [ + "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", + "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE", + "선물", "스팩", "리츠", "SPAC", +] + +_MIN_PRICE = 5_000 # 5,000원 미만 제외 + + +def _parse_page(html: bytes) -> List[Tuple[str, str, int]]: + """HTML 파싱 → [(종목코드, 종목명, 현재가), ...]""" + soup = BeautifulSoup(html, "html.parser", from_encoding="euc-kr") + table = soup.find("table", {"class": "type_2"}) + if not table: + return [] + + result = [] + for row in table.find_all("tr"): + a = row.find("a", class_="tltle") + if not a: + continue + href = a.get("href", "") + if "code=" not in href: + continue + code = href.split("code=")[1] + name = a.text.strip() + + # 현재가 + tds = row.find_all("td", class_="number") + try: + price = int(tds[0].text.strip().replace(",", "")) if tds else 0 + except (ValueError, IndexError): + price = 0 + + result.append((code, name, price)) + return result + + +async def _fetch_page(client: httpx.AsyncClient, sosok: int, page: int) -> List[Tuple[str, str, int]]: + try: + resp = await client.get( + _BASE_URL, + params={"sosok": sosok, "page": page}, + headers=_HEADERS, + timeout=10, + ) + resp.raise_for_status() + return _parse_page(resp.content) + except Exception as exc: + logger.warning("네이버 파이낸스 조회 실패 sosok=%d page=%d: %s", sosok, page, exc) + return [] + + +async def get_naver_stock_list( + max_pages_per_market: int = 10, + min_price: int = _MIN_PRICE, +) -> List[Tuple[str, str]]: + """KOSPI + KOSDAQ 시가총액 순위 종목 조회. + + Args: + max_pages_per_market: 시장별 최대 페이지 수 (페이지당 ~50종목) + min_price: 최소 현재가 필터 (기본 5,000원) + + Returns: + [(종목코드, 종목명), ...] — ETF/레버리지/스팩 제외, 가격 필터 적용 + """ + stocks: List[Tuple[str, str]] = [] + seen_codes: set = set() + + async with httpx.AsyncClient() as client: + for sosok, market_name in ((0, "KOSPI"), (1, "KOSDAQ")): + market_stocks = [] + for page in range(1, max_pages_per_market + 1): + rows = await _fetch_page(client, sosok, page) + if not rows: + break # 마지막 페이지 + + for code, name, price in rows: + if code in seen_codes: + continue + if price < min_price: + continue + name_upper = name.upper() + if any(kw.upper() in name_upper for kw in SKIP_KEYWORDS): + continue + seen_codes.add(code) + market_stocks.append((code, name)) + + await asyncio.sleep(0.3) # 서버 부하 방지 + + logger.info("네이버 %s: %d개 종목 수집", market_name, len(market_stocks)) + stocks.extend(market_stocks) + + logger.info("네이버 파이낸스 총 %d개 종목 수집", len(stocks)) + return stocks diff --git a/backend/batch_daily_analysis.py b/backend/batch_daily_analysis.py index 6fc9994..7d26e42 100644 --- a/backend/batch_daily_analysis.py +++ b/backend/batch_daily_analysis.py @@ -31,7 +31,7 @@ sys.path.insert(0, str(Path(__file__).parent)) from app.core.config import settings from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용 -from sqlalchemy import select as sa_select +from sqlalchemy import select as sa_select, func as sa_func from app.models.models import StockDailyPrice from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화 from app.services.market_analyzer import analyze_stock_for_batch_db, StockRecommendation @@ -118,14 +118,64 @@ async def detect_and_log_market_regime() -> str: # 2. 종목 목록 조회 # ------------------------------------------------------------------- +async def _get_stocks_from_db(limit: int = 500) -> list[tuple[str, str]]: + """stock_daily_prices DB에서 종목 목록 조회 (KIS API 실패 시 폴백). + + 최근 거래일 기준으로 데이터가 있는 종목을 조회한다. + ETF / 레버리지 / 인버스 / 스팩 등 특수종목은 이름 기반으로 제외. + """ + SKIP_KEYWORDS = [ + "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", + "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE", + "선물", "스팩", "리츠", "SPAC", + ] + + async with AsyncSessionLocal() as db: + # 최근 거래일 조회 + latest_date_result = await db.execute( + sa_select(sa_func.max(StockDailyPrice.trade_date)) + ) + latest_date = latest_date_result.scalar() + + if not latest_date: + logger.warning("⚠️ stock_daily_prices 테이블이 비어 있음") + return [] + + logger.info("📂 DB 폴백: %s 기준 종목 조회", latest_date) + + # 최근 거래일 종목 전체 조회 (종목코드, 종목명) + result = await db.execute( + sa_select(StockDailyPrice.stock_code, StockDailyPrice.stock_name) + .where(StockDailyPrice.trade_date == latest_date) + .where(StockDailyPrice.close_price > 5000) + .order_by(StockDailyPrice.volume.desc()) + .limit(limit * 3) # 필터 후 충분한 수가 남도록 여유분 조회 + ) + rows = result.all() + + stocks = [] + for code, name in rows: + if not name: + continue + name_upper = name.upper() + if any(kw.upper() in name_upper for kw in SKIP_KEYWORDS): + continue + stocks.append((code, name)) + if len(stocks) >= limit: + break + + logger.info("📂 DB 폴백 결과: %d개 종목 (%s 기준)", len(stocks), latest_date) + return stocks + + async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]: """KIS 거래량 순위 API로 거래 가능 종목 조회 (KOSPI+KOSDAQ 각 top 40). KIS FHPST01710000: 거래량 순위 — 5,000원 이상 활성 종목. ETF / 레버리지 / 스팩 등 특수종목 자동 제외. - Raises: - RuntimeError: 조회 결과가 MIN_STOCK_COUNT(50) 미만인 경우 배치 중단 + KIS API 결과가 MIN_STOCK_COUNT(50) 미만이면 stock_daily_prices DB 폴백을 사용한다. + DB 폴백도 실패하면 RuntimeError를 발생시켜 배치를 중단한다. """ from app.services.market_analyzer import get_all_tradable_stocks as _kis_get_stocks @@ -133,10 +183,21 @@ async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]: stocks = await _kis_get_stocks() if len(stocks) < MIN_STOCK_COUNT: - raise RuntimeError( - f"종목 조회 실패: KIS API 결과 부족 ({len(stocks)}개 < {MIN_STOCK_COUNT}개). " - f"KIS API 설정을 확인하세요." + logger.warning( + "⚠️ KIS API 결과 부족 (%d개 < %d개) — DB 폴백 사용", + len(stocks), MIN_STOCK_COUNT, ) + db_stocks = await _get_stocks_from_db(limit) + + if len(db_stocks) < MIN_STOCK_COUNT: + raise RuntimeError( + f"종목 조회 실패: KIS API({len(stocks)}개) 및 DB 폴백({len(db_stocks)}개) 모두 " + f"최소 기준({MIN_STOCK_COUNT}개) 미달. KIS API 설정 및 DB 데이터를 확인하세요." + ) + + result = db_stocks[:limit] + logger.info("✅ DB 폴백 선정 %d개", len(result)) + return result result = stocks[:limit] logger.info("✅ 선정 %d개", len(result)) diff --git a/frontend/src/pages/StrategySettings.tsx b/frontend/src/pages/StrategySettings.tsx index d0377aa..e6a30f6 100644 --- a/frontend/src/pages/StrategySettings.tsx +++ b/frontend/src/pages/StrategySettings.tsx @@ -1,8 +1,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../lib/api' -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect } from 'react' import HtsPage from '../components/HtsPage' -import { RotateCcw, Save, Power, PowerOff, ChevronDown, ChevronUp, RefreshCw, Zap, Info } from 'lucide-react' +import { RotateCcw, RefreshCw, Zap, Info } from 'lucide-react' import { STRATEGY_PRESETS, PRESET_ORDER, @@ -28,52 +28,398 @@ interface StrategyConfig { updated_by: string | null } -// ── 통합 파라미터 매핑 ──────────────────────────────────────────────────────── +// ── 소수(0~1)로 저장되지만 % 단위로 입력·표시할 파라미터 ───────────────────────── -// 핵심 설정만 (6개) -const ESSENTIAL_PARAMS = [ - 'RSI_OVERSOLD_LEVEL', // RSI 기준 (통합) - 'MEAN_REVERSION_STOP_LOSS_PCT', // 손절 (%) - 'MEAN_REVERSION_TAKE_PROFIT_PCT', // 익절 (%) - 'MDD_FORCE_THRESHOLD', // 최대낙폭 한도 - 'MAX_HOLDINGS_COUNT', // 최대 보유 종목 - 'ML_ENSEMBLE_ENABLED', // ML 검증 필터 +const PERCENT_DISPLAY_KEYS = new Set([ + 'MAX_POSITION_SIZE', + 'MEAN_REVERSION_MAX_CAPITAL', + 'BEAR_MARKET_POSITION_RATIO', + 'TREND_TOLERANCE_PCT', + 'SHORT_TERM_MIN_ATR_RATIO', + 'DAY_TRADE_STOP_LOSS_PCT', + 'DAY_TRADE_DAILY_LOSS_LIMIT', + 'DAY_TRADE_TRAILING_TRIGGER', + 'DAY_TRADE_CAPITAL_RATIO', + 'DAY_TRADE_PER_STOCK_RATIO', + 'DAY_TRADE_ORB_STOP_LOSS_PCT', + 'DAY_TRADE_REVERSION_STOP_LOSS_PCT', + 'DAY_TRADE_ORB_BREAKOUT_MARGIN', +]) + +// ── 파라미터 표시명 ─────────────────────────────────────────────────────────────── + +const PARAM_DISPLAY_NAMES: Record = { + 'RSI_OVERSOLD_LEVEL': 'RSI 과매도 기준', + 'RSI_OVERBOUGHT_LEVEL': 'RSI 과열 기준', + 'RSI_PERIOD': 'RSI 기간', + 'RSI_RECOVERY_RANGE_MIN': 'RSI 바닥탈출 하한', + 'RSI_RECOVERY_RANGE_MAX': 'RSI 바닥탈출 상한', + 'RSI_STABLE_RANGE_MIN': 'RSI 안정구간 하한', + 'RSI_STABLE_RANGE_MAX': 'RSI 안정구간 상한', + 'BOLLINGER_PERIOD': '볼린저 기간', + 'BOLLINGER_STDDEV': '볼린저 밴드 폭', + 'ATR_PERIOD': 'ATR 기간', + 'MA_SHORT_PERIOD': '단기 MA 기간', + 'MA_MEDIUM_PERIOD': '중기 MA 기간', + 'MA_LONG_PERIOD': '장기 MA 기간', + 'MAX_HOLDINGS_COUNT': '최대 보유 종목수', + 'MDD_FORCE_THRESHOLD': '최대낙폭 강제청산 (%)', + 'MDD_PAUSE_THRESHOLD': '신규진입 중단 기준 (%)', + 'MAX_DAILY_TRADES': '일일 최대 매매 횟수', + 'CONSECUTIVE_LOSS_BAN_COUNT': '연속손실 금지 횟수', + 'CONSECUTIVE_LOSS_BAN_DAYS': '연속손실 금지 기간 (일)', + 'MIN_PRICE_KRW': '최소 가격 (원)', + 'MIN_PRICE_USD': '최소 가격 ($)', + 'TREND_FILTER_ENABLED': '추세 필터', + 'TREND_MA_SHORT': '추세 단기 MA', + 'TREND_MA_LONG': '추세 장기 MA', + 'TREND_TOLERANCE_PCT': '이평선 이탈 허용폭 (%)', + 'OVERHEAT_FILTER_ENABLED':'급등 과열 필터', + 'OVERHEAT_3DAY_MAX': '3일 급등 상한 (%)', + 'OVERHEAT_5DAY_MAX': '5일 급등 상한 (%)', + 'OVERHEAT_10DAY_MAX': '10일 급등 상한 (%)', + 'OVERHEAT_DISPARITY_MAX': '이격도 상한', + 'BEAR_MARKET_FILTER_ENABLED': '하락장 필터', + 'ML_ENSEMBLE_ENABLED': 'ML 신호 검증', + 'MCP_VALIDATION_ENABLED': 'AI 최종 판단', + 'ML_ENSEMBLE_RULE_WEIGHT':'룰 기반 점수 비중', + 'ML_ENSEMBLE_ML_WEIGHT': 'ML 점수 비중', + // 스윙 + 'STOP_LOSS_PERCENT': '기본 손절선 (%)', + 'STOP_LOSS_HIGH_VOL': '고변동 손절선 (%)', + 'TAKE_PROFIT_PERCENT': '기본 익절선 (%)', + 'MAX_POSITION_SIZE': '종목당 최대 비중 (%)', + 'MEAN_REVERSION_MAX_CAPITAL': '평균회귀 최대 자본 (%)', + 'ATR_STOP_MULTIPLIER': '손절 ATR 배수', + 'ATR_TRAILING_MULTIPLIER':'트레일링 ATR 배수', + 'MEAN_REVERSION_MULTIPLIER_STRONG': '강한 신호 포지션 배수', + 'MEAN_REVERSION_MULTIPLIER_NORMAL': '중간 신호 포지션 배수', + 'MEAN_REVERSION_MULTIPLIER_WEAK': '약한 신호 포지션 배수', + 'BEAR_MARKET_MIN_SCORE': '하락장 최소 진입점수', + 'BEAR_MARKET_MAX_TRADES': '하락장 최대 매매수', + 'BEAR_MARKET_POSITION_RATIO': '하락장 포지션 비율 (%)', + 'ML_ENSEMBLE_BUY_THRESHOLD': 'ML 매수 최소점수', + 'ML_ENSEMBLE_CONFIDENCE_MIN': 'ML 최소 신뢰도', + 'MCP_CONFIDENCE_THRESHOLD': 'AI 최소 신뢰도', + 'AUTO_TRADE_TOP_N_PER_RULE': '규칙당 최대 매매 종목', + 'ANALYSIS_STOCK_COUNT': '배치 분석 최대 종목수', + 'WATCHLIST_ENTRY_SCORE': '관심종목 추가 점수', + 'WATCHLIST_EXIT_SCORE': '관심종목 제거 점수', + 'WATCHLIST_INIT_SIZE': '관심종목 초기화 크기', + 'WATCHLIST_MAX_SIZE': '관심종목 최대 수', + // 데이트레이딩 + 'DAY_TRADE_MAX_POSITIONS': '동시 보유 최대 종목', + 'DAY_TRADE_CAPITAL_RATIO': 'DT 자본 비율 (%)', + 'DAY_TRADE_PER_STOCK_RATIO': 'DT 종목당 비율 (%)', + 'DAY_TRADE_DAILY_LOSS_LIMIT': 'DT 일일 손실 한도 (%)', + 'DAY_TRADE_STOP_LOSS_PCT': 'DT 손절선 (%)', + 'DAY_TRADE_TRAILING_TRIGGER': 'DT 트레일링 발동 (%)', + 'DAY_TRADE_TRAILING_ATR_MULT':'DT 트레일링 ATR 배수', + 'DAY_TRADE_ORB_VOL_MULT': 'ORB 거래량 배수', + 'DAY_TRADE_ORB_STOP_LOSS_PCT':'ORB 손절선 (%)', + 'DAY_TRADE_ORB_BREAKOUT_MARGIN': 'ORB 돌파 여유율 (%)', + 'DAY_TRADE_REVERSION_STOP_LOSS_PCT': 'VWAP 손절선 (%)', + 'DAY_TRADE_RSI_BREAKEVEN_THRESHOLD': 'RSI_BB 진입 기준 (RSI)', +} + +// ── 파라미터 설명 ──────────────────────────────────────────────────────────────── + +const PARAM_DESCRIPTIONS: Record = { + 'RSI_OVERSOLD_LEVEL': '이 값 이하로 RSI가 떨어지면 과매도로 판단해 매수 검토. 낮출수록 진입 조건이 까다로워짐 (권장 30~40)', + 'RSI_OVERBOUGHT_LEVEL': '이 값 이상이면 과열 판정 → 신규 매수 차단. 70이 표준, 낮추면 더 보수적', + 'RSI_PERIOD': 'RSI 계산에 사용하는 봉 수. 14봉이 표준. 낮추면 신호 빈도 증가, 노이즈도 증가', + 'RSI_RECOVERY_RANGE_MIN':'RSI가 이 값 이상이면 바닥 탈출 신호 시작', + 'RSI_RECOVERY_RANGE_MAX':'RSI가 이 값 이하여야 바닥 탈출 신호로 인정', + 'RSI_STABLE_RANGE_MIN': 'RSI가 이 값 이상이면 안정 상승 구간으로 판단', + 'RSI_STABLE_RANGE_MAX': 'RSI가 이 값 이하여야 안정 상승으로 인정', + 'BOLLINGER_PERIOD': '볼린저 밴드 기준 기간. 낮추면 밴드가 좁아져 신호 증가 (20이 표준)', + 'BOLLINGER_STDDEV': '배수가 높을수록 밴드 폭이 넓어져 신호 감소. 2.0이 표준', + 'ATR_PERIOD': '변동성(ATR) 계산 기간. 손절·트레일링 계산에 사용 (14이 표준)', + 'MA_SHORT_PERIOD': '단기 이동평균 기간 (5일이 표준)', + 'MA_MEDIUM_PERIOD': '중기 이동평균 기간 (20일이 표준)', + 'MA_LONG_PERIOD': '장기 이동평균 기간 (60일이 표준)', + 'MAX_HOLDINGS_COUNT': '동시에 보유 가능한 최대 종목 수. 많을수록 분산되지만 관리가 어려움 (권장 5~15)', + 'MDD_FORCE_THRESHOLD': '포트폴리오 누적 손실이 이 값(%)에 도달하면 전체 포지션 50% 강제 청산 (권장 -15 ~ -25)', + 'MDD_PAUSE_THRESHOLD': '포트폴리오 손실이 이 값(%)에 도달하면 신규 매수 일시 중단 (권장 -8 ~ -12)', + 'MAX_DAILY_TRADES': '하루 신규 매수 최대 횟수. 낮추면 더 보수적 (5~10 권장)', + 'CONSECUTIVE_LOSS_BAN_COUNT': '연속 N회 손실 발생 시 해당 종목 매수 금지 (권장 2~4회)', + 'CONSECUTIVE_LOSS_BAN_DAYS': '연속 손실 매수 금지 유지 기간 (거래일 수, 권장 20~60일)', + 'MIN_PRICE_KRW': '이 가격(원) 미만 저가주는 매수하지 않음', + 'MIN_PRICE_USD': '해외 주식 최저 매수 가격 (달러)', + 'TREND_FILTER_ENABLED': 'ON이면 상승 추세 종목만 매수. 더 보수적이며 매매 횟수 감소', + 'TREND_MA_SHORT': '추세 판별용 단기 이동평균 기간', + 'TREND_MA_LONG': '추세 판별용 장기 이동평균 기간', + 'TREND_TOLERANCE_PCT': '이동평균 이탈 허용 비율. 예: 0.5 입력 시 0.5% 허용', + 'OVERHEAT_FILTER_ENABLED':'ON이면 단기 급등 종목 추격 매수 차단. 고점 물림 방지', + 'OVERHEAT_3DAY_MAX': '3일 이내 이 % 이상 급등한 종목은 매수 안 함 (15% 권장)', + 'OVERHEAT_5DAY_MAX': '5일 이내 이 % 이상 급등한 종목은 매수 안 함 (20% 권장)', + 'OVERHEAT_10DAY_MAX': '10일 이내 이 % 이상 급등한 종목은 매수 안 함 (30% 권장)', + 'OVERHEAT_DISPARITY_MAX':'20일 이평선 이격도 상한. 108 = 20일선보다 8% 이상 올라간 종목 차단', + 'BEAR_MARKET_FILTER_ENABLED': 'ON이면 코스피 하락장 감지 시 매매 자동 축소. 하락장 피해 방지', + 'ML_ENSEMBLE_ENABLED': 'ON이면 AI 모델 검증을 통과한 종목만 매매. OFF하면 더 많이 매매하지만 신호 품질 저하', + 'MCP_VALIDATION_ENABLED':'ON이면 매매 전 Claude AI가 최종 판단. 정확도 향상, 속도 저하', + 'ML_ENSEMBLE_RULE_WEIGHT':'룰 기반 점수 비중. ML 비중과 합산 1.0 (예: 0.3이면 룰 30%, ML 70%)', + 'ML_ENSEMBLE_ML_WEIGHT': 'ML 점수 비중. 룰 비중과 합산 1.0', + 'STOP_LOSS_PERCENT': '저변동 종목 손절선. -6이면 산 가격에서 6% 손실 시 자동 매도 (권장 -4 ~ -8)', + 'STOP_LOSS_HIGH_VOL': '고변동 종목 손절선. 저변동 손절보다 넓게 설정해 조기 손절 방지 (권장 -8 ~ -12)', + 'TAKE_PROFIT_PERCENT': '기본 익절선. 5이면 5% 수익 시 절반 자동 매도 (권장 4~10)', + 'MAX_POSITION_SIZE': '1종목당 전체 자본의 최대 투입 비율. 예: 4 입력 시 4% 적용. 낮출수록 리스크 분산 (권장 3~5)', + 'MEAN_REVERSION_MAX_CAPITAL': '평균회귀 전략에 투입 가능한 자본 한도. 예: 30 입력 시 전체의 30% (권장 20~40)', + 'ATR_STOP_MULTIPLIER': 'ATR × 이 값 = 손절 폭. 높이면 손절 폭 넓어짐 (2.5가 표준)', + 'ATR_TRAILING_MULTIPLIER':'ATR × 이 값 = 트레일링 스탑. 낮추면 수익 실현이 빠름 (1.5가 표준)', + 'MEAN_REVERSION_MULTIPLIER_STRONG': '강한 신호 시 기본 포지션의 몇 배 투입 (1.5 = 1.5배)', + 'MEAN_REVERSION_MULTIPLIER_NORMAL': '중간 신호 시 포지션 배수', + 'MEAN_REVERSION_MULTIPLIER_WEAK': '약한 신호 시 포지션 배수', + 'BEAR_MARKET_MIN_SCORE': '하락장 시 이 점수 이상 우량 종목만 매수 (1~20, 높을수록 보수적)', + 'BEAR_MARKET_MAX_TRADES':'하락장 시 하루 최대 신규 매수 횟수 (낮게 설정 권장)', + 'BEAR_MARKET_POSITION_RATIO': '하락장 시 포지션 크기 축소 비율. 예: 50 입력 시 평소의 50%로 축소', + 'ML_ENSEMBLE_BUY_THRESHOLD': 'ML 점수가 이 값 이상이어야 매수 신호 인정 (0~100, 높을수록 보수적)', + 'ML_ENSEMBLE_CONFIDENCE_MIN': 'ML 예측 신뢰도 최솟값 (0~1). 높이면 더 보수적, 낮추면 신호 증가', + 'MCP_CONFIDENCE_THRESHOLD': 'AI 신뢰도 최솟값 (0~1). 낮추면 더 많이 매매, 높이면 보수적', + 'AUTO_TRADE_TOP_N_PER_RULE': '매매 규칙 1개당 자동 실행 최대 종목 수', + 'ANALYSIS_STOCK_COUNT': '아침 배치에서 분석할 최대 종목 수 (많을수록 느림)', + 'WATCHLIST_ENTRY_SCORE': '이 점수 이상이면 관심종목에 추가 (1~100)', + 'WATCHLIST_EXIT_SCORE': '이 점수 이하이면 관심종목에서 제거 (0~60)', + 'WATCHLIST_INIT_SIZE': '아침 배치 시 관심종목 초기화 크기', + 'WATCHLIST_MAX_SIZE': '최대 관심종목 수', + 'DAY_TRADE_MAX_POSITIONS': '데이트레이딩 동시 보유 최대 종목 수 (3 권장)', + 'DAY_TRADE_CAPITAL_RATIO': '데이트레이딩 전용 자본 비율. 예: 25 입력 시 전체 자본의 25% 사용', + 'DAY_TRADE_PER_STOCK_RATIO': '데이트레이딩 종목당 최대 투입 비율. 예: 10 입력 시 10%', + 'DAY_TRADE_DAILY_LOSS_LIMIT': '일일 손실 이 값(%) 초과 시 당일 매매 중단. 예: -3 입력 시 -3% 이상 손실 시 중단', + 'DAY_TRADE_STOP_LOSS_PCT': '데이트레이딩 손절선. 예: 2 입력 시 2% 손실에 자동 매도. 너무 좁으면 잦은 손절 (권장 1~3)', + 'DAY_TRADE_TRAILING_TRIGGER': '이 수익률(%) 초과 시 트레일링 스탑 발동. 예: 1.5 입력 시 1.5% 수익 이후 추적 매도', + 'DAY_TRADE_TRAILING_ATR_MULT':'트레일링 스탑 ATR 배수. 높이면 더 넓게 잡아 수익 극대화 시도', + 'DAY_TRADE_ORB_VOL_MULT': 'ORB 돌파 확인 최소 거래량 배수 (1.5 = 평소의 1.5배)', + 'DAY_TRADE_ORB_STOP_LOSS_PCT':'ORB 전략 손절선. 예: 1.5 입력 시 1.5% 손실 시 매도', + 'DAY_TRADE_ORB_BREAKOUT_MARGIN': 'ORB 돌파 판정 최소 여유율. 예: 0.2 입력 시 0.2% 이상 돌파 시 신호', + 'DAY_TRADE_REVERSION_STOP_LOSS_PCT': 'VWAP/RSI_BB 전략 손절선. 예: 1.5 입력 시 1.5%', + 'DAY_TRADE_RSI_BREAKEVEN_THRESHOLD': 'RSI_BB 전략에서 매수 진입을 허용할 RSI 상한값. 이 값 이하일 때만 진입 (권장 30~45)', +} + +// ── 숨김 파라미터 (화면에서 제외) ───────────────────────────────────────────────── + +const HIDDEN_PARAMS = new Set([ + 'DAY_TRADE_EOD_HOUR', // DAY_TRADE_EOD_FLAT_HOUR와 동일 + 'DAY_TRADE_EOD_MIN', // DAY_TRADE_EOD_FLAT_MIN와 동일 + 'MEAN_REVERSION_RSI_THRESHOLD', // RSI_OVERSOLD_LEVEL과 동일 + // 15분봉 단기 전략 파라미터 (스윙·데이트레이딩으로 단순화) + 'SHORT_TERM_MIN_ATR_RATIO', + 'SHORT_TERM_RSI_LOOKBACK', + 'SHORT_TERM_VOLUME_MULTIPLIER', +]) + +// ── 기본 보기에서 숨기는 2차 파라미터 ──────────────────────────────────────────── + +const SECONDARY_PARAMS = new Set([ + // 공통 지표 세부 + 'RSI_RECOVERY_RANGE_MIN', 'RSI_RECOVERY_RANGE_MAX', + 'RSI_STABLE_RANGE_MIN', 'RSI_STABLE_RANGE_MAX', + 'MA_SHORT_PERIOD', 'MA_MEDIUM_PERIOD', 'MA_LONG_PERIOD', + // 공통 리스크 세부 + 'CONSECUTIVE_LOSS_BAN_DAYS', 'MIN_PRICE_KRW', 'MIN_PRICE_USD', + // 필터 세부 + 'TREND_MA_SHORT', 'TREND_MA_LONG', 'TREND_TOLERANCE_PCT', + // AI/ML 가중치 + 'ML_ENSEMBLE_RULE_WEIGHT', 'ML_ENSEMBLE_ML_WEIGHT', + // 스윙 신호 배수 + 'MEAN_REVERSION_MULTIPLIER_STRONG', 'MEAN_REVERSION_MULTIPLIER_NORMAL', 'MEAN_REVERSION_MULTIPLIER_WEAK', + // 스윙 기타 세부 + 'WATCHLIST_INIT_SIZE', 'WATCHLIST_MAX_SIZE', + 'WATCHLIST_ENTRY_SCORE', 'WATCHLIST_EXIT_SCORE', + 'ANALYSIS_STOCK_COUNT', + // 데이트레이딩 세부 + 'DAY_TRADE_TRAILING_ATR_MULT', + 'DAY_TRADE_ORB_BREAKOUT_MARGIN', +]) + +// ── 카드 내 표시 순서 ───────────────────────────────────────────────────────────── + +const PARAM_PRIORITY: Record = { + // 공통 지표 + 'RSI_PERIOD': 1, 'RSI_OVERBOUGHT_LEVEL': 2, 'RSI_OVERSOLD_LEVEL': 3, + 'BOLLINGER_PERIOD': 4, 'BOLLINGER_STDDEV': 5, 'ATR_PERIOD': 6, + 'MA_SHORT_PERIOD': 10, 'MA_MEDIUM_PERIOD': 11, 'MA_LONG_PERIOD': 12, + 'RSI_RECOVERY_RANGE_MIN': 20, 'RSI_RECOVERY_RANGE_MAX': 21, + 'RSI_STABLE_RANGE_MIN': 22, 'RSI_STABLE_RANGE_MAX': 23, + // 공통 리스크 + 'MAX_HOLDINGS_COUNT': 1, 'MDD_FORCE_THRESHOLD': 2, 'MDD_PAUSE_THRESHOLD': 3, + 'MAX_DAILY_TRADES': 4, + 'CONSECUTIVE_LOSS_BAN_COUNT': 10, 'CONSECUTIVE_LOSS_BAN_DAYS': 11, + 'MIN_PRICE_KRW': 20, 'MIN_PRICE_USD': 21, + // 진입 필터 + 'TREND_FILTER_ENABLED': 1, 'OVERHEAT_FILTER_ENABLED': 2, 'BEAR_MARKET_FILTER_ENABLED': 3, + 'OVERHEAT_3DAY_MAX': 4, 'OVERHEAT_5DAY_MAX': 5, + 'OVERHEAT_10DAY_MAX': 6, 'OVERHEAT_DISPARITY_MAX': 7, + 'TREND_MA_SHORT': 20, 'TREND_MA_LONG': 21, 'TREND_TOLERANCE_PCT': 22, + // AI 공통 + 'ML_ENSEMBLE_ENABLED': 1, 'MCP_VALIDATION_ENABLED': 2, + 'ML_ENSEMBLE_RULE_WEIGHT': 10, 'ML_ENSEMBLE_ML_WEIGHT': 11, + // 스윙 손절/익절/포지션 + 'STOP_LOSS_PERCENT': 1, 'STOP_LOSS_HIGH_VOL': 2, 'TAKE_PROFIT_PERCENT': 3, + 'MAX_POSITION_SIZE': 4, 'MEAN_REVERSION_MAX_CAPITAL': 5, + 'ATR_STOP_MULTIPLIER': 6, 'ATR_TRAILING_MULTIPLIER': 7, + // 스윙 신호 배수 + 'MEAN_REVERSION_MULTIPLIER_STRONG': 1, + 'MEAN_REVERSION_MULTIPLIER_NORMAL': 2, + 'MEAN_REVERSION_MULTIPLIER_WEAK': 3, + // 스윙 하락장 + 'BEAR_MARKET_MIN_SCORE': 1, 'BEAR_MARKET_MAX_TRADES': 2, 'BEAR_MARKET_POSITION_RATIO': 3, + // 스윙 ML 상세 + 'ML_ENSEMBLE_BUY_THRESHOLD': 1, 'ML_ENSEMBLE_CONFIDENCE_MIN': 2, 'MCP_CONFIDENCE_THRESHOLD': 3, + // 스윙 기타 + 'AUTO_TRADE_TOP_N_PER_RULE': 1, 'ANALYSIS_STOCK_COUNT': 2, + 'WATCHLIST_ENTRY_SCORE': 10, 'WATCHLIST_EXIT_SCORE': 11, + 'WATCHLIST_INIT_SIZE': 12, 'WATCHLIST_MAX_SIZE': 13, + // 데이트레이딩 + 'DAY_TRADE_MAX_POSITIONS': 1, 'DAY_TRADE_CAPITAL_RATIO': 2, + 'DAY_TRADE_PER_STOCK_RATIO': 3, 'DAY_TRADE_DAILY_LOSS_LIMIT': 4, + 'DAY_TRADE_STOP_LOSS_PCT': 1, 'DAY_TRADE_TRAILING_TRIGGER': 2, 'DAY_TRADE_TRAILING_ATR_MULT': 3, + 'DAY_TRADE_ORB_VOL_MULT': 1, 'DAY_TRADE_ORB_STOP_LOSS_PCT': 2, 'DAY_TRADE_ORB_BREAKOUT_MARGIN': 3, + 'DAY_TRADE_REVERSION_STOP_LOSS_PCT': 1, 'DAY_TRADE_RSI_BREAKEVEN_THRESHOLD': 2, +} + +// ── 탭 타입 ─────────────────────────────────────────────────────────────────────── + +type TabKey = 'common' | 'swing' | 'daytrading' + +// ── 공통 카드 ───────────────────────────────────────────────────────────────────── + +const COMMON_CARDS = [ + { + id: 'indicators', + label: '📊 지표 공통', + keys: [ + 'RSI_PERIOD', 'RSI_OVERBOUGHT_LEVEL', 'RSI_OVERSOLD_LEVEL', + 'BOLLINGER_PERIOD', 'BOLLINGER_STDDEV', 'ATR_PERIOD', + 'MA_SHORT_PERIOD', 'MA_MEDIUM_PERIOD', 'MA_LONG_PERIOD', + 'RSI_RECOVERY_RANGE_MIN', 'RSI_RECOVERY_RANGE_MAX', + 'RSI_STABLE_RANGE_MIN', 'RSI_STABLE_RANGE_MAX', + ], + }, + { + id: 'risk_common', + label: '🛡️ 공통 리스크', + keys: [ + 'MAX_HOLDINGS_COUNT', 'MDD_FORCE_THRESHOLD', 'MDD_PAUSE_THRESHOLD', + 'MAX_DAILY_TRADES', + 'CONSECUTIVE_LOSS_BAN_COUNT', 'CONSECUTIVE_LOSS_BAN_DAYS', + 'MIN_PRICE_KRW', 'MIN_PRICE_USD', + ], + }, + { + id: 'entry_filter', + label: '🚫 진입 필터', + keys: [ + 'TREND_FILTER_ENABLED', 'OVERHEAT_FILTER_ENABLED', 'BEAR_MARKET_FILTER_ENABLED', + 'OVERHEAT_3DAY_MAX', 'OVERHEAT_5DAY_MAX', 'OVERHEAT_10DAY_MAX', 'OVERHEAT_DISPARITY_MAX', + 'TREND_MA_SHORT', 'TREND_MA_LONG', 'TREND_TOLERANCE_PCT', + ], + }, + { + id: 'ai_common', + label: '🤖 AI·ML 공통', + keys: [ + 'ML_ENSEMBLE_ENABLED', 'MCP_VALIDATION_ENABLED', + 'ML_ENSEMBLE_RULE_WEIGHT', 'ML_ENSEMBLE_ML_WEIGHT', + ], + }, ] -// 파라미터 표시명 재정의 (더 직관적으로) -const PARAM_DISPLAY_NAMES: Record = { - 'RSI_OVERSOLD_LEVEL': 'RSI 과매도 기준', - 'MEAN_REVERSION_STOP_LOSS_PCT': '손절선 (%)', - 'MEAN_REVERSION_TAKE_PROFIT_PCT': '익절선 (%)', - 'MDD_FORCE_THRESHOLD': '최대낙폭 한도 (%)', - 'MAX_HOLDINGS_COUNT': '최대 보유 종목수', - 'ML_ENSEMBLE_ENABLED': 'ML 신호 검증', - 'DAY_TRADE_ENABLED': '급등 종목 제외', -} +// ── 스윙 카드 ───────────────────────────────────────────────────────────────────── -// 파라미터 설명 재정의 -const PARAM_DESCRIPTIONS: Record = { - 'RSI_OVERSOLD_LEVEL': '낮을수록 진입 조건이 까다로워집니다 (권장: 30~40)', - 'MEAN_REVERSION_STOP_LOSS_PCT': '마이너스 값, 빠른 손절로 손실 최소화 (권장: -3 ~ -6)', - 'MEAN_REVERSION_TAKE_PROFIT_PCT': '플러스 값, 너무 높으면 익절 기회 감소 (권장: 4~10)', - 'MDD_FORCE_THRESHOLD': '누적 손실이 이 값 도달 시 전량 청산 (권장: -8 ~ -15)', - 'MAX_HOLDINGS_COUNT': '동시에 보유 가능한 최대 종목 수 (권장: 3~10)', - 'ML_ENSEMBLE_ENABLED': 'AI 모델 검증 통과한 종목만 매매', - 'DAY_TRADE_ENABLED': '3일/5일/10일 급등 종목 자동 제외', -} +const SWING_CARDS = [ + { + id: 'swing_sltp', + label: '📦 손절·익절·포지션', + keys: [ + 'STOP_LOSS_PERCENT', 'STOP_LOSS_HIGH_VOL', 'TAKE_PROFIT_PERCENT', + 'MAX_POSITION_SIZE', 'MEAN_REVERSION_MAX_CAPITAL', + 'ATR_STOP_MULTIPLIER', 'ATR_TRAILING_MULTIPLIER', + ], + }, + { + id: 'swing_signal', + label: '📈 신호 배수', + keys: [ + 'MEAN_REVERSION_MULTIPLIER_STRONG', + 'MEAN_REVERSION_MULTIPLIER_NORMAL', + 'MEAN_REVERSION_MULTIPLIER_WEAK', + ], + }, + { + id: 'swing_bear', + label: '🐻 하락장 설정', + keys: [ + 'BEAR_MARKET_MIN_SCORE', 'BEAR_MARKET_MAX_TRADES', 'BEAR_MARKET_POSITION_RATIO', + ], + }, + { + id: 'swing_ml', + label: '🤖 ML 상세', + keys: [ + 'ML_ENSEMBLE_BUY_THRESHOLD', 'ML_ENSEMBLE_CONFIDENCE_MIN', 'MCP_CONFIDENCE_THRESHOLD', + ], + }, + { + id: 'swing_misc', + label: '⚙️ 기타', + keys: [ + 'AUTO_TRADE_TOP_N_PER_RULE', 'ANALYSIS_STOCK_COUNT', + 'WATCHLIST_ENTRY_SCORE', 'WATCHLIST_EXIT_SCORE', + 'WATCHLIST_INIT_SIZE', 'WATCHLIST_MAX_SIZE', + ], + }, +] -const GROUP_LABELS: Record = { - INDICATOR: '📊 기술 지표', - RISK: '🛡️ 리스크 관리', - FILTER: '🔍 진입 필터', - DAY_TRADE: '⚡ 데이트레이딩', - MEAN_REVERSION: '↩️ 평균회귀 전략', - GENERAL: '⚙️ 일반 설정', -} +// ── 데이트레이딩 카드 ────────────────────────────────────────────────────────────── -const GROUP_ORDER = ['GENERAL', 'RISK', 'FILTER', 'INDICATOR', 'MEAN_REVERSION', 'DAY_TRADE'] +const DT_CARDS = [ + { + id: 'dt_basic', + label: '💰 자본·종목', + keys: [ + 'DAY_TRADE_MAX_POSITIONS', 'DAY_TRADE_CAPITAL_RATIO', + 'DAY_TRADE_PER_STOCK_RATIO', 'DAY_TRADE_DAILY_LOSS_LIMIT', + ], + }, + { + id: 'dt_exit', + label: '🚪 손절·트레일링', + keys: [ + 'DAY_TRADE_STOP_LOSS_PCT', + 'DAY_TRADE_TRAILING_TRIGGER', 'DAY_TRADE_TRAILING_ATR_MULT', + 'DAY_TRADE_EOD_FLAT_HOUR', // TimePairControl + 'DAY_TRADE_ENTRY_CUTOFF_HOUR', // TimePairControl + ], + }, + { + id: 'dt_orb', + label: '📈 ORB 전략', + keys: [ + 'DAY_TRADE_ORB_VOL_MULT', + 'DAY_TRADE_ORB_STOP_LOSS_PCT', + 'DAY_TRADE_ORB_BREAKOUT_MARGIN', + ], + }, + { + id: 'dt_reversion', + label: '〰️ VWAP·RSI_BB', + keys: [ + 'DAY_TRADE_REVERSION_STOP_LOSS_PCT', + 'DAY_TRADE_RSI_BREAKEVEN_THRESHOLD', + ], + }, +] -// ── 프리셋 카드 (개선) ──────────────────────────────────────────────────────── +// ── 시:분 쌍 → 하나의 컨트롤로 합침 ───────────────────────────────────────────── + +const TIME_PAIRS = [ + { hour: 'DAY_TRADE_EOD_FLAT_HOUR', min: 'DAY_TRADE_EOD_FLAT_MIN', label: 'EOD 강제 청산', desc: '장 마감 전 강제 청산 시작 시각', priority: 8 }, + { hour: 'DAY_TRADE_ENTRY_CUTOFF_HOUR', min: 'DAY_TRADE_ENTRY_CUTOFF_MIN', label: '신규 진입 마감', desc: '이 시각 이후 신규 진입 불가', priority: 9 }, +] +const PAIR_KEYS = new Set(TIME_PAIRS.flatMap(p => [p.hour, p.min])) + +// ── 프리셋 카드 ─────────────────────────────────────────────────────────────────── interface PresetCardProps { presetKey: PresetKey @@ -83,11 +429,11 @@ interface PresetCardProps { function PresetCard({ presetKey, isSelected, onSelect }: PresetCardProps) { const preset = STRATEGY_PRESETS[presetKey] - + const riskBadge = { - low: { label: '🛡️ 안정형', color: 'bg-green-500/15 text-green-400 border-green-500/40' }, + low: { label: '🛡️ 안정형', color: 'bg-green-500/15 text-green-400 border-green-500/40' }, medium: { label: '💼 균형형', color: 'bg-blue-500/15 text-blue-400 border-blue-500/40' }, - high: { label: '🚀 공격형', color: 'bg-red-500/15 text-red-400 border-red-500/40' }, + high: { label: '🚀 공격형', color: 'bg-red-500/15 text-red-400 border-red-500/40' }, }[preset.risk] return ( @@ -99,7 +445,6 @@ function PresetCard({ presetKey, isSelected, onSelect }: PresetCardProps) { : 'border-slate-700 bg-slate-900/80 hover:border-slate-600 hover:bg-slate-900' }`} > - {/* 선택 체크마크 */} {isSelected && (
@@ -107,8 +452,6 @@ function PresetCard({ presetKey, isSelected, onSelect }: PresetCardProps) {
)} - - {/* 이모지 + 이름 */}
{preset.emoji}
@@ -118,11 +461,7 @@ function PresetCard({ presetKey, isSelected, onSelect }: PresetCardProps) {
- - {/* 설명 */}

{preset.description}

- - {/* 핵심 지표 */}
기대 수익률 @@ -135,7 +474,7 @@ function PresetCard({ presetKey, isSelected, onSelect }: PresetCardProps) {
손절/익절 - {preset.params.MEAN_REVERSION_STOP_LOSS_PCT}% / + {preset.params.MEAN_REVERSION_STOP_LOSS_PCT}% / {preset.params.MEAN_REVERSION_TAKE_PROFIT_PCT}%
@@ -144,196 +483,212 @@ function PresetCard({ presetKey, isSelected, onSelect }: PresetCardProps) { ) } -// ── 개별 파라미터 컴포넌트 (간소화) ──────────────────────────────────────────── +// ── 시:분 쌍 컴포넌트 ───────────────────────────────────────────────────────────── + +interface TimePairControlProps { + hourCfg: StrategyConfig + minCfg: StrategyConfig + label: string + desc: string + onSave: (key: string, value: string) => void +} + +function TimePairControl({ hourCfg, minCfg, label, desc, onSave }: TimePairControlProps) { + const [hour, setHour] = useState(hourCfg.value) + const [min, setMin] = useState(minCfg.value) + const isModified = hourCfg.value !== hourCfg.default_value || minCfg.value !== minCfg.default_value + + const commitHour = (v: string) => { if (v !== hourCfg.value) onSave(hourCfg.key, v) } + const commitMin = (v: string) => { if (v !== minCfg.value) onSave(minCfg.key, v) } + + return ( +
+
+ + {isModified && 수정됨} +
+
+ setHour(e.target.value)} + onBlur={() => commitHour(hour)} + onKeyDown={e => e.key === 'Enter' && commitHour(hour)} + className="w-12 px-2 py-1 text-xs bg-slate-800 border border-slate-700 rounded text-white text-center font-mono focus:outline-none focus:border-blue-500" + /> + : + setMin(e.target.value)} + onBlur={() => commitMin(min)} + onKeyDown={e => e.key === 'Enter' && commitMin(min)} + className="w-12 px-2 py-1 text-xs bg-slate-800 border border-slate-700 rounded text-white text-center font-mono focus:outline-none focus:border-blue-500" + /> +
+
+ 기본: {hourCfg.default_value}:{minCfg.default_value.padStart(2, '0')} +
+
+ ) +} + +// ── 개별 파라미터 컴포넌트 ──────────────────────────────────────────────────────── interface ParamControlProps { cfg: StrategyConfig onSave: (key: string, value: string) => void isSaving: boolean - isEssential?: boolean } -function ParamControl({ cfg, onSave, isSaving, isEssential }: ParamControlProps) { - const [editValue, setEditValue] = useState(cfg.value) - const isDirty = editValue !== cfg.value +function ParamControl({ cfg, onSave }: ParamControlProps) { + const isPct = PERCENT_DISPLAY_KEYS.has(cfg.key) + + const toDisplay = (raw: string) => { + if (!isPct) return raw + const n = parseFloat(raw) + return isNaN(n) ? raw : String(Math.round(n * 10000) / 100) + } + const toSave = (display: string) => { + if (!isPct) return display + const n = parseFloat(display) + return isNaN(n) ? display : parseFloat((n / 100).toFixed(6)).toString() + } + + const [editValue, setEditValue] = useState(() => toDisplay(cfg.value)) + const [isFocused, setIsFocused] = useState(false) + + useEffect(() => { + if (!isFocused) { + setEditValue(toDisplay(cfg.value)) + } + }, [cfg.value]) // eslint-disable-line react-hooks/exhaustive-deps + + const isDirty = isPct + ? !isNaN(parseFloat(editValue)) && Math.abs(parseFloat(editValue) / 100 - parseFloat(cfg.value)) > 1e-9 + : editValue !== cfg.value const isModified = cfg.value !== cfg.default_value const displayName = PARAM_DISPLAY_NAMES[cfg.key] || cfg.label const displayDesc = PARAM_DESCRIPTIONS[cfg.key] || cfg.description - const handleSave = () => { - if (isDirty) { - onSave(cfg.key, editValue) - } - } - const isBool = cfg.value_type === 'bool' const boolValue = cfg.value === 'true' || cfg.value === 'True' || cfg.value === '1' + const hasRange = cfg.min_value != null && cfg.max_value != null + const step = isPct ? '0.1' : (cfg.value_type === 'float' ? '0.1' : '1') + const sliderMin = isPct && cfg.min_value ? String(parseFloat(cfg.min_value) * 100) : cfg.min_value + const sliderMax = isPct && cfg.max_value ? String(parseFloat(cfg.max_value) * 100) : cfg.max_value + + const commit = (val: string) => { + const saveVal = toSave(val) + const diff = isPct + ? Math.abs(parseFloat(saveVal) - parseFloat(cfg.value)) > 1e-9 + : saveVal !== cfg.value + if (diff) onSave(cfg.key, saveVal) + } return ( -
- {/* 상태 배지 */} - {(isModified || isDirty) && ( - - {isDirty ? '●' : '수정됨'} - - )} - - {/* 레이블 */} -
-