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