수정본
This commit is contained in:
731
backend/app/api/endpoints/day_trading.py
Normal file
731
backend/app/api/endpoints/day_trading.py
Normal file
@@ -0,0 +1,731 @@
|
||||
"""
|
||||
데이트레이딩 API 엔드포인트
|
||||
|
||||
라우터 prefix: /api/daytrading
|
||||
|
||||
엔드포인트:
|
||||
GET /watchlist — 감시 종목 목록 조회
|
||||
POST /watchlist — 감시 종목 추가/교체
|
||||
GET /signals — 현재 진입 신호 조회
|
||||
POST /execute — 신호 기반 데이트레이딩 실행
|
||||
GET /positions — 활성 데이트레이딩 포지션 조회
|
||||
POST /close-all — 전체 포지션 강제 청산 (EOD 수동)
|
||||
POST /cycle — 1분 주기 사이클 수동 실행 (디버그용)
|
||||
|
||||
상태 (인메모리, 일일 초기화):
|
||||
_watchlist — 감시 종목 코드+이름 딕셔너리
|
||||
_positions — 활성 포지션 {stock_code: {...}}
|
||||
_orb_cache — 종목별 ORB 데이터
|
||||
_daily_pnl — 오늘 실현 손익
|
||||
_last_date — 마지막 초기화 날짜 (날짜 바뀌면 자동 리셋)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, date, time as dtime
|
||||
import asyncio
|
||||
import logging
|
||||
import pytz
|
||||
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.day_trading_strategy import (
|
||||
MinuteCandle, OpeningRange, DayTradeSignal,
|
||||
parse_minute_candles, aggregate_to_5min,
|
||||
calculate_vwap, calculate_opening_range,
|
||||
check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal,
|
||||
check_day_trade_exit,
|
||||
calculate_day_trade_position_size, is_daily_loss_exceeded,
|
||||
ORB_END, ENTRY_CUTOFF, EOD_FLAT, MAX_POSITIONS,
|
||||
)
|
||||
from app.core.market_schedule import (
|
||||
is_market_open, is_opening_range_period,
|
||||
is_day_trade_entry_window, is_eod_flat_time,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.json_helpers import _dumps
|
||||
from app.services.websocket_manager import websocket_manager
|
||||
from app.core.database import get_db, AsyncSessionLocal
|
||||
from app.models.models import TradeHistory
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
KST = pytz.timezone("Asia/Seoul")
|
||||
|
||||
|
||||
# ── 인메모리 상태 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _kst_now() -> datetime:
|
||||
return datetime.now(KST).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _kst_time() -> dtime:
|
||||
return datetime.now(KST).time()
|
||||
|
||||
|
||||
def _today_kst() -> date:
|
||||
return datetime.now(KST).date()
|
||||
|
||||
|
||||
# 감시 종목 (code → name)
|
||||
_watchlist: Dict[str, str] = {}
|
||||
|
||||
# 활성 포지션
|
||||
# {stock_code: {
|
||||
# "stock_name", "strategy", "entry_price", "stop_loss", "target_price",
|
||||
# "quantity", "highest_price", "entry_time", "reason"
|
||||
# }}
|
||||
_positions: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# ORB 캐시 (당일, stock_code → OpeningRange)
|
||||
_orb_cache: Dict[str, OpeningRange] = {}
|
||||
|
||||
# 일일 실현 손익 (원)
|
||||
_daily_pnl: float = 0.0
|
||||
|
||||
# 마지막 초기화 날짜
|
||||
_last_reset_date: Optional[date] = None
|
||||
|
||||
# 매매 실행 lock
|
||||
_trade_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _reset_if_new_day() -> None:
|
||||
"""날짜가 바뀌면 일일 상태 초기화"""
|
||||
global _daily_pnl, _orb_cache, _last_reset_date
|
||||
today = _today_kst()
|
||||
if _last_reset_date != today:
|
||||
_daily_pnl = 0.0
|
||||
_orb_cache = {}
|
||||
_last_reset_date = today
|
||||
logger.info(f"📅 데이트레이딩 상태 일일 초기화: {today}")
|
||||
|
||||
|
||||
# ── Pydantic 모델 ─────────────────────────────────────────────────────────────
|
||||
|
||||
class WatchlistRequest(BaseModel):
|
||||
stocks: List[Dict[str, str]] # [{"code": "005930", "name": "삼성전자"}, ...]
|
||||
|
||||
|
||||
class ExecuteRequest(BaseModel):
|
||||
stock_code: str
|
||||
dry_run: bool = False # True면 주문 없이 신호만 반환
|
||||
|
||||
|
||||
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _fetch_candles(stock_code: str) -> List[MinuteCandle]:
|
||||
"""당일 전체 분봉 데이터 조회 및 파싱"""
|
||||
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
|
||||
return parse_minute_candles(raw)
|
||||
|
||||
|
||||
async def _get_current_price(stock_code: str) -> float:
|
||||
"""현재가 조회"""
|
||||
try:
|
||||
result = await korea_investment_api.get_current_price(stock_code)
|
||||
return float(result.get("output", {}).get("stck_prpr", 0) or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
async def _get_portfolio_summary() -> Dict[str, float]:
|
||||
"""포트폴리오 요약 (총자산, 가용 현금)"""
|
||||
try:
|
||||
balance = await korea_investment_api.get_balance()
|
||||
output2 = balance.get("output2", [{}])
|
||||
summary = output2[0] if output2 else {}
|
||||
total_assets = float(summary.get("tot_evlu_amt", 0) or 0)
|
||||
available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0)
|
||||
return {"total_assets": total_assets, "available_cash": available_cash}
|
||||
except Exception as e:
|
||||
logger.error(f"포트폴리오 조회 실패: {e}")
|
||||
return {"total_assets": 0.0, "available_cash": 0.0}
|
||||
|
||||
|
||||
async def _place_buy_order(
|
||||
stock_code: str,
|
||||
quantity: int,
|
||||
price: int,
|
||||
) -> Optional[str]:
|
||||
"""매수 주문 실행, 주문번호 반환 (실패 시 None)"""
|
||||
try:
|
||||
result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type="BUY",
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
)
|
||||
if result.get("rt_cd") == "0":
|
||||
return result.get("output", {}).get("ODNO", "")
|
||||
logger.warning(f"매수 주문 실패 [{stock_code}]: {result.get('msg1')}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"매수 주문 오류 [{stock_code}]: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _place_sell_order(
|
||||
stock_code: str,
|
||||
quantity: int,
|
||||
price: int = 0, # 0 = 시장가
|
||||
) -> bool:
|
||||
"""매도 주문 실행"""
|
||||
try:
|
||||
result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type="SELL",
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
)
|
||||
return result.get("rt_cd") == "0"
|
||||
except Exception as e:
|
||||
logger.error(f"매도 주문 오류 [{stock_code}]: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _record_trade(
|
||||
db: AsyncSession,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
order_type: str,
|
||||
price: float,
|
||||
quantity: int,
|
||||
reason: str,
|
||||
profit_loss: float = 0.0,
|
||||
) -> None:
|
||||
"""TradeHistory DB 기록"""
|
||||
try:
|
||||
trade = TradeHistory(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
order_type=order_type,
|
||||
executed_price=price,
|
||||
executed_quantity=quantity,
|
||||
total_amount=price * quantity,
|
||||
profit_loss=profit_loss,
|
||||
profit_loss_rate=(profit_loss / (price * quantity) * 100) if price * quantity > 0 else 0,
|
||||
status="COMPLETED",
|
||||
entry_reason=reason if order_type == "BUY" else None,
|
||||
exit_reason=reason if order_type == "SELL" else None,
|
||||
executed_at=_kst_now(),
|
||||
)
|
||||
db.add(trade)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"TradeHistory 기록 실패: {e}")
|
||||
|
||||
|
||||
# ── 핵심 사이클 로직 ──────────────────────────────────────────────────────────
|
||||
|
||||
async def run_day_trading_cycle() -> Dict[str, Any]:
|
||||
"""1분 주기 데이트레이딩 사이클
|
||||
|
||||
흐름:
|
||||
1. 장 미개장 → 스킵
|
||||
2. 일일 상태 초기화 체크
|
||||
3. Opening Range 수집 (09:00~09:30)
|
||||
4. 활성 포지션 청산 체크
|
||||
5. 신규 진입 신호 체크 (09:30~15:00)
|
||||
6. 결과 반환
|
||||
"""
|
||||
_reset_if_new_day()
|
||||
now_time = _kst_time()
|
||||
result: Dict[str, Any] = {
|
||||
"time": _kst_now().strftime("%H:%M:%S"),
|
||||
"phase": "",
|
||||
"exits": [],
|
||||
"entries": [],
|
||||
"signals": [],
|
||||
}
|
||||
|
||||
if not is_market_open():
|
||||
result["phase"] = "market_closed"
|
||||
return result
|
||||
|
||||
# ── 1. EOD 강제 청산 ──
|
||||
if is_eod_flat_time():
|
||||
result["phase"] = "eod_flat"
|
||||
await _execute_eod_flat()
|
||||
return result
|
||||
|
||||
# ── 2. ORB 수집 단계 (09:00~09:30) ──
|
||||
if is_opening_range_period():
|
||||
result["phase"] = "opening_range"
|
||||
await _collect_opening_range()
|
||||
result["orb_count"] = len(_orb_cache)
|
||||
return result
|
||||
|
||||
# ── 3. 매매 가능 구간 (09:30~15:00) ──
|
||||
if not is_day_trade_entry_window():
|
||||
result["phase"] = "outside_window"
|
||||
return result
|
||||
|
||||
result["phase"] = "active"
|
||||
|
||||
# ── 4. 활성 포지션 청산 체크 ──
|
||||
exits = await _check_exits()
|
||||
result["exits"] = exits
|
||||
|
||||
# ── 5. 신규 진입 신호 체크 ──
|
||||
if len(_positions) < MAX_POSITIONS and settings.AUTO_TRADE_ENABLED:
|
||||
portfolio = await _get_portfolio_summary()
|
||||
total_assets = portfolio["total_assets"]
|
||||
available_cash = portfolio["available_cash"]
|
||||
|
||||
if not is_daily_loss_exceeded(total_assets, _daily_pnl):
|
||||
current_day_trade_amount = sum(
|
||||
p["entry_price"] * p["quantity"] for p in _positions.values()
|
||||
)
|
||||
signals = await _scan_signals(
|
||||
total_assets=total_assets,
|
||||
available_cash=available_cash,
|
||||
current_day_trade_amount=current_day_trade_amount,
|
||||
)
|
||||
result["signals"] = [
|
||||
{
|
||||
"stock_code": s["signal"].stock_code,
|
||||
"strategy": s["signal"].strategy,
|
||||
"entry_price": s["signal"].entry_price,
|
||||
"stop_loss": s["signal"].stop_loss,
|
||||
"target_price": s["signal"].target_price,
|
||||
"reason": s["signal"].reason,
|
||||
"quantity": s["quantity"],
|
||||
}
|
||||
for s in signals
|
||||
]
|
||||
|
||||
for s in signals:
|
||||
if len(_positions) >= MAX_POSITIONS:
|
||||
break
|
||||
entry_result = await _execute_entry(s["signal"], s["quantity"])
|
||||
if entry_result:
|
||||
result["entries"].append(entry_result)
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ 일일 손실 한도 도달 ({_daily_pnl:,.0f}원) → 신규 진입 중단"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _collect_opening_range() -> None:
|
||||
"""Opening Range 집계 (09:00~09:30)"""
|
||||
for code in list(_watchlist.keys()):
|
||||
if code in _orb_cache:
|
||||
continue
|
||||
try:
|
||||
candles = await _fetch_candles(code)
|
||||
orb = calculate_opening_range(candles)
|
||||
if orb:
|
||||
_orb_cache[code] = orb
|
||||
logger.info(
|
||||
f"📊 ORB 수집 [{code}]: "
|
||||
f"H={orb.high:.0f}, L={orb.low:.0f}, "
|
||||
f"분당거래량={orb.avg_vol_per_min:.0f}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"ORB 수집 실패 [{code}]: {e}")
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
|
||||
async def _check_exits() -> List[Dict[str, Any]]:
|
||||
"""활성 포지션 청산 조건 체크 및 실행"""
|
||||
global _daily_pnl
|
||||
exits = []
|
||||
now_time = _kst_time()
|
||||
|
||||
for code, pos in list(_positions.items()):
|
||||
try:
|
||||
current_price = await _get_current_price(code)
|
||||
if current_price <= 0:
|
||||
continue
|
||||
|
||||
# 최고가 갱신
|
||||
if current_price > pos["highest_price"]:
|
||||
_positions[code]["highest_price"] = current_price
|
||||
|
||||
candles = await _fetch_candles(code)
|
||||
candles_5m = aggregate_to_5min(candles)
|
||||
|
||||
exit_signal = check_day_trade_exit(
|
||||
entry_price=pos["entry_price"],
|
||||
current_price=current_price,
|
||||
stop_loss=pos["stop_loss"],
|
||||
target_price=pos["target_price"],
|
||||
highest_since_entry=pos["highest_price"],
|
||||
candles_5min=candles_5m,
|
||||
current_time=now_time,
|
||||
)
|
||||
|
||||
if exit_signal:
|
||||
quantity = pos["quantity"]
|
||||
ok = await _place_sell_order(code, quantity, price=0) # 시장가
|
||||
if ok:
|
||||
pnl = (current_price - pos["entry_price"]) * quantity
|
||||
_daily_pnl += pnl
|
||||
del _positions[code]
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _record_trade(
|
||||
db, code, pos["stock_name"], "SELL",
|
||||
current_price, quantity, exit_signal.reason, pnl,
|
||||
)
|
||||
|
||||
exits.append({
|
||||
"stock_code": code,
|
||||
"reason": exit_signal.reason,
|
||||
"pnl": round(pnl, 0),
|
||||
"price": current_price,
|
||||
})
|
||||
logger.info(
|
||||
f"✅ 데이트레이딩 청산 [{code}]: "
|
||||
f"{exit_signal.reason}, PNL={pnl:+,.0f}원"
|
||||
)
|
||||
await websocket_manager.broadcast(_dumps({
|
||||
"type": "day_trade_exit",
|
||||
"stock_code": code,
|
||||
"reason": exit_signal.reason,
|
||||
"pnl": pnl,
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.error(f"청산 체크 오류 [{code}]: {e}")
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
return exits
|
||||
|
||||
|
||||
async def _scan_signals(
|
||||
total_assets: float,
|
||||
available_cash: float,
|
||||
current_day_trade_amount: float,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""감시 종목에서 진입 신호 스캔"""
|
||||
results = []
|
||||
for code, name in list(_watchlist.items()):
|
||||
if code in _positions:
|
||||
continue
|
||||
try:
|
||||
candles_1m = await _fetch_candles(code)
|
||||
if not candles_1m:
|
||||
continue
|
||||
candles_5m = aggregate_to_5min(candles_1m)
|
||||
current_price = candles_1m[-1].close if candles_1m else 0
|
||||
if current_price < settings.MIN_PRICE_KRW:
|
||||
continue
|
||||
|
||||
vwap = calculate_vwap(candles_1m)
|
||||
orb = _orb_cache.get(code)
|
||||
|
||||
signal: Optional[DayTradeSignal] = None
|
||||
|
||||
# ORB 우선
|
||||
if orb:
|
||||
signal = check_orb_signal(code, name, candles_1m, orb, current_price)
|
||||
|
||||
# VWAP 반등
|
||||
if signal is None and vwap:
|
||||
signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price)
|
||||
|
||||
# RSI+BB
|
||||
if signal is None:
|
||||
signal = check_rsi_bb_signal(code, name, candles_5m, current_price)
|
||||
|
||||
if signal:
|
||||
position_size = calculate_day_trade_position_size(
|
||||
total_assets, available_cash, current_day_trade_amount
|
||||
)
|
||||
if position_size < signal.entry_price:
|
||||
continue # 최소 1주도 살 수 없으면 스킵
|
||||
|
||||
quantity = max(1, int(position_size // signal.entry_price))
|
||||
results.append({"signal": signal, "quantity": quantity})
|
||||
logger.info(
|
||||
f"🎯 데이트레이딩 신호 [{code}] {signal.strategy}: "
|
||||
f"{signal.reason}, 수량={quantity}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"신호 스캔 오류 [{code}]: {e}")
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
# confidence 내림차순 정렬
|
||||
results.sort(key=lambda x: x["signal"].confidence, reverse=True)
|
||||
return results[:MAX_POSITIONS - len(_positions)]
|
||||
|
||||
|
||||
async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict]:
|
||||
"""진입 주문 실행 및 포지션 등록"""
|
||||
global _trade_lock
|
||||
async with _trade_lock:
|
||||
if len(_positions) >= MAX_POSITIONS:
|
||||
return None
|
||||
if signal.stock_code in _positions:
|
||||
return None
|
||||
|
||||
order_no = await _place_buy_order(
|
||||
signal.stock_code, quantity, int(signal.entry_price)
|
||||
)
|
||||
if not order_no:
|
||||
return None
|
||||
|
||||
_positions[signal.stock_code] = {
|
||||
"stock_name": signal.stock_name,
|
||||
"strategy": signal.strategy,
|
||||
"entry_price": signal.entry_price,
|
||||
"stop_loss": signal.stop_loss,
|
||||
"target_price": signal.target_price,
|
||||
"quantity": quantity,
|
||||
"highest_price": signal.entry_price,
|
||||
"entry_time": _kst_now().strftime("%H:%M:%S"),
|
||||
"reason": signal.reason,
|
||||
}
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _record_trade(
|
||||
db, signal.stock_code, signal.stock_name, "BUY",
|
||||
signal.entry_price, quantity, signal.reason,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"🛒 데이트레이딩 진입 [{signal.stock_code}] "
|
||||
f"{signal.strategy}: {signal.entry_price:.0f}원 × {quantity}주"
|
||||
)
|
||||
await websocket_manager.broadcast(_dumps({
|
||||
"type": "day_trade_entry",
|
||||
"stock_code": signal.stock_code,
|
||||
"strategy": signal.strategy,
|
||||
"entry_price": signal.entry_price,
|
||||
"quantity": quantity,
|
||||
}))
|
||||
return {
|
||||
"stock_code": signal.stock_code,
|
||||
"strategy": signal.strategy,
|
||||
"entry_price": signal.entry_price,
|
||||
"quantity": quantity,
|
||||
"reason": signal.reason,
|
||||
}
|
||||
|
||||
|
||||
async def _execute_eod_flat() -> None:
|
||||
"""EOD 전체 포지션 강제 청산"""
|
||||
global _daily_pnl
|
||||
for code, pos in list(_positions.items()):
|
||||
try:
|
||||
current_price = await _get_current_price(code)
|
||||
ok = await _place_sell_order(code, pos["quantity"], price=0)
|
||||
if ok:
|
||||
pnl = (current_price - pos["entry_price"]) * pos["quantity"]
|
||||
_daily_pnl += pnl
|
||||
del _positions[code]
|
||||
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
|
||||
except Exception as e:
|
||||
logger.error(f"EOD 청산 오류 [{code}]: {e}")
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
|
||||
# ── API 엔드포인트 ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/watchlist")
|
||||
async def get_watchlist():
|
||||
"""감시 종목 목록 조회"""
|
||||
_reset_if_new_day()
|
||||
return {
|
||||
"watchlist": [
|
||||
{"code": k, "name": v, "has_orb": k in _orb_cache}
|
||||
for k, v in _watchlist.items()
|
||||
],
|
||||
"count": len(_watchlist),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/watchlist")
|
||||
async def set_watchlist(req: WatchlistRequest):
|
||||
"""감시 종목 설정 (최대 20종목)
|
||||
|
||||
body: {"stocks": [{"code": "005930", "name": "삼성전자"}, ...]}
|
||||
"""
|
||||
global _watchlist
|
||||
if len(req.stocks) > 20:
|
||||
raise HTTPException(400, "감시 종목은 최대 20종목까지만 설정 가능합니다.")
|
||||
|
||||
_watchlist = {s["code"]: s.get("name", s["code"]) for s in req.stocks}
|
||||
logger.info(f"📋 감시 종목 설정: {list(_watchlist.keys())}")
|
||||
return {"status": "ok", "watchlist": list(_watchlist.keys())}
|
||||
|
||||
|
||||
@router.get("/signals")
|
||||
async def get_current_signals():
|
||||
"""현재 진입 가능 신호 조회 (주문 미실행)"""
|
||||
_reset_if_new_day()
|
||||
if not is_day_trade_entry_window():
|
||||
return {
|
||||
"phase": "outside_window",
|
||||
"message": "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).",
|
||||
"signals": [],
|
||||
}
|
||||
|
||||
portfolio = await _get_portfolio_summary()
|
||||
total_assets = portfolio["total_assets"]
|
||||
available_cash = portfolio["available_cash"]
|
||||
current_day_trade_amount = sum(
|
||||
p["entry_price"] * p["quantity"] for p in _positions.values()
|
||||
)
|
||||
|
||||
signals = await _scan_signals(total_assets, available_cash, current_day_trade_amount)
|
||||
return {
|
||||
"phase": "active",
|
||||
"signals": [
|
||||
{
|
||||
"stock_code": s["signal"].stock_code,
|
||||
"stock_name": s["signal"].stock_name,
|
||||
"strategy": s["signal"].strategy,
|
||||
"entry_price": s["signal"].entry_price,
|
||||
"stop_loss": s["signal"].stop_loss,
|
||||
"target_price": s["signal"].target_price,
|
||||
"reason": s["signal"].reason,
|
||||
"confidence": s["signal"].confidence,
|
||||
"quantity": s["quantity"],
|
||||
}
|
||||
for s in signals
|
||||
],
|
||||
"count": len(signals),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
async def execute_day_trade(req: ExecuteRequest):
|
||||
"""특정 종목 데이트레이딩 신호 스캔 후 진입"""
|
||||
_reset_if_new_day()
|
||||
code = req.stock_code
|
||||
name = _watchlist.get(code, code)
|
||||
|
||||
if code in _positions:
|
||||
raise HTTPException(400, f"{code}는 이미 포지션이 있습니다.")
|
||||
if len(_positions) >= MAX_POSITIONS:
|
||||
raise HTTPException(400, f"최대 포지션 수({MAX_POSITIONS}종목)에 도달했습니다.")
|
||||
if not is_day_trade_entry_window():
|
||||
raise HTTPException(400, "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).")
|
||||
|
||||
portfolio = await _get_portfolio_summary()
|
||||
total_assets = portfolio["total_assets"]
|
||||
available_cash = portfolio["available_cash"]
|
||||
|
||||
if is_daily_loss_exceeded(total_assets, _daily_pnl):
|
||||
raise HTTPException(400, "일일 손실 한도(-3%)에 도달하여 신규 진입이 차단됩니다.")
|
||||
|
||||
candles_1m = await _fetch_candles(code)
|
||||
candles_5m = aggregate_to_5min(candles_1m)
|
||||
current_price = candles_1m[-1].close if candles_1m else 0
|
||||
vwap = calculate_vwap(candles_1m)
|
||||
orb = _orb_cache.get(code)
|
||||
|
||||
signal = None
|
||||
if orb:
|
||||
signal = check_orb_signal(code, name, candles_1m, orb, current_price)
|
||||
if signal is None and vwap:
|
||||
signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price)
|
||||
if signal is None:
|
||||
signal = check_rsi_bb_signal(code, name, candles_5m, current_price)
|
||||
|
||||
if not signal:
|
||||
return {"status": "no_signal", "message": "현재 진입 조건을 충족하는 신호가 없습니다."}
|
||||
|
||||
current_day_trade_amount = sum(
|
||||
p["entry_price"] * p["quantity"] for p in _positions.values()
|
||||
)
|
||||
position_size = calculate_day_trade_position_size(
|
||||
total_assets, available_cash, current_day_trade_amount
|
||||
)
|
||||
quantity = max(1, int(position_size // signal.entry_price))
|
||||
|
||||
if req.dry_run:
|
||||
return {
|
||||
"status": "dry_run",
|
||||
"signal": {
|
||||
"strategy": signal.strategy,
|
||||
"entry_price": signal.entry_price,
|
||||
"stop_loss": signal.stop_loss,
|
||||
"target_price": signal.target_price,
|
||||
"reason": signal.reason,
|
||||
"quantity": quantity,
|
||||
},
|
||||
}
|
||||
|
||||
entry = await _execute_entry(signal, quantity)
|
||||
if not entry:
|
||||
raise HTTPException(500, "주문 실행에 실패했습니다.")
|
||||
return {"status": "ok", "entry": entry}
|
||||
|
||||
|
||||
@router.get("/positions")
|
||||
async def get_positions():
|
||||
"""활성 데이트레이딩 포지션 조회"""
|
||||
_reset_if_new_day()
|
||||
result = []
|
||||
for code, pos in _positions.items():
|
||||
try:
|
||||
current_price = await _get_current_price(code)
|
||||
except Exception:
|
||||
current_price = pos["entry_price"]
|
||||
pnl = (current_price - pos["entry_price"]) * pos["quantity"]
|
||||
pnl_pct = (current_price - pos["entry_price"]) / pos["entry_price"] * 100
|
||||
result.append({
|
||||
"stock_code": code,
|
||||
"stock_name": pos["stock_name"],
|
||||
"strategy": pos["strategy"],
|
||||
"entry_price": pos["entry_price"],
|
||||
"current_price": current_price,
|
||||
"stop_loss": pos["stop_loss"],
|
||||
"target_price": pos["target_price"],
|
||||
"quantity": pos["quantity"],
|
||||
"pnl": round(pnl, 0),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"entry_time": pos["entry_time"],
|
||||
})
|
||||
return {
|
||||
"positions": result,
|
||||
"count": len(result),
|
||||
"daily_pnl": round(_daily_pnl, 0),
|
||||
"is_daily_loss_exceeded": is_daily_loss_exceeded(
|
||||
sum(p["entry_price"] * p["quantity"] for p in _positions.values()),
|
||||
_daily_pnl,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/close-all")
|
||||
async def close_all_positions():
|
||||
"""전체 포지션 수동 강제 청산 (EOD 대비 수동 실행)"""
|
||||
_reset_if_new_day()
|
||||
await _execute_eod_flat()
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "전체 데이트레이딩 포지션 청산 완료",
|
||||
"daily_pnl": round(_daily_pnl, 0),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cycle")
|
||||
async def manual_cycle():
|
||||
"""1분 사이클 수동 실행 (테스트/디버그용)"""
|
||||
_reset_if_new_day()
|
||||
result = await run_day_trading_cycle()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status():
|
||||
"""데이트레이딩 시스템 상태 요약"""
|
||||
_reset_if_new_day()
|
||||
return {
|
||||
"watchlist_count": len(_watchlist),
|
||||
"active_positions": len(_positions),
|
||||
"orb_collected": len(_orb_cache),
|
||||
"daily_pnl": round(_daily_pnl, 0),
|
||||
"is_market_open": is_market_open(),
|
||||
"is_opening_range": is_opening_range_period(),
|
||||
"is_entry_window": is_day_trade_entry_window(),
|
||||
"is_eod_flat": is_eod_flat_time(),
|
||||
"today": _today_kst().isoformat(),
|
||||
}
|
||||
Reference in New Issue
Block a user