구조적인 부분 수정

This commit is contained in:
2026-02-24 11:38:35 +09:00
parent 6d9c9cb010
commit 883f87753c
15 changed files with 789 additions and 672 deletions

View File

@@ -84,6 +84,12 @@ _orb_cache: Dict[str, OpeningRange] = {}
# 일일 실현 손익 (원)
_daily_pnl: float = 0.0
# 마지막으로 조회된 총자산 (is_daily_loss_exceeded 계산용 캐시)
_last_total_assets: float = 0.0
# 당일 청산 종목 (재진입 방지 — 청산 후 당일 동일 종목 재매수 차단)
_closed_today: set = set()
# 마지막 초기화 날짜
_last_reset_date: Optional[date] = None
@@ -93,11 +99,13 @@ _trade_lock = asyncio.Lock()
def _reset_if_new_day() -> None:
"""날짜가 바뀌면 일일 상태 초기화"""
global _daily_pnl, _orb_cache, _last_reset_date
global _daily_pnl, _orb_cache, _last_reset_date, _last_total_assets, _closed_today
today = _today_kst()
if _last_reset_date != today:
_daily_pnl = 0.0
_orb_cache = {}
_last_total_assets = 0.0
_closed_today = set()
_last_reset_date = today
logger.info(f"📅 데이트레이딩 상태 일일 초기화: {today}")
@@ -169,7 +177,7 @@ async def _place_buy_order(
async def _place_sell_order(
stock_code: str,
quantity: int,
price: int = 0, # 0 = 시장가
price: int = 0, # 0 = 시장가 (None으로 변환)
) -> bool:
"""매도 주문 실행"""
try:
@@ -177,7 +185,7 @@ async def _place_sell_order(
stock_code=stock_code,
order_type="SELL",
quantity=quantity,
price=price,
price=price if price > 0 else None, # 0이면 None → 시장가(01)
)
return result.get("rt_cd") == "0"
except Exception as e:
@@ -194,18 +202,24 @@ async def _record_trade(
quantity: int,
reason: str,
profit_loss: float = 0.0,
order_price: Optional[float] = None, # 주문가 (슬리피지 분석용)
) -> None:
"""TradeHistory DB 기록"""
try:
total = price * quantity
slippage = (price - order_price) if order_price is not None else None
trade = TradeHistory(
stock_code=stock_code,
stock_name=stock_name,
order_type=order_type,
order_price=order_price,
order_quantity=quantity,
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,
total_amount=total,
net_profit_loss=profit_loss, # ← net_profit_loss (profit_loss 컬럼 없음)
profit_loss_rate=(profit_loss / total * 100) if total > 0 else 0,
slippage=slippage,
status="COMPLETED",
entry_reason=reason if order_type == "BUY" else None,
exit_reason=reason if order_type == "SELL" else None,
@@ -257,8 +271,11 @@ async def run_day_trading_cycle() -> Dict[str, Any]:
result["orb_count"] = len(_orb_cache)
return result
# ── 3. 매매 가능 구간 (09:30~15:00) ──
if not is_day_trade_entry_window():
# ── 3. 매매 가능 구간 ──
in_entry_window = is_day_trade_entry_window() # 09:30~15:00
# 포지션이 있으면 입장 창 밖(15:00~15:20)에서도 청산 체크 수행
if not in_entry_window and not _positions:
result["phase"] = "outside_window"
return result
@@ -271,11 +288,18 @@ async def run_day_trading_cycle() -> Dict[str, Any]:
exits = await _check_exits(candle_cache=_cycle_candle_cache)
result["exits"] = exits
# ── 5. 신규 진입 신호 체크 ──
# ── 5. 신규 진입 신호 체크 (진입 창 안에서만) ──
if not in_entry_window:
result["phase"] = "exit_only"
return result
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"]
global _last_total_assets
if total_assets > 0:
_last_total_assets = total_assets # is_daily_loss_exceeded 계산용 캐시 갱신
if not is_daily_loss_exceeded(total_assets, _daily_pnl):
current_day_trade_amount = sum(
@@ -379,8 +403,10 @@ async def _check_exits(
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 _trade_lock:
_daily_pnl += pnl
_positions.pop(code, None)
_closed_today.add(code) # 당일 재진입 차단
async with AsyncSessionLocal() as db:
await _record_trade(
@@ -432,6 +458,8 @@ async def _scan_signals(
for code, name in list(_watchlist.items()):
if code in _positions:
continue
if code in _closed_today: # 당일 청산 종목 재진입 차단
continue
try:
if candle_cache is not None and code in candle_cache:
candles_1m = candle_cache[code]
@@ -494,6 +522,9 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict
return None
if signal.stock_code in _positions:
return None
if signal.stock_code in _closed_today: # 당일 청산 종목 재진입 최종 방어
logger.info(f"⛔ 당일 청산 종목 재진입 차단: {signal.stock_code}")
return None
order_no = await _place_buy_order(
signal.stock_code, quantity, int(signal.entry_price)
@@ -517,6 +548,7 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict
await _record_trade(
db, signal.stock_code, signal.stock_name, "BUY",
signal.entry_price, quantity, signal.reason,
order_price=float(int(signal.entry_price)), # 주문가 = 지정가
)
# ActiveTrade 저장 — 재시작 시 포지션 복구를 위해
active = ActiveTrade(
@@ -563,8 +595,10 @@ async def _execute_eod_flat() -> None:
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]
async with _trade_lock:
_daily_pnl += pnl
_positions.pop(code, None)
_closed_today.add(code) # 당일 재진입 차단
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
async with AsyncSessionLocal() as db:
@@ -595,10 +629,15 @@ async def restore_positions_from_db() -> None:
strategy_name='day_trade', status='active'인 당일 ActiveTrade 레코드로
인메모리 _positions를 재구성한다.
created_at은 UTC naive datetime으로 저장되므로 날짜 비교는 UTC 기준으로 수행.
한국 장중(09:00~15:30 KST = 00:00~06:30 UTC)은 UTC/KST 날짜가 항상 일치하므로
UTC 기준으로 비교해도 동일한 결과를 얻는다.
"""
if _positions:
return # 이미 포지션 있으면 스킵
today = _today_kst()
from datetime import timezone
today_utc = datetime.now(timezone.utc).date() # UTC 기준 오늘 날짜 (created_at과 동일 기준)
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
@@ -609,9 +648,16 @@ async def restore_positions_from_db() -> None:
)
rows = result.scalars().all()
for row in rows:
if row.created_at and row.created_at.date() != today:
# created_at은 UTC 저장 → UTC 날짜로 비교
if row.created_at and row.created_at.date() != today_utc:
continue
entry_price = float(row.entry_price)
# entry_time 표시: UTC → KST 변환 (UTC+9)
if row.created_at:
created_kst = row.created_at.replace(tzinfo=pytz.utc).astimezone(KST)
entry_time_str = created_kst.strftime("%H:%M:%S")
else:
entry_time_str = "??:??:??"
_positions[row.stock_code] = {
"stock_name": row.stock_name or row.stock_code,
"strategy": row.entry_reason or "unknown",
@@ -620,7 +666,7 @@ async def restore_positions_from_db() -> None:
"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 "??:??:??",
"entry_time": entry_time_str,
"reason": row.entry_reason or "",
}
if _positions:
@@ -793,7 +839,7 @@ async def get_positions():
"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()),
_last_total_assets, # 사이클 캐시 사용 (포지션 가치가 아닌 총자산)
_daily_pnl,
),
}