fix(P0): 인증 누락·레거시 DT 엔진 병존·혼재 종목 재태깅·자정 후 스테일 청산 무력화
감사 CRITICAL 4건:
- /daytrading/{watchlist,execute,close-all,cycle} 에 require_api_key 누락 → 인증 없이
전량 청산 가능했음 (CLAUDE.md §4 위반)
- 레거시 day_trading._positions 가 엔진 day_trading_state 와 별도로 기동 시 복원돼
같은 포지션이 두 엔진에 존재 → /close-all 시 이중 매도. 복원 제거 + close-all/cycle 을
엔진에 위임, /execute 실매수는 410(엔진 미관리 포지션 방지)
- reflect_buy 가 기존 행을 daytrading 으로 재태깅 → 같은 종목 스윙 보유분이
get_swing_holdings 에서 빠져 손절·익절 정지. 신규 행일 때만 태깅
- close_stale_positions 가 인메모리만 순회 → 자정 reset 후 대상이 사라져 무력.
ActiveTrade(day_trade/active/전일) 조회 기반으로 변경하고 인메모리가 비어도 실행
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,7 +70,8 @@ async def check_and_exit_positions() -> list[dict]:
|
||||
exits = []
|
||||
|
||||
if not day_trading_state.positions:
|
||||
return exits
|
||||
# 인메모리가 비어도 DB에 전일 미청산 포지션이 남아 있을 수 있다(자정 reset 이후)
|
||||
return await close_stale_positions()
|
||||
|
||||
# 일일 손실 한도 초과 시 전체 강제 청산
|
||||
# [S5] 미실현 손익도 포함하여 일일 손실 한도 판단
|
||||
@@ -330,16 +331,51 @@ async def close_stale_positions() -> list[dict]:
|
||||
today = _kst_now().date()
|
||||
results: list[dict] = []
|
||||
|
||||
# [2026-09-23] 인메모리 positions 만 순회하면 자정 reset_if_new_day() 이후에는
|
||||
# 대상이 사라져 안전장치가 무력해진다 → DB(ActiveTrade)에서도 전일 포지션을 찾는다.
|
||||
stale: dict[str, dict] = {}
|
||||
for stock_code in list(day_trading_state.positions.keys()):
|
||||
pos = day_trading_state.get_position(stock_code)
|
||||
if not pos:
|
||||
entry_time = (pos or {}).get("entry_time")
|
||||
entry_date = entry_time.date() if hasattr(entry_time, "date") else None
|
||||
if pos and entry_date is not None and entry_date < today:
|
||||
stale[stock_code] = pos
|
||||
|
||||
try:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import ActiveTrade
|
||||
from sqlalchemy import select as _select
|
||||
|
||||
async with AsyncSessionLocal() as _db:
|
||||
_rows = (await _db.execute(
|
||||
_select(ActiveTrade).where(
|
||||
ActiveTrade.strategy_name == "day_trade",
|
||||
ActiveTrade.status == "active",
|
||||
)
|
||||
)).scalars().all()
|
||||
for _row in _rows:
|
||||
_created = _row.created_at.date() if _row.created_at else None
|
||||
if _created is None or _created >= today or _row.stock_code in stale:
|
||||
continue
|
||||
_entry = float(_row.entry_price or 0)
|
||||
stale[_row.stock_code] = {
|
||||
"stock_name": _row.stock_name or _row.stock_code,
|
||||
"strategy": "day_trade",
|
||||
"entry_price": _entry,
|
||||
"quantity": int(_row.quantity or 0),
|
||||
"highest_price": float(_row.highest_since_entry or _entry),
|
||||
"entry_time": _row.created_at,
|
||||
"reason": "",
|
||||
"order_id": _row.order_id or "",
|
||||
}
|
||||
except Exception as _db_err:
|
||||
logger.warning(f"[DayExit] 전일 포지션 DB 조회 실패(인메모리만 처리): {_db_err}")
|
||||
|
||||
for stock_code, pos in stale.items():
|
||||
if int(pos.get("quantity", 0) or 0) <= 0:
|
||||
continue
|
||||
entry_time = pos.get("entry_time")
|
||||
if entry_time is None:
|
||||
continue
|
||||
entry_date = entry_time.date() if hasattr(entry_time, "date") else None
|
||||
if entry_date is None or entry_date >= today:
|
||||
continue
|
||||
|
||||
price = 0.0
|
||||
try:
|
||||
|
||||
@@ -42,8 +42,10 @@ async def reflect_buy(
|
||||
existing.quantity = new_qty
|
||||
existing.average_price = Decimal(str(round(new_avg, 2)))
|
||||
existing.current_price = Decimal(str(price))
|
||||
if strategy_type == "daytrading":
|
||||
existing.strategy_type = "daytrading"
|
||||
# [2026-09-23] 기존 행의 strategy_type 은 변경하지 않는다.
|
||||
# 스윙 8주 보유 중 DT 4주를 사면 행(12주)이 daytrading 이 되어
|
||||
# get_swing_holdings 에서 빠지고 스윙 8주의 손절·익절이 완전히 멈춘다.
|
||||
# 화면의 DT 그룹은 엔진 포지션(/daytrading/positions)으로 별도 표시한다.
|
||||
avg_for_db, qty_for_db = new_avg, new_qty
|
||||
else:
|
||||
cache.holdings.append(Portfolio(
|
||||
@@ -55,15 +57,18 @@ async def reflect_buy(
|
||||
))
|
||||
avg_for_db, qty_for_db = price, quantity
|
||||
|
||||
await enqueue_portfolio_upsert({
|
||||
_upsert = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"quantity": qty_for_db,
|
||||
"average_price": round(avg_for_db, 2),
|
||||
"current_price": price,
|
||||
"market": market,
|
||||
"strategy_type": strategy_type,
|
||||
})
|
||||
}
|
||||
if existing is None:
|
||||
# 신규 행일 때만 전략 태깅 (기존 행은 다른 전략 보유분이 섞여 있을 수 있음)
|
||||
_upsert["strategy_type"] = strategy_type
|
||||
await enqueue_portfolio_upsert(_upsert)
|
||||
await cache.apply_buy(stock_code, stock_name, quantity, price)
|
||||
except Exception as e:
|
||||
logger.warning(f"[cache_bridge] reflect_buy 실패 ({stock_code}): {e}")
|
||||
|
||||
Reference in New Issue
Block a user