fix: 감사 잔여 항목 — 잔량 출처 DB화·ORB 영속화·미국 부분청산·듀얼전략 태깅·NULL 가격

- portfolio_cache_bridge: 잔량 산출을 Portfolio(DB) 기준으로. 캐시는 sync 실패 시 최대
  5분 낡아 실제와 다른 잔량이 기록될 수 있었음(감사 #14). 조회 실패 시 캐시 폴백.
- ORB 캐시 Redis 영속화(save/load_orb_cache) — 재배포 시 인메모리가 날아가 당일 ORB
  전략이 통째로 불가능했음. 수집 후 저장, 스케줄러 기동·수집 시 복원.
- us_market_scheduler: 부분청산이 Portfolio.quantity 를 갱신하지 않던 문제 → reflect_sell 연동.
- 듀얼 전략(MOMENTUM_BREAKOUT/PULLBACK_BUY)이 ActiveTrade.strategy_name 에 저장되지 않아
  CLAUDE.md §8.4 모멘텀 전용 청산(+10%/+20% 분할)이 한 번도 실행된 적 없었음 → 태깅 전달.
  매도 시에는 실제 행의 strategy_name 으로 close (swing 으로 닫으면 유령 행 잔존).
- 수동매도: current_price NULL 시 TypeError 로 주문 후 상태 갱신이 통째로 누락되던 엣지 수정.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:11:20 +09:00
parent 8971a648e2
commit 85cb5cbc09
10 changed files with 241 additions and 8 deletions

View File

@@ -175,6 +175,10 @@ async def collect_opening_ranges() -> None:
KIS API 호출 간격: 0.3초 (레이트 리밋 준수)
"""
day_trading_state.reset_if_new_day()
# 재기동으로 인메모리가 비었을 수 있으므로 먼저 Redis 캐시를 복원한다
await day_trading_state.load_orb_cache()
_collected_before = len(day_trading_state.orb_cache)
for stock_code in list(_watchlist.keys()):
# 이미 ORB가 수집된 종목은 건너뜀
@@ -194,6 +198,9 @@ async def collect_opening_ranges() -> None:
except Exception as e:
logger.debug(f"[DayEntry] ORB 수집 실패 ({stock_code}): {e}")
if len(day_trading_state.orb_cache) != _collected_before:
await day_trading_state.save_orb_cache()
async def scan_and_enter() -> list[dict]:
"""감시 종목의 5분봉 데이터를 스캔하여 진입 신호 발생 시 매수 실행.

View File

@@ -230,6 +230,58 @@ class DayTradingState:
"""
return stock_code in self.orb_cache
async def save_orb_cache(self) -> bool:
"""ORB 캐시를 Redis 에 저장 (당일 키, 자정까지 TTL).
컨테이너 재기동 시 인메모리 캐시가 사라져 당일 ORB 전략이 불가능해지는 것을 막는다.
"""
try:
from app.core.redis import setex_json
payload = {
code: {
"high": float(getattr(o, "high", 0) or 0),
"low": float(getattr(o, "low", 0) or 0),
"total_volume": int(getattr(o, "total_volume", 0) or 0),
"avg_vol_per_min": float(getattr(o, "avg_vol_per_min", 0) or 0),
"open_price": float(getattr(o, "open_price", 0) or 0),
}
for code, o in self.orb_cache.items()
}
if not payload:
return False
return await setex_json(f"daytrading:orb:{_today_kst()}", 86400, payload)
except Exception as e:
logger.warning(f"[DayTrading] ORB 캐시 저장 실패: {e}")
return False
async def load_orb_cache(self) -> int:
"""Redis 에서 당일 ORB 캐시 복원. 복원한 종목 수 반환."""
try:
from app.core.redis import get_json
from app.services.day_trading_strategy import OpeningRange
data = await get_json(f"daytrading:orb:{_today_kst()}")
if not data:
return 0
restored = 0
for code, o in data.items():
if code in self.orb_cache:
continue
self.orb_cache[code] = OpeningRange(
high=float(o.get("high", 0) or 0),
low=float(o.get("low", 0) or 0),
total_volume=int(o.get("total_volume", 0) or 0),
avg_vol_per_min=float(o.get("avg_vol_per_min", 0) or 0),
open_price=float(o.get("open_price", 0) or 0),
)
restored += 1
if restored:
logger.info(f"[DayTrading] ORB 캐시 복원: {restored}개 종목")
return restored
except Exception as e:
logger.warning(f"[DayTrading] ORB 캐시 복원 실패: {e}")
return 0
def set_orb(self, stock_code: str, orb: Any) -> None:
"""ORB(시초가 레인지) 데이터 저장.

View File

@@ -77,6 +77,12 @@ async def daytrading_scheduler() -> None:
await asyncio.sleep(5)
logger.info("⚡ [DayTrading] Scheduler started (every 30s)")
# 재기동 시 당일 ORB 캐시 복원 — 없으면 그날 ORB 전략이 통째로 불가능해진다
try:
await day_trading_state.load_orb_cache()
except Exception as e:
logger.warning(f"[DayTrading] ORB 캐시 복원 실패: {e}")
# 앱 재시작 시 DB에서 당일 데이트레이딩 포지션 복원 (CRITICAL 버그 수정)
try:
from app.core.database import AsyncSessionLocal

View File

@@ -19,6 +19,23 @@ def _cache():
return portfolio_cache
async def _db_quantity(stock_code: str) -> int | None:
"""Portfolio 테이블의 현재 수량 (읽기 전용). 조회 실패 시 None."""
try:
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.models import Portfolio
async with AsyncSessionLocal() as session:
row = (await session.execute(
select(Portfolio.quantity).where(Portfolio.stock_code == stock_code)
)).scalars().first()
return int(row) if row is not None else None
except Exception as e:
logger.debug(f"[cache_bridge] Portfolio 수량 조회 실패 ({stock_code}): {e}")
return None
async def reflect_buy(
stock_code: str,
stock_name: str,
@@ -93,7 +110,13 @@ async def reflect_sell(
cache = _cache()
existing = next((h for h in cache.holdings if h.stock_code == stock_code), None)
avg = float(existing.average_price or price) if existing is not None else price
if existing is not None:
# 잔량 산출 기준은 DB(Portfolio) — 캐시는 sync 실패 시 최대 5분까지 낡을 수 있어
# 캐시 수량으로 절대값을 쓰면 실제와 다른 잔량이 기록된다(2026-09-23 감사 #14).
db_qty = await _db_quantity(stock_code)
if db_qty is not None:
remaining = max(0, db_qty - quantity)
elif existing is not None:
remaining = max(0, int(existing.quantity or 0) - quantity)
elif remaining_qty is not None:
remaining = max(0, int(remaining_qty))

View File

@@ -286,6 +286,9 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
"score": 0.8,
"signal": momentum_signal["description"],
"strategy_type": "swing",
# ActiveTrade.strategy_name 으로 저장돼 swing/exit_service 의 모멘텀 전용
# 청산(+10% 30% → +20% 30% → 트레일링, CLAUDE.md §8.4)을 타게 한다.
"strategy_name": "MOMENTUM_BREAKOUT",
"entry_reason": f"[MOMENTUM_BREAKOUT] {momentum_signal['description']}",
})
logger.info(
@@ -307,6 +310,7 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
"score": 0.6,
"signal": pullback_signal["description"],
"strategy_type": "swing",
"strategy_name": "PULLBACK_BUY",
"entry_reason": f"[PULLBACK_BUY] {pullback_signal['description']}",
})
logger.info(

View File

@@ -1077,7 +1077,9 @@ async def _execute_buy(
"stock_name": stock_name,
"quantity": quantity,
"entry_price": order_price,
"strategy_name": stock.get("strategy_type", "swing"),
# 듀얼 전략(MOMENTUM_BREAKOUT/PULLBACK_BUY)은 strategy_name 으로 전달돼
# 청산 분기를 결정한다. 없으면 strategy_type 폴백.
"strategy_name": stock.get("strategy_name") or stock.get("strategy_type", "swing"),
"stop_loss": stock.get("stop_loss", -float(settings.STOP_LOSS_PERCENT)),
"take_profit": stock.get("take_profit", 5.0),
"entry_reason": entry_reason,
@@ -1320,9 +1322,26 @@ async def _execute_sell(
# [C2] ActiveTrade 종료/갱신
try:
# 실제 행의 strategy_name 을 사용한다. strategy_type("swing")으로 닫으면
# 듀얼 전략 행(MOMENTUM_BREAKOUT/PULLBACK_BUY)과 매칭되지 않아 유령 행이 남는다.
_at_name = stock.get("strategy_name")
if not _at_name:
try:
from app.models.models import ActiveTrade as _AT
_at_row = (await db.execute(
select(_AT)
.where(_AT.stock_code == stock_code, _AT.status == "active")
.order_by(_AT.id.desc())
.limit(1)
)).scalars().first()
_at_name = _at_row.strategy_name if _at_row else None
except Exception:
_at_name = None
_at_name = _at_name or stock.get("strategy_type", "swing")
if sell_pct >= 1.0:
from app.services.db_worker import enqueue_close_active_trade
await enqueue_close_active_trade(stock_code, strategy_name=stock.get("strategy_type", "swing"))
await enqueue_close_active_trade(stock_code, strategy_name=_at_name)
else:
from app.services.db_worker import enqueue_active_trade_upsert
# [BUG FIX] 기존 remaining_pct에서 sell_pct를 차감해야 누적 분할매도가 올바르게 반영됨
@@ -1331,7 +1350,7 @@ async def _execute_sell(
new_remaining = max(0.0, remaining_pct - sell_pct)
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"strategy_name": stock.get("strategy_type", "swing"),
"strategy_name": _at_name,
"remaining_pct": new_remaining,
"quantity": max(0, total_qty - quantity), # 잔량 갱신(과다매도 방지)
"highest_since_entry": current_price,

View File

@@ -395,6 +395,12 @@ async def _monitor_us_exits(exchange_rate: float = _DEFAULT_FX_RATE) -> List[Dic
"strategy_type": "us_swing",
})
else:
# 부분청산 잔량을 Portfolio 에도 반영 (누락 시 다음 청산이 과다매도)
from app.services.portfolio_cache_bridge import reflect_sell as _reflect_sell
await _reflect_sell(
ticker, sell_qty, current_price,
remaining_qty=max(0, total_qty - sell_qty),
)
await enqueue_active_trade_upsert({
"stock_code": ticker,
"strategy_name": "us_mean_reversion",