수정본
Some checks failed
CI/CD Pipeline / test-backend (push) Has been cancelled
CI/CD Pipeline / test-frontend (push) Has been cancelled
CI/CD Pipeline / build-and-push (push) Has been cancelled

This commit is contained in:
2026-03-27 14:47:49 +09:00
parent 844ee8944f
commit c818810667
25 changed files with 2708 additions and 266 deletions

View File

@@ -5,6 +5,7 @@ us_stock_strategy의 매수/매도 신호를 과거 데이터에 적용하여
"""
import logging
import math
import random
from datetime import date, datetime, timedelta
from typing import Dict, List, Any, Optional
from dataclasses import dataclass, asdict
@@ -25,6 +26,27 @@ logger = logging.getLogger(__name__)
US_COMMISSION_RATE = 0.001 # 해외 주식 수수료 약 0.1% (매수+매도 각각)
RISK_FREE_RATE = 0.04 # 무위험 수익률 4% (연간, USD 기준)
TRADING_DAYS_PER_YEAR = 252
US_SLIPPAGE_PCT = 0.0005 # +/-0.05% (미국 주식은 유동성이 높아 슬리피지가 낮음)
def _apply_us_buy_slippage(price: float, seed: Optional[int] = None) -> float:
"""매수 시 슬리피지: 시장가 주문 시 약간 높은 가격에 체결
미국 주식은 유동성이 높아 국내(0.1%) 대비 낮은 0~0.05% 적용.
"""
rng = random.Random(seed)
slip = rng.uniform(0, US_SLIPPAGE_PCT)
return price * (1 + slip)
def _apply_us_sell_slippage(price: float, seed: Optional[int] = None) -> float:
"""매도 시 슬리피지: 시장가 주문 시 약간 낮은 가격에 체결
미국 주식은 유동성이 높아 국내(0.1%) 대비 낮은 0~0.05% 적용.
"""
rng = random.Random(seed)
slip = rng.uniform(0, US_SLIPPAGE_PCT)
return price * (1 - slip)
@dataclass
@@ -229,6 +251,10 @@ async def run_us_backtest(
indicators = _calculate_indicators(daily_data)
# 3) 시뮬레이션 실행
#
# [Look-ahead bias 수정]
# T일 close로 신호 생성 -> T+1일 open + 슬리피지로 체결.
# 마지막 봉 신호는 다음 봉이 없으므로 무시합니다.
cash = initial_capital_usd
position_qty = 0
position_avg_price = 0.0
@@ -236,96 +262,114 @@ async def run_us_backtest(
trades: List[Dict] = []
equity_curve: List[Dict] = []
# 전일 발생한 신호를 저장 (T일 close 신호 -> T+1일 open 체결)
pending_buy_signal: Optional[Dict] = None
pending_sell_signal: Optional[str] = None
for i in range(20, len(daily_data)): # 지표 계산에 최소 20일 필요
bar = daily_data[i]
ind = indicators[i]
close = bar["close"]
bar_date = bar["date"]
bar_open = bar["open"]
# 포지션 보유 시: 매도 조건 체크
# -- 1단계: 전일 신호를 오늘 open + 슬리피지로 체결 --
if pending_sell_signal is not None and position_qty > 0:
exec_price = _apply_us_sell_slippage(bar_open)
amount = position_qty * exec_price
commission = amount * US_COMMISSION_RATE
pnl = (exec_price - position_avg_price) * position_qty - commission
pnl_pct = ((exec_price - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0
trade = {
"date": bar_date,
"action": "SELL",
"ticker": ticker,
"price": round(exec_price, 4),
"quantity": position_qty,
"amount": round(amount, 2),
"commission": round(commission, 2),
"reason": pending_sell_signal,
"pnl": round(pnl, 2),
"pnl_pct": round(pnl_pct, 2),
}
trades.append(trade)
cash += amount - commission
position_qty = 0
position_avg_price = 0.0
position_high = 0.0
pending_sell_signal = None
if pending_buy_signal is not None and position_qty == 0:
exec_price = _apply_us_buy_slippage(bar_open)
buy_amount = min(cash * 0.95, cash)
if buy_amount > 10:
quantity = int(buy_amount / exec_price)
if quantity > 0:
cost = quantity * exec_price
commission = cost * US_COMMISSION_RATE
total_cost = cost + commission
if total_cost <= cash:
cash -= total_cost
position_qty = quantity
position_avg_price = exec_price
position_high = exec_price
trade = {
"date": bar_date,
"action": "BUY",
"ticker": ticker,
"price": round(exec_price, 4),
"quantity": quantity,
"amount": round(cost, 2),
"commission": round(commission, 2),
"reason": pending_buy_signal.get("reason", "buy_signal"),
"pnl": 0.0,
"pnl_pct": 0.0,
}
trades.append(trade)
pending_buy_signal = None
# -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) --
is_last_bar = (i == len(daily_data) - 1)
# 트레일링 스탑용 최고가 갱신 (close 기준)
if position_qty > 0:
# 트레일링 스탑용 최고가 갱신
if close > position_high:
position_high = close
sell_signal = _check_backtest_sell(
close=close,
avg_price=position_avg_price,
high_since_entry=position_high,
rsi=ind.get("rsi", 50),
bb_middle=ind.get("bb_middle", close),
bb_upper=ind.get("bb_upper", close),
)
if not is_last_bar:
# 매도 신호 체크
if position_qty > 0:
sell_signal = _check_backtest_sell(
close=close,
avg_price=position_avg_price,
high_since_entry=position_high,
rsi=ind.get("rsi", 50),
bb_middle=ind.get("bb_middle", close),
bb_upper=ind.get("bb_upper", close),
)
if sell_signal:
pending_sell_signal = sell_signal
if sell_signal:
amount = position_qty * close
commission = amount * US_COMMISSION_RATE
pnl = (close - position_avg_price) * position_qty - commission
pnl_pct = ((close - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0
# 매수 신호 체크
if position_qty == 0 and pending_sell_signal is None:
prev_rsi = indicators[i - 3].get("rsi") if i >= 3 else None
trade = {
"date": bar_date,
"action": "SELL",
"ticker": ticker,
"price": close,
"quantity": position_qty,
"amount": round(amount, 2),
"commission": round(commission, 2),
"reason": sell_signal,
"pnl": round(pnl, 2),
"pnl_pct": round(pnl_pct, 2),
}
trades.append(trade)
cash += amount - commission
position_qty = 0
position_avg_price = 0.0
position_high = 0.0
# 포지션 없을 때: 매수 조건 체크
if position_qty == 0:
prev_rsi = indicators[i - 3].get("rsi") if i >= 3 else None
buy_signal = check_us_buy_signal(
rsi=ind.get("rsi", 50),
close=close,
bb_upper=ind.get("bb_upper", close),
bb_middle=ind.get("bb_middle", close),
bb_lower=ind.get("bb_lower", close),
avg_volume_20d=ind.get("avg_volume_20d", 0),
current_volume=bar.get("volume", 0),
current_price_usd=close,
rsi_prev=prev_rsi,
)
if buy_signal:
# 전체 자본의 최대 95% 투입 (여유 현금 확보)
buy_amount = min(cash * 0.95, cash)
if buy_amount > 10: # 최소 $10 이상
quantity = int(buy_amount / close)
if quantity > 0:
cost = quantity * close
commission = cost * US_COMMISSION_RATE
total_cost = cost + commission
if total_cost <= cash:
cash -= total_cost
position_qty = quantity
position_avg_price = close
position_high = close
trade = {
"date": bar_date,
"action": "BUY",
"ticker": ticker,
"price": close,
"quantity": quantity,
"amount": round(cost, 2),
"commission": round(commission, 2),
"reason": buy_signal.get("reason", "buy_signal"),
"pnl": 0.0,
"pnl_pct": 0.0,
}
trades.append(trade)
buy_signal = check_us_buy_signal(
rsi=ind.get("rsi", 50),
close=close,
bb_upper=ind.get("bb_upper", close),
bb_middle=ind.get("bb_middle", close),
bb_lower=ind.get("bb_lower", close),
avg_volume_20d=ind.get("avg_volume_20d", 0),
current_volume=bar.get("volume", 0),
current_price_usd=close,
rsi_prev=prev_rsi,
)
if buy_signal:
pending_buy_signal = buy_signal
# 일별 자산 기록
position_value = position_qty * close if position_qty > 0 else 0.0
@@ -337,19 +381,20 @@ async def run_us_backtest(
"position_value": round(position_value, 2),
})
# 4) 미청산 포지션 강제 청산 (백테스트 종료)
# 4) 미청산 포지션 강제 청산 (백테스트 종료, 슬리피지 적용)
if position_qty > 0 and daily_data:
last_close = daily_data[-1]["close"]
amount = position_qty * last_close
exec_price = _apply_us_sell_slippage(last_close)
amount = position_qty * exec_price
commission = amount * US_COMMISSION_RATE
pnl = (last_close - position_avg_price) * position_qty - commission
pnl_pct = ((last_close - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0
pnl = (exec_price - position_avg_price) * position_qty - commission
pnl_pct = ((exec_price - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0
trades.append({
"date": daily_data[-1]["date"],
"action": "SELL",
"ticker": ticker,
"price": last_close,
"price": round(exec_price, 4),
"quantity": position_qty,
"amount": round(amount, 2),
"commission": round(commission, 2),