구조적인 부분 수정
This commit is contained in:
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user