수정
This commit is contained in:
@@ -231,6 +231,7 @@ class Settings(BaseSettings):
|
|||||||
DAY_TRADE_ORB_MARGIN_CHECK: float = 0.03 # ORB 목표가 최소 마진 (3%)
|
DAY_TRADE_ORB_MARGIN_CHECK: float = 0.03 # ORB 목표가 최소 마진 (3%)
|
||||||
DAY_TRADE_TRAILING_DEFAULT: float = 0.02 # 트레일링 기본값 -2% (ATR 없을 때)
|
DAY_TRADE_TRAILING_DEFAULT: float = 0.02 # 트레일링 기본값 -2% (ATR 없을 때)
|
||||||
DAY_TRADE_TRAILING_MIN_HOLD_SEC: int = 300 # 트레일링 활성화 최소 보유 시간 (초, 5분)
|
DAY_TRADE_TRAILING_MIN_HOLD_SEC: int = 300 # 트레일링 활성화 최소 보유 시간 (초, 5분)
|
||||||
|
DAY_TRADE_MIN_STOP_LOSS_PCT: float = 0.01 # 최소 손절폭 -1.0% (너무 타이트한 ATR 손절 방지)
|
||||||
|
|
||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
# 코스닥 스캐너 (섹션 7.1)
|
# 코스닥 스캐너 (섹션 7.1)
|
||||||
|
|||||||
@@ -349,6 +349,9 @@ def check_orb_signal(
|
|||||||
orb_stop = max(orb_stop, atr_floor)
|
orb_stop = max(orb_stop, atr_floor)
|
||||||
# 상한 클램프: 일일 손실 한도 대비 종목당 최대 한도
|
# 상한 클램프: 일일 손실 한도 대비 종목당 최대 한도
|
||||||
stop_loss = max(orb_stop, hard_stop)
|
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
|
risk = current_price - stop_loss
|
||||||
if risk <= 0:
|
if risk <= 0:
|
||||||
return None # 손절폭이 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)
|
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)
|
hard_stop_vwap = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
|
||||||
stop_loss = max(raw_stop, hard_stop_vwap, 1.0) # 타이트한 쪽 = 진입가에 가까운 = 높은 가격, 최소 1원 보장
|
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
|
target = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03
|
||||||
|
|
||||||
return DayTradeSignal(
|
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)
|
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)
|
hard_stop_rsi = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
|
||||||
stop_loss = max(raw_stop, hard_stop_rsi, 1.0) # 타이트한 쪽 = 진입가에 가까운 = 높은 가격, 최소 1원 보장
|
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 확보를 위해 목표 상향
|
# 최소 1.5R 보장: bb_middle이 너무 가까우면 R:R 확보를 위해 목표 상향
|
||||||
risk = current_price - stop_loss
|
risk = current_price - stop_loss
|
||||||
target = max(bb_middle, current_price + risk * 1.5) # 1.5R 보장
|
target = max(bb_middle, current_price + risk * 1.5) # 1.5R 보장
|
||||||
|
|||||||
@@ -36,8 +36,19 @@ def _to_float(val) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
def _parse_strategy(entry_reason: Optional[str]) -> str:
|
def _parse_strategy(entry_reason: Optional[str], strategy_type: Optional[str] = None) -> str:
|
||||||
"""entry_reason 문자열에서 전략 이름 추출."""
|
"""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:
|
if not entry_reason:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
reason = entry_reason.lower()
|
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] = {}
|
by_strategy: Dict[str, list] = {}
|
||||||
for t in trades:
|
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))
|
by_strategy.setdefault(strat, []).append(_to_float(t.profit_loss_rate))
|
||||||
|
|
||||||
strategy_stats = {}
|
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)
|
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])
|
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)
|
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)
|
pl_amount = _to_float(trade.net_profit_loss)
|
||||||
exec_price = _to_float(trade.executed_price)
|
exec_price = _to_float(trade.executed_price)
|
||||||
exec_qty = trade.executed_quantity or trade.order_quantity or 0
|
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)
|
exit_type = _parse_exit_type(trade.exit_reason)
|
||||||
|
|
||||||
# 결과 평가
|
# 결과 평가
|
||||||
@@ -729,7 +741,7 @@ async def create_journal_record(
|
|||||||
) -> TradeJournal:
|
) -> TradeJournal:
|
||||||
"""TradeJournal 레코드를 DB에 저장 후 반환."""
|
"""TradeJournal 레코드를 DB에 저장 후 반환."""
|
||||||
pl_rate = _to_float(trade.profit_loss_rate)
|
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)
|
exit_type = _parse_exit_type(trade.exit_reason)
|
||||||
|
|
||||||
mood = "positive" if pl_rate > 0 else ("neutral" if pl_rate > -3.0 else "negative")
|
mood = "positive" if pl_rate > 0 else ("neutral" if pl_rate > -3.0 else "negative")
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ export default function Analysis() {
|
|||||||
{perf.worst_stocks.map((s) => (
|
{perf.worst_stocks.map((s) => (
|
||||||
<div key={s.stock_code} className="flex justify-between text-sm">
|
<div key={s.stock_code} className="flex justify-between text-sm">
|
||||||
<span className="text-gray-200">{s.stock_code}</span>
|
<span className="text-gray-200">{s.stock_code}</span>
|
||||||
<span className="text-red-400">{fmtKRW(s.net_pnl)}</span>
|
<span className={s.net_pnl < 0 ? "text-red-400" : "text-green-400"}>{fmtKRW(s.net_pnl)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -477,7 +477,7 @@ export default function Analysis() {
|
|||||||
{perf.best_stocks.map((s) => (
|
{perf.best_stocks.map((s) => (
|
||||||
<div key={s.stock_code} className="flex justify-between text-sm">
|
<div key={s.stock_code} className="flex justify-between text-sm">
|
||||||
<span className="text-gray-200">{s.stock_code}</span>
|
<span className="text-gray-200">{s.stock_code}</span>
|
||||||
<span className="text-green-400">{fmtKRW(s.net_pnl)}</span>
|
<span className={s.net_pnl > 0 ? "text-green-400" : "text-red-400"}>{fmtKRW(s.net_pnl)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import HtsPage from '../components/HtsPage'
|
|||||||
import api from '../lib/api'
|
import api from '../lib/api'
|
||||||
import { useState, Fragment } from 'react'
|
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 {
|
interface Trade {
|
||||||
id: string
|
id: string
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -76,13 +85,13 @@ function buildPairs(trades: Trade[]): TradePair[] {
|
|||||||
// 매수 이후 가장 가까운 매도 찾기
|
// 매수 이후 가장 가까운 매도 찾기
|
||||||
const sell = sells.find(s =>
|
const sell = sells.find(s =>
|
||||||
!usedSells.has(s.id) &&
|
!usedSells.has(s.id) &&
|
||||||
new Date(s.created_at) >= new Date(buy.created_at)
|
toKST(s.created_at) >= toKST(buy.created_at)
|
||||||
) ?? null
|
) ?? null
|
||||||
|
|
||||||
if (sell) usedSells.add(sell.id)
|
if (sell) usedSells.add(sell.id)
|
||||||
|
|
||||||
const holdMinutes = sell
|
const holdMinutes = sell
|
||||||
? differenceInMinutes(new Date(sell.created_at), new Date(buy.created_at))
|
? differenceInMinutes(toKST(sell.created_at), toKST(buy.created_at))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const pl = sell
|
const pl = sell
|
||||||
@@ -96,7 +105,7 @@ function buildPairs(trades: Trade[]): TradePair[] {
|
|||||||
}
|
}
|
||||||
// 최신 매수 기준 내림차순
|
// 최신 매수 기준 내림차순
|
||||||
return pairs.sort((a, b) =>
|
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[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 px-3 text-xs text-blue-300 whitespace-nowrap">
|
<td className="py-2.5 px-3 text-xs text-blue-300 whitespace-nowrap">
|
||||||
{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 })}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 px-3 text-right text-white tabular-nums">
|
<td className="py-2.5 px-3 text-right text-white tabular-nums">
|
||||||
{fmtPrice(p.buy.executed_price ?? p.buy.order_price)}
|
{fmtPrice(p.buy.executed_price ?? p.buy.order_price)}
|
||||||
@@ -146,7 +155,7 @@ function PairView({ pairs }: { pairs: TradePair[] }) {
|
|||||||
<td className="py-2.5 px-3 text-xs whitespace-nowrap">
|
<td className="py-2.5 px-3 text-xs whitespace-nowrap">
|
||||||
{isOpen
|
{isOpen
|
||||||
? <span className="text-yellow-400 font-medium">미청산</span>
|
? <span className="text-yellow-400 font-medium">미청산</span>
|
||||||
: <span className="text-red-300">{format(new Date(p.sell!.created_at), 'MM/dd HH:mm', { locale: ko })}</span>
|
: <span className="text-red-300">{format(toKST(p.sell!.created_at), 'MM/dd HH:mm', { locale: ko })}</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 px-3 text-right tabular-nums text-slate-300">
|
<td className="py-2.5 px-3 text-right tabular-nums text-slate-300">
|
||||||
|
|||||||
Reference in New Issue
Block a user