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

View File

@@ -56,30 +56,30 @@ const ChartPanel = React.memo(({
emptyReason,
}: ChartPanelProps) => {
return (
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col h-[45%] min-h-0">
<div className="bg-tv-panel border border-tv-border rounded flex flex-col h-[45%] min-h-0">
{/* Header - Bloomberg ticker style */}
<div className="px-3 py-2 flex justify-between items-center border-b border-[#152035] bg-[#091018] flex-shrink-0">
<div className="px-3 py-2 flex justify-between items-center border-b border-tv-border bg-tv-bg flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
{selectedStock ? (
<>
<span className="text-base font-bold text-[#c5d5e8] truncate">{stockName}</span>
<span className="text-[10px] font-mono text-[#3a5570] bg-[#152035] px-1.5 py-px rounded-sm flex-shrink-0">
<span className="text-base font-bold text-tv-text truncate">{stockName}</span>
<span className="text-[11px] font-mono text-tv-muted bg-tv-border px-1.5 py-px rounded-sm flex-shrink-0">
{selectedStock}
</span>
</>
) : (
<span className="text-sm text-[#3a5570]">종목을 선택하세요</span>
<span className="text-sm text-tv-muted">종목을 선택하세요</span>
)}
</div>
<div className="flex items-center bg-[#0a0f1c] border border-[#152035] rounded-sm overflow-hidden flex-shrink-0">
<div className="flex items-center bg-tv-raised border border-tv-border rounded-sm overflow-hidden flex-shrink-0">
{(['D', 'W', 'M'] as const).map((p) => (
<button
key={p}
onClick={() => onPeriodChange(p)}
className={`px-3 py-1 text-[10px] font-mono transition-colors ${
className={`px-3 py-1 text-[11px] font-mono transition-colors ${
chartPeriod === p
? 'bg-[#1a3050] text-[#5b9aff]'
: 'text-[#4a6580] hover:text-[#8faabf]'
? 'bg-tv-raised text-[#5b9aff]'
: 'text-tv-muted hover:text-tv-text'
}`}
>
{p === 'D' ? '일봉' : p === 'W' ? '주봉' : '월봉'}
@@ -89,7 +89,7 @@ const ChartPanel = React.memo(({
</div>
{/* Chart area — 로딩 중에도 기존 차트 유지 (깜빡임 방지) */}
<div className="flex-1 relative bg-[#0b0e14] rounded-b overflow-hidden">
<div className="flex-1 relative bg-tv-bg rounded-b overflow-hidden">
{chartData && chartData.length > 0 ? (
<TradingChart
data={chartData}
@@ -99,18 +99,18 @@ const ChartPanel = React.memo(({
stockCode={selectedStock}
/>
) : !chartLoading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-[#3a5570] text-xs font-mono">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-tv-muted text-xs font-mono">
<span>차트 데이터 없음</span>
{emptyReason && (
<span className="text-[10px] text-[#eab308]/80">({emptyReason})</span>
<span className="text-[11px] text-tv-warn/80">({emptyReason})</span>
)}
</div>
) : null}
{chartLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#0b0e14]/70 z-10">
<div className="absolute inset-0 flex items-center justify-center bg-tv-bg/70 z-10">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="animate-spin w-5 h-5 text-[#2b65d9]" />
<span className="text-xs text-[#3a5570] font-mono">차트 로딩중...</span>
<span className="text-xs text-tv-muted font-mono">차트 로딩중...</span>
</div>
</div>
)}
@@ -163,66 +163,66 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr
const sizingTitleFallback = action === 'HOLD' ? '관망 (매수 제안 없음)' : action === 'SELL' ? '매도 의견 (매수 제안 없음)' : '매수 추천'
const sizingTitle = headline ?? sizingTitleFallback
return (
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col overflow-hidden">
<div className="bg-tv-panel border border-tv-border rounded flex flex-col overflow-hidden">
{/* Header */}
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
<h3 className="flex items-center gap-1.5 text-[#9f7aea] text-xs font-semibold uppercase tracking-wider">
<div className="px-3 py-2 border-b border-tv-border bg-tv-bg flex justify-between items-center flex-shrink-0">
<h3 className="flex items-center gap-1.5 text-tv-accent text-xs font-semibold uppercase tracking-wider">
<Brain size={12} />
AI 투자 분석
</h3>
{aiAnalysisData?.data?.analyzed_at && (
<span className="text-[9px] text-[#3a5570] font-mono">
<span className="text-[11px] text-tv-muted font-mono">
{formatTime(aiAnalysisData.data.analyzed_at)}
</span>
)}
</div>
{/* 매수 추천 금액 — 실제 자동매매 사이징과 동일 기준 (총자산×종목당 비중, 신뢰도 반영) */}
{aiAnalysisData?.data && (
<div className="px-3 py-1.5 border-b border-[#152035] bg-[#0a1120] flex-shrink-0">
<div className="px-3 py-1.5 border-b border-tv-border bg-tv-raised flex-shrink-0">
<div className="flex items-baseline gap-2">
<span className={`text-[9px] uppercase tracking-wider ${action === 'HOLD' ? 'text-[#eab308]' : action === 'SELL' ? 'text-[#3b82f6]' : 'text-[#4a6580]'}`}>{sizingTitle}</span>
<span className={`text-[11px] uppercase tracking-wider ${action === 'HOLD' ? 'text-tv-warn' : action === 'SELL' ? 'text-[#3b82f6]' : 'text-tv-muted'}`}>{sizingTitle}</span>
{showBuyPlan && aiAnalysisData.data.suggested_quantity > 0 ? (
<>
<span className="text-[13px] font-mono text-[#22c55e]">
{Number(aiAnalysisData.data.suggested_amount).toLocaleString('ko-KR')}원
</span>
<span className="text-[10px] font-mono text-[#8faabf]">
<span className="text-[11px] font-mono text-tv-text">
{aiAnalysisData.data.suggested_quantity}주
</span>
<span className="ml-auto text-[9px] font-mono text-[#4a6580]">
<span className="ml-auto text-[11px] font-mono text-tv-muted">
총자산 {aiAnalysisData.data.position_ratio}%
</span>
</>
) : (
<span className="text-[10px] text-[#4a6580]">
<span className="text-[11px] text-tv-muted">
{aiAnalysisData.data.sizing_note || '매수 제안 없음'}
</span>
)}
</div>
{showBuyPlan && aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.sizing_note}</div>
<div className="text-[11px] text-tv-muted mt-0.5">{aiAnalysisData.data.sizing_note}</div>
)}
{/* 목표가 · 손절가 (CLAUDE.md §5.2: 손절은 ATR/퍼센트 중 타이트한 쪽, 목표는 +2R) */}
{showBuyPlan && hasTarget && (
<div className="flex items-baseline gap-3 mt-1">
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">목표</span>
<span className="text-[11px] font-mono text-[#ef4444]">
<span className="text-[11px] uppercase tracking-wider text-tv-muted">목표</span>
<span className="text-[11px] font-mono text-tv-up">
{Number(aiAnalysisData.data.target_price).toLocaleString('ko-KR')}원
<span className="text-[9px] ml-1">{aiAnalysisData.data.target_pct != null && <>(+{aiAnalysisData.data.target_pct}%)</>}</span>
<span className="text-[11px] ml-1">{aiAnalysisData.data.target_pct != null && <>(+{aiAnalysisData.data.target_pct}%)</>}</span>
</span>
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">손절</span>
<span className="text-[11px] uppercase tracking-wider text-tv-muted">손절</span>
<span className="text-[11px] font-mono text-[#3b82f6]">
{aiAnalysisData.data.stop_loss_price != null ? `${Number(aiAnalysisData.data.stop_loss_price).toLocaleString('ko-KR')}원` : '-'}
{aiAnalysisData.data.stop_loss_pct != null && <span className="text-[9px] ml-1">({aiAnalysisData.data.stop_loss_pct}%)</span>}
{aiAnalysisData.data.stop_loss_pct != null && <span className="text-[11px] ml-1">({aiAnalysisData.data.stop_loss_pct}%)</span>}
</span>
<span className="ml-auto text-[9px] font-mono text-[#4a6580]">
<span className="ml-auto text-[11px] font-mono text-tv-muted">
{aiAnalysisData.data.risk_reward != null && <>R:R 1:{aiAnalysisData.data.risk_reward}</>}
</span>
</div>
)}
{aiAnalysisData.data.target_note && (
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.target_note}</div>
<div className="text-[11px] text-tv-muted mt-0.5">{aiAnalysisData.data.target_note}</div>
)}
</div>
)}
@@ -230,11 +230,11 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-3">
{aiAnalysisText ? (
<pre className="text-[11px] text-[#8faabf] leading-relaxed whitespace-pre-wrap font-mono">
<pre className="text-[11px] text-tv-text leading-relaxed whitespace-pre-wrap font-mono">
{typeof aiAnalysisText === 'string' ? aiAnalysisText : JSON.stringify(aiAnalysisText, null, 2)}
</pre>
) : (
<div className="h-full flex items-center justify-center text-[#3a5570] text-xs text-center">
<div className="h-full flex items-center justify-center text-tv-muted text-xs text-center">
<p>종목을 선택하면<br />AI 투자 분석을 표시합니다.</p>
</div>
)}

View File

@@ -69,30 +69,30 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
}, [tradeHistory])
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
<div className="bg-tv-panel border border-tv-border rounded overflow-hidden flex flex-col h-1/2">
{/* Header with tabs */}
<div className="px-3 py-1.5 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
<div className="px-3 py-1.5 border-b border-tv-border bg-tv-bg flex justify-between items-center flex-shrink-0">
<div className="flex items-center gap-0.5">
<button
onClick={() => setTab('trades')}
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider rounded transition-colors ${
className={`flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wider rounded transition-colors ${
tab === 'trades'
? 'text-[#3b82f6] bg-[#3b82f6]/10'
: 'text-[#3a5570] hover:text-[#64748b]'
: 'text-tv-muted hover:text-tv-muted'
}`}
>
<ArrowUpDown size={10} />
매매내역
{tradeHistory && tradeHistory.length > 0 && (
<span className="ml-1 text-[8px] font-mono bg-[#1e293b] px-1 rounded">{tradeHistory.length}</span>
<span className="ml-1 text-[11px] font-mono bg-tv-border px-1 rounded">{tradeHistory.length}</span>
)}
</button>
<button
onClick={() => setTab('journal')}
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider rounded transition-colors ${
className={`flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wider rounded transition-colors ${
tab === 'journal'
? 'text-[#22c55e] bg-[#22c55e]/10'
: 'text-[#3a5570] hover:text-[#64748b]'
: 'text-tv-muted hover:text-tv-muted'
}`}
>
<BookOpen size={10} />
@@ -102,7 +102,7 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
<button
onClick={onResetPending}
disabled={resetDisabled}
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
className="px-2 py-0.5 text-[11px] font-mono text-tv-muted hover:text-tv-up border border-tv-border hover:border-tv-up/30 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-tv-muted disabled:hover:border-tv-border"
title="미체결 주문 정리"
>
미체결 정리
@@ -142,7 +142,7 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
return (
<React.Fragment key={trade.id || idx}>
{header && (
<div className="sticky top-0 z-10 px-1 py-0.5 text-[9px] font-mono text-[#5b9aff] bg-[#0c1522] border-b border-[#152035]">
<div className="sticky top-0 z-10 px-1 py-0.5 text-[11px] font-mono text-[#5b9aff] bg-tv-panel border-b border-tv-border">
{header}
</div>
)}
@@ -150,28 +150,28 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
className={`px-2.5 py-2 rounded-sm border transition-colors ${
isFailed
? 'bg-[#1a0808] border-red-900/20'
: 'bg-[#091018] border-[#152035] hover:border-[#1e3050]'
: 'bg-tv-bg border-tv-border hover:border-tv-raised'
}`}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-1.5 min-w-0">
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${
isBuy ? 'bg-[#ef4444]/15 text-[#ef4444]' : 'bg-[#3b82f6]/15 text-[#3b82f6]'
<span className={`text-[11px] font-bold px-1.5 py-0.5 rounded ${
isBuy ? 'bg-tv-up/15 text-tv-up' : 'bg-[#3b82f6]/15 text-[#3b82f6]'
}`}>
{isBuy ? '매수' : '매도'}
</span>
<span className="text-xs font-semibold text-[#c5d5e8] truncate">
<span className="text-xs font-semibold text-tv-text truncate">
{trade.stock_name || trade.stock_code}
</span>
{isFailed && (
<span className="text-[8px] font-mono px-1 py-px rounded-sm bg-[#7f1d1d] text-[#fca5a5]">실패</span>
<span className="text-[11px] font-mono px-1 py-px rounded-sm bg-[#7f1d1d] text-[#fca5a5]">실패</span>
)}
</div>
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{dateShort} {timeStr}</span>
<span className="text-[11px] font-mono text-tv-muted flex-shrink-0 ml-1">{dateShort} {timeStr}</span>
</div>
<div className="flex justify-between items-center mt-1">
<div className="text-[10px] font-mono text-[#64748b]">
<div className="text-[11px] font-mono text-tv-muted">
{/* 체결가(executed_price) 우선. null 이면 아직 체결 확인 전 — 주문가는 회색으로 구분 */}
{Number(trade.executed_price) > 0 ? (
<>{Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price)).toLocaleString()}원</>
@@ -179,33 +179,33 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
<>
{Number(trade.order_quantity || trade.executed_quantity || 0)}주
{Number(trade.order_price) > 0 && (
<span className="text-[#3a4a5c]" title="체결 전 주문가"> × 주문가 {Math.round(Number(trade.order_price)).toLocaleString()}원</span>
<span className="text-tv-muted" title="체결 전 주문가"> × 주문가 {Math.round(Number(trade.order_price)).toLocaleString()}원</span>
)}
{!isFailed && <span className="ml-1 text-[9px] text-[#eab308]/80">체결 확인 중</span>}
{!isFailed && <span className="ml-1 text-[11px] text-tv-warn/80">체결 확인 중</span>}
</>
)}
</div>
{isSell && statusOk && pnl !== 0 && (
<div className={`text-[10px] font-mono font-semibold ${isProfit ? 'text-[#ef4444]' : isLoss ? 'text-[#3b82f6]' : 'text-[#64748b]'}`}>
<div className={`text-[11px] font-mono font-semibold ${isProfit ? 'text-tv-up' : isLoss ? 'text-[#3b82f6]' : 'text-tv-muted'}`}>
{pnl > 0 ? '+' : ''}{Math.round(pnl).toLocaleString()}원
<span className="ml-1 text-[9px]">({pnlRate > 0 ? '+' : ''}{pnlRate.toFixed(1)}%)</span>
<span className="ml-1 text-[11px]">({pnlRate > 0 ? '+' : ''}{pnlRate.toFixed(1)}%)</span>
</div>
)}
</div>
{trade.entry_reason && isBuy && (
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.entry_reason}</div>
<div className="text-[11px] text-tv-muted mt-1 truncate">{trade.entry_reason}</div>
)}
{trade.exit_reason && isSell && (
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.exit_reason}</div>
<div className="text-[11px] text-tv-muted mt-1 truncate">{trade.exit_reason}</div>
)}
</div>
</React.Fragment>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs text-center gap-1 pt-8">
<ArrowUpDown size={20} className="text-[#1e293b]" />
<div className="h-full flex flex-col items-center justify-center text-tv-muted text-xs text-center gap-1 pt-8">
<ArrowUpDown size={20} className="text-tv-border" />
<span>매매내역 없음</span>
</div>
)
@@ -228,25 +228,25 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : []
return (
<div key={journal.id} className="p-2.5 bg-[#091018] border border-[#152035] rounded-sm hover:border-[#1e3050] transition-colors">
<div key={journal.id} className="p-2.5 bg-tv-bg border border-tv-border rounded-sm hover:border-tv-raised transition-colors">
<div className="flex justify-between items-start mb-1">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm flex-shrink-0">
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
</span>
<span className="text-xs font-semibold text-[#c5d5e8] truncate">
<span className="text-xs font-semibold text-tv-text truncate">
{journal.stock_name || journal.stock_code}
</span>
</div>
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{dateStr} {timeStr}</span>
<span className="text-[11px] font-mono text-tv-muted flex-shrink-0 ml-1">{dateStr} {timeStr}</span>
</div>
<div className="text-[10px] text-[#4a6580] leading-relaxed line-clamp-2 whitespace-pre-line">
<div className="text-[11px] text-tv-muted leading-relaxed line-clamp-2 whitespace-pre-line">
{journal.content}
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{tags.slice(0, 4).map((tag: string, i: number) => (
<span key={i} className="text-[8px] font-mono px-1 py-px rounded-sm bg-[#152035] text-[#3a5570]">#{tag}</span>
<span key={i} className="text-[11px] font-mono px-1 py-px rounded-sm bg-tv-border text-tv-muted">#{tag}</span>
))}
</div>
)}
@@ -254,10 +254,10 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs text-center gap-1 pt-8">
<BookOpen size={20} className="text-[#1e293b]" />
<div className="h-full flex flex-col items-center justify-center text-tv-muted text-xs text-center gap-1 pt-8">
<BookOpen size={20} className="text-tv-border" />
<span>매매일지 없음</span>
<span className="text-[9px] text-[#2a3d55]">장 마감 후 자동 생성됩니다</span>
<span className="text-[11px] text-tv-border">장 마감 후 자동 생성됩니다</span>
</div>
)
)}

View File

@@ -84,18 +84,18 @@ const Layout = ({ children }: LayoutProps) => {
<div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col">
<ToastHost />
{/* Top Navigation Bar */}
<header className="border-b border-gray-800 bg-[#161b22] h-16 flex items-center justify-between px-6 sticky top-0 z-50 backdrop-blur-md bg-opacity-90">
<div className="flex items-center gap-8">
<header className="border-b border-gray-800 bg-[#161b22] h-16 flex items-center justify-between gap-2 px-2 sm:px-6 sticky top-0 z-50 backdrop-blur-md bg-opacity-90">
<div className="flex items-center gap-2 sm:gap-8 min-w-0 flex-1">
{/* Brand */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 shrink-0">
<div className="bg-blue-600 p-2 rounded-lg shadow-lg shadow-blue-500/20">
<Activity className="text-white" size={20} />
</div>
<h1 className="text-lg font-semibold tracking-tight leading-none">AutoStock</h1>
<h1 className="hidden lg:block text-lg font-semibold tracking-tight leading-none">AutoStock</h1>
</div>
{/* Navigation */}
<nav className="flex items-center gap-1">
<nav className="flex items-center gap-1 min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{navItems.map((item) => {
const Icon = item.icon
const isActive = location.pathname === item.path
@@ -103,7 +103,7 @@ const Layout = ({ children }: LayoutProps) => {
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-all text-sm font-medium ${isActive
className={`flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-2 rounded-md whitespace-nowrap shrink-0 transition-all text-sm font-medium ${isActive
? 'bg-gray-800 text-blue-400'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50'
}`}
@@ -117,25 +117,25 @@ const Layout = ({ children }: LayoutProps) => {
</div>
{/* Status Indicators */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800/50 rounded-full border border-gray-700/50">
<div className="flex items-center gap-2 sm:gap-4 shrink-0">
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 bg-gray-800/50 rounded-full border border-gray-700/50">
<div className={`w-2 h-2 rounded-full ${backendConnected ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]' : 'bg-red-500'}`} />
<span className="text-xs text-gray-400">{backendConnected ? 'LIVE' : 'OFFLINE'}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800/50 rounded-full border border-gray-700/50">
<div className="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-gray-800/50 rounded-full border border-gray-700/50">
<div className={`w-2 h-2 rounded-full ${mcpStatus?.status === 'connected' ? 'bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]' : 'bg-yellow-500'}`} />
<span className="text-xs text-gray-400">MCP: {mcpStatus?.status || 'Unknown'}</span>
</div>
{/* 사용자 · 로그아웃 */}
<div className="flex items-center gap-1 pl-3 border-l border-gray-800">
<div className="flex items-center gap-1 sm:pl-3 sm:border-l border-gray-800">
<Link
to="/account"
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-xs text-gray-300 hover:bg-gray-800/60 transition-colors"
title="내 계좌 설정"
>
<UserIcon size={14} className="text-gray-500" />
<span className="max-w-[120px] truncate">
<span className="hidden sm:inline max-w-[120px] truncate">
{me ? (me.auth_type === 'api_key' ? '운영자 (API 키)' : me.username) : '…'}
</span>
{me?.is_admin && me.auth_type === 'session' && (

View File

@@ -33,14 +33,14 @@ interface OrderbookPanelProps {
*/
const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
return (
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col overflow-hidden">
<div className="bg-tv-panel border border-tv-border rounded flex flex-col overflow-hidden">
{/* Header */}
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
<h3 className="text-[#e5a93a] text-xs font-semibold uppercase tracking-wider">
<div className="px-3 py-2 border-b border-tv-border bg-tv-bg flex justify-between items-center flex-shrink-0">
<h3 className="text-tv-warn text-xs font-semibold uppercase tracking-wider">
호가창
</h3>
{orderbook?.stock_name && (
<span className="text-[9px] font-mono text-[#3a5570]">{orderbook.stock_name}</span>
<span className="text-[11px] font-mono text-tv-muted">{orderbook.stock_name}</span>
)}
</div>
@@ -66,25 +66,25 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
return (
<div className="flex flex-col">
{/* Column header */}
<div className="grid grid-cols-3 px-2 py-1 border-b border-[#0f1e30]">
<span className="text-[8px] font-mono text-[#3a5570] text-right">잔량</span>
<span className="text-[8px] font-mono text-[#3a5570] text-center">가격</span>
<span className="text-[8px] font-mono text-[#3a5570]">잔량</span>
<div className="grid grid-cols-3 px-2 py-1 border-b border-tv-raised">
<span className="text-[11px] font-mono text-tv-muted text-right">잔량</span>
<span className="text-[11px] font-mono text-tv-muted text-center">가격</span>
<span className="text-[11px] font-mono text-tv-muted">잔량</span>
</div>
{/* Asks (매도호가) */}
{asks.map((ask, i) => {
const fillPct = Math.min((ask.volume / maxAskVol) * 75, 75)
return (
<div key={`ask-${i}`} className="relative grid grid-cols-3 items-center py-0.5 px-2 hover:bg-[#0f1e30]">
<div key={`ask-${i}`} className="relative grid grid-cols-3 items-center py-0.5 px-2 hover:bg-tv-raised">
<div
className="absolute right-0 top-0 h-full bg-[#e84444]/8"
className="absolute right-0 top-0 h-full bg-tv-up/8"
style={{ width: `${fillPct}%` }}
/>
<span className="text-[10px] font-mono text-[#4a6580] text-right relative z-10">
<span className="text-[11px] font-mono text-tv-muted text-right relative z-10">
{ask.volume > 0 ? ask.volume.toLocaleString() : '—'}
</span>
<span className="text-[11px] font-mono font-semibold text-[#e84444] text-center relative z-10">
<span className="text-[11px] font-mono font-semibold text-tv-up text-center relative z-10">
{ask.price.toLocaleString()}
</span>
<div className="relative z-10" />
@@ -93,12 +93,12 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
})}
{/* Current price bar */}
<div className="py-1.5 border-y border-[#2a3a4e] bg-[#0f1e30] flex justify-center items-center gap-2">
<span className="text-base font-mono font-bold text-[#c5d5e8]">
<div className="py-1.5 border-y border-tv-border bg-tv-raised flex justify-center items-center gap-2">
<span className="text-base font-mono font-bold text-tv-text">
{currentPrice?.toLocaleString()}
</span>
{orderbook.current_volume > 0 && (
<span className="text-[9px] font-mono text-[#3a5570]">
<span className="text-[11px] font-mono text-tv-muted">
{orderbook.current_volume.toLocaleString()}
</span>
)}
@@ -108,7 +108,7 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
{bids.map((bid, i) => {
const fillPct = Math.min((bid.volume / maxBidVol) * 75, 75)
return (
<div key={`bid-${i}`} className="relative grid grid-cols-3 items-center py-0.5 px-2 hover:bg-[#0f1e30]">
<div key={`bid-${i}`} className="relative grid grid-cols-3 items-center py-0.5 px-2 hover:bg-tv-raised">
<div
className="absolute left-0 top-0 h-full bg-[#3d7eff]/8"
style={{ width: `${fillPct}%` }}
@@ -117,7 +117,7 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
<span className="text-[11px] font-mono font-semibold text-[#3d7eff] text-center relative z-10">
{bid.price.toLocaleString()}
</span>
<span className="text-[10px] font-mono text-[#4a6580] relative z-10">
<span className="text-[11px] font-mono text-tv-muted relative z-10">
{bid.volume > 0 ? bid.volume.toLocaleString() : '—'}
</span>
</div>
@@ -126,7 +126,7 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
</div>
)
})() : (
<div className="h-full flex items-center justify-center text-[#3a5570] text-xs font-mono text-center p-4">
<div className="h-full flex items-center justify-center text-tv-muted text-xs font-mono text-center p-4">
종목을 선택하면<br />호가 정보가 표시됩니다.
</div>
)}

View File

@@ -151,7 +151,7 @@ const PortfolioItem = React.memo(({
}
return (
<div className={`border-l-2 ${isSelected ? 'border-[#2b65d9] bg-[#0d1e33]' : 'border-transparent hover:bg-[#0a1628]'} transition-colors`}>
<div className={`border-l-2 ${isSelected ? 'border-[#2b65d9] bg-tv-raised' : 'border-transparent hover:bg-tv-raised'} transition-colors`}>
<div
onClick={() => {
if (isSelected) onRefetchAiAnalysis()
@@ -162,19 +162,19 @@ const PortfolioItem = React.memo(({
{/* Row 1: Name + P&L% */}
<div className="flex justify-between items-start mb-0.5">
<div className="flex items-center gap-1 min-w-0 flex-1 mr-1">
<span className={`text-xs font-semibold truncate ${isSelected ? 'text-[#5b9aff]' : 'text-[#c5d5e8]'}`}>
<span className={`text-xs font-semibold truncate ${isSelected ? 'text-[#5b9aff]' : 'text-tv-text'}`}>
{stock.stock_name}
</span>
<span className={`text-[7px] px-1 py-px rounded-sm flex-shrink-0 font-mono ${isUs ? 'text-[#22c55e] bg-[#22c55e]/10' : 'text-[#5b9aff] bg-[#5b9aff]/10'}`}>
<span className={`text-[11px] px-1 py-px rounded-sm flex-shrink-0 font-mono ${isUs ? 'text-[#22c55e] bg-[#22c55e]/10' : 'text-[#5b9aff] bg-[#5b9aff]/10'}`}>
{isUs ? 'US' : 'KR'}
</span>
{(stock.strategy_type === 'daytrading' || stock.strategy_type === 'day_trade') && (
<span className="text-[7px] px-1 py-px rounded-sm text-[#f97316] bg-[#f97316]/10 flex-shrink-0">데이</span>
<span className="text-[11px] px-1 py-px rounded-sm text-tv-warn bg-tv-warn/10 flex-shrink-0">데이</span>
)}
{(stock.strategy_type === 'external' || stock.strategy_type === 'manual') && (
<span
title="자동 청산 대상 아님"
className="text-[7px] px-1 py-px rounded-sm text-[#eab308] bg-[#eab308]/10 flex-shrink-0"
className="text-[11px] px-1 py-px rounded-sm text-tv-warn bg-tv-warn/10 flex-shrink-0"
>
{stock.strategy_type === 'external' ? '외부' : '수동'}
</span>
@@ -182,7 +182,7 @@ const PortfolioItem = React.memo(({
{Number((stock as GroupedHolding).unmanaged) > 0 && (
<span
title="데이트레이딩 엔진이 관리하지 않는 잔고 - 자동 청산되지 않음"
className="text-[7px] px-1 py-px rounded-sm text-[#fb923c] bg-[#fb923c]/15 flex-shrink-0"
className="text-[11px] px-1 py-px rounded-sm text-tv-warn bg-tv-warn/15 flex-shrink-0"
>
미관리
</span>
@@ -190,34 +190,34 @@ const PortfolioItem = React.memo(({
{Number((stock as GroupedHolding).dtIncluded) > 0 && (
<span
title="데이트레이딩 엔진 수량을 확인할 수 없어 DT 보유분이 포함됐을 수 있음"
className="text-[7px] px-1 py-px rounded-sm text-[#f97316]/70 bg-[#f97316]/10 flex-shrink-0"
className="text-[11px] px-1 py-px rounded-sm text-tv-warn/70 bg-tv-warn/10 flex-shrink-0"
>
DT 포함?
</span>
)}
</div>
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-tv-up' : 'text-[#3d7eff]'}`}>
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
</span>
</div>
{/* Row 2: Code + qty */}
<div className="flex justify-between items-center">
<span className="text-[9px] font-mono text-[#3a5570]">{stock.stock_code}</span>
<span className="text-[9px] text-[#3a5570]">{quantity}{isUs ? 'shares' : '주'}</span>
<span className="text-[11px] font-mono text-tv-muted">{stock.stock_code}</span>
<span className="text-[11px] text-tv-muted">{quantity}{isUs ? 'shares' : '주'}</span>
</div>
{/* Row 3: Avg → Current */}
<div className="flex justify-between items-center mt-0.5">
<span className="text-[9px] font-mono text-[#4a6580] flex items-center gap-1">
<span className="text-[11px] font-mono text-tv-muted flex items-center gap-1">
{formatPrice(avgPrice, isUs)}
{avgMixed && (
<span title={mixedTitle} className="text-[7px] px-1 py-px rounded-sm text-[#a78bfa] bg-[#a78bfa]/10 cursor-help">
<span title={mixedTitle} className="text-[11px] px-1 py-px rounded-sm text-tv-accent bg-tv-accent/10 cursor-help">
혼합평단 분리
</span>
)}
</span>
<span className={`text-[10px] font-mono font-semibold ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
<span className={`text-[11px] font-mono font-semibold ${isUp ? 'text-tv-up' : 'text-[#3d7eff]'}`}>
{formatPrice(currentPrice, isUs)}
</span>
</div>
@@ -229,7 +229,7 @@ const PortfolioItem = React.memo(({
onClick={handleSellClick}
disabled={sellBlocked || selling}
title={splitLocked ? '스윙·데이트레이딩 합산 종목 — DT 포지션은 15:20 자동 청산, 수동 매도 시 DT 몫부터 차감되므로 여기서는 분할 매도할 수 없습니다' : undefined}
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
className="w-full py-0.5 text-[11px] font-mono text-tv-muted hover:text-[#3d7eff] border border-tv-border hover:border-[#3d7eff]/40 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-tv-muted disabled:hover:border-tv-border"
>
{selling ? '주문 처리 중...' : '전량 청산 (시장가)'}
</button>
@@ -243,11 +243,11 @@ const PortfolioItem = React.memo(({
onConfirm={handleConfirmSell}
onCancel={() => setConfirmOpen(false)}
lines={[
<><b className="text-[#c5d5e8]">{stock.stock_name}</b> ({stock.stock_code})</>,
<>수량 <b className="text-[#c5d5e8]">{quantity.toLocaleString()}{isUs ? '주(shares)' : '주'}</b> 전량을 <b className="text-[#5b9aff]">시장가</b>로 매도합니다.</>,
<><b className="text-tv-text">{stock.stock_name}</b> ({stock.stock_code})</>,
<>수량 <b className="text-tv-text">{quantity.toLocaleString()}{isUs ? '주(shares)' : '주'}</b> 전량을 <b className="text-[#5b9aff]">시장가</b>로 매도합니다.</>,
<>현재가 {formatPrice(currentPrice, isUs)} · 평가손익 {profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%{profitLoss != null && !isUs ? ` (${profitLoss > 0 ? '+' : ''}${Math.round(profitLoss).toLocaleString()}원)` : ''}</>,
...(Number((stock as GroupedHolding).unmanaged) > 0 ? [<span className="text-[#fb923c]">엔진 미관리 잔고입니다.</span>] : []),
<span className="text-[#4a6580]">체결 후에는 되돌릴 수 없습니다.</span>,
...(Number((stock as GroupedHolding).unmanaged) > 0 ? [<span className="text-tv-warn">엔진 미관리 잔고입니다.</span>] : []),
<span className="text-tv-muted">체결 후에는 되돌릴 수 없습니다.</span>,
]}
/>
</div>
@@ -364,19 +364,19 @@ const PortfolioPanel = React.memo(({
: null
return (
<section key={label}>
<div className={`px-3 py-1 border-b sticky top-0 z-10 ${warn ? 'bg-[#1c1208] border-[#fb923c]/40' : 'bg-[#091018] border-[#152035]'}`} title={note}>
<div className={`px-3 py-1 border-b sticky top-0 z-10 ${warn ? 'bg-[#1c1208] border-tv-warn/40' : 'bg-tv-bg border-tv-border'}`} title={note}>
<div className="flex items-center gap-1.5">
{icon}
<span className={`text-[9px] font-semibold uppercase tracking-wider ${color}`}>{label}</span>
<span className="ml-auto text-[8px] font-mono bg-[#152035] text-[#4a6580] px-1.5 py-px rounded-sm">{stocks.length}</span>
<span className={`text-[11px] font-semibold uppercase tracking-wider ${color}`}>{label}</span>
<span className="ml-auto text-[11px] font-mono bg-tv-border text-tv-muted px-1.5 py-px rounded-sm">{stocks.length}</span>
</div>
<div className="flex justify-between items-center mt-0.5 text-[8px] font-mono text-[#4a6580]">
<span>{fmt(sub.value)}{krwNote && <span className={`ml-1 ${rateOk ? '' : 'text-[#eab308]'}`}>{krwNote}</span>}</span>
<span className={up ? 'text-[#e84444]' : 'text-[#3d7eff]'}>
<div className="flex justify-between items-center mt-0.5 text-[11px] font-mono text-tv-muted">
<span>{fmt(sub.value)}{krwNote && <span className={`ml-1 ${rateOk ? '' : 'text-tv-warn'}`}>{krwNote}</span>}</span>
<span className={up ? 'text-tv-up' : 'text-[#3d7eff]'}>
{up ? '+' : ''}{fmt(sub.pnl)} ({up ? '+' : ''}{sub.rate.toFixed(2)}%)
</span>
</div>
{note && <p className="mt-0.5 text-[8px] text-[#3a5570] leading-tight">{note}</p>}
{note && <p className="mt-0.5 text-[11px] text-tv-muted leading-tight">{note}</p>}
</div>
{stocks.map((stock) => (
<PortfolioItem
@@ -396,32 +396,32 @@ const PortfolioPanel = React.memo(({
}
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-full">
<div className="bg-tv-panel border border-tv-border rounded overflow-hidden flex flex-col h-full">
{/* Header */}
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
<h3 className="flex items-center gap-1.5 text-[#8faabf] text-xs font-semibold uppercase tracking-wider">
<div className="px-3 py-2 border-b border-tv-border bg-tv-bg flex justify-between items-center flex-shrink-0">
<h3 className="flex items-center gap-1.5 text-tv-text text-xs font-semibold uppercase tracking-wider">
<Activity className="w-3 h-3 text-[#2b65d9]" />
보유 종목
</h3>
<div className="flex items-center gap-1">
{groups.swing.length > 0 && (
<span className="text-[8px] font-mono text-[#a78bfa] bg-[#a78bfa]/10 px-1.5 py-px rounded-sm">스윙 {groups.swing.length}</span>
<span className="text-[11px] font-mono text-tv-accent bg-tv-accent/10 px-1.5 py-px rounded-sm">스윙 {groups.swing.length}</span>
)}
{groups.unmanaged.length > 0 && (
<span title="엔진이 관리하지 않는 DT 잔고" className="text-[8px] font-mono text-[#fb923c] bg-[#fb923c]/15 px-1.5 py-px rounded-sm">미관리 {groups.unmanaged.length}</span>
<span title="엔진이 관리하지 않는 DT 잔고" className="text-[11px] font-mono text-tv-warn bg-tv-warn/15 px-1.5 py-px rounded-sm">미관리 {groups.unmanaged.length}</span>
)}
{groups.manual.length > 0 && (
<span className="text-[8px] font-mono text-[#eab308] bg-[#eab308]/10 px-1.5 py-px rounded-sm">수동 {groups.manual.length}</span>
<span className="text-[11px] font-mono text-tv-warn bg-tv-warn/10 px-1.5 py-px rounded-sm">수동 {groups.manual.length}</span>
)}
{groups.overseas.length > 0 && (
<span className="text-[8px] font-mono text-[#22c55e] bg-[#22c55e]/10 px-1.5 py-px rounded-sm">해외 {groups.overseas.length}</span>
<span className="text-[11px] font-mono text-[#22c55e] bg-[#22c55e]/10 px-1.5 py-px rounded-sm">해외 {groups.overseas.length}</span>
)}
<span className="text-[8px] font-mono text-[#2b65d9] bg-[#2b65d9]/10 px-1.5 py-px rounded-sm">{total}</span>
<span className="text-[11px] font-mono text-[#2b65d9] bg-[#2b65d9]/10 px-1.5 py-px rounded-sm">{total}</span>
</div>
</div>
{/* List */}
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
<div className="divide-y divide-tv-raised overflow-y-auto custom-scrollbar flex-1">
{accountSetupRequired ? (
<div className="h-full flex flex-col items-center justify-center p-3">
<AccountSetupNotice message={accountSetupMessage} compact />
@@ -430,13 +430,13 @@ const PortfolioPanel = React.memo(({
<>
{/* 엔진이 관리 중인 데이트레이딩 포지션은 목록에서 제외(사용자 요청 2026-10-06) — 하단 DayTradingStatusBar 에서 확인.
단 엔진이 모르는 DT 잔고는 반드시 경고 그룹으로 표시한다. */}
{renderGroup(groups.unmanaged, '미관리 (엔진 외) 보유', <AlertTriangle className="w-2.5 h-2.5 text-[#fb923c]" />, 'text-[#fb923c]', '데이트레이딩 엔진이 관리하지 않는 잔고 - 자동 청산되지 않으니 직접 확인/청산 필요', false, 'unmanaged', true)}
{renderGroup(groups.swing, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]', undefined, false, 'swing')}
{renderGroup(groups.manual, '수동 / 외부', <UserCircle className="w-2.5 h-2.5 text-[#eab308]" />, 'text-[#eab308]', '자동 청산 대상 아님 - 직접 청산 필요', false, 'manual')}
{renderGroup(groups.unmanaged, '미관리 (엔진 외) 보유', <AlertTriangle className="w-2.5 h-2.5 text-tv-warn" />, 'text-tv-warn', '데이트레이딩 엔진이 관리하지 않는 잔고 - 자동 청산되지 않으니 직접 확인/청산 필요', false, 'unmanaged', true)}
{renderGroup(groups.swing, '스윙', <TrendingUp className="w-2.5 h-2.5 text-tv-accent" />, 'text-tv-accent', undefined, false, 'swing')}
{renderGroup(groups.manual, '수동 / 외부', <UserCircle className="w-2.5 h-2.5 text-tv-warn" />, 'text-tv-warn', '자동 청산 대상 아님 - 직접 청산 필요', false, 'manual')}
{renderGroup(groups.overseas, '해외', <Globe className="w-2.5 h-2.5 text-[#22c55e]" />, 'text-[#22c55e]', undefined, true, 'overseas')}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
<div className="h-full flex flex-col items-center justify-center text-tv-muted p-4 text-center">
<p className="text-xs">보유 중인 종목이 없습니다.</p>
</div>
)}

View File

@@ -151,8 +151,8 @@ const RecommendationItem = React.memo(({
<div
className={`w-full text-left transition-colors border-l-2 ${
isSelected
? 'bg-[#0d1e33] border-[#2b65d9]'
: 'border-transparent hover:bg-[#0a1628]'
? 'bg-tv-raised border-[#2b65d9]'
: 'border-transparent hover:bg-tv-raised'
}`}
>
<div
@@ -164,54 +164,54 @@ const RecommendationItem = React.memo(({
>
{/* Row 1: rank + name + badge + change% */}
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[8px] font-mono text-[#2a3d55] w-4 flex-shrink-0 text-right">{idx + 1}</span>
<span className={`text-[11px] font-semibold truncate flex-1 ${isSelected ? 'text-[#5b9aff]' : 'text-[#c5d5e8]'}`}>
<span className="text-[11px] font-mono text-tv-border w-4 flex-shrink-0 text-right">{idx + 1}</span>
<span className={`text-[11px] font-semibold truncate flex-1 ${isSelected ? 'text-[#5b9aff]' : 'text-tv-text'}`}>
{stock.stock_name}
</span>
{isPending ? (
<span className="text-[8px] text-[#f97316] bg-[#f97316]/10 px-1 py-px rounded-sm flex items-center gap-0.5 flex-shrink-0">
<span className="text-[11px] text-tv-warn bg-tv-warn/10 px-1 py-px rounded-sm flex items-center gap-0.5 flex-shrink-0">
<RefreshCw className="w-2 h-2 animate-spin" />매수중
</span>
) : isHeld ? (
<span className="text-[8px] text-[#5b9aff] bg-[#5b9aff]/10 px-1 py-px rounded-sm flex-shrink-0">보유</span>
<span className="text-[11px] text-[#5b9aff] bg-[#5b9aff]/10 px-1 py-px rounded-sm flex-shrink-0">보유</span>
) : isRecommended && isFallback ? (
<span title="매수 규칙은 충족하지 못했고 점수 상위 종목으로 채워진 관심 종목" className="text-[8px] text-[#eab308] bg-[#eab308]/10 px-1 py-px rounded-sm flex-shrink-0">관심(규칙 미충족)</span>
<span title="매수 규칙은 충족하지 못했고 점수 상위 종목으로 채워진 관심 종목" className="text-[11px] text-tv-warn bg-tv-warn/10 px-1 py-px rounded-sm flex-shrink-0">관심(규칙 미충족)</span>
) : isRecommended ? (
<span className="text-[8px] text-[#22c55e] bg-[#22c55e]/10 px-1 py-px rounded-sm flex-shrink-0">신호</span>
<span className="text-[11px] text-[#22c55e] bg-[#22c55e]/10 px-1 py-px rounded-sm flex-shrink-0">신호</span>
) : null}
{blocked && !isHeld && !isPending && (
<span title={blockReasons || '매수 차단'} className="text-[8px] text-[#fb923c] bg-[#fb923c]/10 px-1 py-px rounded-sm flex-shrink-0">차단</span>
<span title={blockReasons || '매수 차단'} className="text-[11px] text-tv-warn bg-tv-warn/10 px-1 py-px rounded-sm flex-shrink-0">차단</span>
)}
<span className={`text-[10px] font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
<span className={`text-[11px] font-mono font-bold flex-shrink-0 ${isUp ? 'text-tv-up' : 'text-[#3d7eff]'}`}>
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
</span>
</div>
{/* Row 2: code + price + RSI + score */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 pl-5">
<span className="text-[9px] font-mono text-[#3a5570]">{stock.stock_code}</span>
<span className="text-[9px] font-mono text-[#4a6580]">
<span className="text-[11px] font-mono text-tv-muted">{stock.stock_code}</span>
<span className="text-[11px] font-mono text-tv-muted">
{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원
{stock.price_source === 'batch' && livePrices?.[stock.stock_code] == null && (
<span title="실시간 시세 없음 — 배치 분석 시점 가격" className="ml-0.5 text-[7px] text-[#3a5570]">배치</span>
<span title="실시간 시세 없음 — 배치 분석 시점 가격" className="ml-0.5 text-[11px] text-tv-muted">배치</span>
)}
</span>
{stock.rsi != null && (
<span className={`text-[8px] font-mono ${stock.rsi < 30 ? 'text-[#22c55e]' : stock.rsi > 70 ? 'text-[#e84444]' : 'text-[#4a6580]'}`}>
<span className={`text-[11px] font-mono ${stock.rsi < 30 ? 'text-[#22c55e]' : stock.rsi > 70 ? 'text-tv-up' : 'text-tv-muted'}`}>
RSI {stock.rsi.toFixed(0)}
</span>
)}
{stock.stochastic_k != null && (
<span className="text-[8px] font-mono text-[#4a6580]">K {stock.stochastic_k.toFixed(0)}</span>
<span className="text-[11px] font-mono text-tv-muted">K {stock.stochastic_k.toFixed(0)}</span>
)}
{(stock.score ?? 0) > 0 && (
<span className="text-[8px] font-mono text-[#9f7aea]">{stock.score}pt</span>
<span className="text-[11px] font-mono text-tv-accent">{stock.score}pt</span>
)}
{mlSignal && (
<span className={`text-[8px] font-mono px-1 rounded-sm ${
<span className={`text-[11px] font-mono px-1 rounded-sm ${
mlSignal === 'SELL' ? 'text-[#3d7eff] bg-[#3d7eff]/10'
: mlSignal === 'BUY' ? 'text-[#e84444] bg-[#e84444]/10'
: 'text-[#4a6580] bg-[#152035]'
: mlSignal === 'BUY' ? 'text-tv-up bg-tv-up/10'
: 'text-tv-muted bg-tv-border'
}`}>
ML {mlSignal}
</span>
@@ -222,7 +222,7 @@ const RecommendationItem = React.memo(({
{stock.signals && stock.signals.length > 0 && (
<div className="pl-5 mt-0.5">
{/* 잘림 방지: 한 줄 truncate 대신 줄바꿈 허용 (ML SELL 등 뒤쪽 신호가 가려지지 않게) */}
<span className="text-[8px] text-[#3a5570] block break-words leading-tight">
<span className="text-[11px] text-tv-muted block break-words leading-tight">
{stock.signals.join(' · ')}
</span>
</div>
@@ -236,7 +236,7 @@ const RecommendationItem = React.memo(({
onClick={handleBuyClick}
disabled={buying || buyDisabled || blocked}
title={blocked ? `매수 차단: ${blockReasons || '사유 미상'}` : undefined}
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/40 rounded-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
className="w-full py-0.5 text-[11px] font-mono text-tv-muted hover:text-tv-up border border-tv-border hover:border-tv-up/40 rounded-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-tv-muted disabled:hover:border-tv-border"
>
{buying ? '매수 주문 중...' : blocked ? '매수 차단' : '매수 (시장가)'}
</button>
@@ -249,7 +249,7 @@ const RecommendationItem = React.memo(({
onConfirm={handleConfirmBuy}
onCancel={() => setConfirmOpen(false)}
lines={[
<><b className="text-[#c5d5e8]">{stock.stock_name}</b> ({stock.stock_code})</>,
<><b className="text-tv-text">{stock.stock_name}</b> ({stock.stock_code})</>,
<>시장가 매수 (수량: 총자산 x 종목당 최대비중으로 자동 산출)</>,
]}
/>
@@ -314,24 +314,24 @@ const RecommendationPanel = React.memo(({
}, [recommendations])
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
<div className="bg-tv-panel border border-tv-border rounded overflow-hidden flex flex-col h-1/2">
{/* Header */}
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
<h3 className="text-[#9f7aea] text-xs font-semibold uppercase tracking-wider">
<div className="px-3 py-2 border-b border-tv-border bg-tv-bg flex justify-between items-center flex-shrink-0">
<h3 className="text-tv-accent text-xs font-semibold uppercase tracking-wider">
분석 종목 스캐너
</h3>
<div className="flex items-center gap-2">
{(recommendations?.batch_time || recommendations?.batch_date) && (
<span className="text-[9px] text-[#3a5570] font-mono">
<span className="text-[11px] text-tv-muted font-mono">
{formatBatchTime(recommendations.batch_time) ?? formatBatchTime(recommendations.batch_date)}
</span>
)}
<span className="text-[8px] font-mono text-[#9f7aea] bg-[#9f7aea]/10 px-1.5 py-px rounded-sm">
<span className="text-[11px] font-mono text-tv-accent bg-tv-accent/10 px-1.5 py-px rounded-sm">
{recommendations?.analyzed_stocks || 0}
</span>
<button
onClick={onRefetch}
className="p-1 text-[#3a5570] hover:text-[#9f7aea] transition-colors"
className="p-1 text-tv-muted hover:text-tv-accent transition-colors"
title="새로고침"
>
<RefreshCw className="w-3 h-3" />
@@ -340,18 +340,18 @@ const RecommendationPanel = React.memo(({
</div>
{/* List */}
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
<div className="divide-y divide-tv-raised overflow-y-auto custom-scrollbar flex-1">
{isError ? (
<div className="h-full flex flex-col items-center justify-center text-xs p-4 text-center">
<p className="text-[#e84444]">⚠ API 오류</p>
<p className="text-[8px] text-[#3a5570] mt-1">백엔드 로그 확인 필요</p>
<button onClick={onRefetch} className="mt-2 px-2 py-0.5 text-[#e84444] border border-[#e84444]/30 rounded-sm text-[9px] font-mono">재시도</button>
<p className="text-tv-up">⚠ API 오류</p>
<p className="text-[11px] text-tv-muted mt-1">백엔드 로그 확인 필요</p>
<button onClick={onRefetch} className="mt-2 px-2 py-0.5 text-tv-up border border-tv-up/30 rounded-sm text-[11px] font-mono">재시도</button>
</div>
) : recommendations === undefined ? (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs p-4 text-center">
<RefreshCw className="w-5 h-5 animate-spin mb-2 text-[#9f7aea]" />
<div className="h-full flex flex-col items-center justify-center text-tv-muted text-xs p-4 text-center">
<RefreshCw className="w-5 h-5 animate-spin mb-2 text-tv-accent" />
<p>로딩 중...</p>
<p className="text-[9px] text-[#2a3d55] mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
<p className="text-[11px] text-tv-border mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
</div>
) : (recommendations?.analyzed_stock_list || []).length > 0 ? (
(recommendations.analyzed_stock_list).map((stock: RecommendedStock, idx: number) => {
@@ -381,9 +381,9 @@ const RecommendationPanel = React.memo(({
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
<div className="h-full flex flex-col items-center justify-center text-tv-muted p-4 text-center">
<p className="text-xs">배치 분석 데이터 없음</p>
<p className="text-[9px] text-[#2a3d55] mt-1">make batch-test 실행 필요</p>
<p className="text-[11px] text-tv-border mt-1">make batch-test 실행 필요</p>
</div>
)}
</div>

View File

@@ -0,0 +1,84 @@
/**
* BottomTabs.tsx — 하단 탭: 보유 | 체결내역 | DT 포지션 | 손익곡선 | 로그/저널
* 각 탭은 선택된 탭만 마운트한다(손익곡선·DT 쿼리 불필요 호출 방지). 기존 패널은 노드로 주입받는다.
*/
import React, { useState } from 'react'
import type { TradeHistory } from '../../types/trading'
import type { StrategyDisplay } from '../../lib/tvApi'
import { EquityPanel } from './EquityPanel'
import { DtPositionCards } from './DtPositionCards'
import { fmtPrice } from './chartUtils'
type Tab = 'hold' | 'trades' | 'dt' | 'equity' | 'journal'
interface Props {
holdNode: React.ReactNode
journalNode: React.ReactNode
trades: TradeHistory[] | undefined
onSelect: (code: string) => void
cfg: StrategyDisplay | null
totalAssets?: number
}
const TABS: { v: Tab; label: string }[] = [
{ v: 'hold', label: '보유' }, { v: 'trades', label: '체결내역' }, { v: 'dt', label: 'DT 포지션' },
{ v: 'equity', label: '손익곡선' }, { v: 'journal', label: '로그/저널' },
]
const cls = (v?: number | null) => (v == null || v === 0 ? 'text-tv-text' : v > 0 ? 'text-tv-up' : 'text-tv-down')
const TradesTable = ({ trades, onSelect }: { trades: TradeHistory[] | undefined; onSelect: (c: string) => void }) => {
if (!trades) return <div className="p-4 text-sm text-tv-muted">불러오는 중…</div>
if (trades.length === 0) return <div className="p-4 text-sm text-tv-muted">체결 내역 없음</div>
return (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-tv-panel text-tv-muted">
<tr className="text-left">
<th className="px-2 py-1">시각</th><th className="px-2">종목</th><th className="px-2">구분</th><th className="px-2">전략</th>
<th className="px-2 text-right">수량</th><th className="px-2 text-right">체결가</th><th className="px-2 text-right">순손익</th><th className="px-2">상태</th>
</tr>
</thead>
<tbody>
{trades.map((t) => {
const side = String(t.side ?? t.order_type ?? t.trade_type ?? '').toUpperCase()
const net = t.net_profit_loss != null ? Number(t.net_profit_loss) : t.profit_loss != null ? Number(t.profit_loss) : null
const ts = String(t.executed_at || t.created_at || '')
return (
<tr key={t.id} onClick={() => onSelect(t.stock_code)} className="cursor-pointer hover:bg-tv-raised border-t border-tv-border/50">
<td className="px-2 py-1 font-mono text-tv-muted whitespace-nowrap">{ts.replace('T', ' ').slice(5, 16)}</td>
<td className="px-2 truncate max-w-[9rem]">{t.stock_name || t.stock_code}</td>
<td className={`px-2 font-semibold ${side === 'BUY' ? 'text-tv-up' : 'text-tv-down'}`}>{side === 'BUY' ? '매수' : side === 'SELL' ? '매도' : side}</td>
<td className="px-2 text-tv-muted">{String(t.strategy_type ?? '-')}</td>
<td className="px-2 text-right font-mono">{Number(t.executed_quantity ?? t.quantity ?? 0).toLocaleString('ko-KR')}</td>
<td className="px-2 text-right font-mono">{fmtPrice(Number(t.executed_price ?? t.price))}</td>
<td className={`px-2 text-right font-mono ${cls(net)}`}>{net == null || side === 'BUY' ? '-' : `${net > 0 ? '+' : ''}${Math.round(net).toLocaleString('ko-KR')}`}</td>
<td className="px-2 text-tv-muted">{String(t.status)}</td>
</tr>
)
})}
</tbody>
</table>
)
}
export const BottomTabs = React.memo(({ holdNode, journalNode, trades, onSelect, cfg, totalAssets }: Props) => {
const [tab, setTab] = useState<Tab>('hold')
return (
<div className="flex flex-col h-full min-h-0 bg-tv-panel border-t border-tv-border">
<div className="flex border-b border-tv-border flex-shrink-0 overflow-x-auto" role="tablist">
{TABS.map((t) => (
<button key={t.v} role="tab" aria-selected={tab === t.v} onClick={() => setTab(t.v)}
className={`px-4 py-1.5 text-xs whitespace-nowrap border-b-2 ${tab === t.v ? 'border-tv-accent text-tv-text' : 'border-transparent text-tv-muted hover:text-tv-text'}`}>{t.label}</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-y-auto [&>.tv-fill>div]:!h-full [&>.tv-fill>div]:!max-h-none">
{tab === 'hold' && <div className="tv-fill h-full">{holdNode}</div>}
{tab === 'trades' && <TradesTable trades={trades} onSelect={onSelect} />}
{tab === 'dt' && <DtPositionCards cfg={cfg} totalAssets={totalAssets} />}
{tab === 'equity' && <EquityPanel active />}
{tab === 'journal' && <div className="tv-fill h-full">{journalNode}</div>}
</div>
</div>
)
})
BottomTabs.displayName = 'BottomTabs'

View File

@@ -0,0 +1,126 @@
/**
* DtPositionCards.tsx — 데이트레이딩 포지션 카드 탭
* 손절-현재-목표 게이지, 경과/시간손절, 트레일링, 일일 손실한도.
* 소스: GET /daytrading/positions (기존) + GET /positions/overlay (신규, 실패 시 기존 필드만으로 축소 표시)
*/
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '../../lib/api'
import { usePositionOverlay, StrategyDisplay, OverlayPosition } from '../../lib/tvApi'
import { fmtPrice } from './chartUtils'
interface DtPos {
stock_code: string; stock_name?: string; strategy?: string; entry_price?: number; current_price?: number | null
stop_loss?: number | null; target_price?: number | null; partial_sold?: boolean; quantity?: number
pnl?: number | null; pnl_pct?: number | null; entry_time?: string
}
interface DtResp { positions: DtPos[]; daily_pnl?: number; is_daily_loss_exceeded?: boolean }
const pnlCls = (v?: number | null) => (v == null ? 'text-tv-muted' : v > 0 ? 'text-tv-up' : v < 0 ? 'text-tv-down' : 'text-tv-text')
const signed = (v?: number | null, d = 0) => (v == null ? '-' : `${v > 0 ? '+' : ''}${v.toLocaleString('ko-KR', { maximumFractionDigits: d })}`)
const Gauge = ({ stop, cur, target, entry }: { stop?: number | null; cur?: number | null; target?: number | null; entry?: number | null }) => {
if (stop == null || target == null || cur == null || !(target > stop)) {
return <div className="text-[11px] text-tv-muted">손절/목표 데이터 없음{stop != null ? ` · 손절 ${fmtPrice(stop)}` : ''}</div>
}
const pos = (v: number) => Math.max(0, Math.min(100, ((v - stop) / (target - stop)) * 100))
return (
<div>
<div className="relative h-2.5 rounded bg-tv-raised overflow-hidden">
<div className="absolute inset-y-0 left-0 bg-tv-down/40" style={{ width: `${entry != null ? pos(entry) : 0}%` }} />
<div className="absolute inset-y-0 right-0 bg-tv-up/40" style={{ width: `${entry != null ? 100 - pos(entry) : 0}%` }} />
{entry != null && <div className="absolute inset-y-0 w-px bg-tv-text/60" style={{ left: `${pos(entry)}%` }} />}
<div className="absolute -top-0.5 bottom-[-2px] w-1 bg-tv-text rounded" style={{ left: `calc(${pos(cur)}% - 2px)` }} />
</div>
<div className="flex justify-between text-[11px] font-mono mt-0.5">
<span className="text-tv-down">손절 {fmtPrice(stop)}</span>
<span className="text-tv-text">현재 {fmtPrice(cur)}</span>
<span className="text-tv-up">목표 {fmtPrice(target)}</span>
</div>
</div>
)
}
export const DtPositionCards = React.memo(({ cfg, totalAssets }: { cfg: StrategyDisplay | null; totalAssets?: number }) => {
const { data, isLoading, isError } = useQuery<DtResp>({
queryKey: ['tv-dt-positions'],
queryFn: async () => (await api.get('/daytrading/positions')).data,
refetchInterval: 15000, staleTime: 8000, retry: false,
})
const { data: ov } = usePositionOverlay(undefined)
const ovMap = new Map<string, OverlayPosition>()
for (const p of ov?.positions ?? []) {
const st = (p.strategy ?? '').toLowerCase()
if (st === 'dt' || st === 'daytrading' || st === 'day_trade') ovMap.set(p.stock_code, p)
}
if (isLoading) return <div className="p-4 text-sm text-tv-muted">불러오는 중…</div>
if (isError || !data) return <div className="p-4 text-sm text-tv-muted">DT 포지션 데이터 없음</div>
const lim = cfg?.dt?.daily_loss_pct
const limPct = typeof lim === 'number' ? Math.abs(lim) : null // 백엔드 계약: 퍼센트 단위(-3.0)
const limAmt = limPct != null && totalAssets ? (totalAssets * limPct) / 100 : null
const daily = data.daily_pnl ?? 0
const used = limAmt && daily < 0 ? Math.min(100, (Math.abs(daily) / limAmt) * 100) : 0
const tsMin = cfg?.dt?.time_stop_min
return (
<div className="p-2 flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3 px-2 py-1.5 rounded border border-tv-border bg-tv-panel text-xs">
<span className="text-tv-muted">일일 손익(순)</span>
<b className={`font-mono ${pnlCls(daily)}`}>{signed(daily)}원</b>
<span className="text-tv-muted">한도</span>
<span className="font-mono">{limPct != null ? `-${limPct}%` : '-'}{limAmt != null ? ` (≈-${Math.round(limAmt).toLocaleString('ko-KR')}원)` : ''}</span>
{limAmt != null && (
<span className="flex-1 min-w-[80px] h-1.5 rounded bg-tv-raised overflow-hidden">
<span className={`block h-full ${used >= 80 ? 'bg-tv-up' : 'bg-tv-warn'}`} style={{ width: `${used}%` }} />
</span>
)}
{data.is_daily_loss_exceeded && <span className="px-1.5 rounded bg-tv-up/20 text-tv-up font-semibold">한도 초과 — 당일 중단</span>}
</div>
{data.positions.length === 0 && <div className="p-4 text-sm text-tv-muted text-center">보유 중인 DT 포지션 없음</div>}
<div className="grid gap-2 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{data.positions.map((p) => {
const o = ovMap.get(p.stock_code)
const stop = o?.stop_loss ?? p.stop_loss
const target = o?.target_price ?? p.target_price
const cur = o?.current_price ?? p.current_price
const el = o?.elapsed_min
const tsPct = el != null && tsMin ? Math.min(100, (el / tsMin) * 100) : null
return (
<div key={p.stock_code} className="rounded border border-tv-border bg-tv-panel p-2 text-xs">
<div className="flex items-baseline gap-2 mb-1.5">
<b className="text-sm truncate">{p.stock_name || p.stock_code}</b>
<span className="text-tv-muted font-mono">{p.stock_code}</span>
<span className="px-1 rounded border border-tv-border text-tv-muted">{p.strategy || 'DT'}</span>
{p.partial_sold && <span className="px-1 rounded bg-tv-warn/15 text-tv-warn">분할익절 후</span>}
<span className={`ml-auto font-mono font-semibold ${pnlCls(o?.pnl_pct ?? p.pnl_pct)}`}>
{signed(o?.pnl_pct ?? p.pnl_pct, 2)}%
</span>
</div>
<Gauge stop={stop} cur={cur} target={target} entry={o?.avg_price ?? o?.entry_price ?? p.entry_price} />
<div className="mt-1.5 grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
<span className="text-tv-muted">수량 <b className="text-tv-text font-mono">{p.quantity ?? '-'}</b></span>
<span className="text-tv-muted">평가손익 <b className={`font-mono ${pnlCls(o?.pnl ?? p.pnl)}`}>{signed(o?.pnl ?? p.pnl)}</b></span>
<span className="text-tv-muted">경과 <b className="text-tv-text font-mono">{el != null ? `${Math.round(el)}분` : '-'}</b>
{tsMin ? ` / 시간손절 ${tsMin}분` : ''}</span>
<span className="text-tv-muted">
트레일링 {o?.trailing_active
? <b className="text-tv-warn font-mono">{fmtPrice(o.trailing_stop)}</b>
: <b className="text-tv-text">{o ? '미발동' : '-'}</b>}
</span>
</div>
{tsPct != null && (
<div className="mt-1 h-1 rounded bg-tv-raised overflow-hidden" title="시간손절까지 경과 비율 (수익률이 기준 미만일 때만 청산)">
<div className={`h-full ${tsPct >= 100 ? 'bg-tv-warn' : 'bg-tv-muted'}`} style={{ width: `${tsPct}%` }} />
</div>
)}
</div>
)
})}
</div>
</div>
)
})
DtPositionCards.displayName = 'DtPositionCards'

View File

@@ -0,0 +1,142 @@
/**
* EquityPanel.tsx — 손익곡선 탭 (GET /risk/equity-history)
* 비용 후 누적 순손익(Baseline: 0 위 빨강/아래 파랑) + 낙폭 영역 + MDD 임계선(축소/강제), 전체/스윙/DT 구분.
* 엔드포인트가 없거나 실패하면 "데이터 없음" 만 표시한다.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createChart, LineStyle, IChartApi, ISeriesApi, Time } from 'lightweight-charts'
import { useEquityHistory } from '../../lib/tvApi'
import { tvColor } from './chartUtils'
type Mode = 'all' | 'swing' | 'dt'
const MODES: { v: Mode; label: string }[] = [{ v: 'all', label: '전체' }, { v: 'swing', label: '스윙' }, { v: 'dt', label: 'DT' }]
const DAYS = [30, 90, 180]
export const EquityPanel = React.memo(({ active }: { active: boolean }) => {
const [days, setDays] = useState(90)
const [mode, setMode] = useState<Mode>('all')
const { data, isLoading } = useEquityHistory(days, active)
const el = useRef<HTMLDivElement>(null)
const ddEl = useRef<HTMLDivElement>(null)
const chartRef = useRef<{ cum: IChartApi; dd: IChartApi } | null>(null)
const seriesRef = useRef<{ cum: ISeriesApi<'Baseline'>; dd: ISeriesApi<'Area'> } | null>(null)
const linesRef = useRef<ReturnType<ISeriesApi<'Area'>['createPriceLine']>[]>([])
const thrRef = useRef<{ lo: number }>({ lo: 0 })
const points = useMemo(() => data?.points ?? [], [data])
const cum = useMemo(() => {
let run = 0
return points.map((p) => {
const d = mode === 'swing' ? p.realized_net_swing : mode === 'dt' ? p.realized_net_dt : undefined
if (mode === 'all') return { date: p.date.slice(0, 10), v: p.cum_net }
run += Number(d ?? 0)
return { date: p.date.slice(0, 10), v: run }
})
}, [points, mode])
useEffect(() => {
if (!el.current || !ddEl.current) return
const base = () => ({
autoSize: true,
layout: { background: { color: 'transparent' }, textColor: tvColor('--tv-muted'), fontSize: 12 },
grid: { vertLines: { color: tvColor('--tv-border', 0.3) }, horzLines: { color: tvColor('--tv-border', 0.3) } },
rightPriceScale: { borderColor: tvColor('--tv-border'), minimumWidth: 80 },
timeScale: { borderColor: tvColor('--tv-border') },
})
const cumC = createChart(el.current, { ...base(), localization: { priceFormatter: (v: number) => Math.round(v).toLocaleString('ko-KR') } })
const ddC = createChart(ddEl.current, { ...base(), localization: { priceFormatter: (v: number) => `${v.toFixed(1)}%` } })
const up = tvColor('--tv-up'), down = tvColor('--tv-down')
const cumS = cumC.addBaselineSeries({
baseValue: { type: 'price', price: 0 },
topLineColor: up, topFillColor1: tvColor('--tv-up', 0.3), topFillColor2: tvColor('--tv-up', 0.02),
bottomLineColor: down, bottomFillColor1: tvColor('--tv-down', 0.02), bottomFillColor2: tvColor('--tv-down', 0.3),
lineWidth: 2,
priceFormat: { type: 'price', precision: 0, minMove: 1 },
})
const ddS = ddC.addAreaSeries({
lineColor: tvColor('--tv-warn'), topColor: tvColor('--tv-warn', 0.02), bottomColor: tvColor('--tv-warn', 0.35),
lineWidth: 1, priceLineVisible: false,
priceFormat: { type: 'price', precision: 1, minMove: 0.1 },
autoscaleInfoProvider: () => ({ priceRange: { minValue: Math.min(thrRef.current.lo, -1), maxValue: 0 } }),
})
let syncing = false
const link = (src: IChartApi, dst: IChartApi) => src.timeScale().subscribeVisibleLogicalRangeChange((r) => {
if (!r || syncing) return
syncing = true; dst.timeScale().setVisibleLogicalRange(r); syncing = false
})
link(cumC, ddC); link(ddC, cumC)
chartRef.current = { cum: cumC, dd: ddC }
seriesRef.current = { cum: cumS, dd: ddS }
return () => { cumC.remove(); ddC.remove(); chartRef.current = null; seriesRef.current = null; linesRef.current = [] }
}, [])
useEffect(() => {
const s = seriesRef.current, c = chartRef.current
if (!s || !c) return
s.cum.setData(cum.map((p) => ({ time: p.date as Time, value: p.v })))
const showDd = mode === 'all'
s.dd.setData(showDd ? points
.filter((p) => Number.isFinite(p.drawdown_pct))
.map((p) => ({ time: p.date.slice(0, 10) as Time, value: -Math.abs(p.drawdown_pct as number) })) : [])
linesRef.current.forEach((l) => s.dd.removePriceLine(l))
linesRef.current = []
const th = data?.mdd_thresholds
const lows: number[] = []
if (showDd && th) {
const add = (v: number | undefined, title: string, color: string) => {
if (typeof v !== 'number' || !Number.isFinite(v)) return
const price = -Math.abs(v)
lows.push(price - 2)
linesRef.current.push(s.dd.createPriceLine({ price, color, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: `${title} ${price}%` }))
}
add(th.reduce_pct, 'MDD 축소', tvColor('--tv-warn'))
add(th.force_pct, 'MDD 강제', tvColor('--tv-up'))
}
thrRef.current.lo = lows.length ? Math.min(...lows) : -1
c.cum.timeScale().fitContent()
}, [cum, points, data, mode])
const last = cum.length ? cum[cum.length - 1].v : null
const empty = !isLoading && points.length === 0
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 text-xs border-b border-tv-border">
<span className="text-tv-muted">비용 차감 후 누적 순손익 · 낙폭=실현손익 낙폭(자산 MDD와 다름)</span>
{last != null && (
<b className={`font-mono ${last > 0 ? 'text-tv-up' : last < 0 ? 'text-tv-down' : ''}`}>
{last > 0 ? '+' : ''}{Math.round(last).toLocaleString('ko-KR')}원
</b>
)}
{!!data?.null_net_rows && (
<span className="text-tv-warn" title="net_profit_loss 가 비어 있는 체결 행 수 — 손익 과대/과소 가능">비용 미기록 {data.null_net_rows}건 제외</span>
)}
{!!data?.excluded_non_krw_rows && (
<span className="text-tv-muted" title="원화 외 체결은 합산에서 제외">비원화 {data.excluded_non_krw_rows}건 제외</span>
)}
<span className="ml-auto flex gap-1">
{MODES.map((m) => (
<button key={m.v} onClick={() => setMode(m.v)} className={`px-2 py-0.5 rounded border ${mode === m.v ? 'border-tv-accent text-tv-accent' : 'border-tv-border text-tv-muted'}`}>{m.label}</button>
))}
<span className="w-2" />
{DAYS.map((d) => (
<button key={d} onClick={() => setDays(d)} className={`px-2 py-0.5 rounded border ${days === d ? 'border-tv-accent text-tv-accent' : 'border-tv-border text-tv-muted'}`}>{d}일</button>
))}
</span>
</div>
<div className="relative flex-1 min-h-[180px] flex flex-col">
<div className="relative flex-1 min-h-[100px]"><div ref={el} className="absolute inset-0" /></div>
<div className={`relative border-t border-tv-border ${mode === 'all' ? 'h-[84px]' : 'hidden'}`}>
<span className="absolute left-2 top-0.5 z-10 text-xs text-tv-muted pointer-events-none">실현손익 낙폭 (%)</span>
<div ref={ddEl} className="absolute inset-0" />
</div>
{(empty || isLoading) && (
<div className="absolute inset-0 z-20 flex items-center justify-center text-sm text-tv-muted bg-tv-bg/70">
{isLoading ? '불러오는 중…' : '데이터 없음'}
</div>
)}
</div>
</div>
)
})
EquityPanel.displayName = 'EquityPanel'

View File

@@ -0,0 +1,140 @@
/**
* RightPanel.tsx — 우측 패널: 선택 종목 포지션 요약 + 탭(관심종목 | 추천 | 호가 | AI)
* 관심종목 = 보유 + DT 감시(/daytrading/watchlist) + 추천(analyzed_stock_list). 클릭 시 차트 심볼 전환.
* 기존 RecommendationPanel(매수 버튼)·OrderbookPanel·AiAnalysisPanel 은 각 탭으로 이동했다.
*/
import React, { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '../../lib/api'
import type { OverlayPosition } from '../../lib/tvApi'
import type { Position, RecommendationsData } from '../../types/trading'
import { fmtPrice } from './chartUtils'
type Tab = 'watch' | 'reco' | 'book' | 'ai'
type Src = 'hold' | 'dt' | 'reco'
interface Item { code: string; name: string; price?: number; chg?: number; srcs: Set<Src> }
interface Props {
portfolio: Position[] | undefined
recommendations: RecommendationsData | undefined
selectedStock: string
onSelect: (code: string) => void
livePrices: Record<string, number> | undefined
selectedOverlay: OverlayPosition[] | null
recoNode: React.ReactNode
bookNode: React.ReactNode
aiNode: React.ReactNode
}
const SRC_LABEL: Record<Src, string> = { hold: '보유', dt: 'DT', reco: '추천' }
const cls = (v?: number | null) => (v == null ? 'text-tv-muted' : v > 0 ? 'text-tv-up' : v < 0 ? 'text-tv-down' : 'text-tv-text')
export const RightPanel = React.memo(({
portfolio, recommendations, selectedStock, onSelect, livePrices, selectedOverlay, recoNode, bookNode, aiNode,
}: Props) => {
const [tab, setTab] = useState<Tab>('watch')
const [filter, setFilter] = useState<'all' | Src>('all')
const { data: dtWl } = useQuery<{ watchlist: { code: string; name: string }[] } | null>({
queryKey: ['tv-dt-watchlist'],
queryFn: async () => { try { return (await api.get('/daytrading/watchlist')).data } catch { return null } },
refetchInterval: 60000, staleTime: 30000, retry: false,
})
const items = useMemo(() => {
const m = new Map<string, Item>()
const put = (code: string, name: string, src: Src, price?: number, chg?: number) => {
const it = m.get(code) ?? { code, name, srcs: new Set<Src>() }
it.srcs.add(src)
if (name && (!it.name || it.name === code)) it.name = name
if (price && !it.price) it.price = price
if (chg != null && it.chg == null) it.chg = chg
m.set(code, it)
}
for (const p of portfolio ?? []) put(p.stock_code, p.stock_name, 'hold', p.current_price, p.profit_loss_rate)
for (const w of dtWl?.watchlist ?? []) put(w.code, w.name, 'dt')
for (const s of recommendations?.analyzed_stock_list ?? []) put(s.stock_code, s.stock_name, 'reco', s.current_price, s.change_rate)
return [...m.values()]
}, [portfolio, dtWl, recommendations])
const shown = filter === 'all' ? items : items.filter((i) => i.srcs.has(filter))
const held = portfolio?.find((p) => p.stock_code === selectedStock)
const TABS: { v: Tab; label: string }[] = [{ v: 'watch', label: '관심종목' }, { v: 'reco', label: '추천' }, { v: 'book', label: '호가' }, { v: 'ai', label: 'AI' }]
return (
<div className="flex flex-col h-full min-h-0 bg-tv-panel border-l border-tv-border">
{/* 선택 종목 포지션 요약 */}
<div className="px-3 py-2 border-b border-tv-border text-xs flex-shrink-0">
<div className="text-tv-muted mb-1">선택 종목 포지션</div>
{selectedOverlay === null && !held && <div className="text-tv-muted">포지션 없음 / 오버레이 데이터 없음</div>}
{(selectedOverlay ?? []).map((p, i) => (
<div key={i} className="grid grid-cols-2 gap-x-3 gap-y-0.5 mb-1 font-mono">
<span className="col-span-2 text-tv-text font-sans font-semibold">{(p.strategy ?? '').toUpperCase() || '포지션'} · {p.quantity ?? '-'}주{p.orphan && <span className="ml-1 px-1 rounded bg-tv-warn/15 text-tv-warn font-normal" title="엔진 관리 밖(고아) 포지션">고아</span>}
<b className={`ml-2 ${cls(p.pnl_pct)}`}>{p.pnl_pct != null ? `${p.pnl_pct > 0 ? '+' : ''}${p.pnl_pct.toFixed(2)}%` : ''}</b></span>
<span className="text-tv-muted">평단 <b className="text-tv-text">{fmtPrice(p.avg_price ?? p.entry_price)}</b></span>
<span className="text-tv-muted" title={p.stop_note ?? undefined}>손절 <b className="text-tv-down">{fmtPrice(p.stop_loss)}{p.stop_basis === 'pct' ? '(추정)' : ''}</b></span>
<span className="text-tv-muted">목표 <b className="text-tv-up">{fmtPrice(p.target_price)}</b></span>
<span className="text-tv-muted">트레일링 <b className="text-tv-warn">{p.trailing_active ? fmtPrice(p.trailing_stop) : '-'}</b></span>
</div>
))}
{selectedOverlay !== null && selectedOverlay.length === 0 && held && (
<div className="font-mono text-tv-muted">보유 {held.quantity}주 · 평단 <b className="text-tv-text">{fmtPrice(held.average_price)}</b>
<b className={`ml-2 ${cls(held.profit_loss_rate)}`}>{held.profit_loss_rate > 0 ? '+' : ''}{Number(held.profit_loss_rate).toFixed(2)}%</b></div>
)}
{selectedOverlay !== null && selectedOverlay.length === 0 && !held && <div className="text-tv-muted">보유 없음</div>}
{selectedOverlay === null && held && (
<div className="font-mono text-tv-muted">보유 {held.quantity}주 · 평단 <b className="text-tv-text">{fmtPrice(held.average_price)}</b>
<span className="ml-2">(가격선 데이터 없음)</span></div>
)}
</div>
<div className="flex border-b border-tv-border flex-shrink-0" role="tablist">
{TABS.map((t) => (
<button key={t.v} role="tab" aria-selected={tab === t.v} onClick={() => setTab(t.v)}
className={`flex-1 py-1.5 text-xs border-b-2 ${tab === t.v ? 'border-tv-accent text-tv-text' : 'border-transparent text-tv-muted hover:text-tv-text'}`}>{t.label}</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{tab === 'watch' && (
<div className="h-full flex flex-col">
<div className="flex gap-1 px-2 py-1.5 flex-shrink-0">
{(['all', 'hold', 'dt', 'reco'] as const).map((f) => (
<button key={f} onClick={() => setFilter(f)}
className={`px-2 py-0.5 text-xs rounded border ${filter === f ? 'border-tv-accent text-tv-accent' : 'border-tv-border text-tv-muted'}`}>
{f === 'all' ? '전체' : SRC_LABEL[f]}</button>
))}
</div>
<div className="flex-1 overflow-y-auto">
{shown.length === 0 && <div className="p-4 text-sm text-tv-muted text-center">종목 없음</div>}
{shown.map((it) => {
const price = livePrices?.[it.code] ?? it.price
return (
<button key={it.code} onClick={() => onSelect(it.code)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-tv-border/50 hover:bg-tv-raised ${it.code === selectedStock ? 'bg-tv-raised' : ''}`}>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-tv-text">{it.name || it.code}</span>
<span className="text-tv-muted font-mono">{it.code}
{[...it.srcs].map((s) => <span key={s} className="ml-1 px-1 rounded border border-tv-border">{SRC_LABEL[s]}</span>)}
</span>
</span>
<span className="text-right font-mono">
<span className="block text-tv-text">{price ? fmtPrice(price) : '-'}</span>
<span className={cls(it.chg)}>{it.chg != null ? `${it.chg > 0 ? '+' : ''}${Number(it.chg).toFixed(2)}%` : ''}</span>
</span>
</button>
)
})}
</div>
</div>
)}
{tab === 'reco' && <div className="h-full overflow-y-auto [&>div]:!h-full [&>div]:!max-h-none">{recoNode}</div>}
{tab === 'book' && <div className="h-full overflow-y-auto [&>div]:!h-full">{bookNode}</div>}
{tab === 'ai' && <div className="h-full overflow-y-auto [&>div]:!h-full">{aiNode}</div>}
</div>
</div>
)
})
RightPanel.displayName = 'RightPanel'

View File

@@ -0,0 +1,130 @@
/**
* SymbolBar.tsx — 상단 심볼 바
* 심볼·현재가·등락 | 타임프레임(D/W/M/5m) | 지표 토글 | 모드 배지(적응형·국면·MDD·WS) | 계좌 요약
* 배지 데이터(/adaptive-strategy/status, /risk/mdd-status)가 실패하면 해당 배지만 "—" 로 표시한다.
*/
import React from 'react'
import { Wifi, WifiOff, ChevronDown, ChevronUp } from 'lucide-react'
import { useAdaptiveStatus, useMddStatus, StrategyDisplay } from '../../lib/tvApi'
import type { Timeframe } from './chartUtils'
import type { ChartToggles } from './TvChart'
interface SymbolBarProps {
stockCode: string
stockName: string
price?: number
changePct?: number | null
timeframe: Timeframe
onTimeframe: (tf: Timeframe) => void
intradayAvailable: boolean
toggles: ChartToggles
onToggle: (k: keyof ChartToggles) => void
wsConnected: boolean
totalAssets?: number
todayPlRate?: number
summaryOpen: boolean
onToggleSummary: () => void
displayCfg: StrategyDisplay | null
}
const MODE_LABEL: Record<string, string> = { aggressive: '공격', normal: '보통', defensive: '방어', crisis: '위기' }
const MODE_CLS: Record<string, string> = {
aggressive: 'text-tv-up border-tv-up/50',
normal: 'text-tv-text border-tv-border',
defensive: 'text-tv-warn border-tv-warn/60',
crisis: 'text-tv-warn border-tv-warn bg-tv-warn/15 font-semibold',
}
const Badge = ({ label, value, cls = '', title }: { label: string; value: string; cls?: string; title?: string }) => (
<span title={title} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[11px] leading-4 whitespace-nowrap ${cls || 'border-tv-border text-tv-text'}`}>
<span className="text-tv-muted">{label}</span>{value}
</span>
)
const TF: { v: Timeframe; label: string }[] = [
{ v: 'D', label: '1D' }, { v: 'W', label: '1W' }, { v: 'M', label: '1M' }, { v: '5m', label: '5m' },
]
const IND: { k: keyof ChartToggles; label: string }[] = [
{ k: 'ma', label: 'MA' }, { k: 'bb', label: 'BB' }, { k: 'vol', label: 'VOL' }, { k: 'rsi', label: 'RSI' }, { k: 'macd', label: 'MACD' },
]
export const SymbolBar = React.memo(({
stockCode, stockName, price, changePct, timeframe, onTimeframe, intradayAvailable,
toggles, onToggle, wsConnected, totalAssets, todayPlRate, summaryOpen, onToggleSummary, displayCfg,
}: SymbolBarProps) => {
const { data: adaptive } = useAdaptiveStatus()
const { data: mdd } = useMddStatus()
const mode = adaptive?.trading_mode ?? displayCfg?.adaptive_mode
const dd = mdd?.drawdown_pct == null ? null : Math.abs(mdd.drawdown_pct)
const reduce = Math.abs(mdd?.thresholds?.reduce_pct ?? displayCfg?.mdd?.reduce_pct ?? NaN)
const force = Math.abs(mdd?.thresholds?.force_pct ?? displayCfg?.mdd?.force_pct ?? NaN)
// drawdown_pct 는 양수=하락폭. 임계는 절대값으로 비교
const mddCls = dd == null ? '' : Number.isFinite(force) && dd >= force ? 'border-tv-up text-tv-up bg-tv-up/15 font-semibold'
: Number.isFinite(reduce) && dd >= reduce ? 'border-tv-up/70 text-tv-up'
: Number.isFinite(reduce) && dd >= reduce * 0.7 ? 'border-tv-warn/60 text-tv-warn' : ''
const regime = adaptive?.condition?.trend_strength
const chgCls = changePct == null ? 'text-tv-muted' : changePct > 0 ? 'text-tv-up' : changePct < 0 ? 'text-tv-down' : 'text-tv-text'
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-3 py-1.5 bg-tv-panel border-b border-tv-border text-tv-text">
<div className="flex items-baseline gap-2 min-w-0">
<span className={stockName && stockName !== stockCode ? 'text-sm font-mono text-tv-muted' : 'text-base font-bold'}>{stockCode || '종목 선택'}</span>
{stockName && stockName !== stockCode && <span className="text-base font-bold truncate max-w-[10rem]">{stockName}</span>}
<span className={`text-lg font-mono font-bold ${chgCls}`}>{price ? Math.round(price).toLocaleString('ko-KR') : '-'}</span>
<span className={`text-sm font-mono ${chgCls}`}>
{changePct == null ? '' : `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%`}
</span>
</div>
<div className="flex items-center rounded overflow-hidden border border-tv-border" role="group" aria-label="타임프레임">
{TF.map(({ v, label }) => {
const disabled = v === '5m' && !intradayAvailable
return (
<button
key={v}
disabled={disabled}
title={disabled ? '분봉 데이터 없음 (장중에만 제공)' : undefined}
onClick={() => onTimeframe(v)}
className={`px-2.5 py-1 text-xs font-mono ${timeframe === v ? 'bg-tv-raised text-tv-text' : 'text-tv-muted hover:text-tv-text'} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
>{label}</button>
)
})}
</div>
<div className="flex items-center gap-1" role="group" aria-label="지표">
<span className="text-xs text-tv-muted mr-0.5">지표</span>
{IND.map(({ k, label }) => (
<button
key={k}
onClick={() => onToggle(k)}
aria-pressed={toggles[k]}
className={`px-2 py-0.5 text-xs rounded border ${toggles[k] ? 'border-tv-accent text-tv-accent bg-tv-accent/10' : 'border-tv-border text-tv-muted hover:text-tv-text'}`}
>{label}</button>
))}
</div>
<div className="flex flex-wrap items-center gap-1.5 ml-auto">
<Badge label="모드" value={mode ? (MODE_LABEL[mode] ?? mode) + (adaptive?.fallback_used ? '*' : '') : '—'}
cls={mode ? MODE_CLS[mode] : ''} title={adaptive?.fallback_used ? '적응형 폴백 사용 중' : '적응형 모드'} />
<Badge label="국면" value={regime != null ? String(regime) : '—'} />
<Badge label="MDD" value={dd == null ? '—' : `-${Math.abs(dd).toFixed(1)}%${mdd?.is_paused ? ' 중단' : ''}`}
cls={mddCls} title={Number.isFinite(reduce) ? `축소 -${reduce}% / 강제 -${force}%` : 'MDD 임계 데이터 없음'} />
<span className={`inline-flex items-center gap-1 text-[11px] ${wsConnected ? 'text-tv-text' : 'text-tv-warn'}`} title="실시간 연결">
{wsConnected ? <Wifi className="w-3.5 h-3.5" /> : <WifiOff className="w-3.5 h-3.5" />}
{wsConnected ? 'LIVE' : '연결 끊김'}
</span>
<button onClick={onToggleSummary} className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded border border-tv-border text-tv-muted hover:text-tv-text">
{totalAssets != null && <span className="font-mono text-tv-text">{Math.round(totalAssets).toLocaleString('ko-KR')}</span>}
{todayPlRate != null && (
<span className={`font-mono ${todayPlRate > 0 ? 'text-tv-up' : todayPlRate < 0 ? 'text-tv-down' : ''}`}>
{todayPlRate > 0 ? '+' : ''}{todayPlRate.toFixed(2)}%
</span>
)}
계좌 {summaryOpen ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
</div>
)
})
SymbolBar.displayName = 'SymbolBar'

View File

@@ -0,0 +1,335 @@
/**
* TvChart.tsx — TradingView 스타일 메인 차트
*
* 구성: 캔들 + 거래량(오버레이 스케일) + MA5/20/60 + 볼린저(20,2) | RSI 패널 | MACD 패널(토글)
* - 가격선: /positions/overlay 의 평단·손절·목표·트레일링 (null 이면 그리지 않음)
* - 체결 마커: ▲매수(빨강) ▼매도(파랑) — 봉에 스냅
* - RSI 기준선: /strategy-config/display 의 rsi 설정값. 값이 없으면 기준선 숨김(하드코딩 금지)
* - 패널 간 시간축 동기화, autoSize(리사이즈 대응)
* - 라이브 시세: 일봉(D)에서 오늘 캔들만 update (차트 재생성 없음)
*/
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
createChart, CrosshairMode, LineStyle,
IChartApi, ISeriesApi, IPriceLine, SeriesMarker, Time, LogicalRange,
} from 'lightweight-charts'
import { calculateSMA, calculateBollingerBands, calculateRSI, calculateMACD } from '../../utils/technicalIndicators'
import type { OverlayPosition, StrategyDisplay } from '../../lib/tvApi'
import { Bar, Timeframe, TradeMark, snapToBar, tvColor, todayKst, fmtPrice } from './chartUtils'
export interface ChartToggles { ma: boolean; bb: boolean; vol: boolean; rsi: boolean; macd: boolean }
interface TvChartProps {
bars: Bar[]
timeframe: Timeframe
loading: boolean
emptyReason?: string | null
livePrice?: number
overlays: OverlayPosition[] | null
trades: TradeMark[]
rsiCfg: StrategyDisplay['rsi'] | null
toggles: ChartToggles
/** 값이 바뀔 때마다 화면 맞춤(fitContent) */
fitSignal: number
/** 미국 종목이면 소수 2자리, 원화는 정수 */
isUs: boolean
}
interface LegendRow { o: number; h: number; l: number; c: number; v: number; ma5?: number; ma20?: number; ma60?: number; rsi?: number }
const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
const clean = <T extends { value: number }>(arr: (T | null)[]): T[] => arr.filter((x): x is T => x !== null && Number.isFinite(x.value))
export const TvChart = React.memo(({
bars, timeframe, loading, emptyReason, livePrice, overlays, trades, rsiCfg, toggles, fitSignal, isUs,
}: TvChartProps) => {
const mainEl = useRef<HTMLDivElement>(null)
const rsiEl = useRef<HTMLDivElement>(null)
const macdEl = useRef<HTMLDivElement>(null)
const charts = useRef<{ main: IChartApi; rsi: IChartApi; macd: IChartApi } | null>(null)
const series = useRef<{
candle: ISeriesApi<'Candlestick'>; vol: ISeriesApi<'Histogram'>
ma5: ISeriesApi<'Line'>; ma20: ISeriesApi<'Line'>; ma60: ISeriesApi<'Line'>
bbU: ISeriesApi<'Line'>; bbL: ISeriesApi<'Line'>
rsi: ISeriesApi<'Line'>
macdHist: ISeriesApi<'Histogram'>; macd: ISeriesApi<'Line'>; macdSig: ISeriesApi<'Line'>
} | null>(null)
const priceLines = useRef<IPriceLine[]>([])
const rsiLines = useRef<IPriceLine[]>([])
const lastBar = useRef<Bar | null>(null)
const legendMap = useRef<Map<string | number, LegendRow>>(new Map())
const [legend, setLegend] = useState<LegendRow | null>(null)
const rsiPeriod = rsiCfg?.period && rsiCfg.period > 1 ? rsiCfg.period : 14
// ── 1) 차트 3개 생성 (마운트 1회) ──────────────────────────────
useEffect(() => {
if (!mainEl.current || !rsiEl.current || !macdEl.current) return
const base = () => ({
autoSize: true,
layout: { background: { color: 'transparent' }, textColor: tvColor('--tv-muted'), fontSize: 12 },
grid: { vertLines: { color: tvColor('--tv-border', 0.35) }, horzLines: { color: tvColor('--tv-border', 0.35) } },
crosshair: { mode: CrosshairMode.Normal },
rightPriceScale: { borderColor: tvColor('--tv-border'), minimumWidth: 68 },
timeScale: { borderColor: tvColor('--tv-border'), rightOffset: 4, timeVisible: false },
handleScale: { axisPressedMouseMove: true },
})
const main = createChart(mainEl.current, base())
const rsiC = createChart(rsiEl.current, { ...base(), timeScale: { ...base().timeScale, visible: false } })
rsiC.priceScale('right').applyOptions({ scaleMargins: { top: 0.1, bottom: 0.1 } })
const macdC = createChart(macdEl.current, base())
const up = tvColor('--tv-up'), down = tvColor('--tv-down')
const candle = main.addCandlestickSeries({
upColor: up, downColor: down, borderUpColor: up, borderDownColor: down, wickUpColor: up, wickDownColor: down,
priceLineVisible: true,
})
const vol = main.addHistogramSeries({ priceFormat: { type: 'volume' }, priceScaleId: 'vol', lastValueVisible: false, priceLineVisible: false })
main.priceScale('vol').applyOptions({ scaleMargins: { top: 0.86, bottom: 0 } })
candle.priceScale().applyOptions({ scaleMargins: { top: 0.06, bottom: 0.16 } })
const line = (color: string, w: 1 | 2 = 1) => main.addLineSeries({ color, lineWidth: w, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: false })
const bbColor = tvColor('--tv-muted', 0.7)
const s = {
candle, vol,
ma5: line('#ffd54f'), ma20: line('#26c6da'), ma60: line('#ab47bc'),
bbU: line(bbColor), bbL: line(bbColor),
rsi: rsiC.addLineSeries({
priceFormat: { type: 'price', precision: 0, minMove: 1 },
color: tvColor('--tv-accent'), lineWidth: 2, priceLineVisible: false,
autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: 100 } }),
}),
macdHist: macdC.addHistogramSeries({ priceLineVisible: false, lastValueVisible: false }),
macd: macdC.addLineSeries({ color: '#26c6da', lineWidth: 1, priceLineVisible: false, lastValueVisible: false }),
macdSig: macdC.addLineSeries({ color: '#ffd54f', lineWidth: 1, priceLineVisible: false, lastValueVisible: false }),
}
charts.current = { main, rsi: rsiC, macd: macdC }
series.current = s
// 시간축 동기화 (논리 범위). 재귀 호출 방지용 플래그
let syncing = false
const all = [main, rsiC, macdC]
const handlers = all.map((src) => {
const h = (r: LogicalRange | null) => {
if (!r || syncing) return
syncing = true
for (const dst of all) if (dst !== src) dst.timeScale().setVisibleLogicalRange(r)
syncing = false
}
src.timeScale().subscribeVisibleLogicalRangeChange(h)
return h
})
main.subscribeCrosshairMove((p) => {
const key = p.time as string | number | undefined
const row = key != null ? legendMap.current.get(key) : undefined
setLegend(row ?? null)
})
return () => {
all.forEach((c, i) => c.timeScale().unsubscribeVisibleLogicalRangeChange(handlers[i]))
all.forEach((c) => c.remove())
charts.current = null
series.current = null
priceLines.current = []
rsiLines.current = []
}
}, [])
// ── 2) 데이터/지표 ────────────────────────────────────────────
const computed = useMemo(() => {
if (!bars.length) return null
const closes = bars.map((b) => b.close)
const bb = calculateBollingerBands(closes, 20)
return {
ma5: calculateSMA(closes, 5), ma20: calculateSMA(closes, 20), ma60: calculateSMA(closes, 60),
bbU: bb.upper, bbL: bb.lower,
rsi: calculateRSI(closes, rsiPeriod),
macd: calculateMACD(closes),
}
}, [bars, rsiPeriod])
useEffect(() => {
const s = series.current, c = charts.current
if (!s || !c) return
const intraday = timeframe === '5m'
for (const ch of [c.main, c.macd]) ch.applyOptions({ timeScale: { timeVisible: intraday, secondsVisible: false } })
if (!bars.length || !computed) {
for (const k of Object.keys(s) as (keyof typeof s)[]) (s[k] as ISeriesApi<'Line'>).setData([])
lastBar.current = null
legendMap.current = new Map()
setLegend(null)
return
}
const up = tvColor('--tv-up'), down = tvColor('--tv-down')
const upA = tvColor('--tv-up', 0.28), downA = tvColor('--tv-down', 0.28)
const t = (i: number) => bars[i].time as Time
const mk = (arr: number[]) => clean(arr.map((v, i) => (num(v) ? { time: t(i), value: v } : null)))
s.candle.setData(bars.map((b) => ({ time: b.time as Time, open: b.open, high: b.high, low: b.low, close: b.close })))
s.vol.setData(bars.map((b) => ({ time: b.time as Time, value: b.volume, color: b.close >= b.open ? upA : downA })))
s.ma5.setData(mk(computed.ma5)); s.ma20.setData(mk(computed.ma20)); s.ma60.setData(mk(computed.ma60))
s.bbU.setData(mk(computed.bbU)); s.bbL.setData(mk(computed.bbL))
s.rsi.setData(mk(computed.rsi))
s.macd.setData(mk(computed.macd.macd)); s.macdSig.setData(mk(computed.macd.signal))
s.macdHist.setData(clean(computed.macd.histogram.map((v, i) => (num(v) ? { time: t(i), value: v, color: v >= 0 ? up : down } : null))))
lastBar.current = bars[bars.length - 1]
const m = new Map<string | number, LegendRow>()
bars.forEach((b, i) => m.set(b.time, {
o: b.open, h: b.high, l: b.low, c: b.close, v: b.volume,
ma5: computed.ma5[i], ma20: computed.ma20[i], ma60: computed.ma60[i], rsi: computed.rsi[i],
}))
legendMap.current = m
setLegend(null)
c.main.timeScale().fitContent()
}, [bars, computed, timeframe])
// 표시 토글
useEffect(() => {
const s = series.current
if (!s) return
const vis = (x: ISeriesApi<'Line'> | ISeriesApi<'Histogram'>, v: boolean) => x.applyOptions({ visible: v })
vis(s.ma5, toggles.ma); vis(s.ma20, toggles.ma); vis(s.ma60, toggles.ma)
vis(s.bbU, toggles.bb); vis(s.bbL, toggles.bb)
vis(s.vol, toggles.vol)
}, [toggles.ma, toggles.bb, toggles.vol])
// 가격 포맷: 원화=정수+천단위, 미국=소수 2자리 (축·가격선·크로스헤어 공통)
useEffect(() => {
const c = charts.current, s = series.current
if (!c || !s) return
const fmt = (v: number) => (isUs ? v.toFixed(2) : Math.round(v).toLocaleString('ko-KR'))
c.main.applyOptions({ localization: { priceFormatter: fmt } })
const pf = isUs ? { type: 'price' as const, precision: 2, minMove: 0.01 } : { type: 'price' as const, precision: 0, minMove: 1 }
for (const x of [s.candle, s.ma5, s.ma20, s.ma60, s.bbU, s.bbL]) x.applyOptions({ priceFormat: pf })
}, [isUs])
// 화면 맞춤
useEffect(() => {
charts.current?.main.timeScale().fitContent()
}, [fitSignal])
// 라이브 시세: 일봉의 오늘 캔들만 갱신
useEffect(() => {
const s = series.current, lb = lastBar.current
if (!s || !lb || !num(livePrice) || livePrice <= 0 || timeframe !== 'D') return
if (lb.time !== todayKst()) return
const next = { ...lb, close: livePrice, high: Math.max(lb.high, livePrice), low: Math.min(lb.low, livePrice) }
lastBar.current = next
s.candle.update({ time: next.time as Time, open: next.open, high: next.high, low: next.low, close: next.close })
}, [livePrice, timeframe])
// ── 3) 가격선 (진입/손절/목표/트레일링) ─────────────────────────
useEffect(() => {
const s = series.current
if (!s) return
priceLines.current.forEach((l) => s.candle.removePriceLine(l))
priceLines.current = []
if (!overlays) return
const multi = overlays.length > 1
for (const p of overlays) {
const tag = multi || p.strategy ? `${(p.strategy ?? '').toUpperCase().slice(0, 5)} ` : ''
const add = (price: number | null | undefined, title: string, color: string, style: LineStyle) => {
if (!num(price) || price <= 0) return
priceLines.current.push(s.candle.createPriceLine({
price, color, lineWidth: 1, lineStyle: style, axisLabelVisible: true, title: `${tag}${title}`,
}))
}
add(p.avg_price ?? p.entry_price, '평단', tvColor('--tv-text'), LineStyle.Dashed)
add(p.stop_loss, p.stop_basis === 'pct' ? (p.stop_loss_alt != null ? '손절(추정 -7~-10%)' : '손절(추정)') : p.stop_basis ? `손절(${p.stop_basis})` : '손절', tvColor('--tv-down'), LineStyle.Solid)
add(p.target_price, '목표', tvColor('--tv-up'), LineStyle.Solid)
if (p.trailing_active) add(p.trailing_stop, '트레일링', tvColor('--tv-warn'), LineStyle.Dotted)
}
}, [overlays, bars])
// ── 4) 체결 마커 ───────────────────────────────────────────────
useEffect(() => {
const s = series.current
if (!s) return
if (!bars.length || !trades.length) { s.candle.setMarkers([]); return }
const times = bars.map((b) => b.time)
const up = tvColor('--tv-up'), down = tvColor('--tv-down')
const marks: SeriesMarker<Time>[] = []
for (const tr of trades) {
const bt = snapToBar(tr.ts, times, timeframe)
if (bt == null) continue
const net = tr.net != null && tr.side === 'sell' ? ` ${tr.net > 0 ? '+' : ''}${Math.round(tr.net).toLocaleString('ko-KR')}` : ''
marks.push(tr.side === 'buy'
? { time: bt as Time, position: 'belowBar', shape: 'arrowUp', color: up, text: `매수 ${fmtPrice(tr.price)}` }
: { time: bt as Time, position: 'aboveBar', shape: 'arrowDown', color: down, text: `매도${net}` })
}
marks.sort((a, b) => ((a.time as string | number) < (b.time as string | number) ? -1 : (a.time as string | number) > (b.time as string | number) ? 1 : 0))
s.candle.setMarkers(marks)
}, [trades, bars, timeframe])
// ── 5) RSI 기준선 (설정값만) ───────────────────────────────────
useEffect(() => {
const s = series.current
if (!s) return
rsiLines.current.forEach((l) => s.rsi.removePriceLine(l))
rsiLines.current = []
if (!rsiCfg) return
const add = (v: number | undefined, _title: string, color: string) => {
if (!num(v)) return
rsiLines.current.push(s.rsi.createPriceLine({ price: v, color, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: String(v) }))
}
add(rsiCfg.bull_entry, '진입', tvColor('--tv-up'))
add(rsiCfg.bear_entry, '약세진입', tvColor('--tv-muted'))
add(rsiCfg.partial_exit, '부분익절', tvColor('--tv-down', 0.8))
add(rsiCfg.full_exit, '전량', tvColor('--tv-down'))
}, [rsiCfg])
const lg = legend ?? (bars.length ? legendMap.current.get(bars[bars.length - 1].time) ?? null : null)
const chg = lg ? lg.c - lg.o : 0
const col = chg >= 0 ? 'text-tv-up' : 'text-tv-down'
const f = (v?: number) => (num(v) ? Math.round(v).toLocaleString('ko-KR') : '-')
return (
<div className="relative flex flex-col w-full h-full min-h-0 bg-tv-bg">
{/* OHLC 범례 */}
<div className="absolute left-2 top-1 z-10 pointer-events-none text-xs font-mono flex flex-wrap gap-x-3 gap-y-0.5 max-w-[calc(100%-5rem)]">
{lg && (
<>
<span className="text-tv-muted">시 <b className={col}>{f(lg.o)}</b></span>
<span className="text-tv-muted">고 <b className={col}>{f(lg.h)}</b></span>
<span className="text-tv-muted">저 <b className={col}>{f(lg.l)}</b></span>
<span className="text-tv-muted">종 <b className={col}>{f(lg.c)}</b></span>
<span className="text-tv-muted">거래량 <b className="text-tv-text">{f(lg.v)}</b></span>
{toggles.ma && (
<>
<span style={{ color: '#ffd54f' }}>MA5 {f(lg.ma5)}</span>
<span style={{ color: '#26c6da' }}>MA20 {f(lg.ma20)}</span>
<span style={{ color: '#ab47bc' }}>MA60 {f(lg.ma60)}</span>
</>
)}
</>
)}
</div>
<div ref={mainEl} className="flex-1 min-h-[160px]" />
<div className={`relative border-t border-tv-border ${toggles.rsi ? 'h-[160px]' : 'hidden'}`}>
<span className="absolute left-2 top-0.5 z-10 text-xs text-tv-muted pointer-events-none">
RSI({rsiPeriod}) <b className="text-tv-accent">{num(lg?.rsi) ? lg!.rsi!.toFixed(1) : '-'}</b>
{!rsiCfg && <span className="ml-2 text-tv-muted">기준선: 설정 데이터 없음</span>}
</span>
<div ref={rsiEl} className="absolute inset-0" />
</div>
<div className={`relative border-t border-tv-border ${toggles.macd ? 'h-[100px]' : 'hidden'}`}>
<span className="absolute left-2 top-0.5 z-10 text-xs text-tv-muted pointer-events-none">MACD(12,26,9)</span>
<div ref={macdEl} className="absolute inset-0" />
</div>
{bars.length === 0 && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-tv-bg/70 text-sm text-tv-muted">
{loading ? '차트 불러오는 중…' : emptyReason || '데이터 없음'}
</div>
)}
{bars.length > 0 && loading && (
<div className="absolute right-3 top-1 z-20 text-xs text-tv-muted">갱신 중…</div>
)}
</div>
)
})
TvChart.displayName = 'TvChart'

View File

@@ -0,0 +1,105 @@
/**
* chartUtils.ts — TvChart 공용 순수 헬퍼 (시간 변환, 정규화, 마커 스냅, CSS 토큰 읽기)
*/
import type { CandleData } from '../../types/trading'
import type { IntradayCandle } from '../../lib/tvApi'
export type Timeframe = 'D' | 'W' | 'M' | '5m'
export interface Bar { time: string | number; open: number; high: number; low: number; close: number; volume: number }
/** KST 는 UTC+9 고정(서머타임 없음). lightweight-charts 는 epoch 를 UTC 로 그리므로 9시간을 더해 KST 시각으로 표시한다 */
export const KST_OFFSET_SEC = 9 * 3600
/** CSS 토큰(:root --tv-*) → 'rgb(r,g,b)' (alpha 지정 시 rgba). 라이트/다크 토큰 전환을 차트가 따라가게 한다 */
export const tvColor = (name: string, alpha?: number): string => {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
const parts = raw.split(/\s+/).filter(Boolean)
if (parts.length !== 3) return alpha == null ? '#888' : `rgba(136,136,136,${alpha})`
return alpha == null ? `rgb(${parts.join(',')})` : `rgba(${parts.join(',')},${alpha})`
}
const ymdKst = (d: Date): string =>
new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d)
export const todayKst = (): string => ymdKst(new Date())
/** 'YYYY-MM-DD' / 'YYYYMMDD' / Date → 'YYYY-MM-DD' */
export const toYmd = (date: string | Date): string => {
if (typeof date === 'string') {
if (/^\d{4}-\d{2}-\d{2}/.test(date)) return date.slice(0, 10)
if (/^\d{8}$/.test(date)) return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`
}
return ymdKst(new Date(date))
}
/** 시간 오름차순 정렬 + 중복 시각 제거(lightweight-charts 는 중복/역순에서 예외를 던진다) */
const normalize = (bars: Bar[]): Bar[] => {
const sorted = [...bars].sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0))
const out: Bar[] = []
for (const b of sorted) {
if (out.length && out[out.length - 1].time === b.time) out[out.length - 1] = b
else out.push(b)
}
return out
}
export const barsFromDaily = (data: CandleData[]): Bar[] =>
normalize(
data
.map((c) => ({ time: toYmd(c.date), open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume }))
.filter((b) => [b.open, b.high, b.low, b.close].every(Number.isFinite) && b.close > 0)
)
export const barsFromIntraday = (data: IntradayCandle[]): Bar[] =>
normalize(data.map((c) => ({ ...c, time: c.time + KST_OFFSET_SEC })))
/** 거래 레코드(신/구 필드 혼용)에서 방향·가격·시각을 뽑는다. 구 필드: order_type/executed_price/executed_at */
export interface TradeMark { side: 'buy' | 'sell'; price: number; ts: number; qty: number; net: number | null }
const DONE = ['EXECUTED', 'COMPLETED', 'FILLED', 'PARTIAL', 'PARTIALLY_FILLED']
export const toTradeMarks = (rows: unknown): TradeMark[] => {
if (!Array.isArray(rows)) return []
const out: TradeMark[] = []
for (const r of rows as Record<string, unknown>[]) {
const status = typeof r.status === 'string' ? r.status.toUpperCase() : ''
if (status && !DONE.includes(status)) continue
const sideRaw = String(r.side ?? r.order_type ?? r.trade_type ?? '').toLowerCase()
if (sideRaw !== 'buy' && sideRaw !== 'sell') continue
const price = Number(r.executed_price ?? r.price)
const rawTs = (r.executed_at ?? r.created_at) as string | undefined
if (!rawTs || !(price > 0)) continue
// 타임존 표기 없는 문자열은 서버 KST 로컬 시각으로 취급(parseKst 규약과 동일)
const hasTz = /[zZ]|[+-]\d{2}:?\d{2}$/.test(rawTs)
const ms = new Date(hasTz ? rawTs : `${rawTs}+09:00`).getTime()
if (!Number.isFinite(ms)) continue
const net = r.net_profit_loss
out.push({
side: sideRaw as 'buy' | 'sell',
price,
ts: ms / 1000,
qty: Number(r.executed_quantity ?? r.quantity) || 0,
net: net == null || !Number.isFinite(Number(net)) ? null : Number(net),
})
}
return out.sort((a, b) => a.ts - b.ts)
}
/** 체결 시각 → 해당 봉 time. 대응 봉이 없으면 null */
export const snapToBar = (ts: number, times: (string | number)[], tf: Timeframe): string | number | null => {
if (!times.length) return null
const key: string | number = tf === '5m' ? ts + KST_OFFSET_SEC : ymdKst(new Date(ts * 1000))
let lo = 0, hi = times.length - 1, idx = -1
while (lo <= hi) {
const mid = (lo + hi) >> 1
if (times[mid] <= key) { idx = mid; lo = mid + 1 } else hi = mid - 1
}
if (idx < 0) return null
// 분봉은 같은 5분 버킷 안일 때만(다른 날 체결이 마지막 봉에 붙는 것 방지)
if (tf === '5m' && (key as number) - (times[idx] as number) >= 5 * 60) return null
return times[idx]
}
export const fmtPrice = (v: number | null | undefined): string =>
v == null || !Number.isFinite(v) ? '-' : Math.round(v).toLocaleString('ko-KR')

View File

@@ -17,6 +17,33 @@
-moz-osx-font-smoothing: grayscale;
}
/* TradingView 스타일 토큰 (RGB 3원소 — tailwind 의 <alpha-value> 와 호환).
한국식: 상승/이익 = 빨강(up), 하락/손실 = 파랑(down), 경고 = 앰버(warn, 빨강과 분리) */
:root {
--tv-bg: 19 23 34;
--tv-panel: 30 34 45;
--tv-raised: 42 46 57;
--tv-border: 54 58 69;
--tv-text: 209 212 220;
--tv-muted: 138 142 154;
--tv-up: 242 54 69;
--tv-down: 41 98 255;
--tv-warn: 255 167 38;
--tv-accent: 179 136 255;
}
[data-theme='light'] {
--tv-bg: 255 255 255;
--tv-panel: 249 249 249;
--tv-raised: 240 243 250;
--tv-border: 224 227 235;
--tv-text: 19 23 34;
--tv-muted: 106 109 120;
--tv-up: 214 40 56;
--tv-down: 33 80 224;
--tv-warn: 230 120 0;
--tv-accent: 103 58 183;
}
body {
margin: 0;
min-width: 320px;

163
frontend/src/lib/tvApi.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* tvApi.ts — TradingView 스타일 대시보드용 신규 엔드포인트 훅 모음
*
* 모든 쿼리는 404/5xx/네트워크 오류를 `null` 로 흡수한다(구 백엔드 배포 중일 수 있음).
* 호출부는 null 이면 해당 오버레이/패널만 "데이터 없음" 으로 처리하고, 화면 전체는 깨지지 않는다.
* retry:false — 404 를 반복 호출하지 않는다. 미지원(404)이면 재조회 주기를 길게 둔다.
*/
import { useQuery } from '@tanstack/react-query'
import api from './api'
/** GET /positions/overlay 의 한 포지션 */
export interface OverlayPosition {
stock_code: string
stock_name?: string | null
strategy?: string | null
entry_price?: number | null
avg_price?: number | null
quantity?: number | null
current_price?: number | null
pnl?: number | null
pnl_pct?: number | null
stop_loss?: number | null
stop_basis?: 'atr' | 'pct' | 'stored' | null
target_price?: number | null
trailing_active?: boolean | null
trailing_stop?: number | null
highest_price?: number | null
entry_time?: string | null
elapsed_min?: number | null
time_stop_at?: string | null
orphan?: boolean | null
stop_loss_alt?: number | string | null
stop_note?: string | null
}
export interface EquityPoint {
date: string
realized_net: number
realized_net_swing?: number
realized_net_dt?: number
realized_net_other?: number
cum_net: number
peak?: number
drawdown_pct?: number
}
export interface EquityHistory {
cost_basis?: string
points: EquityPoint[]
mdd_thresholds?: { reduce_pct?: number; force_pct?: number }
null_net_rows?: number
excluded_non_krw_rows?: number
}
export interface StrategyDisplay {
rsi?: { period?: number; bull_entry?: number; bear_entry?: number; partial_exit?: number; full_exit?: number }
atr?: { stop_mult?: number; trailing_mult?: number }
stop_loss_pct?: number
dt?: { daily_loss_pct?: number; time_stop_min?: number; time_stop_min_gain?: number; eod_flat?: string; entry_cutoff?: string }
mdd?: { reduce_pct?: number; force_pct?: number }
adaptive_mode?: string
source?: string
sources?: { entry?: string; exit?: string }
}
export interface MddStatus {
drawdown_pct?: number | null
is_paused?: boolean
reduction_applied?: unknown
thresholds?: { reduce_pct?: number; force_pct?: number }
}
export interface AdaptiveStatus {
trading_mode?: string
fallback_used?: boolean
condition?: { trend_strength?: string | number; volume_regime?: string; adx_value?: number }
}
/** 오류 → null. 호출부에서 "데이터 없음" 처리 */
async function getOrNull<T>(url: string, params?: Record<string, unknown>): Promise<T | null> {
try {
const res = await api.get(url, { params })
return (res.data ?? null) as T | null
} catch {
return null
}
}
const isOpenHint = () => document.visibilityState === 'visible'
/** 선택 종목(code) 또는 전체(code 생략)의 포지션 오버레이 */
export const usePositionOverlay = (code?: string, enabled = true) =>
useQuery<{ positions: OverlayPosition[]; as_of?: string } | null>({
queryKey: ['tv-pos-overlay', code ?? 'all'],
queryFn: () => getOrNull('/positions/overlay', code ? { code } : undefined),
enabled,
staleTime: 10_000,
refetchInterval: (q) => (q.state.data === null ? 120_000 : isOpenHint() ? 15_000 : false),
retry: false,
})
export const useEquityHistory = (days: number, enabled: boolean) =>
useQuery<EquityHistory | null>({
queryKey: ['tv-equity-history', days],
queryFn: () => getOrNull('/risk/equity-history', { days }),
enabled,
staleTime: 60_000,
refetchInterval: false,
retry: false,
})
export const useStrategyDisplay = () =>
useQuery<StrategyDisplay | null>({
queryKey: ['tv-strategy-display'],
queryFn: () => getOrNull('/strategy-config/display'),
staleTime: 60_000,
refetchInterval: 5 * 60_000,
retry: false,
})
export const useMddStatus = () =>
useQuery<MddStatus | null>({
queryKey: ['tv-mdd-status'],
queryFn: () => getOrNull('/risk/mdd-status'),
staleTime: 20_000,
refetchInterval: 30_000,
retry: false,
})
export const useAdaptiveStatus = () =>
useQuery<AdaptiveStatus | null>({
queryKey: ['tv-adaptive-status'],
queryFn: () => getOrNull('/adaptive-strategy/status'),
staleTime: 30_000,
refetchInterval: 60_000,
retry: false,
})
/** 분봉 캔들 (period=5m). 성공(데이터 있음)해야만 5m 타임프레임이 활성화된다. */
export interface IntradayCandle { time: number; open: number; high: number; low: number; close: number; volume: number }
export const useIntradayCandles = (code: string, enabled: boolean, live: boolean) =>
useQuery<IntradayCandle[] | null>({
queryKey: ['tv-chart-5m', code],
queryFn: async () => {
const r = await getOrNull<{ data?: Record<string, unknown>[]; is_sample?: boolean; error?: string }>(
`/market-data/chart/${code}`, { period: '5m' })
if (!r || r.is_sample || r.error || !Array.isArray(r.data) || r.data.length === 0) return null
const out: IntradayCandle[] = []
for (const c of r.data) {
const t = Number(c.time)
const o = Number(c.open), h = Number(c.high), l = Number(c.low), cl = Number(c.close)
if (![t, o, h, l, cl].every(Number.isFinite)) continue
out.push({ time: t, open: o, high: h, low: l, close: cl, volume: Number(c.volume) || 0 })
}
out.sort((a, b) => a.time - b.time)
return out.length ? out : null
},
enabled: enabled && !!code,
staleTime: 30_000,
refetchInterval: (q) => (live && q.state.data && !document.hidden ? 60_000 : false),
retry: false,
})

View File

@@ -31,7 +31,7 @@ import { useTenantMode } from '../lib/tenant'
import { getTradesV2 } from '../lib/api'
import { PortfolioSummary } from '../components/PortfolioSummary'
import PortfolioPanel from '../components/PortfolioPanel'
import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
import { AiAnalysisPanel } from '../components/ChartPanel'
import OrderbookPanel from '../components/OrderbookPanel'
import RecommendationPanel from '../components/RecommendationPanel'
import JournalPanel from '../components/JournalPanel'
@@ -41,6 +41,12 @@ import { CandleData, Position, TradeHistory } from '../types/trading'
import { isUsStock } from '../utils/stockUtils'
import { isKrMarketOpen } from '../utils/marketHours'
import { parseKst } from '../utils/formatUtils'
import { SymbolBar } from '../components/tv/SymbolBar'
import { TvChart, ChartToggles } from '../components/tv/TvChart'
import { RightPanel } from '../components/tv/RightPanel'
import { BottomTabs } from '../components/tv/BottomTabs'
import { Timeframe, barsFromDaily, barsFromIntraday, toTradeMarks, todayKst } from '../components/tv/chartUtils'
import { useIntradayCandles, usePositionOverlay, useStrategyDisplay } from '../lib/tvApi'
import './Dashboard.css'
/**
@@ -154,6 +160,17 @@ const Dashboard = () => {
const isMarketOpen = checkMarketOpen()
// TradingView 스타일 UI 상태
const [tf, setTf] = useState<Timeframe>('D')
const [toggles, setToggles] = useState<ChartToggles>({ ma: true, bb: true, vol: true, rsi: true, macd: false })
const [fitSignal, setFitSignal] = useState(0)
const [summaryOpen, setSummaryOpen] = useState(false)
const handleTimeframe = useCallback((v: Timeframe) => {
setTf(v)
if (v !== '5m') setChartPeriod(v) // D/W/M 은 기존 WS get_chart 흐름, 5m 은 REST(useIntradayCandles)
}, [])
const handleToggle = useCallback((k: keyof ChartToggles) => setToggles((t) => ({ ...t, [k]: !t[k] })), [])
// 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
// useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지
const handleSelectStock = useCallback((code: string) => {
@@ -184,7 +201,6 @@ const Dashboard = () => {
// Market data hook (with throttled live prices and market update handler)
const {
livePrices,
liveVolumes,
liveQuote,
handleMarketUpdate,
} = useMarketData(
@@ -850,7 +866,6 @@ const Dashboard = () => {
const livePrice = livePrices[selectedStock]
// 차트에는 chartData 가 선택 종목의 데이터일 때만 실시간가 적용 (종목 전환 중 옛 차트 오염 방지 — M-3)
const chartLivePrice = chartStock === selectedStock ? livePrice : undefined
const chartLiveVolume = chartStock === selectedStock ? liveVolumes[selectedStock] : undefined
// 실시간 호가: WebSocket quote(H0STASP0) → REST 초기값 폴백
const activeOrderbook = useMemo(() => {
@@ -1023,109 +1038,202 @@ const Dashboard = () => {
}
}, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock])
return (
<div className="bg-[#080d16] text-[#c5d5e8] font-sans flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
// ── TradingView 레이아웃 파생 데이터 ─────────────────────────────
const displayCfgQ = useStrategyDisplay()
const displayCfg = displayCfgQ.data ?? null
const isKr = !!selectedStock && !isUsStock(selectedStock)
const intradayQ = useIntradayCandles(selectedStock, isKr, isKr && tf === '5m' && isMarketOpen)
const intradayAvailable = !!intradayQ.data && intradayQ.data.length > 0
const effTf: Timeframe = tf === '5m' && !intradayAvailable ? chartPeriod : tf
<main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-3 overflow-hidden">
<div className="flex-shrink-0">
<MarketIndexBar />
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-0 bg-[#0c1522] border border-[#152035] rounded-sm overflow-hidden">
{([['ALL', '전체'], ['KR', '국내'], ['US', '미국']] as const).map(([value, label]) => (
<button
key={value}
onClick={() => setMarketFilter(value as MarketFilter)}
className={`px-3 py-1.5 text-[10px] font-mono transition-colors ${
marketFilter === value
? 'bg-[#1a3050] text-[#5b9aff]'
: 'text-[#4a6580] hover:text-[#8faabf]'
}`}
>
{label}
</button>
))}
</div>
</div>
{tradeAccountNotice && (
<AccountSetupNotice message={tradeAccountNotice} className="mb-2" />
)}
const bars = useMemo(() => {
if (effTf === '5m') return intradayQ.data ? barsFromIntraday(intradayQ.data) : []
return chartData && chartStock === selectedStock ? barsFromDaily(chartData) : []
}, [effTf, intradayQ.data, chartData, chartStock, selectedStock])
const overlayQ = usePositionOverlay(selectedStock || undefined, !!selectedStock)
const overlayPositions = overlayQ.data ? overlayQ.data.positions.filter((p) => p.stock_code === selectedStock) : null
const { data: chartTrades } = useQuery({
queryKey: ['tv-chart-trades', selectedStock, tenantMode],
queryFn: async () => {
try {
const params = { stock_code: selectedStock, days: 180, limit: 500 }
const res = tenantMode === 'v2' ? await getTradesV2(params) : await api.get('/trades', { params })
return toTradeMarks(res.data)
} catch {
return []
}
},
enabled: !!selectedStock && tenantMode !== undefined,
staleTime: 15000,
refetchInterval: isMarketOpen ? 30000 : false,
retry: false,
})
const tradeMarks = useMemo(() => chartTrades ?? [], [chartTrades])
const headerPrice = livePrice ?? (bars.length ? bars[bars.length - 1].close : undefined)
const headerChg = useMemo(() => {
if (effTf !== 'D' || bars.length === 0 || !headerPrice) return null
const last = bars[bars.length - 1]
const prev = last.time === todayKst() ? (bars.length > 1 ? bars[bars.length - 2].close : null) : last.close
return prev ? ((headerPrice - prev) / prev) * 100 : null
}, [effTf, bars, headerPrice])
const { data: dtWlForName } = useQuery<{ watchlist: { code: string; name: string }[] } | null>({
queryKey: ['tv-dt-watchlist'],
queryFn: async () => { try { return (await api.get('/daytrading/watchlist')).data } catch { return null } },
refetchInterval: 60000, staleTime: 30000, retry: false,
})
const headerName = useMemo(() => {
const cands = [
overlayPositions?.find((p) => p.stock_name)?.stock_name,
portfolio?.find((s: Position) => s.stock_code === selectedStock)?.stock_name,
ruleBasedRecommendations?.analyzed_stock_list?.find((s: { stock_code: string }) => s.stock_code === selectedStock)?.stock_name,
dtWlForName?.watchlist?.find((w) => w.code === selectedStock)?.name,
stockName,
]
return (cands.find((n) => !!n && n !== selectedStock) as string | undefined) ?? ''
}, [overlayPositions, portfolio, ruleBasedRecommendations, dtWlForName, stockName, selectedStock])
const accountBlocked = accountSetupRequired || accountStatus.required
const holdNode = (
<div className="flex flex-col h-full min-h-0">
<div className="flex gap-1 px-2 py-1 flex-shrink-0">
{([['ALL', '전체'], ['KR', '국내'], ['US', '미국']] as const).map(([value, label]) => (
<button key={value} onClick={() => setMarketFilter(value as MarketFilter)}
className={`px-2 py-0.5 text-xs rounded border ${marketFilter === value ? 'border-tv-accent text-tv-accent' : 'border-tv-border text-tv-muted'}`}>{label}</button>
))}
</div>
<div className="flex-1 min-h-0 [&>div]:!h-full">
<PortfolioPanel
portfolio={filteredPortfolio}
livePrices={livePrices}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
onSell={handleSellGuarded}
sellDisabled={!tradeActionsReady}
accountSetupRequired={accountBlocked}
accountSetupMessage={accountSetupMessage || accountStatus.message}
exchangeRate={portfolioSummary?.exchange_rate_unavailable === true ? 0 : (Number(portfolioSummary?.exchange_rate) || 0)}
/>
</div>
</div>
)
const railBtn = 'w-9 py-1 text-[11px] rounded border text-center'
const railOn = 'border-tv-accent text-tv-accent bg-tv-accent/10'
const railOff = 'border-tv-border text-tv-muted hover:text-tv-text'
return (
<div className="bg-tv-bg text-tv-text font-sans flex flex-col lg:h-[calc(100vh-4rem)] lg:overflow-hidden">
<div className="flex-shrink-0 px-2 pt-1">
<MarketIndexBar />
{tradeAccountNotice && <AccountSetupNotice message={tradeAccountNotice} className="mb-2" />}
</div>
<SymbolBar
stockCode={selectedStock}
stockName={headerName}
price={headerPrice}
changePct={headerChg}
timeframe={effTf}
onTimeframe={handleTimeframe}
intradayAvailable={intradayAvailable}
toggles={toggles}
onToggle={handleToggle}
wsConnected={wsConnected}
totalAssets={liveSummary?.total_assets}
todayPlRate={liveSummary?.today_profit_loss_rate}
summaryOpen={summaryOpen}
onToggleSummary={() => setSummaryOpen((v) => !v)}
displayCfg={displayCfg}
/>
{summaryOpen && (
<div className="flex-shrink-0 px-2 py-1 max-h-[40vh] overflow-y-auto border-b border-tv-border">
<PortfolioSummary
summary={liveSummary}
error={portfolioSummaryError?.message ?? null}
todayTradeStats={todayTradeStats}
accountSetupRequired={accountSetupRequired || accountStatus.required}
accountSetupRequired={accountBlocked}
accountSetupMessage={accountSetupMessage || accountStatus.message}
/>
</div>
)}
<div className="grid grid-cols-12 gap-3 flex-1 min-h-0">
{/* [Left Column] 보유 종목 */}
<div className="col-span-2 flex flex-col gap-3 h-full overflow-hidden">
<PortfolioPanel
portfolio={filteredPortfolio}
livePrices={livePrices}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
onSell={handleSellGuarded}
sellDisabled={!tradeActionsReady}
accountSetupRequired={accountSetupRequired || accountStatus.required}
accountSetupMessage={accountSetupMessage || accountStatus.message}
exchangeRate={portfolioSummary?.exchange_rate_unavailable === true ? 0 : (Number(portfolioSummary?.exchange_rate) || 0)}
/>
</div>
{/* [Center Column] 차트 & 트레이딩 정보 */}
<div className="col-span-7 flex flex-col h-full gap-3 overflow-hidden">
{/* 차트 섹션 */}
<ChartPanel
chartData={chartData}
chartLoading={chartLoading}
selectedStock={selectedStock}
chartPeriod={chartPeriod}
onPeriodChange={setChartPeriod}
stockName={stockName}
livePrice={chartLivePrice}
liveVolume={chartLiveVolume}
emptyReason={chartEmptyReason}
/>
{/* 하단: AI 투자 분석 & 호가창
- AiAnalysisPanel: selectedStock 변경 시에만 리렌더
- OrderbookPanel: 10초마다 독립 리렌더 (다른 패널 영향 없음) */}
<div className="flex-1 grid grid-cols-2 gap-3 min-h-0">
<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />
<OrderbookPanel orderbook={activeOrderbook} />
</div>
</div>
{/* [Right Column] 분석 종목 & 매매일지 */}
<div className="col-span-3 flex flex-col gap-3 h-full overflow-hidden">
<RecommendationPanel
recommendations={ruleBasedRecommendations}
isError={isRecommendationsError}
onRefetch={stableRefetchRecommendations}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
heldStockCodes={heldStockCodes}
pendingBuyStockCodes={pendingBuyStockCodes}
livePrices={livePrices}
onBuy={handleBuyGuarded}
buyDisabled={!tradeActionsReady}
/>
<JournalPanel
journals={journals}
tradeHistory={tradeHistory}
onResetPending={resetPendingTrades}
resetDisabled={!tradeActionsReady}
/>
</div>
<div className="flex-1 min-h-0 flex flex-col lg:grid lg:grid-cols-[44px_minmax(0,1fr)_320px] lg:grid-rows-[minmax(0,1fr)_270px]">
{/* 도구 레일 */}
<div className="hidden lg:flex lg:row-span-2 flex-col items-center gap-1 py-2 bg-tv-panel border-r border-tv-border">
<button className={`${railBtn} ${railOff}`} title="화면 맞춤" onClick={() => setFitSignal((n) => n + 1)}>맞춤</button>
{(['ma', 'bb', 'vol', 'rsi', 'macd'] as const).map((k) => (
<button key={k} className={`${railBtn} ${toggles[k] ? railOn : railOff}`} onClick={() => handleToggle(k)}>{k.toUpperCase()}</button>
))}
</div>
</main>
<div className="h-[460px] lg:h-auto min-h-0 min-w-0">
<TvChart
bars={bars}
timeframe={effTf}
loading={effTf === '5m' ? intradayQ.isLoading : chartLoading}
emptyReason={chartEmptyReason}
livePrice={chartLivePrice}
overlays={overlayPositions}
trades={tradeMarks}
rsiCfg={displayCfg?.rsi ?? null}
toggles={toggles}
fitSignal={fitSignal}
isUs={!!selectedStock && isUsStock(selectedStock)}
/>
</div>
<div className="h-[460px] lg:h-auto min-h-0 min-w-0 border-t lg:border-t-0 border-tv-border">
<RightPanel
portfolio={portfolio}
recommendations={ruleBasedRecommendations}
selectedStock={selectedStock}
onSelect={handleSelectStock}
livePrices={livePrices}
selectedOverlay={overlayPositions}
recoNode={
<RecommendationPanel
recommendations={ruleBasedRecommendations}
isError={isRecommendationsError}
onRefetch={stableRefetchRecommendations}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
heldStockCodes={heldStockCodes}
pendingBuyStockCodes={pendingBuyStockCodes}
livePrices={livePrices}
onBuy={handleBuyGuarded}
buyDisabled={!tradeActionsReady}
/>
}
bookNode={<OrderbookPanel orderbook={activeOrderbook} />}
aiNode={<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />}
/>
</div>
<div className="h-[380px] lg:h-auto min-h-0 min-w-0 lg:col-span-2">
<BottomTabs
holdNode={holdNode}
journalNode={
<JournalPanel
journals={journals}
tradeHistory={tradeHistory}
onResetPending={resetPendingTrades}
resetDisabled={!tradeActionsReady}
/>
}
trades={tradeHistory}
onSelect={handleSelectStock}
cfg={displayCfg}
totalAssets={liveSummary?.total_assets}
/>
</div>
</div>
</div>
);
)
}
export default Dashboard;

View File

@@ -1,71 +0,0 @@
/**
* Type declarations for lightweight-charts library
*/
export interface BarData {
time: string | number;
open: number;
high: number;
low: number;
close: number;
}
export interface CandlestickData {
time: string | number;
open: number;
high: number;
low: number;
close: number;
}
export interface LineData {
time: string | number;
value: number;
}
export interface HistogramData {
time: string | number;
value: number;
color?: string;
}
export interface AreaData {
time: string | number;
value: number;
}
export enum CrosshairMode {
Normal = 0,
Magnet = 1,
Hidden = 2,
}
export interface ISeriesApi<T extends string = 'Candlestick'> {
setData(data: CandlestickData[] | LineData[] | HistogramData[] | BarData[]): void;
update(data: CandlestickData | LineData | HistogramData | BarData): void;
}
export interface IPriceScaleApi {
applyOptions(options: any): void;
}
export interface ITimeScaleApi {
fitContent(): void;
getVisibleRange(): { from: number; to: number } | null;
setVisibleRange(range: { from: number; to: number }): void;
subscribeVisibleTimeRangeChange(callback: (range: { from: number; to: number } | null) => void): void;
}
export interface IChartApi {
addCandlestickSeries(options?: any): ISeriesApi<'Candlestick'>;
addLineSeries(options?: any): ISeriesApi<'Line'>;
addHistogramSeries(options?: any): ISeriesApi<'Histogram'>;
addBarSeries(options?: any): ISeriesApi<'Bar'>;
addAreaSeries(options?: any): ISeriesApi<'Area'>;
priceScale(position: string): IPriceScaleApi;
timeScale(): ITimeScaleApi;
applyOptions(options: any): void;
remove(): void;
}
export function createChart(container: HTMLElement, options?: any): IChartApi;

View File

@@ -13,6 +13,19 @@ export default {
danger: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6',
// TradingView 스타일 디자인 토큰 (값은 src/index.css 의 :root / [data-theme=light])
tv: {
bg: 'rgb(var(--tv-bg) / <alpha-value>)',
panel: 'rgb(var(--tv-panel) / <alpha-value>)',
raised: 'rgb(var(--tv-raised) / <alpha-value>)',
border: 'rgb(var(--tv-border) / <alpha-value>)',
text: 'rgb(var(--tv-text) / <alpha-value>)',
muted: 'rgb(var(--tv-muted) / <alpha-value>)',
up: 'rgb(var(--tv-up) / <alpha-value>)',
down: 'rgb(var(--tv-down) / <alpha-value>)',
warn: 'rgb(var(--tv-warn) / <alpha-value>)',
accent: 'rgb(var(--tv-accent) / <alpha-value>)',
},
},
},
},