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:
@@ -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)],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
324
backend/app/api/endpoints/tv_overlay.py
Normal file
324
backend/app/api/endpoints/tv_overlay.py
Normal 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},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
149
backend/app/services/tv_view_core.py
Normal file
149
backend/app/services/tv_view_core.py
Normal 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
|
||||
83
backend/tests/test_swing_stop_ignore_crisis_20261017.py
Normal file
83
backend/tests/test_swing_stop_ignore_crisis_20261017.py
Normal 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))
|
||||
308
backend/tests/test_tv_overlay_20261017.py
Normal file
308
backend/tests/test_tv_overlay_20261017.py
Normal 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
|
||||
Reference in New Issue
Block a user