날짜 수정

This commit is contained in:
2026-02-23 08:37:24 +09:00
parent 1814f5e55e
commit d2829a47a7
7 changed files with 163 additions and 32 deletions

View File

@@ -45,8 +45,9 @@ 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 app.models.models import TradeHistory, ActiveTrade
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sa_update
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -263,8 +264,11 @@ async def run_day_trading_cycle() -> Dict[str, Any]:
result["phase"] = "active"
# 사이클 단위 분봉 캐시 — 청산 체크와 신규 진입에서 동일 종목 중복 fetch 방지
_cycle_candle_cache: Dict[str, List[MinuteCandle]] = {}
# ── 4. 활성 포지션 청산 체크 ──
exits = await _check_exits()
exits = await _check_exits(candle_cache=_cycle_candle_cache)
result["exits"] = exits
# ── 5. 신규 진입 신호 체크 ──
@@ -281,6 +285,7 @@ async def run_day_trading_cycle() -> Dict[str, Any]:
total_assets=total_assets,
available_cash=available_cash,
current_day_trade_amount=current_day_trade_amount,
candle_cache=_cycle_candle_cache,
)
result["signals"] = [
{
@@ -329,8 +334,14 @@ async def _collect_opening_range() -> None:
await asyncio.sleep(0.6)
async def _check_exits() -> List[Dict[str, Any]]:
"""활성 포지션 청산 조건 체크 및 실행"""
async def _check_exits(
candle_cache: Optional[Dict[str, List[MinuteCandle]]] = None,
) -> List[Dict[str, Any]]:
"""활성 포지션 청산 조건 체크 및 실행
Args:
candle_cache: 사이클 단위 분봉 캐시 {stock_code: candles}. None이면 직접 fetch.
"""
global _daily_pnl
exits = []
now_time = _kst_time()
@@ -345,7 +356,12 @@ async def _check_exits() -> List[Dict[str, Any]]:
if current_price > pos["highest_price"]:
_positions[code]["highest_price"] = current_price
candles = await _fetch_candles(code)
if candle_cache is not None and code in candle_cache:
candles = candle_cache[code]
else:
candles = await _fetch_candles(code)
if candle_cache is not None:
candle_cache[code] = candles
candles_5m = aggregate_to_5min(candles)
exit_signal = check_day_trade_exit(
@@ -371,6 +387,12 @@ async def _check_exits() -> List[Dict[str, Any]]:
db, code, pos["stock_name"], "SELL",
current_price, quantity, exit_signal.reason, pnl,
)
await db.execute(
sa_update(ActiveTrade)
.where(ActiveTrade.stock_code == code, ActiveTrade.strategy_name == "day_trade", ActiveTrade.status == "active")
.values(status="closed")
)
await db.commit()
exits.append({
"stock_code": code,
@@ -399,14 +421,24 @@ async def _scan_signals(
total_assets: float,
available_cash: float,
current_day_trade_amount: float,
candle_cache: Optional[Dict[str, List[MinuteCandle]]] = None,
) -> List[Dict[str, Any]]:
"""감시 종목에서 진입 신호 스캔"""
"""감시 종목에서 진입 신호 스캔
Args:
candle_cache: 사이클 단위 분봉 캐시 {stock_code: candles}. None이면 직접 fetch.
"""
results = []
for code, name in list(_watchlist.items()):
if code in _positions:
continue
try:
candles_1m = await _fetch_candles(code)
if candle_cache is not None and code in candle_cache:
candles_1m = candle_cache[code]
else:
candles_1m = await _fetch_candles(code)
if candle_cache is not None:
candle_cache[code] = candles_1m
if not candles_1m:
continue
candles_5m = aggregate_to_5min(candles_1m)
@@ -486,6 +518,21 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict
db, signal.stock_code, signal.stock_name, "BUY",
signal.entry_price, quantity, signal.reason,
)
# ActiveTrade 저장 — 재시작 시 포지션 복구를 위해
active = ActiveTrade(
stock_code=signal.stock_code,
stock_name=signal.stock_name,
quantity=quantity,
entry_price=signal.entry_price,
stop_loss=signal.stop_loss,
take_profit=signal.target_price,
highest_since_entry=signal.entry_price,
strategy_name="day_trade",
entry_reason=signal.strategy,
status="active",
)
db.add(active)
await db.commit()
logger.info(
f"🛒 데이트레이딩 진입 [{signal.stock_code}] "
@@ -519,11 +566,69 @@ async def _execute_eod_flat() -> None:
_daily_pnl += pnl
del _positions[code]
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
async with AsyncSessionLocal() as db:
await _record_trade(
db, code, pos["stock_name"], "SELL",
current_price, pos["quantity"], "EOD_flat", pnl,
)
await db.execute(
sa_update(ActiveTrade)
.where(ActiveTrade.stock_code == code, ActiveTrade.strategy_name == "day_trade", ActiveTrade.status == "active")
.values(status="closed")
)
await db.commit()
await websocket_manager.broadcast(_dumps({
"type": "day_trade_exit",
"stock_code": code,
"reason": "EOD_flat",
"pnl": pnl,
}))
except Exception as e:
logger.error(f"EOD 청산 오류 [{code}]: {e}")
await asyncio.sleep(0.6)
async def restore_positions_from_db() -> None:
"""앱 재시작 후 오늘 데이트레이딩 포지션 DB 복구.
strategy_name='day_trade', status='active'인 당일 ActiveTrade 레코드로
인메모리 _positions를 재구성한다.
"""
if _positions:
return # 이미 포지션 있으면 스킵
today = _today_kst()
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ActiveTrade).where(
ActiveTrade.strategy_name == "day_trade",
ActiveTrade.status == "active",
)
)
rows = result.scalars().all()
for row in rows:
if row.created_at and row.created_at.date() != today:
continue
entry_price = float(row.entry_price)
_positions[row.stock_code] = {
"stock_name": row.stock_name or row.stock_code,
"strategy": row.entry_reason or "unknown",
"entry_price": entry_price,
"stop_loss": float(row.stop_loss) if row.stop_loss else entry_price * 0.97,
"target_price": float(row.take_profit) if row.take_profit else entry_price * 1.03,
"quantity": row.quantity,
"highest_price": float(row.highest_since_entry or row.entry_price),
"entry_time": row.created_at.strftime("%H:%M:%S") if row.created_at else "??:??:??",
"reason": row.entry_reason or "",
}
if _positions:
logger.info(f"📋 데이트레이딩 포지션 복구: {list(_positions.keys())}")
except Exception as e:
logger.error(f"데이트레이딩 포지션 복구 실패: {e}")
# ── API 엔드포인트 ────────────────────────────────────────────────────────────
@router.get("/watchlist")