feat: TradingView 스타일 대시보드 + 차트 오버레이 API, Crisis 손절 소급 차단 플래그(기본 OFF)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 18:47:00 +09:00
parent d140055e64
commit a45007cbb2
28 changed files with 2607 additions and 351 deletions

View File

@@ -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

View File

@@ -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