fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
797
backend/app/services/us_market_scheduler.py
Normal file
797
backend/app/services/us_market_scheduler.py
Normal file
@@ -0,0 +1,797 @@
|
||||
"""미국장 자동매매 스케줄러
|
||||
|
||||
미국 정규장 시간(한국시간 기준 22:30/23:30 ~ 05:00/06:00)에
|
||||
자동으로 매매 전략을 실행하는 백그라운드 스케줄러.
|
||||
|
||||
[스케줄]
|
||||
- 장 시작 10분 후부터 시작 (변동성 안정화 대기)
|
||||
- 5분 간격으로 종목 스캔 및 매매 신호 체크
|
||||
- 장 마감 15분 전 신규 진입 중단
|
||||
- 장 마감 시 일일 리포트 생성
|
||||
|
||||
[시간대 참고 — KST 기준]
|
||||
서머타임 적용 (3월 둘째 일요일 ~ 11월 첫째 일요일):
|
||||
- 미국 정규장: KST 22:30 ~ 05:00 (익일)
|
||||
서머타임 미적용:
|
||||
- 미국 정규장: KST 23:30 ~ 06:00 (익일)
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta, time as dtime
|
||||
from typing import List, Dict, Optional, Any
|
||||
|
||||
import pytz
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.us_stock_strategy import (
|
||||
scan_us_stocks,
|
||||
get_us_watchlist,
|
||||
check_us_exit_signal,
|
||||
calculate_us_position_size,
|
||||
)
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.exchange_detector import detect_exchange
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
from app.services.db_worker import (
|
||||
enqueue_trade_history,
|
||||
enqueue_active_trade_upsert,
|
||||
enqueue_close_active_trade,
|
||||
enqueue_portfolio_upsert,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ET = pytz.timezone("US/Eastern")
|
||||
_KST = pytz.timezone("Asia/Seoul")
|
||||
|
||||
# 미국 정규장 시간 (Eastern Time)
|
||||
_US_MARKET_OPEN = dtime(9, 30) # 09:30 ET
|
||||
_US_MARKET_CLOSE = dtime(16, 0) # 16:00 ET
|
||||
_US_ENTRY_START = dtime(9, 40) # 장 시작 10분 후 (변동성 안정화 대기)
|
||||
_US_ENTRY_CUTOFF = dtime(15, 45) # 장 마감 15분 전 신규 진입 중단
|
||||
|
||||
# 사이클당 최대 신규 진입 종목 수
|
||||
_MAX_NEW_ENTRIES_PER_CYCLE = 3
|
||||
# 기본 환율 폴백 (USD/KRW)
|
||||
_DEFAULT_FX_RATE = 1350.0
|
||||
|
||||
|
||||
def _is_us_market_open() -> bool:
|
||||
"""미국 정규장 개장 여부 확인 (ET 기준, 주말 제외)"""
|
||||
now_et = datetime.now(_ET)
|
||||
|
||||
# 주말 (토=5, 일=6)
|
||||
if now_et.weekday() >= 5:
|
||||
return False
|
||||
|
||||
current_time = now_et.time()
|
||||
return _US_MARKET_OPEN <= current_time < _US_MARKET_CLOSE
|
||||
|
||||
|
||||
def _can_enter_new_position() -> bool:
|
||||
"""신규 진입 가능 여부 (장 시작 10분 후 ~ 장 마감 15분 전)"""
|
||||
now_et = datetime.now(_ET)
|
||||
current_time = now_et.time()
|
||||
return _US_ENTRY_START <= current_time < _US_ENTRY_CUTOFF
|
||||
|
||||
|
||||
def _seconds_until_us_market_open() -> float:
|
||||
"""다음 미국장 개장까지 남은 초 (최소 60초 반환)"""
|
||||
now_et = datetime.now(_ET)
|
||||
|
||||
today_open = _ET.localize(
|
||||
datetime(now_et.year, now_et.month, now_et.day, 9, 30, 0)
|
||||
)
|
||||
|
||||
if now_et < today_open:
|
||||
delta = today_open - now_et
|
||||
else:
|
||||
days_ahead = 1
|
||||
if now_et.weekday() == 4: # 금요일 → 월요일
|
||||
days_ahead = 3
|
||||
elif now_et.weekday() == 5: # 토요일 → 월요일
|
||||
days_ahead = 2
|
||||
elif now_et.weekday() == 6: # 일요일 → 월요일
|
||||
days_ahead = 1
|
||||
|
||||
next_open = _ET.localize(
|
||||
datetime(now_et.year, now_et.month, now_et.day, 9, 30, 0)
|
||||
+ timedelta(days=days_ahead)
|
||||
)
|
||||
delta = next_open - now_et
|
||||
|
||||
return max(delta.total_seconds(), 60)
|
||||
|
||||
|
||||
# ── KIS API 어댑터 ─────────────────────────────────────────────────────────────
|
||||
class _KISAdapter:
|
||||
"""scan_us_stocks()가 기대하는 인터페이스를 korea_investment_api로 어댑팅.
|
||||
|
||||
scan_us_stocks()는 api_client.get_us_stock_history(ticker, period) 와
|
||||
api_client.get_us_stock_quote(ticker) 메서드를 기대하므로 KIS API 응답을
|
||||
해당 형식으로 변환한다.
|
||||
"""
|
||||
|
||||
async def get_us_stock_history(
|
||||
self, ticker: str, period: str = "3mo"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""KIS 해외주식 일봉 → [{"date", "open", "high", "low", "close", "volume"}] 변환."""
|
||||
try:
|
||||
exchange = detect_exchange(ticker)
|
||||
result = await korea_investment_api.get_overseas_daily_chart(ticker, exchange)
|
||||
items = result.get("output2", [])
|
||||
if not items:
|
||||
return []
|
||||
|
||||
bars: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
try:
|
||||
close_val = float(item.get("clos", 0) or 0)
|
||||
if close_val <= 0:
|
||||
continue
|
||||
bars.append({
|
||||
"date": item.get("xymd", ""),
|
||||
"open": float(item.get("open", close_val) or close_val),
|
||||
"high": float(item.get("high", close_val) or close_val),
|
||||
"low": float(item.get("low", close_val) or close_val),
|
||||
"close": close_val,
|
||||
"volume": float(item.get("tvol", 0) or 0),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# 오름차순 정렬 (오래된 것 → 최신)
|
||||
bars.sort(key=lambda x: x["date"])
|
||||
return bars
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("US history fetch failed for %s: %s", ticker, e)
|
||||
return []
|
||||
|
||||
async def get_us_stock_quote(self, ticker: str) -> Dict[str, Any]:
|
||||
"""현재가 조회"""
|
||||
exchange = detect_exchange(ticker)
|
||||
return await korea_investment_api.get_overseas_price(ticker, exchange)
|
||||
|
||||
|
||||
# ── 계좌 정보 파싱 ──────────────────────────────────────────────────────────────
|
||||
async def _get_us_account_info() -> Dict[str, Any]:
|
||||
"""해외주식 잔고에서 포지션 사이징에 필요한 정보를 추출한다.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total_assets_krw": float,
|
||||
"available_cash_krw": float,
|
||||
"current_us_allocation_krw": float,
|
||||
"exchange_rate": float,
|
||||
"holdings": list[dict],
|
||||
}
|
||||
"""
|
||||
try:
|
||||
bal = await korea_investment_api.get_overseas_balance()
|
||||
|
||||
# output2: 계좌 요약
|
||||
summary = bal.get("output2", {})
|
||||
if isinstance(summary, list):
|
||||
summary = summary[0] if summary else {}
|
||||
|
||||
# 미국 주식 평가총액 (USD)
|
||||
frcr_evlu = float(summary.get("frcr_evlu_tota", 0) or 0)
|
||||
# USD 예수금
|
||||
usd_cash = float(summary.get("usd_amt", 0) or 0)
|
||||
# 총 평가 (KRW 환산)
|
||||
tot_evlu_krw = float(summary.get("tot_evlu_amt", 0) or 0)
|
||||
|
||||
# 환율 역산
|
||||
total_usd = frcr_evlu + usd_cash
|
||||
if total_usd > 0 and tot_evlu_krw > 0:
|
||||
exchange_rate = tot_evlu_krw / total_usd
|
||||
else:
|
||||
exchange_rate = _DEFAULT_FX_RATE
|
||||
|
||||
current_us_allocation_krw = frcr_evlu * exchange_rate
|
||||
available_cash_krw = usd_cash * exchange_rate
|
||||
|
||||
holdings = bal.get("output1", [])
|
||||
if not isinstance(holdings, list):
|
||||
holdings = []
|
||||
|
||||
total_assets_krw = tot_evlu_krw if tot_evlu_krw > 0 else (
|
||||
current_us_allocation_krw + available_cash_krw
|
||||
)
|
||||
if total_assets_krw < 1_000_000:
|
||||
total_assets_krw = 10_000_000 # 폴백
|
||||
|
||||
return {
|
||||
"total_assets_krw": total_assets_krw,
|
||||
"available_cash_krw": available_cash_krw,
|
||||
"current_us_allocation_krw": current_us_allocation_krw,
|
||||
"exchange_rate": exchange_rate,
|
||||
"holdings": holdings,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[US Account] 잔고 조회 실패: %s", e)
|
||||
return {
|
||||
"total_assets_krw": 10_000_000,
|
||||
"available_cash_krw": 0.0,
|
||||
"current_us_allocation_krw": 0.0,
|
||||
"exchange_rate": _DEFAULT_FX_RATE,
|
||||
"holdings": [],
|
||||
}
|
||||
|
||||
|
||||
# ── ATR 계산 ────────────────────────────────────────────────────────────────────
|
||||
def _calc_atr(bars: List[Dict[str, Any]], period: int = 14) -> float:
|
||||
"""True Range 기반 ATR(Average True Range) 계산.
|
||||
|
||||
Args:
|
||||
bars: [{"high", "low", "close"}, ...] 오름차순 정렬된 일봉
|
||||
period: ATR 기간 (기본 14)
|
||||
|
||||
Returns:
|
||||
ATR 값. 데이터 부족 시 0.0
|
||||
"""
|
||||
if len(bars) < period + 1:
|
||||
return 0.0
|
||||
|
||||
trs: List[float] = []
|
||||
for i in range(1, len(bars)):
|
||||
h = bars[i]["high"]
|
||||
l = bars[i]["low"]
|
||||
pc = bars[i - 1]["close"]
|
||||
tr = max(h - l, abs(h - pc), abs(l - pc))
|
||||
trs.append(tr)
|
||||
|
||||
if len(trs) < period:
|
||||
return 0.0
|
||||
|
||||
return sum(trs[-period:]) / period
|
||||
|
||||
|
||||
# ── 기존 US 포지션 청산 모니터링 ──────────────────────────────────────────────
|
||||
async def _monitor_us_exits(exchange_rate: float = _DEFAULT_FX_RATE) -> List[Dict]:
|
||||
"""보유 중인 미국 주식 포지션의 청산 신호를 체크하고 매도를 실행한다."""
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import ActiveTrade
|
||||
from sqlalchemy import select
|
||||
from app.services.mean_reversion_strategy import calculate_rsi, calculate_bollinger
|
||||
|
||||
executed_exits: List[Dict] = []
|
||||
|
||||
try:
|
||||
# DB에서 활성 미국 주식 포지션 조회 (읽기 전용)
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(ActiveTrade).where(
|
||||
ActiveTrade.status == "active",
|
||||
ActiveTrade.currency_code == "USD",
|
||||
)
|
||||
)
|
||||
active_trades = result.scalars().all()
|
||||
|
||||
if not active_trades:
|
||||
return []
|
||||
|
||||
adapter = _KISAdapter()
|
||||
|
||||
for trade in active_trades:
|
||||
ticker = trade.stock_code
|
||||
try:
|
||||
bars = await adapter.get_us_stock_history(ticker)
|
||||
if not bars or len(bars) < 20:
|
||||
continue
|
||||
|
||||
closes = [b["close"] for b in bars]
|
||||
current_price = closes[-1]
|
||||
|
||||
rsi = calculate_rsi(closes, 14)
|
||||
bb = calculate_bollinger(closes, 20)
|
||||
atr = _calc_atr(bars, 14)
|
||||
|
||||
if rsi is None or bb is None or atr <= 0:
|
||||
continue
|
||||
|
||||
bb_upper, bb_middle, bb_lower = bb
|
||||
entry_price = float(trade.entry_price)
|
||||
remaining_pct = float(trade.remaining_pct or 1.0)
|
||||
highest = float(trade.highest_since_entry or entry_price)
|
||||
|
||||
# 최고가 갱신
|
||||
if current_price > highest:
|
||||
highest = current_price
|
||||
await enqueue_active_trade_upsert({
|
||||
"stock_code": ticker,
|
||||
"strategy_name": "us_mean_reversion",
|
||||
"highest_since_entry": highest,
|
||||
"current_price": current_price,
|
||||
})
|
||||
|
||||
exit_signal = check_us_exit_signal(
|
||||
rsi=rsi,
|
||||
close=current_price,
|
||||
bb_upper=bb_upper,
|
||||
bb_middle=bb_middle,
|
||||
bb_lower=bb_lower,
|
||||
entry_price=entry_price,
|
||||
atr=atr,
|
||||
remaining_pct=remaining_pct,
|
||||
trailing_high=highest,
|
||||
)
|
||||
|
||||
if exit_signal is None:
|
||||
continue
|
||||
|
||||
total_qty = trade.quantity
|
||||
if exit_signal.action == "SELL_ALL":
|
||||
sell_qty = total_qty
|
||||
else:
|
||||
sell_qty = max(1, math.floor(total_qty * exit_signal.sell_pct))
|
||||
|
||||
if sell_qty <= 0:
|
||||
continue
|
||||
|
||||
sell_price = round(current_price * 0.995, 2)
|
||||
exchange = detect_exchange(ticker)
|
||||
|
||||
logger.info(
|
||||
"[US Exit] %s %s — qty=%d, price=$%.2f, reason=%s",
|
||||
ticker, exit_signal.action, sell_qty, sell_price, exit_signal.reason,
|
||||
)
|
||||
|
||||
from app.services.trade_executor import _place_order_with_retry
|
||||
order_result = await _place_order_with_retry(
|
||||
is_domestic=False,
|
||||
stock_code=ticker,
|
||||
order_type="SELL",
|
||||
quantity=sell_qty,
|
||||
price=sell_price,
|
||||
exchange_code=exchange,
|
||||
)
|
||||
|
||||
rt_cd = order_result.get("rt_cd")
|
||||
order_id = (
|
||||
order_result.get("output", {}).get("ODNO")
|
||||
or order_result.get("odno")
|
||||
)
|
||||
|
||||
pnl_usd = (current_price - entry_price) * sell_qty
|
||||
pnl_rate = ((current_price - entry_price) / entry_price) * 100 if entry_price > 0 else 0.0
|
||||
|
||||
await enqueue_trade_history({
|
||||
"stock_code": ticker,
|
||||
"stock_name": trade.stock_name or ticker,
|
||||
"order_type": "SELL",
|
||||
"order_price": sell_price,
|
||||
"order_quantity": sell_qty,
|
||||
"executed_price": sell_price if rt_cd == "0" else None,
|
||||
"executed_quantity": sell_qty if rt_cd == "0" else 0,
|
||||
"total_amount": sell_price * sell_qty if rt_cd == "0" else 0,
|
||||
"status": "COMPLETED" if rt_cd == "0" else "FAILED",
|
||||
"order_id": order_id,
|
||||
"exit_reason": exit_signal.reason,
|
||||
"strategy_type": "us_swing",
|
||||
"currency_code": "USD",
|
||||
"exchange_code": exchange,
|
||||
"fx_rate": exchange_rate,
|
||||
"net_profit_loss": pnl_usd if rt_cd == "0" else None,
|
||||
"profit_loss_rate": round(pnl_rate, 4) if rt_cd == "0" else None,
|
||||
})
|
||||
|
||||
if rt_cd == "0":
|
||||
new_remaining = remaining_pct - exit_signal.sell_pct
|
||||
if new_remaining <= 0.05 or exit_signal.action == "SELL_ALL":
|
||||
await enqueue_close_active_trade(ticker, "us_mean_reversion")
|
||||
await enqueue_portfolio_upsert({
|
||||
"stock_code": ticker,
|
||||
"quantity": 0,
|
||||
"average_price": entry_price,
|
||||
"current_price": current_price,
|
||||
"profit_loss": pnl_usd * exchange_rate,
|
||||
"profit_loss_rate": round(pnl_rate, 4),
|
||||
"currency_code": "USD",
|
||||
"exchange_code": exchange,
|
||||
"strategy_type": "us_swing",
|
||||
})
|
||||
else:
|
||||
await enqueue_active_trade_upsert({
|
||||
"stock_code": ticker,
|
||||
"strategy_name": "us_mean_reversion",
|
||||
"remaining_pct": round(new_remaining, 2),
|
||||
"current_price": current_price,
|
||||
})
|
||||
|
||||
executed_exits.append({
|
||||
"ticker": ticker,
|
||||
"action": exit_signal.action,
|
||||
"qty": sell_qty,
|
||||
"price": sell_price,
|
||||
"pnl_usd": round(pnl_usd, 2),
|
||||
"reason": exit_signal.reason,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[US Exit] %s 처리 오류: %s", ticker, e)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[US Exit Monitor] 오류: %s", e, exc_info=True)
|
||||
|
||||
return executed_exits
|
||||
|
||||
|
||||
# ── 단일 매매 사이클 ────────────────────────────────────────────────────────────
|
||||
async def us_trade_cycle() -> dict:
|
||||
"""단일 매매 사이클: 스캔 → 신호 체크 → 주문 실행 → 포지션 청산 모니터링
|
||||
|
||||
Returns:
|
||||
사이클 결과 dict {"scanned": int, "signals": int, "trades": [...], "exits": [...]}
|
||||
"""
|
||||
result: Dict[str, Any] = {
|
||||
"scanned": 0,
|
||||
"signals": 0,
|
||||
"trades": [],
|
||||
"exits": [],
|
||||
"timestamp": datetime.now(_KST).isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info("[US Trade] 매매 사이클 시작 (ET: %s)", datetime.now(_ET).strftime("%H:%M"))
|
||||
|
||||
# ── 1. 계좌 정보 조회 ──────────────────────────────────────────────
|
||||
account = await _get_us_account_info()
|
||||
exchange_rate = account["exchange_rate"]
|
||||
total_assets_krw = account["total_assets_krw"]
|
||||
available_cash_krw = account["available_cash_krw"]
|
||||
current_us_allocation_krw = account["current_us_allocation_krw"]
|
||||
|
||||
# ── 2. 기존 포지션 청산 모니터링 (장 중 항상 실행) ────────────────
|
||||
exits = await _monitor_us_exits(exchange_rate)
|
||||
result["exits"] = exits
|
||||
|
||||
# ── 3. 신규 진입 가능 시간대 확인 ─────────────────────────────────
|
||||
if not _can_enter_new_position():
|
||||
logger.info(
|
||||
"[US Trade] 신규 진입 불가 시간대 (ET: %s). 청산 모니터링만 수행.",
|
||||
datetime.now(_ET).strftime("%H:%M"),
|
||||
)
|
||||
result["phase"] = "monitoring_only"
|
||||
return result
|
||||
|
||||
# ── 4. MDD 체크 ────────────────────────────────────────────────────
|
||||
if not await mdd_manager.can_open_new_position():
|
||||
logger.info("[US Trade] MDD 한도 초과 — 신규 진입 중단")
|
||||
result["phase"] = "mdd_paused"
|
||||
return result
|
||||
|
||||
# ── 5. 가용 현금 확인 ──────────────────────────────────────────────
|
||||
if available_cash_krw < 500_000: # ~$370 미만
|
||||
logger.info(
|
||||
"[US Trade] 가용 현금 부족 (%.0f KRW) — 신규 진입 건너뜀",
|
||||
available_cash_krw,
|
||||
)
|
||||
result["phase"] = "insufficient_cash"
|
||||
return result
|
||||
|
||||
# ── 6. 매수 신호 스캔 ──────────────────────────────────────────────
|
||||
adapter = _KISAdapter()
|
||||
signals = await scan_us_stocks(adapter)
|
||||
watchlist = get_us_watchlist()
|
||||
result["scanned"] = len(watchlist)
|
||||
result["signals"] = len(signals)
|
||||
|
||||
if not signals:
|
||||
logger.info("[US Trade] 매수 신호 없음 (스캔: %d종목)", len(watchlist))
|
||||
result["phase"] = "no_signals"
|
||||
return result
|
||||
|
||||
logger.info("[US Trade] 매수 신호 %d개 발견", len(signals))
|
||||
|
||||
# ── 7. 신호별 주문 처리 ────────────────────────────────────────────
|
||||
new_entries = 0
|
||||
for sig in signals:
|
||||
if new_entries >= _MAX_NEW_ENTRIES_PER_CYCLE:
|
||||
break
|
||||
|
||||
ticker = sig["ticker"]
|
||||
current_price_usd = float(sig["price"])
|
||||
exchange = detect_exchange(ticker)
|
||||
|
||||
try:
|
||||
# 7-1. 포지션 사이징
|
||||
buy_budget_krw = calculate_us_position_size(
|
||||
total_assets_krw=total_assets_krw,
|
||||
available_cash_krw=available_cash_krw,
|
||||
current_us_allocation_krw=current_us_allocation_krw,
|
||||
exchange_rate=exchange_rate,
|
||||
)
|
||||
if buy_budget_krw <= 0:
|
||||
logger.info("[US Buy] %s — US 자본 한도 초과, 중단", ticker)
|
||||
break
|
||||
|
||||
buy_budget_usd = buy_budget_krw / exchange_rate
|
||||
quantity = max(1, math.floor(buy_budget_usd / current_price_usd))
|
||||
|
||||
if current_price_usd * quantity < 100:
|
||||
logger.info(
|
||||
"[US Buy] %s — 금액 부족 ($%.0f < $100), 건너뜀",
|
||||
ticker, current_price_usd * quantity,
|
||||
)
|
||||
continue
|
||||
|
||||
# 7-2. 매수가 산정 (+0.5% 지정가)
|
||||
buy_price = round(current_price_usd * 1.005, 2)
|
||||
order_amount_usd = buy_price * quantity
|
||||
|
||||
logger.info(
|
||||
"[US Buy] %s — qty=%d, price=$%.2f ($%.0f), RSI=%.1f",
|
||||
ticker, quantity, buy_price, order_amount_usd, sig["rsi"],
|
||||
)
|
||||
|
||||
# 7-3. 주문 실행 (재시도 포함)
|
||||
from app.services.trade_executor import _place_order_with_retry
|
||||
order_result = await _place_order_with_retry(
|
||||
is_domestic=False,
|
||||
stock_code=ticker,
|
||||
order_type="BUY",
|
||||
quantity=quantity,
|
||||
price=buy_price,
|
||||
exchange_code=exchange,
|
||||
)
|
||||
|
||||
rt_cd = order_result.get("rt_cd")
|
||||
order_id = (
|
||||
order_result.get("output", {}).get("ODNO")
|
||||
or order_result.get("odno")
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[US Buy] %s 주문 결과: rt_cd=%s, order_id=%s",
|
||||
ticker, rt_cd, order_id,
|
||||
)
|
||||
|
||||
# 7-4. DB 기록 (db_worker 큐 사용)
|
||||
await enqueue_trade_history({
|
||||
"stock_code": ticker,
|
||||
"stock_name": ticker,
|
||||
"order_type": "BUY",
|
||||
"order_price": buy_price,
|
||||
"order_quantity": quantity,
|
||||
"executed_price": buy_price if rt_cd == "0" else None,
|
||||
"executed_quantity": quantity if rt_cd == "0" else 0,
|
||||
"total_amount": buy_price * quantity if rt_cd == "0" else 0,
|
||||
"status": "COMPLETED" if rt_cd == "0" else "FAILED",
|
||||
"order_id": order_id,
|
||||
"entry_reason": sig.get("description", ""),
|
||||
"signals": sig,
|
||||
"strategy_type": "us_swing",
|
||||
"currency_code": "USD",
|
||||
"exchange_code": exchange,
|
||||
"fx_rate": exchange_rate,
|
||||
})
|
||||
|
||||
if rt_cd == "0":
|
||||
# ATR 기반 손절가 계산
|
||||
bars = await adapter.get_us_stock_history(ticker)
|
||||
atr = _calc_atr(bars) if bars else 0.0
|
||||
stop_loss = (
|
||||
round(buy_price - 2.5 * atr, 2) if atr > 0
|
||||
else round(buy_price * 0.93, 2)
|
||||
)
|
||||
|
||||
await enqueue_active_trade_upsert({
|
||||
"stock_code": ticker,
|
||||
"stock_name": ticker,
|
||||
"quantity": quantity,
|
||||
"entry_price": buy_price,
|
||||
"current_price": buy_price,
|
||||
"stop_loss": stop_loss,
|
||||
"order_id": order_id,
|
||||
"entry_reason": sig.get("description", ""),
|
||||
"strategy_name": "us_mean_reversion",
|
||||
"remaining_pct": 1.0,
|
||||
"highest_since_entry": buy_price,
|
||||
"currency_code": "USD",
|
||||
"exchange_code": exchange,
|
||||
})
|
||||
|
||||
await enqueue_portfolio_upsert({
|
||||
"stock_code": ticker,
|
||||
"stock_name": ticker,
|
||||
"quantity": quantity,
|
||||
"average_price": buy_price,
|
||||
"current_price": buy_price,
|
||||
"market": "US",
|
||||
"currency_code": "USD",
|
||||
"exchange_code": exchange,
|
||||
"strategy_type": "us_swing",
|
||||
})
|
||||
|
||||
# 다음 종목 사이징을 위해 로컬 변수 업데이트
|
||||
current_us_allocation_krw += buy_price * quantity * exchange_rate
|
||||
available_cash_krw -= buy_price * quantity * exchange_rate
|
||||
|
||||
new_entries += 1
|
||||
result["trades"].append({
|
||||
"ticker": ticker,
|
||||
"action": "BUY",
|
||||
"qty": quantity,
|
||||
"price": buy_price,
|
||||
"amount_usd": round(order_amount_usd, 2),
|
||||
"signal": sig.get("description", ""),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[US Buy] %s 주문 처리 오류: %s", ticker, e, exc_info=True)
|
||||
continue
|
||||
|
||||
result["phase"] = "scan_complete"
|
||||
logger.info(
|
||||
"[US Trade] 사이클 완료: scanned=%d, signals=%d, buys=%d, sells=%d",
|
||||
result["scanned"], result["signals"],
|
||||
len(result["trades"]), len(result["exits"]),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[US Trade] 매매 사이클 오류: %s", e, exc_info=True)
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 일일 리포트 ─────────────────────────────────────────────────────────────────
|
||||
async def us_post_market_report():
|
||||
"""미국장 종료 후 일일 리포트 생성"""
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import TradeHistory, ActiveTrade
|
||||
from sqlalchemy import select
|
||||
|
||||
now_kst = datetime.now(_KST)
|
||||
today_str = now_kst.strftime("%Y-%m-%d")
|
||||
logger.info("[US PostMarket] 일일 리포트 생성 시작 — %s", today_str)
|
||||
|
||||
report: Dict[str, Any] = {
|
||||
"date": today_str,
|
||||
"market": "US",
|
||||
"total_trades": 0,
|
||||
"buy_count": 0,
|
||||
"sell_count": 0,
|
||||
"realized_pnl_usd": 0.0,
|
||||
"unrealized_pnl_usd": 0.0,
|
||||
"positions": [],
|
||||
}
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 당일 미국 주식 매매 내역 조회
|
||||
today_start = now_kst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
trade_result = await session.execute(
|
||||
select(TradeHistory).where(
|
||||
TradeHistory.currency_code == "USD",
|
||||
TradeHistory.strategy_type == "us_swing",
|
||||
TradeHistory.created_at >= today_start,
|
||||
TradeHistory.status == "COMPLETED",
|
||||
)
|
||||
)
|
||||
trades = trade_result.scalars().all()
|
||||
|
||||
buy_count = sum(1 for t in trades if t.order_type == "BUY")
|
||||
sell_count = sum(1 for t in trades if t.order_type == "SELL")
|
||||
realized_pnl = sum(
|
||||
float(t.net_profit_loss or 0)
|
||||
for t in trades if t.order_type == "SELL"
|
||||
)
|
||||
|
||||
# 활성 포지션 현황
|
||||
pos_result = await session.execute(
|
||||
select(ActiveTrade).where(
|
||||
ActiveTrade.currency_code == "USD",
|
||||
ActiveTrade.status == "active",
|
||||
)
|
||||
)
|
||||
active_positions = pos_result.scalars().all()
|
||||
|
||||
unrealized_pnl = 0.0
|
||||
positions_summary = []
|
||||
for pos in active_positions:
|
||||
entry = float(pos.entry_price or 0)
|
||||
current = float(pos.current_price or entry)
|
||||
qty = pos.quantity or 0
|
||||
pnl = (current - entry) * qty
|
||||
pnl_rate = ((current - entry) / entry * 100) if entry > 0 else 0.0
|
||||
unrealized_pnl += pnl
|
||||
positions_summary.append({
|
||||
"ticker": pos.stock_code,
|
||||
"qty": qty,
|
||||
"entry": entry,
|
||||
"current": current,
|
||||
"pnl_usd": round(pnl, 2),
|
||||
"pnl_pct": round(pnl_rate, 2),
|
||||
"exchange": pos.exchange_code,
|
||||
})
|
||||
|
||||
report.update({
|
||||
"total_trades": len(trades),
|
||||
"buy_count": buy_count,
|
||||
"sell_count": sell_count,
|
||||
"realized_pnl_usd": round(realized_pnl, 2),
|
||||
"unrealized_pnl_usd": round(unrealized_pnl, 2),
|
||||
"positions": positions_summary,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"[US PostMarket] 리포트 완료 — buys=%d, sells=%d, 실현손익=$%.2f, "
|
||||
"미실현=$%.2f, 포지션=%d",
|
||||
buy_count, sell_count, realized_pnl, unrealized_pnl, len(positions_summary),
|
||||
)
|
||||
return report
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[US PostMarket] 리포트 생성 오류: %s", e, exc_info=True)
|
||||
return report
|
||||
|
||||
|
||||
# ── 메인 스케줄러 ───────────────────────────────────────────────────────────────
|
||||
async def us_auto_trade_scheduler():
|
||||
"""미국장 자동매매 메인 스케줄러
|
||||
|
||||
미국 정규장 시간에 US_SCAN_INTERVAL(기본 5분) 간격으로
|
||||
매매 사이클을 반복 실행하는 백그라운드 태스크.
|
||||
|
||||
장 외 시간에는 다음 개장까지 자동 대기.
|
||||
장 마감 후 일일 리포트 생성.
|
||||
"""
|
||||
await asyncio.sleep(10) # 부팅 안정화
|
||||
|
||||
scan_interval = getattr(settings, "US_SCAN_INTERVAL", 300) # 기본 5분
|
||||
logger.info(
|
||||
"[US Scheduler] 미국장 자동매매 스케줄러 시작 (scan interval: %ds)", scan_interval
|
||||
)
|
||||
|
||||
_reported_today = False # 당일 리포트 생성 여부 추적
|
||||
|
||||
try:
|
||||
while True:
|
||||
if not _is_us_market_open():
|
||||
now_et = datetime.now(_ET)
|
||||
|
||||
# 장 마감 직후 리포트 생성 (16:00~16:30 ET, 한 번만)
|
||||
if (
|
||||
not _reported_today
|
||||
and now_et.weekday() < 5
|
||||
and dtime(16, 0) <= now_et.time() <= dtime(16, 30)
|
||||
):
|
||||
logger.info("[US Scheduler] 미국장 마감 — 일일 리포트 생성")
|
||||
await us_post_market_report()
|
||||
_reported_today = True
|
||||
|
||||
# 자정(ET) 지나면 리포트 플래그 리셋
|
||||
if now_et.time() < dtime(9, 0):
|
||||
_reported_today = False
|
||||
|
||||
sleep_sec = _seconds_until_us_market_open()
|
||||
logger.info(
|
||||
"[US Scheduler] 미국장 폐장 중. 다음 개장까지 %.1f시간 대기 (ET: %s)",
|
||||
sleep_sec / 3600,
|
||||
now_et.strftime("%Y-%m-%d %H:%M"),
|
||||
)
|
||||
# 최대 30분 단위로 재확인 (서머타임 전환 등 대비)
|
||||
await asyncio.sleep(min(sleep_sec, 1800))
|
||||
continue
|
||||
|
||||
# ── 장 중: 매매 사이클 실행 ──────────────────────────────────
|
||||
try:
|
||||
cycle_result = await us_trade_cycle()
|
||||
if cycle_result.get("error"):
|
||||
logger.warning(
|
||||
"[US Scheduler] 매매 사이클 오류: %s", cycle_result["error"]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[US Scheduler] 매매 사이클 실행 오류: %s", e, exc_info=True)
|
||||
|
||||
await asyncio.sleep(scan_interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[US Scheduler] 미국장 자동매매 스케줄러 종료")
|
||||
raise
|
||||
Reference in New Issue
Block a user