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

@@ -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)],

View File

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

View File

@@ -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(),
}

View File

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

View File

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

View File

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

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

View File

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

View File

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