From a45007cbb23862aa1e021548f4911102ce3a67ec Mon Sep 17 00:00:00 2001 From: shuim Date: Sat, 17 Oct 2026 18:47:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20TradingView=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20+=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20API?= =?UTF-8?q?,=20Crisis=20=EC=86=90=EC=A0=88=20=EC=86=8C=EA=B8=89=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=ED=94=8C=EB=9E=98=EA=B7=B8(=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20OFF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frontend: 대시보드를 TradingView 레이아웃으로 재구성(components/tv/*) — 캔들+MA/BB/거래량, 평단·손절·목표·트레일링 가격선, 체결 마커, RSI 기준선은 실효 설정값, 하단 탭(보유·체결·DT 카드· 비용 후 손익곡선/낙폭·저널), 디자인 토큰·한국식 색·최소 11px, 모바일 가로 스크롤 제거 - backend(GET 전용, require_api_key): /positions/overlay, /risk/equity-history, /strategy-config/display, /trades 응답 필드(side·strategy_type·commission·net_profit_loss), /risk/mdd-status thresholds, /market-data/chart 1m|5m(캐시·in-flight 공유) - mean_reversion_strategy: RSI 청산 임계를 resolve_swing_exit_rsi_v2 로 추출(동작 동일) — 청산과 화면 표시가 같은 값을 쓴다 - §8.5-39: SWING_STOP_IGNORE_CRISIS_OVERLAY(기본 False) — 반사실 재생에서 Crisis 조기 손절이 오히려 손실을 줄여 기본 OFF 유지, 정식 백테스트 전 켜지 말 것 - 유닛 스위트 2816 passed / 0 failed, tsc·build 통과 Co-Authored-By: Claude Opus 5.5 --- backend/app/api/__init__.py | 8 +- backend/app/api/endpoints/market_data.py | 45 +++ backend/app/api/endpoints/risk_management.py | 7 + backend/app/api/endpoints/trades.py | 17 +- backend/app/api/endpoints/tv_overlay.py | 324 +++++++++++++++++ backend/app/core/config.py | 3 + .../app/services/mean_reversion_strategy.py | 42 ++- backend/app/services/tv_view_core.py | 149 ++++++++ .../test_swing_stop_ignore_crisis_20261017.py | 83 +++++ backend/tests/test_tv_overlay_20261017.py | 308 ++++++++++++++++ frontend/src/components/ChartPanel.tsx | 66 ++-- frontend/src/components/JournalPanel.tsx | 64 ++-- frontend/src/components/Layout.tsx | 22 +- frontend/src/components/OrderbookPanel.tsx | 36 +- frontend/src/components/PortfolioPanel.tsx | 76 ++-- .../src/components/RecommendationPanel.tsx | 74 ++-- frontend/src/components/tv/BottomTabs.tsx | 84 +++++ .../src/components/tv/DtPositionCards.tsx | 126 +++++++ frontend/src/components/tv/EquityPanel.tsx | 142 ++++++++ frontend/src/components/tv/RightPanel.tsx | 140 ++++++++ frontend/src/components/tv/SymbolBar.tsx | 130 +++++++ frontend/src/components/tv/TvChart.tsx | 335 ++++++++++++++++++ frontend/src/components/tv/chartUtils.ts | 105 ++++++ frontend/src/index.css | 27 ++ frontend/src/lib/tvApi.ts | 163 +++++++++ frontend/src/pages/Dashboard.tsx | 298 +++++++++++----- frontend/src/types/lightweight-charts.d.ts | 71 ---- frontend/tailwind.config.js | 13 + 28 files changed, 2607 insertions(+), 351 deletions(-) create mode 100644 backend/app/api/endpoints/tv_overlay.py create mode 100644 backend/app/services/tv_view_core.py create mode 100644 backend/tests/test_swing_stop_ignore_crisis_20261017.py create mode 100644 backend/tests/test_tv_overlay_20261017.py create mode 100644 frontend/src/components/tv/BottomTabs.tsx create mode 100644 frontend/src/components/tv/DtPositionCards.tsx create mode 100644 frontend/src/components/tv/EquityPanel.tsx create mode 100644 frontend/src/components/tv/RightPanel.tsx create mode 100644 frontend/src/components/tv/SymbolBar.tsx create mode 100644 frontend/src/components/tv/TvChart.tsx create mode 100644 frontend/src/components/tv/chartUtils.ts create mode 100644 frontend/src/lib/tvApi.ts delete mode 100644 frontend/src/types/lightweight-charts.d.ts diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 7afc59a..a28bfda 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -24,7 +24,7 @@ from app.api.endpoints import ( journals, day_trading, strategy_config, backtest, analysis, swing_portfolio, daytrading_v2, us_trading, kr_backtest, market_intelligence, market_playbook, news, market_indices, - adaptive_strategy, manual_buy, auth, kis_credentials, + adaptive_strategy, manual_buy, auth, kis_credentials, tv_overlay, ) # H-2: 위험 쓰기는 라우터 파일을 건드리지 않고 여기서 일괄 관리자 확인 (목록: security.ADMIN_ONLY_ROUTES) @@ -37,6 +37,12 @@ api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) # portfolio_v2/trades_v2 는 반드시 기존 portfolio/trades 라우터보다 먼저 등록한다 # (라우트는 등록 순서대로 매칭되므로, 이렇게 해야 "/portfolio/v2..."·"/trades/v2..."가 # 기존 동적 경로(예: /trades/{trade_id})에 먼저 잡히지 않는다). +# TV 재구성용 읽기 전용 GET — /strategy-config/display 가 /strategy-config/{key} 에 잡히지 않도록 +# strategy_config 라우터보다 먼저 등록한다. +api_router.include_router( + tv_overlay.router, tags=["tv_overlay"], + dependencies=[Depends(require_api_key)], +) api_router.include_router( portfolio_v2.router, prefix="/portfolio/v2", tags=["portfolio_v2"], dependencies=[Depends(require_api_key)], diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py index 4400a7b..f45a9c5 100644 --- a/backend/app/api/endpoints/market_data.py +++ b/backend/app/api/endpoints/market_data.py @@ -700,6 +700,48 @@ async def build_ws_chart_response_v2(stock_code: str, period, limit_raw) -> dict "error": type(e).__name__} +_INTRADAY_CACHE: dict = {} # key -> (monotonic, response) — 빈 결과 포함 60초 +_INTRADAY_INFLIGHT: dict = {} # key -> asyncio.Task — 같은 종목·분 동시 요청 공유 +_INTRADAY_CACHE_TTL = 60.0 + + +async def _get_intraday_chart_v2(stock_code: str, period: str) -> dict: + """당일 분봉 차트(1m|5m) — 60초 캐시(빈 결과 포함) + 같은 종목·분 in-flight 공유.""" + key = (stock_code, period, datetime.now().strftime("%Y%m%d%H%M")) + now = time.monotonic() + for k in [k for k, (t, _) in _INTRADAY_CACHE.items() if now - t > _INTRADAY_CACHE_TTL]: + _INTRADAY_CACHE.pop(k, None) + hit = _INTRADAY_CACHE.get(key) + if hit: + return hit[1] + task = _INTRADAY_INFLIGHT.get(key) + if task is None: + task = asyncio.ensure_future(_build_intraday_chart_impl(stock_code, period)) + _INTRADAY_INFLIGHT[key] = task + task.add_done_callback(lambda _t, _k=key: _INTRADAY_INFLIGHT.pop(_k, None)) + res = await asyncio.shield(task) + if res.get("reason") != "chart_fetch_failed": + _INTRADAY_CACHE[key] = (time.monotonic(), res) + return res + + +async def _build_intraday_chart_impl(stock_code: str, period: str) -> dict: + """KIS get_minute_chart_full_day 재사용(당일 봉만), 5m 은 1m 집계.""" + from app.services.tv_view_core import minute_rows_to_candles + from app.core.timeutil import today_kst + base = {"stock_code": stock_code, "period": period, "is_sample": False, + "realtime": False, "latest": None} + if _is_overseas_ticker(stock_code): + return {**base, "data": [], "reason": "overseas_chart_unavailable"} + try: + rows = await korea_investment_api.get_minute_chart_full_day(stock_code) + data = minute_rows_to_candles(rows, today_kst(), 5 if period == "5m" else 1) + return {**base, "data": data, "reason": None if data else "no_data"} + except Exception as e: + logger.error(f"분봉 차트 조회 오류 ({stock_code}): {type(e).__name__}: {e}") + return {**base, "data": [], "reason": "chart_fetch_failed", "error": type(e).__name__} + + @router.get("/chart/{stock_code}") async def get_stock_chart( stock_code: str, @@ -715,7 +757,10 @@ async def get_stock_chart( limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터) 데이터가 없으면 data=[] + reason. is_sample 은 항상 False(샘플 난수 차트를 만들지 않는다). + period=1m|5m 은 당일 분봉(epoch 초 time) — D/W/M 동작은 그대로다. """ + if period in ("1m", "5m"): + return await _get_intraday_chart_v2(stock_code, period) try: res = await get_chart_data_cached_v2(stock_code, period, parse_chart_limit_v2(limit, 100)) return { diff --git a/backend/app/api/endpoints/risk_management.py b/backend/app/api/endpoints/risk_management.py index fa6380c..77427eb 100644 --- a/backend/app/api/endpoints/risk_management.py +++ b/backend/app/api/endpoints/risk_management.py @@ -138,6 +138,11 @@ async def _swing_realized_today_v2(db, stock_code: Optional[str] = None) -> tupl return total, streak +def _mdd_thresholds_v2() -> dict: + from app.api.endpoints.tv_overlay import mdd_thresholds + return mdd_thresholds() + + def _mdd_state_v2() -> dict: """실제 MDD 게이트(mdd_manager) 상태 스냅샷. 읽기 전용, DB 접근 없음. @@ -156,6 +161,8 @@ def _mdd_state_v2() -> dict: "is_short_term_strategy_paused": bool(getattr(mdd_manager, "is_short_term_strategy_paused", False)), "reduction_applied": mdd_manager.reduction_applied, "update_health": mdd_manager.get_update_health(), + # TV 재구성 계약(Optional): settings 의 MDD 단계 임계(%, 음수) + "thresholds": _mdd_thresholds_v2(), } diff --git a/backend/app/api/endpoints/trades.py b/backend/app/api/endpoints/trades.py index e20a8bf..5a53ee0 100644 --- a/backend/app/api/endpoints/trades.py +++ b/backend/app/api/endpoints/trades.py @@ -16,7 +16,7 @@ import re from app.core.database import get_db from app.models.models import TradeHistory -from pydantic import BaseModel +from pydantic import BaseModel, model_validator from app.core.timeutil import now_kst router = APIRouter() @@ -44,7 +44,20 @@ class TradeHistoryResponse(BaseModel): executed_at: Optional[datetime] profit_loss: Optional[Decimal] = None profit_loss_rate: Optional[float] = None - + # TV 재구성 계약(Optional 추가): 전략·수수료·순손익·방향 + strategy_type: Optional[str] = None + commission: Optional[float] = None + net_profit_loss: Optional[float] = None + side: Optional[str] = None + + @model_validator(mode="after") + def _derive_side(self): + if self.side is None and self.order_type: + ot = str(self.order_type).lower() + if ot in ("buy", "sell"): + self.side = ot + return self + class Config: from_attributes = True diff --git a/backend/app/api/endpoints/tv_overlay.py b/backend/app/api/endpoints/tv_overlay.py new file mode 100644 index 0000000..a4370f0 --- /dev/null +++ b/backend/app/api/endpoints/tv_overlay.py @@ -0,0 +1,324 @@ +"""TradingView 스타일 프론트용 읽기 전용 GET 엔드포인트 (계약: tv_redesign/CONTRACT.md). + +- GET /positions/overlay — 포지션 오버레이(손절·목표·트레일링·시간손절) +- GET /risk/equity-history — 일별 실현 순손익 곡선(전략 그룹별) +- GET /strategy-config/display — §0 우선순위 실효 전략 파라미터 + +전부 SELECT 전용 · KIS 호출 없음 · DB 쓰기 없음. 라우터 단위 require_api_key (api/__init__.py). +""" +import logging +import math +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.core.timeutil import now_kst +from app.models.models import ActiveTrade, Portfolio, TradeHistory +from app.services import tv_view_core as core + +router = APIRouter() +logger = logging.getLogger(__name__) + +_DT_STRATEGIES = ("daytrading", "day_trade") +_EXECUTED = ("EXECUTED", "COMPLETED", "FILLED") +_KST = timezone(timedelta(hours=9)) + + +def _num(v: Any) -> Optional[float]: + try: + f = float(v) + except (TypeError, ValueError): + return None + return f if math.isfinite(f) else None + + +def _cached_atr(stock_code: str, entry_at=None) -> Optional[float]: + """swing/exit_service 폴백 경로(_stale_fallback_stop_pct_v2)와 같은 검증을 거친 ATR만 반환. + + 나이 ≤ SWING_FALLBACK_PRICE_MAX_AGE_SEC 이고 진입 이후 값이어야 한다. 아니면 None. + """ + try: + from app.services.swing.exit_service import _last_atr + except Exception as e: # import 실패는 ATR 미상으로 처리하되 숨기지 않는다 + logger.warning("[tv_overlay] swing ATR 캐시 import 실패: %s", e) + return None + c = _last_atr.get(stock_code) + if not (isinstance(c, tuple) and len(c) == 3): + return None + a, mono, wall = c + if (time.monotonic() - mono) > float(settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC): + return None + w, e_at = core.to_naive_kst(wall), core.to_naive_kst(entry_at) + if e_at is not None and (w is None or w < e_at): + return None + return _num(a) + + +def _swing_overlay(row: ActiveTrade, pf_row: Optional[Portfolio], now) -> Dict[str, Any]: + entry = _num(row.entry_price) or 0.0 + qty = int(row.quantity or 0) + # 현재가: Portfolio(KIS sync 가 갱신) 우선, 없으면 ActiveTrade + cur = _num(pf_row.current_price) if pf_row is not None else None + if cur is None: + cur = _num(row.current_price) + avg = _num(pf_row.average_price) if pf_row is not None else None + pnl = pnl_pct = None + if cur is not None and entry > 0: + pnl = round((cur - entry) * qty, 0) + pnl_pct = round((cur - entry) / entry * 100, 2) + + atr = _cached_atr(row.stock_code, row.created_at) + stop = basis = stop_alt = stop_note = None + stored = core.plausible_stop_price(row.stop_loss, entry) + if entry > 0: + try: + from app.services.mean_reversion_strategy import resolve_swing_stop_params_v2 + pct, mult = resolve_swing_stop_params_v2(atr, cur if cur else entry) + except Exception as e: + logger.warning("[tv_overlay] 손절 파라미터 조회 실패(%s): %s", row.stock_code, e) + pct = mult = None + if pct is not None and atr: + # §5.2: 가격 기준 비교 → 더 높은 손절가가 타이트 + stop = round(max(entry - mult * atr, entry * (1 + pct / 100)), 2) + basis = "atr" + elif stored is not None: + stop, basis = round(stored, 2), "stored" + elif pct is not None: + # ATR 미상 — 퍼센트 손절만(실제 손절은 ATR 손절과 비교한 타이트한 쪽이라 이보다 높을 수 있다) + stop, basis = round(entry * (1 + pct / 100), 2), "pct" + try: + # 고변동(ATR/종가>3%) 가정 손절률 — 신선한 ATR 이 없어 어느 쪽인지 알 수 없다 + hv_pct, _ = resolve_swing_stop_params_v2(entry * 0.04, entry) + stop_alt = round(entry * (1 + hv_pct / 100), 2) + stop_note = "ATR 미상: stop_loss=일반 변동성 손절가, stop_loss_alt=고변동 손절가 (실제는 둘 사이이거나 ATR 손절로 더 높을 수 있음)" + except Exception as e: + logger.warning("[tv_overlay] 고변동 손절 조회 실패(%s): %s", row.stock_code, e) + + rem = _num(row.remaining_pct) + trailing_active = rem is not None and rem <= 0.5 + highest = _num(row.highest_since_entry) + trailing_stop = None + if trailing_active and atr and highest: + try: + from app.services.mean_reversion_strategy import resolve_swing_trailing_atr_mult_v2 + trailing_stop = round(highest - resolve_swing_trailing_atr_mult_v2() * atr, 2) + except Exception as e: + logger.warning("[tv_overlay] 트레일링 배수 조회 실패(%s): %s", row.stock_code, e) + + et = core.to_naive_kst(row.created_at) + return { + "stock_code": row.stock_code, + "stock_name": row.stock_name, + "strategy": (row.strategy_name or "swing"), + "entry_price": entry, + "avg_price": avg if avg is not None else entry, + "quantity": qty, + "current_price": cur, + "pnl": pnl, + "pnl_pct": pnl_pct, + "stop_loss": stop, + "stop_basis": basis, + "stop_loss_alt": stop_alt, + "stop_note": stop_note, + "target_price": core.plausible_target_price(row.take_profit, entry), + "trailing_active": bool(trailing_active), + "trailing_stop": trailing_stop, + "highest_price": highest, + "entry_time": et.isoformat() if et else None, + "elapsed_min": int((now - et).total_seconds() // 60) if et else None, + "time_stop_at": None, # 스윙 시간손절은 KRX 거래일 기준(20일)이라 시각으로 환산하지 않는다 + } + + +def _dt_overlay(code: str, pos: Dict[str, Any], now) -> Dict[str, Any]: + entry = _num(pos.get("entry_price")) or 0.0 + qty = int(pos.get("quantity", 0) or 0) + cur = _num(pos.get("current_price")) + pnl = pnl_pct = None + if cur is not None and cur > 0 and entry > 0: + pnl = round((cur - entry) * qty, 0) + pnl_pct = round((cur - entry) / entry * 100, 2) + et = core.to_naive_kst(pos.get("entry_time")) + stop = _num(pos.get("stop_loss")) + trailing_active = bool(pos.get("partial_sold", False)) + return { + "stock_code": code, + "stock_name": pos.get("stock_name") or code, + "strategy": "dt", + "entry_price": entry, + "avg_price": entry, + "quantity": qty, + "current_price": cur if (cur is not None and cur > 0) else None, + "pnl": pnl, + "pnl_pct": pnl_pct, + "stop_loss": stop, + "stop_basis": "stored" if stop is not None else None, + "target_price": _num(pos.get("target_price")), # 분할 후 inf 는 None + "trailing_active": trailing_active, + "trailing_stop": _num(pos.get("trailing_stop")), + "highest_price": _num(pos.get("highest_price")), + "entry_time": et.isoformat() if et else None, + "elapsed_min": int((now - et).total_seconds() // 60) if et else None, + "time_stop_at": (et + timedelta(minutes=int(settings.DAY_TRADE_TIME_STOP_MINUTES))).isoformat() if et else None, + } + + +@router.get("/positions/overlay") +async def get_positions_overlay( + code: Optional[str] = Query(None, description="종목코드(생략 시 전체)"), + db: AsyncSession = Depends(get_db), +): + now = datetime.now(_KST).replace(tzinfo=None) + q = select(ActiveTrade).where(ActiveTrade.status == "active") + from app.services.db_worker import not_dt_strategy_clause + q = q.where(not_dt_strategy_clause(ActiveTrade.strategy_name)) + if code: + q = q.where(ActiveTrade.stock_code == code) + # 종목당 청산이 쓰는 1행(get_swing_active_trade 와 동일: id 최대)만 + raw = (await db.execute(q.order_by(ActiveTrade.id.desc()))).scalars().all() + seen, rows = set(), [] + for r in raw: + if r.stock_code not in seen: + seen.add(r.stock_code) + rows.append(r) + pf: Dict[str, Portfolio] = {} + if rows: + codes = list(seen) + pf = {p.stock_code: p for p in (await db.execute( + select(Portfolio).where(Portfolio.stock_code.in_(codes)))).scalars().all()} + # Portfolio 에 보유가 없는 active 행(고아)은 제외 + out: List[Dict[str, Any]] = [ + _swing_overlay(r, pf[r.stock_code], now) + for r in rows if r.stock_code in pf and int(pf[r.stock_code].quantity or 0) > 0 + ] + + # DT: 인메모리 포지션(읽기 전용) + try: + from app.services.daytrading.position_manager import day_trading_state + for c, p in list(day_trading_state.positions.items()): + if code and c != code: + continue + out.append(_dt_overlay(c, p, now)) + except Exception as e: + logger.warning("[tv_overlay] DT 인메모리 포지션 조회 실패: %s", e) + return {"positions": out, "as_of": datetime.now(_KST).isoformat()} + + +@router.get("/risk/equity-history") +async def get_equity_history( + days: int = Query(90, ge=1, le=730), + db: AsyncSession = Depends(get_db), +): + since = now_kst() - timedelta(days=days) + ts = func.coalesce(TradeHistory.executed_at, TradeHistory.created_at) + res = await db.execute( + select(ts, TradeHistory.strategy_type, TradeHistory.net_profit_loss, TradeHistory.currency_code) + .where(TradeHistory.order_type == "SELL") + .where(TradeHistory.status.in_(_EXECUTED)) + .where(ts >= since) + .order_by(ts) + ) + rows, excluded = [], 0 + for t, st, net, cur in res.all(): + if cur is not None and str(cur).upper() != "KRW": + excluded += 1 # 미국/USD 행은 원화 곡선에 섞지 않는다 + continue + rows.append((t.date(), st, net)) + # 낙폭 % 기준: MDD 관리자가 보는 계좌 peak (없으면 최근 잔고). 모르면 drawdown_pct=null + base = None + try: + from app.services.mdd_manager import mdd_manager + base = _num(getattr(mdd_manager, "peak_balance", 0)) or _num(getattr(mdd_manager, "_last_balance", 0)) + if base is not None and base <= 0: + base = None + except Exception as e: + logger.warning("[tv_overlay] mdd_manager 조회 실패 — drawdown_pct 미산출: %s", e) + points, null_rows = core.build_equity_points(rows, base) + return { + "cost_basis": "net_after_costs", + "points": points, + "mdd_thresholds": mdd_thresholds(), + "null_net_rows": null_rows, + "excluded_non_krw_rows": excluded, + "drawdown_pct_base": base, + } + + +def mdd_thresholds() -> Dict[str, float]: + """MDD 단계 임계(%, 음수) — mdd_manager.update() 가 실제로 전이하는 값. + + mdd_manager 는 -10/-20 리터럴로 전이하고 swing.scheduler 가 그 값을 상수로 보관한다 + (settings.MDD_*_THRESHOLD 는 실효 손잡이가 아님 — scheduler 의 mismatch 경보 참조). + scheduler 를 못 불러오면 settings 로 폴백(warning). + """ + try: + from app.services.swing.scheduler import _MDD_MANAGER_PAUSE_PCT, _MDD_MANAGER_FORCE_PCT + return {"reduce_pct": float(_MDD_MANAGER_PAUSE_PCT), "force_pct": float(_MDD_MANAGER_FORCE_PCT)} + except Exception as e: + logger.warning("[tv_overlay] scheduler MDD 상수 조회 실패 — settings 폴백: %s", e) + return {"reduce_pct": float(settings.MDD_PAUSE_THRESHOLD), "force_pct": float(settings.MDD_FORCE_THRESHOLD)} + + +def _eff_num(key: str, fallback: float) -> tuple: + """strategy_cfg(DB) → settings 순 실효값과 출처.""" + from app.services.strategy_config_service import strategy_cfg + v = _num(strategy_cfg.get(key)) + if v is not None and v > 0: + return v, "strategy_cfg" + return float(fallback), "settings" + + +@router.get("/strategy-config/display") +async def get_strategy_display(): + from app.services.mean_reversion_strategy import ( + resolve_swing_stop_params_v2, resolve_swing_trailing_atr_mult_v2, resolve_swing_exit_rsi_v2, + ) + adp = None + try: + from app.services.adaptive_strategy import get_current_adaptive_params + adp = get_current_adaptive_params() + except Exception as e: + logger.warning("[strategy-display] 적응형 파라미터 조회 실패 — strategy_cfg/settings 사용: %s", e) + adp_live = adp is not None and not getattr(adp, "fallback_used", True) + + bull, src_bull = _eff_num("MEAN_REVERSION_RSI_THRESHOLD", settings.MEAN_REVERSION_RSI_THRESHOLD) + bear, src_bear = _eff_num("MEAN_REVERSION_BEAR_RSI_THRESHOLD", settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD) + period, _ = _eff_num("RSI_PERIOD", 14) + if adp_live: + bull = float(adp.rsi_entry) + bear = float(adp.bear_rsi_entry) + # 청산 RSI: check_exit_signal 과 같은 함수(settings 기본 + 적응형) + part, full, exit_adp = resolve_swing_exit_rsi_v2() + part, full = float(part), float(full) + sl_pct, atr_mult = resolve_swing_stop_params_v2(None, 0.0) # 일반 변동성 기준(적응형 → cfg → settings) + trail = resolve_swing_trailing_atr_mult_v2() + if adp_live: + source = "adaptive" + elif "strategy_cfg" in (src_bull, src_bear): + source = "strategy_cfg" + else: + source = "settings" + exit_source = "adaptive" if exit_adp else "settings" + return { + "rsi": {"period": int(period), "bull_entry": bull, "bear_entry": bear, + "partial_exit": part, "full_exit": full}, + "atr": {"stop_mult": atr_mult, "trailing_mult": trail}, + "stop_loss_pct": sl_pct, + "dt": { + "daily_loss_pct": float(settings.DAY_TRADE_DAILY_LOSS_LIMIT) * 100, + "time_stop_min": int(settings.DAY_TRADE_TIME_STOP_MINUTES), + "time_stop_min_gain": float(settings.DAY_TRADE_TIME_STOP_MIN_GAIN) * 100, + "eod_flat": f"{int(settings.DAY_TRADE_EOD_HOUR):02d}:{int(settings.DAY_TRADE_EOD_MIN):02d}", + "entry_cutoff": f"{int(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR):02d}:{int(settings.DAY_TRADE_ENTRY_CUTOFF_MIN):02d}", + }, + "mdd": mdd_thresholds(), + "adaptive_mode": getattr(adp, "trading_mode", None) if adp is not None else None, + "source": source, + "sources": {"entry": source, "exit": exit_source}, + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f76931f..f728183 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -624,6 +624,9 @@ class Settings(BaseSettings): # [L-6 2026-10-02] 스윙 손절 파라미터 합리 범위(resolve_swing_stop_params_v2 의 소스 값 검증). # 범위 밖 값(strategy_cfg·적응형)은 warning 후 하위 소스(최종 config)로 폴백한다. # 손절률은 크기(abs, %) 기준, ATR 배수는 배수 그대로. + # [§8.5-39] True 면 적응형 모드가 crisis 일 때 스윙 손절 오버레이(stop_loss_pct/high_vol/ + # atr_stop_multiplier)를 적용하지 않는다(strategy_cfg→config). 진입 차단은 무변경. 기본 OFF. + SWING_STOP_IGNORE_CRISIS_OVERLAY: bool = False SWING_STOP_LOSS_PCT_MIN: float = 1.0 SWING_STOP_LOSS_PCT_MAX: float = 30.0 SWING_ATR_STOP_MULT_MIN: float = 0.5 diff --git a/backend/app/services/mean_reversion_strategy.py b/backend/app/services/mean_reversion_strategy.py index 02983e4..faae5cd 100644 --- a/backend/app/services/mean_reversion_strategy.py +++ b/backend/app/services/mean_reversion_strategy.py @@ -858,6 +858,26 @@ def _check_buy_signal_impl( return None +def resolve_swing_exit_rsi_v2() -> Tuple[float, float, bool]: + """스윙 RSI 청산 임계 단일 출처 → (1차 익절 RSI, 전량 RSI, 적응형 적용 여부). + + settings 기본 + 적응형(fallback_used=False)이면 적응형 값. check_exit_signal 과 프론트 표시 + (/strategy-config/display)가 같은 함수를 쓴다. 조회 실패는 warning 후 settings 값. + """ + from app.core.config import settings + partial = settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD + full = settings.MEAN_REVERSION_RSI_FULL_EXIT + try: + from app.services.adaptive_strategy import get_current_adaptive_params + _adp = get_current_adaptive_params() + if not _adp.fallback_used: + return _adp.rsi_partial_exit, _adp.rsi_full_exit, True + except Exception as _adp_err: + warn_throttled(logger, "mean_reversion:exit_rsi_overlay", + "[MeanReversion] 적응형 RSI 청산 임계 조회 실패 — config 값 사용: %s", _adp_err) + return partial, full, False + + def resolve_swing_stop_params_v2(atr: Optional[float], close: float) -> Tuple[float, float]: """스윙 손절 파라미터 단일 출처 → (stop_loss_pct[항상 음수, %], atr_stop_mult). @@ -897,6 +917,14 @@ def resolve_swing_stop_params_v2(atr: Optional[float], close: float) -> Tuple[fl warn_throttled(logger, "mean_reversion:exit_sl_overlay", "[MeanReversion] 적응형 손절/ATR 배수 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err) _adp_exit = None + # [§8.5-39] Crisis 는 신규 진입 차단 용도 — 보유분 손절선을 소급 조정하지 않는다(플래그, 기본 OFF). + # 플래그 조회 자체는 예외 가능성이 없는 getattr 이며, 모드 판정은 단순 속성 비교다. + _ignore_crisis = getattr(settings, "SWING_STOP_IGNORE_CRISIS_OVERLAY", False) is True + if (_ignore_crisis and _adp_exit is not None + and str(getattr(_adp_exit, "trading_mode", "")) == "crisis"): + warn_throttled(logger, "mean_reversion:exit_sl_crisis_ignored", + "[MeanReversion] 적응형 모드=crisis — 손절 오버레이 미적용(strategy_cfg/config 값 사용)") + _adp_exit = None if _adp_exit is not None and not getattr(_adp_exit, "fallback_used", True): _adp_mult = _stop_param_or_none_v2(getattr(_adp_exit, "atr_stop_multiplier", None), "adaptive.atr_stop_multiplier") @@ -1128,19 +1156,7 @@ def check_exit_signal( # ── 2. 1차 익절: 아직 1차 안 했을 때 ────────────────────────── # CLAUDE.md 6.3: RSI >= 50 OR 가격 > BB 중앙선 OR 수익 > 2*ATR (OR 로직) # 적응형 전략: 1차 익절 RSI / 전량 청산 RSI 오버라이드 - _adaptive_rsi_partial = settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD - _adaptive_rsi_full = settings.MEAN_REVERSION_RSI_FULL_EXIT - try: - from app.services.adaptive_strategy import get_current_adaptive_params - _adp_exit2 = get_current_adaptive_params() - if not _adp_exit2.fallback_used: - _adaptive_rsi_partial = _adp_exit2.rsi_partial_exit - _adaptive_rsi_full = _adp_exit2.rsi_full_exit - except Exception as _adp_err: - _adaptive_rsi_partial = settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD - _adaptive_rsi_full = settings.MEAN_REVERSION_RSI_FULL_EXIT - warn_throttled(logger, "mean_reversion:exit_rsi_overlay", - "[MeanReversion] 적응형 RSI 청산 임계 조회 실패 — config 값 사용: %s", _adp_err) + _adaptive_rsi_partial, _adaptive_rsi_full, _ = resolve_swing_exit_rsi_v2() min_profit_price = entry_price + 2.0 * atr # CLAUDE.md 6.3: 2×ATR _profit_pct = ((close - entry_price) / entry_price * 100) if entry_price > 0 else 0 diff --git a/backend/app/services/tv_view_core.py b/backend/app/services/tv_view_core.py new file mode 100644 index 0000000..9425fae --- /dev/null +++ b/backend/app/services/tv_view_core.py @@ -0,0 +1,149 @@ +"""TradingView 스타일 프론트용 읽기 전용 뷰 계산 (순수 함수 — settings/DB/네트워크 참조 없음). + +계약: scratchpad/tv_redesign/CONTRACT.md. 여기서는 집계·변환만 하고 임계값을 정의하지 않는다. +""" +from __future__ import annotations + +import math +from datetime import date, datetime, timedelta, timezone +from typing import Any, Dict, Iterable, List, Optional, Tuple + +_KST = timezone(timedelta(hours=9)) +_DT_NAMES = ("daytrading", "day_trade", "dt") + + +def group_of_strategy(strategy_type: Optional[str]) -> str: + """TradeHistory.strategy_type → 'dt' | 'swing' | 'other' (NULL 은 컬럼 기본값 swing 과 동일 취급하지 않고 other).""" + s = (strategy_type or "").strip().lower() + if s in _DT_NAMES: + return "dt" + if s == "swing": + return "swing" + return "other" + + +def build_equity_points( + rows: Iterable[Tuple[date, Optional[str], Optional[float]]], + pct_base: Optional[float], +) -> Tuple[List[Dict[str, Any]], int]: + """(체결일, strategy_type, net) → 일별 포인트. net 이 None 인 행은 합계에서 제외하고 개수만 반환. + + cum_net 은 조회 구간 시작을 0 으로 한 누적 실현 순손익, peak 는 그 누적의 최고치(시작 0 포함). + drawdown_pct = (cum_net - peak) / pct_base * 100 (≤0). pct_base(계좌 기준 잔고)를 모르면 None. + """ + per_day: Dict[date, Dict[str, float]] = {} + null_rows = 0 + for d, st, net in rows: + if net is None: + null_rows += 1 + continue + try: + v = float(net) + except (TypeError, ValueError): + null_rows += 1 + continue + if not math.isfinite(v): + null_rows += 1 + continue + b = per_day.setdefault(d, {"swing": 0.0, "dt": 0.0, "other": 0.0}) + b[group_of_strategy(st)] += v + points: List[Dict[str, Any]] = [] + cum = 0.0 + peak = 0.0 + for d in sorted(per_day): + b = per_day[d] + day_total = b["swing"] + b["dt"] + b["other"] + cum += day_total + peak = max(peak, cum) + dd = round((cum - peak) / pct_base * 100, 4) if pct_base and pct_base > 0 else None + points.append({ + "date": d.isoformat(), + "realized_net": round(day_total, 2), + "realized_net_swing": round(b["swing"], 2), + "realized_net_dt": round(b["dt"], 2), + "realized_net_other": round(b["other"], 2), + "cum_net": round(cum, 2), + "peak": round(peak, 2), + "drawdown_pct": dd, + }) + return points, null_rows + + +def plausible_stop_price(stored: Any, entry: float) -> Optional[float]: + """ActiveTrade.stop_loss 가 '가격'으로 보이는지 판정 (퍼센트·가격 혼재 — §8.5-47). + + 0 < stored < entry 이고 entry 의 절반 이상일 때만 가격으로 인정한다. -7/7 같은 퍼센트 + 표기이거나 범위 밖이면 None (거짓 값 금지). + """ + try: + s = float(stored) + except (TypeError, ValueError): + return None + if not (math.isfinite(s) and math.isfinite(entry)) or entry <= 0: + return None + return s if (0.5 * entry <= s < entry) else None + + +def plausible_target_price(stored: Any, entry: float) -> Optional[float]: + try: + t = float(stored) + except (TypeError, ValueError): + return None + if not (math.isfinite(t) and math.isfinite(entry)) or entry <= 0: + return None + return t if entry < t <= entry * 3 else None + + +def to_naive_kst(dt: Any) -> Optional[datetime]: + if isinstance(dt, str): + try: + dt = datetime.fromisoformat(dt) + except ValueError: + return None + if not isinstance(dt, datetime): + return None + if dt.tzinfo is not None: + dt = dt.astimezone(_KST).replace(tzinfo=None) + return dt + + +def minute_rows_to_candles(rows: List[dict], today: date, step_min: int) -> List[Dict[str, Any]]: + """KIS 분봉 output2 → [{time(epoch s), open, high, low, close, volume}] (당일만, 오름차순). + + time 은 KST 벽시계를 UTC epoch 로 변환한 실제 epoch 초. step_min>1 이면 1분봉을 버킷 집계. + 중복 키(날짜+시각)는 마지막 값, 0/결측 OHLC 봉은 제외한다. + """ + ymd = today.strftime("%Y%m%d") + uniq: Dict[str, dict] = {} + for r in rows or []: + if str(r.get("stck_bsop_date", "")) != ymd: + continue + hhmm = str(r.get("stck_cntg_hour", "")).zfill(6) + uniq[hhmm] = r + out: List[Dict[str, Any]] = [] + cur: Optional[Dict[str, Any]] = None + for hhmm in sorted(uniq): + r = uniq[hhmm] + try: + o = float(r.get("stck_oprc") or 0) + h = float(r.get("stck_hgpr") or 0) + lo = float(r.get("stck_lwpr") or 0) + c = float(r.get("stck_prpr") or 0) + v = int(float(r.get("cntg_vol") or 0)) + hh, mm = int(hhmm[0:2]), int(hhmm[2:4]) + except (TypeError, ValueError): + continue + if min(o, h, lo, c) <= 0: + continue + mins = hh * 60 + mm + bucket = mins - (mins % step_min) if step_min > 1 else mins + ts = int(datetime(today.year, today.month, today.day, bucket // 60, bucket % 60, tzinfo=_KST).timestamp()) + if cur is not None and cur["time"] == ts: + cur["high"] = max(cur["high"], h) + cur["low"] = min(cur["low"], lo) + cur["close"] = c + cur["volume"] += v + else: + cur = {"time": ts, "open": o, "high": h, "low": lo, "close": c, "volume": v} + out.append(cur) + return out diff --git a/backend/tests/test_swing_stop_ignore_crisis_20261017.py b/backend/tests/test_swing_stop_ignore_crisis_20261017.py new file mode 100644 index 0000000..fc7173d --- /dev/null +++ b/backend/tests/test_swing_stop_ignore_crisis_20261017.py @@ -0,0 +1,83 @@ +"""§8.5-39: SWING_STOP_IGNORE_CRISIS_OVERLAY — Crisis 모드가 스윙 손절을 소급 조정하지 않는다.""" +from types import SimpleNamespace + +import pytest + +from app.core.config import settings +from app.services import mean_reversion_strategy as mr + + +class _NoCfg: + def get(self, *a, **k): + return None + + +def _adp(mode, sl, sl_hv, mult, fallback=False): + return SimpleNamespace(trading_mode=mode, stop_loss_pct=sl, stop_loss_high_vol=sl_hv, + atr_stop_multiplier=mult, fallback_used=fallback) + + +@pytest.fixture +def patch_env(monkeypatch): + monkeypatch.setattr(mr, "strategy_cfg", _NoCfg()) + + def _set(adp, flag): + import app.services.adaptive_strategy as ad + monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: adp) + monkeypatch.setattr(settings, "SWING_STOP_IGNORE_CRISIS_OVERLAY", flag, raising=False) + return _set + + +CRISIS = lambda: _adp("crisis", -3.0, -5.0, 1.5) +# ATR/close: 저변동 1%, 고변동 5% +LOW, HIGH = (1.0, 100.0), (5.0, 100.0) + + +def test_default_flag_is_false(): + assert settings.SWING_STOP_IGNORE_CRISIS_OVERLAY is False + + +def test_flag_off_crisis_overlay_still_applies(patch_env): + patch_env(CRISIS(), False) + assert mr.resolve_swing_stop_params_v2(*LOW) == (-3.0, 1.5) + assert mr.resolve_swing_stop_params_v2(*HIGH) == (-5.0, 1.5) + + +def test_flag_on_crisis_uses_config(patch_env): + patch_env(CRISIS(), True) + assert mr.resolve_swing_stop_params_v2(*LOW) == (-abs(float(settings.STOP_LOSS_PERCENT)), + float(settings.ATR_STOP_MULTIPLIER)) + assert mr.resolve_swing_stop_params_v2(*HIGH) == (-abs(float(settings.STOP_LOSS_HIGH_VOL)), + float(settings.ATR_STOP_MULTIPLIER)) + + +@pytest.mark.parametrize("flag", [False, True]) +@pytest.mark.parametrize("mode,sl,hv,m", [("normal", -7.0, -11.0, 2.5), ("defensive", -6.0, -8.0, 2.0), + ("aggressive", -9.0, -12.0, 3.0)]) +def test_other_modes_unchanged_by_flag(patch_env, flag, mode, sl, hv, m): + patch_env(_adp(mode, sl, hv, m), flag) + assert mr.resolve_swing_stop_params_v2(*LOW) == (sl, m) + assert mr.resolve_swing_stop_params_v2(*HIGH) == (hv, m) + + +def test_flag_on_crisis_respects_strategy_cfg(patch_env, monkeypatch): + class Cfg: + def get(self, k, *a): + return {"STOP_LOSS_PERCENT": -6.5, "ATR_STOP_MULTIPLIER": 2.2}.get(k) + patch_env(CRISIS(), True) + monkeypatch.setattr(mr, "strategy_cfg", Cfg()) + assert mr.resolve_swing_stop_params_v2(*LOW) == (-6.5, 2.2) + + +def test_sign_guard_kept_with_flag_on(patch_env, monkeypatch): + class Cfg: + def get(self, k, *a): + return 6.0 if k == "STOP_LOSS_PERCENT" else None + patch_env(CRISIS(), True) + monkeypatch.setattr(mr, "strategy_cfg", Cfg()) + assert mr.resolve_swing_stop_params_v2(*LOW)[0] == -6.0 + + +def test_flag_on_fallback_used_adaptive_ignored_as_before(patch_env): + patch_env(_adp("crisis", -3.0, -5.0, 1.5, fallback=True), True) + assert mr.resolve_swing_stop_params_v2(*LOW)[0] == -abs(float(settings.STOP_LOSS_PERCENT)) diff --git a/backend/tests/test_tv_overlay_20261017.py b/backend/tests/test_tv_overlay_20261017.py new file mode 100644 index 0000000..aeafaf7 --- /dev/null +++ b/backend/tests/test_tv_overlay_20261017.py @@ -0,0 +1,308 @@ +"""TV 재구성용 읽기 전용 엔드포인트 테스트 (스키마·인증·빈 데이터·NULL net).""" +import asyncio +from datetime import date, datetime, timedelta +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.core.config import settings +from app.core.security import require_api_key +from app.services import tv_view_core as core +from app.api.endpoints import tv_overlay + + +class _Res: + def __init__(self, rows): + self._rows = rows + + def all(self): + return self._rows + + def scalars(self): + return SimpleNamespace(all=lambda: self._rows) + + +class _DB: + """execute 호출 순서대로 결과 반환 (SELECT 전용 페이크).""" + def __init__(self, *results): + self._results = list(results) + + async def execute(self, _q): + return _Res(self._results.pop(0)) + + +def _active(**kw): + d = dict(stock_code="005930", stock_name="삼성전자", quantity=10, entry_price=Decimal("70000"), + current_price=Decimal("72000"), stop_loss=None, take_profit=None, remaining_pct=Decimal("1.0"), + highest_since_entry=None, strategy_name=None, created_at=datetime.now() - timedelta(minutes=90)) + d.update(kw) + return SimpleNamespace(**d) + + +def _pf(code="005930", qty=10, cur=None): + return SimpleNamespace(stock_code=code, quantity=qty, average_price=Decimal("70000"), + current_price=Decimal(cur) if cur is not None else None) + + +def _run(c): + return asyncio.new_event_loop().run_until_complete(c) + + +# ── 인증 ──────────────────────────────────────────────────────────────────── +def test_all_new_routes_require_api_key(): + from app.api import api_router + want = {"/positions/overlay", "/risk/equity-history", "/strategy-config/display"} + found = set() + for r in api_router.routes: + if r.path in want and "GET" in r.methods: + deps = [d.call for d in r.dependant.dependencies] + assert require_api_key in deps, r.path + found.add(r.path) + assert found == want + + +def test_display_route_precedes_strategy_config_key_route(): + from app.api import api_router + paths = [r.path for r in api_router.routes if "GET" in getattr(r, "methods", ())] + assert paths.index("/strategy-config/display") < paths.index("/strategy-config/{key}") + + +# ── equity-history ────────────────────────────────────────────────────────── +def test_equity_points_groups_and_null_net(): + d1, d2 = date(2026, 10, 1), date(2026, 10, 2) + rows = [(d1, "swing", Decimal("1000")), (d1, "daytrading", -300), (d1, "swing", None), + (d2, "external", -2000), (d2, None, 50)] + pts, nulls = core.build_equity_points(rows, 1_000_000) + assert nulls == 1 + assert pts[0]["realized_net_swing"] == 1000 and pts[0]["realized_net_dt"] == -300 + assert pts[0]["cum_net"] == 700 and pts[0]["peak"] == 700 and pts[0]["drawdown_pct"] == 0 + assert pts[1]["realized_net_other"] == -1950 and pts[1]["cum_net"] == -1250 + assert pts[1]["drawdown_pct"] == pytest.approx((-1250 - 700) / 1_000_000 * 100, abs=1e-3) + + +def test_equity_no_base_gives_null_drawdown(): + pts, _ = core.build_equity_points([(date(2026, 10, 1), "swing", 5)], None) + assert pts[0]["drawdown_pct"] is None + + +def test_equity_endpoint_empty(): + out = _run(tv_overlay.get_equity_history(days=90, db=_DB([]))) + assert out["points"] == [] and out["null_net_rows"] == 0 and out["cost_basis"] == "net_after_costs" + assert out["mdd_thresholds"] == {"reduce_pct": settings.MDD_PAUSE_THRESHOLD, + "force_pct": settings.MDD_FORCE_THRESHOLD} + + +def test_equity_endpoint_null_net_counted(): + rows = [(datetime(2026, 10, 1, 10), "swing", None, "KRW"), (datetime(2026, 10, 1, 11), "swing", Decimal("10"), None), + (datetime(2026, 10, 1, 12), "swing", Decimal("999"), "USD")] + out = _run(tv_overlay.get_equity_history(days=90, db=_DB(rows))) + assert out["null_net_rows"] == 1 and out["points"][0]["realized_net"] == 10 + assert out["excluded_non_krw_rows"] == 1 + + +# ── positions/overlay ─────────────────────────────────────────────────────── +def _dt_state(positions): + return patch("app.services.daytrading.position_manager.day_trading_state", + SimpleNamespace(positions=positions)) + + +def test_overlay_empty(): + with _dt_state({}): + out = _run(tv_overlay.get_positions_overlay(code=None, db=_DB([]))) + assert out["positions"] == [] and "as_of" in out + + +def test_overlay_swing_pct_basis_without_atr(): + row = _active() + with _dt_state({}), patch.object(tv_overlay, "_cached_atr", return_value=None): + out = _run(tv_overlay.get_positions_overlay(code=None, db=_DB([row], [_pf()]))) + p = out["positions"][0] + assert p["strategy"] == "swing" and p["stop_basis"] == "pct" + assert p["stop_loss_alt"] < p["stop_loss"] < 70000 and p["stop_note"] and p["pnl"] == 20000 and p["time_stop_at"] is None + assert p["trailing_active"] is False and p["elapsed_min"] >= 89 + + +def test_overlay_swing_atr_basis_and_stored_percent_ignored(): + row = _active(stop_loss=Decimal("-7"), remaining_pct=Decimal("0.5"), highest_since_entry=Decimal("75000")) + with _dt_state({}), patch.object(tv_overlay, "_cached_atr", return_value=1000.0): + p = _run(tv_overlay.get_positions_overlay(code=None, db=_DB([row], [_pf()])))["positions"][0] + assert p["stop_basis"] == "atr" and p["trailing_active"] is True and p["trailing_stop"] < 75000 + + +def test_overlay_stored_price_used_when_no_atr_and_percent_value_not_trusted(): + row = _active(stop_loss=Decimal("66000")) + with _dt_state({}), patch.object(tv_overlay, "_cached_atr", return_value=None): + p = _run(tv_overlay.get_positions_overlay(code=None, db=_DB([row], [_pf()])))["positions"][0] + assert p["stop_basis"] == "stored" and p["stop_loss"] == 66000 + assert core.plausible_stop_price(-7, 70000) is None and core.plausible_stop_price(7, 70000) is None + + +def test_overlay_dt_from_memory(): + et = datetime.now() - timedelta(minutes=10) + pos = {"000660": {"entry_price": 100000.0, "quantity": 3, "current_price": 101000.0, "stop_loss": 98000.0, + "target_price": float("inf"), "highest_price": 101500.0, "entry_time": et, + "partial_sold": False, "stock_name": "SK하이닉스"}} + with _dt_state(pos): + out = _run(tv_overlay.get_positions_overlay(code="000660", db=_DB([]))) + p = out["positions"][0] + assert p["strategy"] == "dt" and p["target_price"] is None and p["highest_price"] == 101500.0 + assert datetime.fromisoformat(p["time_stop_at"]) == et + timedelta(minutes=settings.DAY_TRADE_TIME_STOP_MINUTES) + assert p["pnl"] == 3000 and p["stop_basis"] == "stored" + + +# ── strategy-config/display ───────────────────────────────────────────────── +def test_display_schema_and_settings_values(): + out = _run(tv_overlay.get_strategy_display()) + assert set(out) == {"rsi", "atr", "stop_loss_pct", "dt", "mdd", "adaptive_mode", "source", "sources"} + assert out["source"] in ("adaptive", "strategy_cfg", "settings") + assert out["stop_loss_pct"] < 0 + assert out["dt"]["time_stop_min"] == settings.DAY_TRADE_TIME_STOP_MINUTES + assert out["dt"]["eod_flat"] == f"{settings.DAY_TRADE_EOD_HOUR:02d}:{settings.DAY_TRADE_EOD_MIN:02d}" + assert out["mdd"]["force_pct"] == settings.MDD_FORCE_THRESHOLD + assert out["rsi"]["period"] == 14 or isinstance(out["rsi"]["period"], int) + + +# ── trades 확장 / mdd thresholds ──────────────────────────────────────────── +def test_trade_response_new_optional_fields(): + from app.api.endpoints.trades import TradeHistoryResponse + base = dict(id=1, rule_id=None, stock_code="005930", stock_name="x", order_type="SELL", order_price=None, + order_quantity=1, executed_price=None, executed_quantity=None, total_amount=None, + status="EXECUTED", order_id=None, message=None, entry_reason=None, exit_reason=None, + signals=None, created_at=datetime.now(), executed_at=None) + r = TradeHistoryResponse(**base) + assert r.side == "sell" and r.net_profit_loss is None and r.commission is None and r.strategy_type is None + assert TradeHistoryResponse(**{**base, "order_type": "BUY", "net_profit_loss": Decimal("5")}).side == "buy" + + +def test_mdd_status_has_thresholds(): + from app.api.endpoints.risk_management import _mdd_state_v2 + t = _mdd_state_v2()["thresholds"] + assert t == {"reduce_pct": settings.MDD_PAUSE_THRESHOLD, "force_pct": settings.MDD_FORCE_THRESHOLD} + + +# ── 분봉 ──────────────────────────────────────────────────────────────────── +def _m(hhmm, o, h, lo, c, v, ymd="20261016"): + return {"stck_bsop_date": ymd, "stck_cntg_hour": hhmm + "00", "stck_oprc": str(o), "stck_hgpr": str(h), + "stck_lwpr": str(lo), "stck_prpr": str(c), "cntg_vol": str(v)} + + +def test_minute_candles_today_only_and_5m_aggregation(): + today = date(2026, 10, 16) + rows = [_m("0900", 10, 12, 9, 11, 5), _m("0901", 11, 15, 10, 14, 7), _m("0905", 14, 14, 13, 13, 1), + _m("0902", 1, 1, 1, 1, 1, ymd="20261015"), _m("0903", 0, 0, 0, 0, 0)] + c1 = core.minute_rows_to_candles(rows, today, 1) + assert [x["volume"] for x in c1] == [5, 7, 1] + c5 = core.minute_rows_to_candles(rows, today, 5) + assert len(c5) == 2 and c5[0]["open"] == 10 and c5[0]["high"] == 15 and c5[0]["close"] == 14 and c5[0]["volume"] == 12 + assert c5[1]["time"] - c5[0]["time"] == 300 + assert core.minute_rows_to_candles([], today, 5) == [] + + +def test_chart_endpoint_intraday_and_error_path(): + from app.api.endpoints import market_data + today = datetime.now(tz=None).date() + market_data._INTRADAY_CACHE.clear() + fake = MagicMock() + fake.get_minute_chart_full_day = AsyncMock(return_value=[]) + with patch.object(market_data, "korea_investment_api", fake): + out = _run(market_data.get_stock_chart("005930", period="5m", realtime=False, limit=100)) + assert out["data"] == [] and out["reason"] == "no_data" and out["period"] == "5m" + fake.get_minute_chart_full_day = AsyncMock(side_effect=RuntimeError("boom")) + market_data._INTRADAY_CACHE.clear() + with patch.object(market_data, "korea_investment_api", fake): + out = _run(market_data.get_stock_chart("005930", period="1m", realtime=False, limit=100)) + assert out["reason"] == "chart_fetch_failed" + + +def test_overlay_portfolio_price_preferred_one_row_per_code_and_orphan_excluded(): + newer = _active(current_price=Decimal("1")) + newer.id = 9 + older = _active(entry_price=Decimal("50000")) + older.id = 3 + orphan = _active(stock_code="000660") + orphan.id = 5 + with _dt_state({}), patch.object(tv_overlay, "_cached_atr", return_value=None): + out = _run(tv_overlay.get_positions_overlay(code=None, db=_DB([newer, orphan, older], [_pf(cur="73000")]))) + assert [p["stock_code"] for p in out["positions"]] == ["005930"] + assert out["positions"][0]["current_price"] == 73000 and out["positions"][0]["entry_price"] == 70000 + assert out["as_of"].endswith("+09:00") + + +def test_cached_atr_requires_fresh_and_after_entry(): + import time as _t + from app.services.swing import exit_service as ex + now = datetime.now() + ex._last_atr["T1"] = (500.0, _t.monotonic(), now) + ex._last_atr["T2"] = (500.0, _t.monotonic() - settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC - 5, now) + try: + assert tv_overlay._cached_atr("T1", now - timedelta(minutes=5)) == 500.0 + assert tv_overlay._cached_atr("T1", now + timedelta(minutes=5)) is None # 진입 이전 값 + assert tv_overlay._cached_atr("T2", now - timedelta(minutes=5)) is None # 오래됨 + finally: + ex._last_atr.pop("T1", None) + ex._last_atr.pop("T2", None) + + +def test_display_exit_rsi_matches_exit_logic_helper_and_adaptive(): + from app.services import mean_reversion_strategy as m + adp = SimpleNamespace(fallback_used=False, rsi_partial_exit=51.0, rsi_full_exit=66.0, + rsi_entry=44.0, bear_rsi_entry=31.0, trading_mode="defensive") + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=adp): + assert m.resolve_swing_exit_rsi_v2() == (51.0, 66.0, True) + out = _run(tv_overlay.get_strategy_display()) + assert out["rsi"]["partial_exit"] == 51.0 and out["rsi"]["full_exit"] == 66.0 + assert out["sources"] == {"entry": "adaptive", "exit": "adaptive"} and out["source"] == "adaptive" + adp.fallback_used = True + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=adp): + assert m.resolve_swing_exit_rsi_v2() == (settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD, + settings.MEAN_REVERSION_RSI_FULL_EXIT, False) + + +def test_exit_signal_uses_adaptive_rsi_partial_unchanged(): + """check_exit_signal 이 헬퍼 경유로도 적응형 1차 익절 RSI 를 그대로 쓴다(동작 무변경).""" + import inspect + from app.services import mean_reversion_strategy as m + src = inspect.getsource(m.check_exit_signal) + assert "resolve_swing_exit_rsi_v2()" in src and "_adp_exit2" not in src + + +def test_trade_response_numeric_floats(): + from app.api.endpoints.trades import TradeHistoryResponse + r = TradeHistoryResponse(id=1, rule_id=None, stock_code="x", stock_name=None, order_type="SELL", + order_price=None, order_quantity=1, executed_price=None, executed_quantity=None, + total_amount=None, status="EXECUTED", order_id=None, message=None, + entry_reason=None, exit_reason=None, signals=None, created_at=datetime.now(), + executed_at=None, commission=Decimal("12.5"), net_profit_loss=Decimal("-3")) + assert isinstance(r.commission, float) and isinstance(r.net_profit_loss, float) + + +def test_mdd_thresholds_equal_actual_transition_values(): + """표시 임계 == mdd_manager 가 실제로 전이하는 값(-10/-20).""" + from app.services.mdd_manager import MDDManager + t = tv_overlay.mdd_thresholds() + m = MDDManager() + _run(m.update(1_000_000)) + assert _run(m.update(1_000_000 * (1 + (t["reduce_pct"] - 0.1) / 100)))["action"] == "REDUCE_AND_PAUSE" + assert _run(m.update(1_000_000 * (1 + (t["force_pct"] - 0.1) / 100)))["action"] == "FORCE_REDUCE" + m2 = MDDManager() + _run(m2.update(1_000_000)) + assert _run(m2.update(1_000_000 * (1 + (t["reduce_pct"] + 0.5) / 100)))["action"] != "REDUCE_AND_PAUSE" + + +def test_intraday_cache_and_inflight_share(): + from app.api.endpoints import market_data + market_data._INTRADAY_CACHE.clear() + fake = MagicMock() + fake.get_minute_chart_full_day = AsyncMock(return_value=[]) + + async def both(): + a, b = await asyncio.gather(market_data.get_stock_chart("005930", period="1m", realtime=False, limit=100), + market_data.get_stock_chart("005930", period="1m", realtime=False, limit=100)) + c = await market_data.get_stock_chart("005930", period="1m", realtime=False, limit=100) + return a, b, c + with patch.object(market_data, "korea_investment_api", fake): + a, b, c = _run(both()) + assert a["reason"] == "no_data" == c["reason"] + assert fake.get_minute_chart_full_day.await_count == 1 diff --git a/frontend/src/components/ChartPanel.tsx b/frontend/src/components/ChartPanel.tsx index db18fa0..c798c45 100644 --- a/frontend/src/components/ChartPanel.tsx +++ b/frontend/src/components/ChartPanel.tsx @@ -56,30 +56,30 @@ const ChartPanel = React.memo(({ emptyReason, }: ChartPanelProps) => { return ( -
+
{/* Header - Bloomberg ticker style */} -
+
{selectedStock ? ( <> - {stockName} - + {stockName} + {selectedStock} ) : ( - 종목을 선택하세요 + 종목을 선택하세요 )}
-
+
{(['D', 'W', 'M'] as const).map((p) => (
{/* Chart area — 로딩 중에도 기존 차트 유지 (깜빡임 방지) */} -
+
{chartData && chartData.length > 0 ? ( ) : !chartLoading ? ( -
+
차트 데이터 없음 {emptyReason && ( - ({emptyReason}) + ({emptyReason}) )}
) : null} {chartLoading && ( -
+
- 차트 로딩중... + 차트 로딩중...
)} @@ -163,66 +163,66 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr const sizingTitleFallback = action === 'HOLD' ? '관망 (매수 제안 없음)' : action === 'SELL' ? '매도 의견 (매수 제안 없음)' : '매수 추천' const sizingTitle = headline ?? sizingTitleFallback return ( -
+
{/* Header */} -
-

+
+

AI 투자 분석

{aiAnalysisData?.data?.analyzed_at && ( - + {formatTime(aiAnalysisData.data.analyzed_at)} )}
{/* 매수 추천 금액 — 실제 자동매매 사이징과 동일 기준 (총자산×종목당 비중, 신뢰도 반영) */} {aiAnalysisData?.data && ( -
+
- {sizingTitle} + {sizingTitle} {showBuyPlan && aiAnalysisData.data.suggested_quantity > 0 ? ( <> {Number(aiAnalysisData.data.suggested_amount).toLocaleString('ko-KR')}원 - + {aiAnalysisData.data.suggested_quantity}주 - + 총자산 {aiAnalysisData.data.position_ratio}% ) : ( - + {aiAnalysisData.data.sizing_note || '매수 제안 없음'} )}
{showBuyPlan && aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && ( -
{aiAnalysisData.data.sizing_note}
+
{aiAnalysisData.data.sizing_note}
)} {/* 목표가 · 손절가 (CLAUDE.md §5.2: 손절은 ATR/퍼센트 중 타이트한 쪽, 목표는 +2R) */} {showBuyPlan && hasTarget && (
- 목표 - + 목표 + {Number(aiAnalysisData.data.target_price).toLocaleString('ko-KR')}원 - {aiAnalysisData.data.target_pct != null && <>(+{aiAnalysisData.data.target_pct}%)} + {aiAnalysisData.data.target_pct != null && <>(+{aiAnalysisData.data.target_pct}%)} - 손절 + 손절 {aiAnalysisData.data.stop_loss_price != null ? `${Number(aiAnalysisData.data.stop_loss_price).toLocaleString('ko-KR')}원` : '-'} - {aiAnalysisData.data.stop_loss_pct != null && ({aiAnalysisData.data.stop_loss_pct}%)} + {aiAnalysisData.data.stop_loss_pct != null && ({aiAnalysisData.data.stop_loss_pct}%)} - + {aiAnalysisData.data.risk_reward != null && <>R:R 1:{aiAnalysisData.data.risk_reward}}
)} {aiAnalysisData.data.target_note && ( -
{aiAnalysisData.data.target_note}
+
{aiAnalysisData.data.target_note}
)}
)} @@ -230,11 +230,11 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr {/* Content */}
{aiAnalysisText ? ( -
+                    
                         {typeof aiAnalysisText === 'string' ? aiAnalysisText : JSON.stringify(aiAnalysisText, null, 2)}
                     
) : ( -
+

종목을 선택하면
AI 투자 분석을 표시합니다.

)} diff --git a/frontend/src/components/JournalPanel.tsx b/frontend/src/components/JournalPanel.tsx index f6dc160..9bb5cf5 100644 --- a/frontend/src/components/JournalPanel.tsx +++ b/frontend/src/components/JournalPanel.tsx @@ -69,30 +69,30 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset }, [tradeHistory]) return ( -
+
{/* Header with tabs */} -
+
@@ -243,11 +243,11 @@ const PortfolioItem = React.memo(({ onConfirm={handleConfirmSell} onCancel={() => setConfirmOpen(false)} lines={[ - <>{stock.stock_name} ({stock.stock_code}), - <>수량 {quantity.toLocaleString()}{isUs ? '주(shares)' : '주'} 전량을 시장가로 매도합니다., + <>{stock.stock_name} ({stock.stock_code}), + <>수량 {quantity.toLocaleString()}{isUs ? '주(shares)' : '주'} 전량을 시장가로 매도합니다., <>현재가 {formatPrice(currentPrice, isUs)} · 평가손익 {profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%{profitLoss != null && !isUs ? ` (${profitLoss > 0 ? '+' : ''}${Math.round(profitLoss).toLocaleString()}원)` : ''}, - ...(Number((stock as GroupedHolding).unmanaged) > 0 ? [엔진 미관리 잔고입니다.] : []), - 체결 후에는 되돌릴 수 없습니다., + ...(Number((stock as GroupedHolding).unmanaged) > 0 ? [엔진 미관리 잔고입니다.] : []), + 체결 후에는 되돌릴 수 없습니다., ]} />
@@ -364,19 +364,19 @@ const PortfolioPanel = React.memo(({ : null return (
-
+
{icon} - {label} - {stocks.length} + {label} + {stocks.length}
-
- {fmt(sub.value)}{krwNote && {krwNote}} - +
+ {fmt(sub.value)}{krwNote && {krwNote}} + {up ? '+' : ''}{fmt(sub.pnl)} ({up ? '+' : ''}{sub.rate.toFixed(2)}%)
- {note &&

{note}

} + {note &&

{note}

}
{stocks.map((stock) => ( +
{/* Header */} -
-

+
+

보유 종목

{groups.swing.length > 0 && ( - 스윙 {groups.swing.length} + 스윙 {groups.swing.length} )} {groups.unmanaged.length > 0 && ( - 미관리 {groups.unmanaged.length} + 미관리 {groups.unmanaged.length} )} {groups.manual.length > 0 && ( - 수동 {groups.manual.length} + 수동 {groups.manual.length} )} {groups.overseas.length > 0 && ( - 해외 {groups.overseas.length} + 해외 {groups.overseas.length} )} - {total} + {total}
{/* List */} -
+
{accountSetupRequired ? (
@@ -430,13 +430,13 @@ const PortfolioPanel = React.memo(({ <> {/* 엔진이 관리 중인 데이트레이딩 포지션은 목록에서 제외(사용자 요청 2026-10-06) — 하단 DayTradingStatusBar 에서 확인. 단 엔진이 모르는 DT 잔고는 반드시 경고 그룹으로 표시한다. */} - {renderGroup(groups.unmanaged, '미관리 (엔진 외) 보유', , 'text-[#fb923c]', '데이트레이딩 엔진이 관리하지 않는 잔고 - 자동 청산되지 않으니 직접 확인/청산 필요', false, 'unmanaged', true)} - {renderGroup(groups.swing, '스윙', , 'text-[#a78bfa]', undefined, false, 'swing')} - {renderGroup(groups.manual, '수동 / 외부', , 'text-[#eab308]', '자동 청산 대상 아님 - 직접 청산 필요', false, 'manual')} + {renderGroup(groups.unmanaged, '미관리 (엔진 외) 보유', , 'text-tv-warn', '데이트레이딩 엔진이 관리하지 않는 잔고 - 자동 청산되지 않으니 직접 확인/청산 필요', false, 'unmanaged', true)} + {renderGroup(groups.swing, '스윙', , 'text-tv-accent', undefined, false, 'swing')} + {renderGroup(groups.manual, '수동 / 외부', , 'text-tv-warn', '자동 청산 대상 아님 - 직접 청산 필요', false, 'manual')} {renderGroup(groups.overseas, '해외', , 'text-[#22c55e]', undefined, true, 'overseas')} ) : ( -
+

보유 중인 종목이 없습니다.

)} diff --git a/frontend/src/components/RecommendationPanel.tsx b/frontend/src/components/RecommendationPanel.tsx index 0839875..03ff461 100644 --- a/frontend/src/components/RecommendationPanel.tsx +++ b/frontend/src/components/RecommendationPanel.tsx @@ -151,8 +151,8 @@ const RecommendationItem = React.memo(({
{/* Row 1: rank + name + badge + change% */}
- {idx + 1} - + {idx + 1} + {stock.stock_name} {isPending ? ( - + 매수중 ) : isHeld ? ( - 보유 + 보유 ) : isRecommended && isFallback ? ( - 관심(규칙 미충족) + 관심(규칙 미충족) ) : isRecommended ? ( - 신호 + 신호 ) : null} {blocked && !isHeld && !isPending && ( - 차단 + 차단 )} - + {changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
{/* Row 2: code + price + RSI + score */}
- {stock.stock_code} - + {stock.stock_code} + {Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원 {stock.price_source === 'batch' && livePrices?.[stock.stock_code] == null && ( - 배치 + 배치 )} {stock.rsi != null && ( - 70 ? 'text-[#e84444]' : 'text-[#4a6580]'}`}> + 70 ? 'text-tv-up' : 'text-tv-muted'}`}> RSI {stock.rsi.toFixed(0)} )} {stock.stochastic_k != null && ( - K {stock.stochastic_k.toFixed(0)} + K {stock.stochastic_k.toFixed(0)} )} {(stock.score ?? 0) > 0 && ( - {stock.score}pt + {stock.score}pt )} {mlSignal && ( - ML {mlSignal} @@ -222,7 +222,7 @@ const RecommendationItem = React.memo(({ {stock.signals && stock.signals.length > 0 && (
{/* 잘림 방지: 한 줄 truncate 대신 줄바꿈 허용 (ML SELL 등 뒤쪽 신호가 가려지지 않게) */} - + {stock.signals.join(' · ')}
@@ -236,7 +236,7 @@ const RecommendationItem = React.memo(({ onClick={handleBuyClick} disabled={buying || buyDisabled || blocked} title={blocked ? `매수 차단: ${blockReasons || '사유 미상'}` : undefined} - className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/40 rounded-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]" + className="w-full py-0.5 text-[11px] font-mono text-tv-muted hover:text-tv-up border border-tv-border hover:border-tv-up/40 rounded-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-tv-muted disabled:hover:border-tv-border" > {buying ? '매수 주문 중...' : blocked ? '매수 차단' : '매수 (시장가)'} @@ -249,7 +249,7 @@ const RecommendationItem = React.memo(({ onConfirm={handleConfirmBuy} onCancel={() => setConfirmOpen(false)} lines={[ - <>{stock.stock_name} ({stock.stock_code}), + <>{stock.stock_name} ({stock.stock_code}), <>시장가 매수 (수량: 총자산 x 종목당 최대비중으로 자동 산출), ]} /> @@ -314,24 +314,24 @@ const RecommendationPanel = React.memo(({ }, [recommendations]) return ( -
+
{/* Header */} -
-

+
+

분석 종목 스캐너

{(recommendations?.batch_time || recommendations?.batch_date) && ( - + {formatBatchTime(recommendations.batch_time) ?? formatBatchTime(recommendations.batch_date)} )} - + {recommendations?.analyzed_stocks || 0}
{/* List */} -
+
{isError ? (
-

⚠ API 오류

-

백엔드 로그 확인 필요

- +

⚠ API 오류

+

백엔드 로그 확인 필요

+
) : recommendations === undefined ? ( -
- +
+

로딩 중...

-

최초 로딩 시 10초 정도 소요됩니다

+

최초 로딩 시 10초 정도 소요됩니다

) : (recommendations?.analyzed_stock_list || []).length > 0 ? ( (recommendations.analyzed_stock_list).map((stock: RecommendedStock, idx: number) => { @@ -381,9 +381,9 @@ const RecommendationPanel = React.memo(({ ) }) ) : ( -
+

배치 분석 데이터 없음

-

make batch-test 실행 필요

+

make batch-test 실행 필요

)}
diff --git a/frontend/src/components/tv/BottomTabs.tsx b/frontend/src/components/tv/BottomTabs.tsx new file mode 100644 index 0000000..9f12196 --- /dev/null +++ b/frontend/src/components/tv/BottomTabs.tsx @@ -0,0 +1,84 @@ +/** + * BottomTabs.tsx — 하단 탭: 보유 | 체결내역 | DT 포지션 | 손익곡선 | 로그/저널 + * 각 탭은 선택된 탭만 마운트한다(손익곡선·DT 쿼리 불필요 호출 방지). 기존 패널은 노드로 주입받는다. + */ +import React, { useState } from 'react' +import type { TradeHistory } from '../../types/trading' +import type { StrategyDisplay } from '../../lib/tvApi' +import { EquityPanel } from './EquityPanel' +import { DtPositionCards } from './DtPositionCards' +import { fmtPrice } from './chartUtils' + +type Tab = 'hold' | 'trades' | 'dt' | 'equity' | 'journal' + +interface Props { + holdNode: React.ReactNode + journalNode: React.ReactNode + trades: TradeHistory[] | undefined + onSelect: (code: string) => void + cfg: StrategyDisplay | null + totalAssets?: number +} + +const TABS: { v: Tab; label: string }[] = [ + { v: 'hold', label: '보유' }, { v: 'trades', label: '체결내역' }, { v: 'dt', label: 'DT 포지션' }, + { v: 'equity', label: '손익곡선' }, { v: 'journal', label: '로그/저널' }, +] + +const cls = (v?: number | null) => (v == null || v === 0 ? 'text-tv-text' : v > 0 ? 'text-tv-up' : 'text-tv-down') + +const TradesTable = ({ trades, onSelect }: { trades: TradeHistory[] | undefined; onSelect: (c: string) => void }) => { + if (!trades) return
불러오는 중…
+ if (trades.length === 0) return
체결 내역 없음
+ return ( + + + + + + + + + {trades.map((t) => { + const side = String(t.side ?? t.order_type ?? t.trade_type ?? '').toUpperCase() + const net = t.net_profit_loss != null ? Number(t.net_profit_loss) : t.profit_loss != null ? Number(t.profit_loss) : null + const ts = String(t.executed_at || t.created_at || '') + return ( + onSelect(t.stock_code)} className="cursor-pointer hover:bg-tv-raised border-t border-tv-border/50"> + + + + + + + + + + ) + })} + +
시각종목구분전략수량체결가순손익상태
{ts.replace('T', ' ').slice(5, 16)}{t.stock_name || t.stock_code}{side === 'BUY' ? '매수' : side === 'SELL' ? '매도' : side}{String(t.strategy_type ?? '-')}{Number(t.executed_quantity ?? t.quantity ?? 0).toLocaleString('ko-KR')}{fmtPrice(Number(t.executed_price ?? t.price))}{net == null || side === 'BUY' ? '-' : `${net > 0 ? '+' : ''}${Math.round(net).toLocaleString('ko-KR')}`}{String(t.status)}
+ ) +} + +export const BottomTabs = React.memo(({ holdNode, journalNode, trades, onSelect, cfg, totalAssets }: Props) => { + const [tab, setTab] = useState('hold') + return ( +
+
+ {TABS.map((t) => ( + + ))} +
+
+ {tab === 'hold' &&
{holdNode}
} + {tab === 'trades' && } + {tab === 'dt' && } + {tab === 'equity' && } + {tab === 'journal' &&
{journalNode}
} +
+
+ ) +}) +BottomTabs.displayName = 'BottomTabs' diff --git a/frontend/src/components/tv/DtPositionCards.tsx b/frontend/src/components/tv/DtPositionCards.tsx new file mode 100644 index 0000000..277cfd9 --- /dev/null +++ b/frontend/src/components/tv/DtPositionCards.tsx @@ -0,0 +1,126 @@ +/** + * DtPositionCards.tsx — 데이트레이딩 포지션 카드 탭 + * 손절-현재-목표 게이지, 경과/시간손절, 트레일링, 일일 손실한도. + * 소스: GET /daytrading/positions (기존) + GET /positions/overlay (신규, 실패 시 기존 필드만으로 축소 표시) + */ +import React from 'react' +import { useQuery } from '@tanstack/react-query' +import api from '../../lib/api' +import { usePositionOverlay, StrategyDisplay, OverlayPosition } from '../../lib/tvApi' +import { fmtPrice } from './chartUtils' + +interface DtPos { + stock_code: string; stock_name?: string; strategy?: string; entry_price?: number; current_price?: number | null + stop_loss?: number | null; target_price?: number | null; partial_sold?: boolean; quantity?: number + pnl?: number | null; pnl_pct?: number | null; entry_time?: string +} +interface DtResp { positions: DtPos[]; daily_pnl?: number; is_daily_loss_exceeded?: boolean } + +const pnlCls = (v?: number | null) => (v == null ? 'text-tv-muted' : v > 0 ? 'text-tv-up' : v < 0 ? 'text-tv-down' : 'text-tv-text') +const signed = (v?: number | null, d = 0) => (v == null ? '-' : `${v > 0 ? '+' : ''}${v.toLocaleString('ko-KR', { maximumFractionDigits: d })}`) + +const Gauge = ({ stop, cur, target, entry }: { stop?: number | null; cur?: number | null; target?: number | null; entry?: number | null }) => { + if (stop == null || target == null || cur == null || !(target > stop)) { + return
손절/목표 데이터 없음{stop != null ? ` · 손절 ${fmtPrice(stop)}` : ''}
+ } + const pos = (v: number) => Math.max(0, Math.min(100, ((v - stop) / (target - stop)) * 100)) + return ( +
+
+
+
+ {entry != null &&
} +
+
+
+ 손절 {fmtPrice(stop)} + 현재 {fmtPrice(cur)} + 목표 {fmtPrice(target)} +
+
+ ) +} + +export const DtPositionCards = React.memo(({ cfg, totalAssets }: { cfg: StrategyDisplay | null; totalAssets?: number }) => { + const { data, isLoading, isError } = useQuery({ + queryKey: ['tv-dt-positions'], + queryFn: async () => (await api.get('/daytrading/positions')).data, + refetchInterval: 15000, staleTime: 8000, retry: false, + }) + const { data: ov } = usePositionOverlay(undefined) + const ovMap = new Map() + for (const p of ov?.positions ?? []) { + const st = (p.strategy ?? '').toLowerCase() + if (st === 'dt' || st === 'daytrading' || st === 'day_trade') ovMap.set(p.stock_code, p) + } + + if (isLoading) return
불러오는 중…
+ if (isError || !data) return
DT 포지션 데이터 없음
+ + const lim = cfg?.dt?.daily_loss_pct + const limPct = typeof lim === 'number' ? Math.abs(lim) : null // 백엔드 계약: 퍼센트 단위(-3.0) + const limAmt = limPct != null && totalAssets ? (totalAssets * limPct) / 100 : null + const daily = data.daily_pnl ?? 0 + const used = limAmt && daily < 0 ? Math.min(100, (Math.abs(daily) / limAmt) * 100) : 0 + const tsMin = cfg?.dt?.time_stop_min + + return ( +
+
+ 일일 손익(순) + {signed(daily)}원 + 한도 + {limPct != null ? `-${limPct}%` : '-'}{limAmt != null ? ` (≈-${Math.round(limAmt).toLocaleString('ko-KR')}원)` : ''} + {limAmt != null && ( + + = 80 ? 'bg-tv-up' : 'bg-tv-warn'}`} style={{ width: `${used}%` }} /> + + )} + {data.is_daily_loss_exceeded && 한도 초과 — 당일 중단} +
+ + {data.positions.length === 0 &&
보유 중인 DT 포지션 없음
} +
+ {data.positions.map((p) => { + const o = ovMap.get(p.stock_code) + const stop = o?.stop_loss ?? p.stop_loss + const target = o?.target_price ?? p.target_price + const cur = o?.current_price ?? p.current_price + const el = o?.elapsed_min + const tsPct = el != null && tsMin ? Math.min(100, (el / tsMin) * 100) : null + return ( +
+
+ {p.stock_name || p.stock_code} + {p.stock_code} + {p.strategy || 'DT'} + {p.partial_sold && 분할익절 후} + + {signed(o?.pnl_pct ?? p.pnl_pct, 2)}% + +
+ +
+ 수량 {p.quantity ?? '-'} + 평가손익 {signed(o?.pnl ?? p.pnl)} + 경과 {el != null ? `${Math.round(el)}분` : '-'} + {tsMin ? ` / 시간손절 ${tsMin}분` : ''} + + 트레일링 {o?.trailing_active + ? {fmtPrice(o.trailing_stop)} + : {o ? '미발동' : '-'}} + +
+ {tsPct != null && ( +
+
= 100 ? 'bg-tv-warn' : 'bg-tv-muted'}`} style={{ width: `${tsPct}%` }} /> +
+ )} +
+ ) + })} +
+
+ ) +}) +DtPositionCards.displayName = 'DtPositionCards' diff --git a/frontend/src/components/tv/EquityPanel.tsx b/frontend/src/components/tv/EquityPanel.tsx new file mode 100644 index 0000000..6722ae0 --- /dev/null +++ b/frontend/src/components/tv/EquityPanel.tsx @@ -0,0 +1,142 @@ +/** + * EquityPanel.tsx — 손익곡선 탭 (GET /risk/equity-history) + * 비용 후 누적 순손익(Baseline: 0 위 빨강/아래 파랑) + 낙폭 영역 + MDD 임계선(축소/강제), 전체/스윙/DT 구분. + * 엔드포인트가 없거나 실패하면 "데이터 없음" 만 표시한다. + */ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { createChart, LineStyle, IChartApi, ISeriesApi, Time } from 'lightweight-charts' +import { useEquityHistory } from '../../lib/tvApi' +import { tvColor } from './chartUtils' + +type Mode = 'all' | 'swing' | 'dt' +const MODES: { v: Mode; label: string }[] = [{ v: 'all', label: '전체' }, { v: 'swing', label: '스윙' }, { v: 'dt', label: 'DT' }] +const DAYS = [30, 90, 180] + +export const EquityPanel = React.memo(({ active }: { active: boolean }) => { + const [days, setDays] = useState(90) + const [mode, setMode] = useState('all') + const { data, isLoading } = useEquityHistory(days, active) + const el = useRef(null) + const ddEl = useRef(null) + const chartRef = useRef<{ cum: IChartApi; dd: IChartApi } | null>(null) + const seriesRef = useRef<{ cum: ISeriesApi<'Baseline'>; dd: ISeriesApi<'Area'> } | null>(null) + const linesRef = useRef['createPriceLine']>[]>([]) + const thrRef = useRef<{ lo: number }>({ lo: 0 }) + + const points = useMemo(() => data?.points ?? [], [data]) + const cum = useMemo(() => { + let run = 0 + return points.map((p) => { + const d = mode === 'swing' ? p.realized_net_swing : mode === 'dt' ? p.realized_net_dt : undefined + if (mode === 'all') return { date: p.date.slice(0, 10), v: p.cum_net } + run += Number(d ?? 0) + return { date: p.date.slice(0, 10), v: run } + }) + }, [points, mode]) + + useEffect(() => { + if (!el.current || !ddEl.current) return + const base = () => ({ + autoSize: true, + layout: { background: { color: 'transparent' }, textColor: tvColor('--tv-muted'), fontSize: 12 }, + grid: { vertLines: { color: tvColor('--tv-border', 0.3) }, horzLines: { color: tvColor('--tv-border', 0.3) } }, + rightPriceScale: { borderColor: tvColor('--tv-border'), minimumWidth: 80 }, + timeScale: { borderColor: tvColor('--tv-border') }, + }) + const cumC = createChart(el.current, { ...base(), localization: { priceFormatter: (v: number) => Math.round(v).toLocaleString('ko-KR') } }) + const ddC = createChart(ddEl.current, { ...base(), localization: { priceFormatter: (v: number) => `${v.toFixed(1)}%` } }) + const up = tvColor('--tv-up'), down = tvColor('--tv-down') + const cumS = cumC.addBaselineSeries({ + baseValue: { type: 'price', price: 0 }, + topLineColor: up, topFillColor1: tvColor('--tv-up', 0.3), topFillColor2: tvColor('--tv-up', 0.02), + bottomLineColor: down, bottomFillColor1: tvColor('--tv-down', 0.02), bottomFillColor2: tvColor('--tv-down', 0.3), + lineWidth: 2, + priceFormat: { type: 'price', precision: 0, minMove: 1 }, + }) + const ddS = ddC.addAreaSeries({ + lineColor: tvColor('--tv-warn'), topColor: tvColor('--tv-warn', 0.02), bottomColor: tvColor('--tv-warn', 0.35), + lineWidth: 1, priceLineVisible: false, + priceFormat: { type: 'price', precision: 1, minMove: 0.1 }, + autoscaleInfoProvider: () => ({ priceRange: { minValue: Math.min(thrRef.current.lo, -1), maxValue: 0 } }), + }) + let syncing = false + const link = (src: IChartApi, dst: IChartApi) => src.timeScale().subscribeVisibleLogicalRangeChange((r) => { + if (!r || syncing) return + syncing = true; dst.timeScale().setVisibleLogicalRange(r); syncing = false + }) + link(cumC, ddC); link(ddC, cumC) + chartRef.current = { cum: cumC, dd: ddC } + seriesRef.current = { cum: cumS, dd: ddS } + return () => { cumC.remove(); ddC.remove(); chartRef.current = null; seriesRef.current = null; linesRef.current = [] } + }, []) + + useEffect(() => { + const s = seriesRef.current, c = chartRef.current + if (!s || !c) return + s.cum.setData(cum.map((p) => ({ time: p.date as Time, value: p.v }))) + const showDd = mode === 'all' + s.dd.setData(showDd ? points + .filter((p) => Number.isFinite(p.drawdown_pct)) + .map((p) => ({ time: p.date.slice(0, 10) as Time, value: -Math.abs(p.drawdown_pct as number) })) : []) + linesRef.current.forEach((l) => s.dd.removePriceLine(l)) + linesRef.current = [] + const th = data?.mdd_thresholds + const lows: number[] = [] + if (showDd && th) { + const add = (v: number | undefined, title: string, color: string) => { + if (typeof v !== 'number' || !Number.isFinite(v)) return + const price = -Math.abs(v) + lows.push(price - 2) + linesRef.current.push(s.dd.createPriceLine({ price, color, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: `${title} ${price}%` })) + } + add(th.reduce_pct, 'MDD 축소', tvColor('--tv-warn')) + add(th.force_pct, 'MDD 강제', tvColor('--tv-up')) + } + thrRef.current.lo = lows.length ? Math.min(...lows) : -1 + c.cum.timeScale().fitContent() + }, [cum, points, data, mode]) + + const last = cum.length ? cum[cum.length - 1].v : null + const empty = !isLoading && points.length === 0 + + return ( +
+
+ 비용 차감 후 누적 순손익 · 낙폭=실현손익 낙폭(자산 MDD와 다름) + {last != null && ( + 0 ? 'text-tv-up' : last < 0 ? 'text-tv-down' : ''}`}> + {last > 0 ? '+' : ''}{Math.round(last).toLocaleString('ko-KR')}원 + + )} + {!!data?.null_net_rows && ( + 비용 미기록 {data.null_net_rows}건 제외 + )} + {!!data?.excluded_non_krw_rows && ( + 비원화 {data.excluded_non_krw_rows}건 제외 + )} + + {MODES.map((m) => ( + + ))} + + {DAYS.map((d) => ( + + ))} + +
+
+
+
+ 실현손익 낙폭 (%) +
+
+ {(empty || isLoading) && ( +
+ {isLoading ? '불러오는 중…' : '데이터 없음'} +
+ )} +
+
+ ) +}) +EquityPanel.displayName = 'EquityPanel' diff --git a/frontend/src/components/tv/RightPanel.tsx b/frontend/src/components/tv/RightPanel.tsx new file mode 100644 index 0000000..2bba857 --- /dev/null +++ b/frontend/src/components/tv/RightPanel.tsx @@ -0,0 +1,140 @@ +/** + * RightPanel.tsx — 우측 패널: 선택 종목 포지션 요약 + 탭(관심종목 | 추천 | 호가 | AI) + * 관심종목 = 보유 + DT 감시(/daytrading/watchlist) + 추천(analyzed_stock_list). 클릭 시 차트 심볼 전환. + * 기존 RecommendationPanel(매수 버튼)·OrderbookPanel·AiAnalysisPanel 은 각 탭으로 이동했다. + */ +import React, { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import api from '../../lib/api' +import type { OverlayPosition } from '../../lib/tvApi' +import type { Position, RecommendationsData } from '../../types/trading' +import { fmtPrice } from './chartUtils' + +type Tab = 'watch' | 'reco' | 'book' | 'ai' +type Src = 'hold' | 'dt' | 'reco' + +interface Item { code: string; name: string; price?: number; chg?: number; srcs: Set } + +interface Props { + portfolio: Position[] | undefined + recommendations: RecommendationsData | undefined + selectedStock: string + onSelect: (code: string) => void + livePrices: Record | undefined + selectedOverlay: OverlayPosition[] | null + recoNode: React.ReactNode + bookNode: React.ReactNode + aiNode: React.ReactNode +} + +const SRC_LABEL: Record = { hold: '보유', dt: 'DT', reco: '추천' } +const cls = (v?: number | null) => (v == null ? 'text-tv-muted' : v > 0 ? 'text-tv-up' : v < 0 ? 'text-tv-down' : 'text-tv-text') + +export const RightPanel = React.memo(({ + portfolio, recommendations, selectedStock, onSelect, livePrices, selectedOverlay, recoNode, bookNode, aiNode, +}: Props) => { + const [tab, setTab] = useState('watch') + const [filter, setFilter] = useState<'all' | Src>('all') + + const { data: dtWl } = useQuery<{ watchlist: { code: string; name: string }[] } | null>({ + queryKey: ['tv-dt-watchlist'], + queryFn: async () => { try { return (await api.get('/daytrading/watchlist')).data } catch { return null } }, + refetchInterval: 60000, staleTime: 30000, retry: false, + }) + + const items = useMemo(() => { + const m = new Map() + const put = (code: string, name: string, src: Src, price?: number, chg?: number) => { + const it = m.get(code) ?? { code, name, srcs: new Set() } + it.srcs.add(src) + if (name && (!it.name || it.name === code)) it.name = name + if (price && !it.price) it.price = price + if (chg != null && it.chg == null) it.chg = chg + m.set(code, it) + } + for (const p of portfolio ?? []) put(p.stock_code, p.stock_name, 'hold', p.current_price, p.profit_loss_rate) + for (const w of dtWl?.watchlist ?? []) put(w.code, w.name, 'dt') + for (const s of recommendations?.analyzed_stock_list ?? []) put(s.stock_code, s.stock_name, 'reco', s.current_price, s.change_rate) + return [...m.values()] + }, [portfolio, dtWl, recommendations]) + + const shown = filter === 'all' ? items : items.filter((i) => i.srcs.has(filter)) + const held = portfolio?.find((p) => p.stock_code === selectedStock) + + const TABS: { v: Tab; label: string }[] = [{ v: 'watch', label: '관심종목' }, { v: 'reco', label: '추천' }, { v: 'book', label: '호가' }, { v: 'ai', label: 'AI' }] + + return ( +
+ {/* 선택 종목 포지션 요약 */} +
+
선택 종목 포지션
+ {selectedOverlay === null && !held &&
포지션 없음 / 오버레이 데이터 없음
} + {(selectedOverlay ?? []).map((p, i) => ( +
+ {(p.strategy ?? '').toUpperCase() || '포지션'} · {p.quantity ?? '-'}주{p.orphan && 고아} + {p.pnl_pct != null ? `${p.pnl_pct > 0 ? '+' : ''}${p.pnl_pct.toFixed(2)}%` : ''} + 평단 {fmtPrice(p.avg_price ?? p.entry_price)} + 손절 {fmtPrice(p.stop_loss)}{p.stop_basis === 'pct' ? '(추정)' : ''} + 목표 {fmtPrice(p.target_price)} + 트레일링 {p.trailing_active ? fmtPrice(p.trailing_stop) : '-'} +
+ ))} + {selectedOverlay !== null && selectedOverlay.length === 0 && held && ( +
보유 {held.quantity}주 · 평단 {fmtPrice(held.average_price)} + {held.profit_loss_rate > 0 ? '+' : ''}{Number(held.profit_loss_rate).toFixed(2)}%
+ )} + {selectedOverlay !== null && selectedOverlay.length === 0 && !held &&
보유 없음
} + {selectedOverlay === null && held && ( +
보유 {held.quantity}주 · 평단 {fmtPrice(held.average_price)} + (가격선 데이터 없음)
+ )} +
+ +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {tab === 'watch' && ( +
+
+ {(['all', 'hold', 'dt', 'reco'] as const).map((f) => ( + + ))} +
+
+ {shown.length === 0 &&
종목 없음
} + {shown.map((it) => { + const price = livePrices?.[it.code] ?? it.price + return ( + + ) + })} +
+
+ )} + {tab === 'reco' &&
{recoNode}
} + {tab === 'book' &&
{bookNode}
} + {tab === 'ai' &&
{aiNode}
} +
+
+ ) +}) +RightPanel.displayName = 'RightPanel' diff --git a/frontend/src/components/tv/SymbolBar.tsx b/frontend/src/components/tv/SymbolBar.tsx new file mode 100644 index 0000000..73b415e --- /dev/null +++ b/frontend/src/components/tv/SymbolBar.tsx @@ -0,0 +1,130 @@ +/** + * SymbolBar.tsx — 상단 심볼 바 + * 심볼·현재가·등락 | 타임프레임(D/W/M/5m) | 지표 토글 | 모드 배지(적응형·국면·MDD·WS) | 계좌 요약 + * 배지 데이터(/adaptive-strategy/status, /risk/mdd-status)가 실패하면 해당 배지만 "—" 로 표시한다. + */ +import React from 'react' +import { Wifi, WifiOff, ChevronDown, ChevronUp } from 'lucide-react' +import { useAdaptiveStatus, useMddStatus, StrategyDisplay } from '../../lib/tvApi' +import type { Timeframe } from './chartUtils' +import type { ChartToggles } from './TvChart' + +interface SymbolBarProps { + stockCode: string + stockName: string + price?: number + changePct?: number | null + timeframe: Timeframe + onTimeframe: (tf: Timeframe) => void + intradayAvailable: boolean + toggles: ChartToggles + onToggle: (k: keyof ChartToggles) => void + wsConnected: boolean + totalAssets?: number + todayPlRate?: number + summaryOpen: boolean + onToggleSummary: () => void + displayCfg: StrategyDisplay | null +} + +const MODE_LABEL: Record = { aggressive: '공격', normal: '보통', defensive: '방어', crisis: '위기' } +const MODE_CLS: Record = { + aggressive: 'text-tv-up border-tv-up/50', + normal: 'text-tv-text border-tv-border', + defensive: 'text-tv-warn border-tv-warn/60', + crisis: 'text-tv-warn border-tv-warn bg-tv-warn/15 font-semibold', +} + +const Badge = ({ label, value, cls = '', title }: { label: string; value: string; cls?: string; title?: string }) => ( + + {label}{value} + +) + +const TF: { v: Timeframe; label: string }[] = [ + { v: 'D', label: '1D' }, { v: 'W', label: '1W' }, { v: 'M', label: '1M' }, { v: '5m', label: '5m' }, +] +const IND: { k: keyof ChartToggles; label: string }[] = [ + { k: 'ma', label: 'MA' }, { k: 'bb', label: 'BB' }, { k: 'vol', label: 'VOL' }, { k: 'rsi', label: 'RSI' }, { k: 'macd', label: 'MACD' }, +] + +export const SymbolBar = React.memo(({ + stockCode, stockName, price, changePct, timeframe, onTimeframe, intradayAvailable, + toggles, onToggle, wsConnected, totalAssets, todayPlRate, summaryOpen, onToggleSummary, displayCfg, +}: SymbolBarProps) => { + const { data: adaptive } = useAdaptiveStatus() + const { data: mdd } = useMddStatus() + + const mode = adaptive?.trading_mode ?? displayCfg?.adaptive_mode + const dd = mdd?.drawdown_pct == null ? null : Math.abs(mdd.drawdown_pct) + const reduce = Math.abs(mdd?.thresholds?.reduce_pct ?? displayCfg?.mdd?.reduce_pct ?? NaN) + const force = Math.abs(mdd?.thresholds?.force_pct ?? displayCfg?.mdd?.force_pct ?? NaN) + // drawdown_pct 는 양수=하락폭. 임계는 절대값으로 비교 + const mddCls = dd == null ? '' : Number.isFinite(force) && dd >= force ? 'border-tv-up text-tv-up bg-tv-up/15 font-semibold' + : Number.isFinite(reduce) && dd >= reduce ? 'border-tv-up/70 text-tv-up' + : Number.isFinite(reduce) && dd >= reduce * 0.7 ? 'border-tv-warn/60 text-tv-warn' : '' + const regime = adaptive?.condition?.trend_strength + const chgCls = changePct == null ? 'text-tv-muted' : changePct > 0 ? 'text-tv-up' : changePct < 0 ? 'text-tv-down' : 'text-tv-text' + + return ( +
+
+ {stockCode || '종목 선택'} + {stockName && stockName !== stockCode && {stockName}} + {price ? Math.round(price).toLocaleString('ko-KR') : '-'} + + {changePct == null ? '' : `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%`} + +
+ +
+ {TF.map(({ v, label }) => { + const disabled = v === '5m' && !intradayAvailable + return ( + + ) + })} +
+ +
+ 지표 + {IND.map(({ k, label }) => ( + + ))} +
+ +
+ + + + + {wsConnected ? : } + {wsConnected ? 'LIVE' : '연결 끊김'} + + +
+
+ ) +}) +SymbolBar.displayName = 'SymbolBar' diff --git a/frontend/src/components/tv/TvChart.tsx b/frontend/src/components/tv/TvChart.tsx new file mode 100644 index 0000000..25ccb2b --- /dev/null +++ b/frontend/src/components/tv/TvChart.tsx @@ -0,0 +1,335 @@ +/** + * TvChart.tsx — TradingView 스타일 메인 차트 + * + * 구성: 캔들 + 거래량(오버레이 스케일) + MA5/20/60 + 볼린저(20,2) | RSI 패널 | MACD 패널(토글) + * - 가격선: /positions/overlay 의 평단·손절·목표·트레일링 (null 이면 그리지 않음) + * - 체결 마커: ▲매수(빨강) ▼매도(파랑) — 봉에 스냅 + * - RSI 기준선: /strategy-config/display 의 rsi 설정값. 값이 없으면 기준선 숨김(하드코딩 금지) + * - 패널 간 시간축 동기화, autoSize(리사이즈 대응) + * - 라이브 시세: 일봉(D)에서 오늘 캔들만 update (차트 재생성 없음) + */ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { + createChart, CrosshairMode, LineStyle, + IChartApi, ISeriesApi, IPriceLine, SeriesMarker, Time, LogicalRange, +} from 'lightweight-charts' +import { calculateSMA, calculateBollingerBands, calculateRSI, calculateMACD } from '../../utils/technicalIndicators' +import type { OverlayPosition, StrategyDisplay } from '../../lib/tvApi' +import { Bar, Timeframe, TradeMark, snapToBar, tvColor, todayKst, fmtPrice } from './chartUtils' + +export interface ChartToggles { ma: boolean; bb: boolean; vol: boolean; rsi: boolean; macd: boolean } + +interface TvChartProps { + bars: Bar[] + timeframe: Timeframe + loading: boolean + emptyReason?: string | null + livePrice?: number + overlays: OverlayPosition[] | null + trades: TradeMark[] + rsiCfg: StrategyDisplay['rsi'] | null + toggles: ChartToggles + /** 값이 바뀔 때마다 화면 맞춤(fitContent) */ + fitSignal: number + /** 미국 종목이면 소수 2자리, 원화는 정수 */ + isUs: boolean +} + +interface LegendRow { o: number; h: number; l: number; c: number; v: number; ma5?: number; ma20?: number; ma60?: number; rsi?: number } + +const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) +const clean = (arr: (T | null)[]): T[] => arr.filter((x): x is T => x !== null && Number.isFinite(x.value)) + +export const TvChart = React.memo(({ + bars, timeframe, loading, emptyReason, livePrice, overlays, trades, rsiCfg, toggles, fitSignal, isUs, +}: TvChartProps) => { + const mainEl = useRef(null) + const rsiEl = useRef(null) + const macdEl = useRef(null) + + const charts = useRef<{ main: IChartApi; rsi: IChartApi; macd: IChartApi } | null>(null) + const series = useRef<{ + candle: ISeriesApi<'Candlestick'>; vol: ISeriesApi<'Histogram'> + ma5: ISeriesApi<'Line'>; ma20: ISeriesApi<'Line'>; ma60: ISeriesApi<'Line'> + bbU: ISeriesApi<'Line'>; bbL: ISeriesApi<'Line'> + rsi: ISeriesApi<'Line'> + macdHist: ISeriesApi<'Histogram'>; macd: ISeriesApi<'Line'>; macdSig: ISeriesApi<'Line'> + } | null>(null) + const priceLines = useRef([]) + const rsiLines = useRef([]) + const lastBar = useRef(null) + const legendMap = useRef>(new Map()) + const [legend, setLegend] = useState(null) + + const rsiPeriod = rsiCfg?.period && rsiCfg.period > 1 ? rsiCfg.period : 14 + + // ── 1) 차트 3개 생성 (마운트 1회) ────────────────────────────── + useEffect(() => { + if (!mainEl.current || !rsiEl.current || !macdEl.current) return + const base = () => ({ + autoSize: true, + layout: { background: { color: 'transparent' }, textColor: tvColor('--tv-muted'), fontSize: 12 }, + grid: { vertLines: { color: tvColor('--tv-border', 0.35) }, horzLines: { color: tvColor('--tv-border', 0.35) } }, + crosshair: { mode: CrosshairMode.Normal }, + rightPriceScale: { borderColor: tvColor('--tv-border'), minimumWidth: 68 }, + timeScale: { borderColor: tvColor('--tv-border'), rightOffset: 4, timeVisible: false }, + handleScale: { axisPressedMouseMove: true }, + }) + const main = createChart(mainEl.current, base()) + const rsiC = createChart(rsiEl.current, { ...base(), timeScale: { ...base().timeScale, visible: false } }) + rsiC.priceScale('right').applyOptions({ scaleMargins: { top: 0.1, bottom: 0.1 } }) + const macdC = createChart(macdEl.current, base()) + + const up = tvColor('--tv-up'), down = tvColor('--tv-down') + const candle = main.addCandlestickSeries({ + upColor: up, downColor: down, borderUpColor: up, borderDownColor: down, wickUpColor: up, wickDownColor: down, + priceLineVisible: true, + }) + const vol = main.addHistogramSeries({ priceFormat: { type: 'volume' }, priceScaleId: 'vol', lastValueVisible: false, priceLineVisible: false }) + main.priceScale('vol').applyOptions({ scaleMargins: { top: 0.86, bottom: 0 } }) + candle.priceScale().applyOptions({ scaleMargins: { top: 0.06, bottom: 0.16 } }) + const line = (color: string, w: 1 | 2 = 1) => main.addLineSeries({ color, lineWidth: w, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: false }) + const bbColor = tvColor('--tv-muted', 0.7) + const s = { + candle, vol, + ma5: line('#ffd54f'), ma20: line('#26c6da'), ma60: line('#ab47bc'), + bbU: line(bbColor), bbL: line(bbColor), + rsi: rsiC.addLineSeries({ + priceFormat: { type: 'price', precision: 0, minMove: 1 }, + color: tvColor('--tv-accent'), lineWidth: 2, priceLineVisible: false, + autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: 100 } }), + }), + macdHist: macdC.addHistogramSeries({ priceLineVisible: false, lastValueVisible: false }), + macd: macdC.addLineSeries({ color: '#26c6da', lineWidth: 1, priceLineVisible: false, lastValueVisible: false }), + macdSig: macdC.addLineSeries({ color: '#ffd54f', lineWidth: 1, priceLineVisible: false, lastValueVisible: false }), + } + charts.current = { main, rsi: rsiC, macd: macdC } + series.current = s + + // 시간축 동기화 (논리 범위). 재귀 호출 방지용 플래그 + let syncing = false + const all = [main, rsiC, macdC] + const handlers = all.map((src) => { + const h = (r: LogicalRange | null) => { + if (!r || syncing) return + syncing = true + for (const dst of all) if (dst !== src) dst.timeScale().setVisibleLogicalRange(r) + syncing = false + } + src.timeScale().subscribeVisibleLogicalRangeChange(h) + return h + }) + + main.subscribeCrosshairMove((p) => { + const key = p.time as string | number | undefined + const row = key != null ? legendMap.current.get(key) : undefined + setLegend(row ?? null) + }) + + return () => { + all.forEach((c, i) => c.timeScale().unsubscribeVisibleLogicalRangeChange(handlers[i])) + all.forEach((c) => c.remove()) + charts.current = null + series.current = null + priceLines.current = [] + rsiLines.current = [] + } + }, []) + + // ── 2) 데이터/지표 ──────────────────────────────────────────── + const computed = useMemo(() => { + if (!bars.length) return null + const closes = bars.map((b) => b.close) + const bb = calculateBollingerBands(closes, 20) + return { + ma5: calculateSMA(closes, 5), ma20: calculateSMA(closes, 20), ma60: calculateSMA(closes, 60), + bbU: bb.upper, bbL: bb.lower, + rsi: calculateRSI(closes, rsiPeriod), + macd: calculateMACD(closes), + } + }, [bars, rsiPeriod]) + + useEffect(() => { + const s = series.current, c = charts.current + if (!s || !c) return + const intraday = timeframe === '5m' + for (const ch of [c.main, c.macd]) ch.applyOptions({ timeScale: { timeVisible: intraday, secondsVisible: false } }) + + if (!bars.length || !computed) { + for (const k of Object.keys(s) as (keyof typeof s)[]) (s[k] as ISeriesApi<'Line'>).setData([]) + lastBar.current = null + legendMap.current = new Map() + setLegend(null) + return + } + const up = tvColor('--tv-up'), down = tvColor('--tv-down') + const upA = tvColor('--tv-up', 0.28), downA = tvColor('--tv-down', 0.28) + const t = (i: number) => bars[i].time as Time + const mk = (arr: number[]) => clean(arr.map((v, i) => (num(v) ? { time: t(i), value: v } : null))) + + s.candle.setData(bars.map((b) => ({ time: b.time as Time, open: b.open, high: b.high, low: b.low, close: b.close }))) + s.vol.setData(bars.map((b) => ({ time: b.time as Time, value: b.volume, color: b.close >= b.open ? upA : downA }))) + s.ma5.setData(mk(computed.ma5)); s.ma20.setData(mk(computed.ma20)); s.ma60.setData(mk(computed.ma60)) + s.bbU.setData(mk(computed.bbU)); s.bbL.setData(mk(computed.bbL)) + s.rsi.setData(mk(computed.rsi)) + s.macd.setData(mk(computed.macd.macd)); s.macdSig.setData(mk(computed.macd.signal)) + s.macdHist.setData(clean(computed.macd.histogram.map((v, i) => (num(v) ? { time: t(i), value: v, color: v >= 0 ? up : down } : null)))) + + lastBar.current = bars[bars.length - 1] + const m = new Map() + bars.forEach((b, i) => m.set(b.time, { + o: b.open, h: b.high, l: b.low, c: b.close, v: b.volume, + ma5: computed.ma5[i], ma20: computed.ma20[i], ma60: computed.ma60[i], rsi: computed.rsi[i], + })) + legendMap.current = m + setLegend(null) + c.main.timeScale().fitContent() + }, [bars, computed, timeframe]) + + // 표시 토글 + useEffect(() => { + const s = series.current + if (!s) return + const vis = (x: ISeriesApi<'Line'> | ISeriesApi<'Histogram'>, v: boolean) => x.applyOptions({ visible: v }) + vis(s.ma5, toggles.ma); vis(s.ma20, toggles.ma); vis(s.ma60, toggles.ma) + vis(s.bbU, toggles.bb); vis(s.bbL, toggles.bb) + vis(s.vol, toggles.vol) + }, [toggles.ma, toggles.bb, toggles.vol]) + + // 가격 포맷: 원화=정수+천단위, 미국=소수 2자리 (축·가격선·크로스헤어 공통) + useEffect(() => { + const c = charts.current, s = series.current + if (!c || !s) return + const fmt = (v: number) => (isUs ? v.toFixed(2) : Math.round(v).toLocaleString('ko-KR')) + c.main.applyOptions({ localization: { priceFormatter: fmt } }) + const pf = isUs ? { type: 'price' as const, precision: 2, minMove: 0.01 } : { type: 'price' as const, precision: 0, minMove: 1 } + for (const x of [s.candle, s.ma5, s.ma20, s.ma60, s.bbU, s.bbL]) x.applyOptions({ priceFormat: pf }) + }, [isUs]) + + // 화면 맞춤 + useEffect(() => { + charts.current?.main.timeScale().fitContent() + }, [fitSignal]) + + // 라이브 시세: 일봉의 오늘 캔들만 갱신 + useEffect(() => { + const s = series.current, lb = lastBar.current + if (!s || !lb || !num(livePrice) || livePrice <= 0 || timeframe !== 'D') return + if (lb.time !== todayKst()) return + const next = { ...lb, close: livePrice, high: Math.max(lb.high, livePrice), low: Math.min(lb.low, livePrice) } + lastBar.current = next + s.candle.update({ time: next.time as Time, open: next.open, high: next.high, low: next.low, close: next.close }) + }, [livePrice, timeframe]) + + // ── 3) 가격선 (진입/손절/목표/트레일링) ───────────────────────── + useEffect(() => { + const s = series.current + if (!s) return + priceLines.current.forEach((l) => s.candle.removePriceLine(l)) + priceLines.current = [] + if (!overlays) return + const multi = overlays.length > 1 + for (const p of overlays) { + const tag = multi || p.strategy ? `${(p.strategy ?? '').toUpperCase().slice(0, 5)} ` : '' + const add = (price: number | null | undefined, title: string, color: string, style: LineStyle) => { + if (!num(price) || price <= 0) return + priceLines.current.push(s.candle.createPriceLine({ + price, color, lineWidth: 1, lineStyle: style, axisLabelVisible: true, title: `${tag}${title}`, + })) + } + add(p.avg_price ?? p.entry_price, '평단', tvColor('--tv-text'), LineStyle.Dashed) + add(p.stop_loss, p.stop_basis === 'pct' ? (p.stop_loss_alt != null ? '손절(추정 -7~-10%)' : '손절(추정)') : p.stop_basis ? `손절(${p.stop_basis})` : '손절', tvColor('--tv-down'), LineStyle.Solid) + add(p.target_price, '목표', tvColor('--tv-up'), LineStyle.Solid) + if (p.trailing_active) add(p.trailing_stop, '트레일링', tvColor('--tv-warn'), LineStyle.Dotted) + } + }, [overlays, bars]) + + // ── 4) 체결 마커 ─────────────────────────────────────────────── + useEffect(() => { + const s = series.current + if (!s) return + if (!bars.length || !trades.length) { s.candle.setMarkers([]); return } + const times = bars.map((b) => b.time) + const up = tvColor('--tv-up'), down = tvColor('--tv-down') + const marks: SeriesMarker