From ab1b1a4cb6790d627c002b69380ed8512b9b77d6 Mon Sep 17 00:00:00 2001 From: shuim Date: Mon, 6 Apr 2026 13:16:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/core/config.py | 1 + backend/app/services/day_trading_strategy.py | 9 +++++++ backend/app/services/trade_analyzer.py | 26 ++++++++++++++------ frontend/src/pages/Analysis.tsx | 4 +-- frontend/src/pages/TradeHistory.tsx | 19 ++++++++++---- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 945d983..f6575ad 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -231,6 +231,7 @@ class Settings(BaseSettings): DAY_TRADE_ORB_MARGIN_CHECK: float = 0.03 # ORB 목표가 최소 마진 (3%) DAY_TRADE_TRAILING_DEFAULT: float = 0.02 # 트레일링 기본값 -2% (ATR 없을 때) DAY_TRADE_TRAILING_MIN_HOLD_SEC: int = 300 # 트레일링 활성화 최소 보유 시간 (초, 5분) + DAY_TRADE_MIN_STOP_LOSS_PCT: float = 0.01 # 최소 손절폭 -1.0% (너무 타이트한 ATR 손절 방지) # ═════════════════════════════════════════════════════════════════════════════ # 코스닥 스캐너 (섹션 7.1) diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index 52a852a..237ec59 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -349,6 +349,9 @@ def check_orb_signal( orb_stop = max(orb_stop, atr_floor) # 상한 클램프: 일일 손실 한도 대비 종목당 최대 한도 stop_loss = max(orb_stop, hard_stop) + # 최소 손절폭 보장: ATR/ORB가 너무 좁으면 노이즈에 즉시 손절 → 최소 -1.0% 확보 + min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT) + stop_loss = min(stop_loss, min_stop) risk = current_price - stop_loss if risk <= 0: return None # 손절폭이 0 이하면 진입 무의미 @@ -412,6 +415,9 @@ def check_vwap_bounce_signal( raw_stop = (current_price - settings.DAY_TRADE_VWAP_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT) hard_stop_vwap = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT) stop_loss = max(raw_stop, hard_stop_vwap, 1.0) # 타이트한 쪽 = 진입가에 가까운 = 높은 가격, 최소 1원 보장 + # 최소 손절폭 보장: ATR이 너무 작으면 노이즈에 즉시 손절 → 최소 -1.0% 확보 + min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT) + stop_loss = min(stop_loss, min_stop) target = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03 return DayTradeSignal( @@ -471,6 +477,9 @@ def check_rsi_bb_signal( raw_stop = (current_price - settings.DAY_TRADE_RSI_BB_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT) hard_stop_rsi = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT) stop_loss = max(raw_stop, hard_stop_rsi, 1.0) # 타이트한 쪽 = 진입가에 가까운 = 높은 가격, 최소 1원 보장 + # 최소 손절폭 보장: ATR이 너무 작으면 노이즈에 즉시 손절 → 최소 -1.0% 확보 + min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT) + stop_loss = min(stop_loss, min_stop) # 최소 1.5R 보장: bb_middle이 너무 가까우면 R:R 확보를 위해 목표 상향 risk = current_price - stop_loss target = max(bb_middle, current_price + risk * 1.5) # 1.5R 보장 diff --git a/backend/app/services/trade_analyzer.py b/backend/app/services/trade_analyzer.py index 63c4d33..c8b3e04 100644 --- a/backend/app/services/trade_analyzer.py +++ b/backend/app/services/trade_analyzer.py @@ -36,8 +36,19 @@ def _to_float(val) -> float: return 0.0 -def _parse_strategy(entry_reason: Optional[str]) -> str: - """entry_reason 문자열에서 전략 이름 추출.""" +def _parse_strategy(entry_reason: Optional[str], strategy_type: Optional[str] = None) -> str: + """strategy_type 컬럼 우선, 없으면 entry_reason 문자열에서 전략 이름 추출.""" + # strategy_type 컬럼이 있으면 우선 사용 + if strategy_type and strategy_type.strip(): + st = strategy_type.strip().lower() + _type_map = { + "swing": "mean_reversion", + "daytrading": "day_trade", + "kosdaq_scan": "kosdaq_scanner", + "short_term": "short_term", + } + return _type_map.get(st, st) + if not entry_reason: return "unknown" reason = entry_reason.lower() @@ -439,7 +450,7 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any # ── 전략별 성과 ────────────────────────────────────────────────────── by_strategy: Dict[str, list] = {} for t in trades: - strat = _parse_strategy(t.entry_reason) + strat = _parse_strategy(t.entry_reason, getattr(t, "strategy_type", None)) by_strategy.setdefault(strat, []).append(_to_float(t.profit_loss_rate)) strategy_stats = {} @@ -491,8 +502,9 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any stock_pnl[code] = stock_pnl.get(code, 0.0) + _to_float(t.net_profit_loss) sorted_stocks = sorted(stock_pnl.items(), key=lambda x: x[1]) - worst_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[:5]] - best_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[-5:][::-1]] + # 손실 종목만 (음수), 수익 종목만 (양수) 필터링하여 색상 혼동 방지 + worst_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks if p < 0][:5] + best_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[::-1] if p > 0][:5] # ── 리스크 조정 수익률 지표 ──────────────────────────────────────────── risk_metrics = _calc_risk_adjusted_metrics(trades) @@ -660,7 +672,7 @@ def generate_journal_for_trade(trade: TradeHistory, perf_context: Optional[Dict] pl_amount = _to_float(trade.net_profit_loss) exec_price = _to_float(trade.executed_price) exec_qty = trade.executed_quantity or trade.order_quantity or 0 - strategy = _parse_strategy(trade.entry_reason) + strategy = _parse_strategy(trade.entry_reason, getattr(trade, "strategy_type", None)) exit_type = _parse_exit_type(trade.exit_reason) # 결과 평가 @@ -729,7 +741,7 @@ async def create_journal_record( ) -> TradeJournal: """TradeJournal 레코드를 DB에 저장 후 반환.""" pl_rate = _to_float(trade.profit_loss_rate) - strategy = _parse_strategy(trade.entry_reason) + strategy = _parse_strategy(trade.entry_reason, getattr(trade, "strategy_type", None)) exit_type = _parse_exit_type(trade.exit_reason) mood = "positive" if pl_rate > 0 else ("neutral" if pl_rate > -3.0 else "negative") diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 257966c..d5789bf 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -466,7 +466,7 @@ export default function Analysis() { {perf.worst_stocks.map((s) => (
{s.stock_code} - {fmtKRW(s.net_pnl)} + {fmtKRW(s.net_pnl)}
))} @@ -477,7 +477,7 @@ export default function Analysis() { {perf.best_stocks.map((s) => (
{s.stock_code} - {fmtKRW(s.net_pnl)} + 0 ? "text-green-400" : "text-red-400"}>{fmtKRW(s.net_pnl)}
))} diff --git a/frontend/src/pages/TradeHistory.tsx b/frontend/src/pages/TradeHistory.tsx index 9a1bae0..9af934f 100644 --- a/frontend/src/pages/TradeHistory.tsx +++ b/frontend/src/pages/TradeHistory.tsx @@ -6,6 +6,15 @@ import HtsPage from '../components/HtsPage' import api from '../lib/api' import { useState, Fragment } from 'react' +/** DB에서 KST로 저장된 datetime 문자열을 Date 객체로 변환 + * trade_executor가 _get_kst_now()로 KST naive datetime을 저장하므로 +09:00 보정 */ +const toKST = (dtStr: string) => { + if (!dtStr) return new Date() + const s = dtStr.trim() + if (s.includes('+') || s.endsWith('Z')) return new Date(s) + return new Date(s + '+09:00') +} + interface Trade { id: string created_at: string @@ -76,13 +85,13 @@ function buildPairs(trades: Trade[]): TradePair[] { // 매수 이후 가장 가까운 매도 찾기 const sell = sells.find(s => !usedSells.has(s.id) && - new Date(s.created_at) >= new Date(buy.created_at) + toKST(s.created_at) >= toKST(buy.created_at) ) ?? null if (sell) usedSells.add(sell.id) const holdMinutes = sell - ? differenceInMinutes(new Date(sell.created_at), new Date(buy.created_at)) + ? differenceInMinutes(toKST(sell.created_at), toKST(buy.created_at)) : null const pl = sell @@ -96,7 +105,7 @@ function buildPairs(trades: Trade[]): TradePair[] { } // 최신 매수 기준 내림차순 return pairs.sort((a, b) => - new Date(b.buy.created_at).getTime() - new Date(a.buy.created_at).getTime() + toKST(b.buy.created_at).getTime() - toKST(a.buy.created_at).getTime() ) } @@ -138,7 +147,7 @@ function PairView({ pairs }: { pairs: TradePair[] }) { - {format(new Date(p.buy.created_at), 'MM/dd HH:mm', { locale: ko })} + {format(toKST(p.buy.created_at), 'MM/dd HH:mm', { locale: ko })} {fmtPrice(p.buy.executed_price ?? p.buy.order_price)} @@ -146,7 +155,7 @@ function PairView({ pairs }: { pairs: TradePair[] }) { {isOpen ? 미청산 - : {format(new Date(p.sell!.created_at), 'MM/dd HH:mm', { locale: ko })} + : {format(toKST(p.sell!.created_at), 'MM/dd HH:mm', { locale: ko })} }