diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 11ca3c9..7a1a349 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -1493,9 +1493,13 @@ async def sell_stock( raise HTTPException(status_code=500, detail=f"매도 주문 실패: {error_msg}") # TradeHistory 기록 - executed_price = price if price else holding.current_price # 체결가는 나중에 업데이트 - profit_loss = (executed_price - holding.average_price) * quantity - profit_loss_rate = ((executed_price / holding.average_price) - 1) * 100 + # 체결가는 나중에 업데이트. current_price 가 NULL 이면 평균단가로 폴백 — + # None 연산으로 TypeError 가 나면 주문은 이미 나간 뒤라 상태 갱신이 전부 누락된다. + _exec_raw = price or holding.current_price or holding.average_price or 0 + executed_price = Decimal(str(_exec_raw)) + _avg_dec = Decimal(str(original_avg_price)) if original_avg_price > 0 else None + profit_loss = float((executed_price - _avg_dec) * quantity) if _avg_dec else 0.0 + profit_loss_rate = float((executed_price / _avg_dec - 1) * 100) if _avg_dec else 0.0 # CLAUDE.md 3.2: db_worker 큐 경유 from app.services.db_worker import enqueue_trade_history diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index 478c21b..7030c77 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -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분봉 데이터를 스캔하여 진입 신호 발생 시 매수 실행. diff --git a/backend/app/services/daytrading/position_manager.py b/backend/app/services/daytrading/position_manager.py index fbac0ea..018ca9c 100644 --- a/backend/app/services/daytrading/position_manager.py +++ b/backend/app/services/daytrading/position_manager.py @@ -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(시초가 레인지) 데이터 저장. diff --git a/backend/app/services/daytrading/scheduler.py b/backend/app/services/daytrading/scheduler.py index b86ff0f..3afe273 100644 --- a/backend/app/services/daytrading/scheduler.py +++ b/backend/app/services/daytrading/scheduler.py @@ -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 diff --git a/backend/app/services/portfolio_cache_bridge.py b/backend/app/services/portfolio_cache_bridge.py index dad65cd..27f69f6 100644 --- a/backend/app/services/portfolio_cache_bridge.py +++ b/backend/app/services/portfolio_cache_bridge.py @@ -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)) diff --git a/backend/app/services/swing/entry_service.py b/backend/app/services/swing/entry_service.py index db90d95..8b23bcc 100644 --- a/backend/app/services/swing/entry_service.py +++ b/backend/app/services/swing/entry_service.py @@ -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( diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index d3621d5..5de2b6d 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -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, diff --git a/backend/app/services/us_market_scheduler.py b/backend/app/services/us_market_scheduler.py index 740bd6e..2e6735e 100644 --- a/backend/app/services/us_market_scheduler.py +++ b/backend/app/services/us_market_scheduler.py @@ -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", diff --git a/backend/tests/test_p1_audit_fixes.py b/backend/tests/test_p1_audit_fixes.py index 6c3a122..02216de 100644 --- a/backend/tests/test_p1_audit_fixes.py +++ b/backend/tests/test_p1_audit_fixes.py @@ -32,7 +32,8 @@ def test_executor_sell_excludes_daytrading_shares(): assert "day_trading_state" in src and "_dt_qty" in src assert "owned_by_daytrading" in src assert 'total_qty = _own' in src - assert '"strategy_name": stock.get("strategy_type", "swing")' in src + # 분할매도 upsert 는 실제 ActiveTrade 행의 전략명(_at_name)을 사용한다 + assert '"strategy_name": _at_name' in src def test_sync_dt_tagging_applies_to_domestic_and_survives_missing_output1(): diff --git a/backend/tests/test_p3_audit_fixes.py b/backend/tests/test_p3_audit_fixes.py new file mode 100644 index 0000000..a6018b0 --- /dev/null +++ b/backend/tests/test_p3_audit_fixes.py @@ -0,0 +1,111 @@ +"""감사 잔여 항목: 잔량 출처 DB화 / ORB 영속화 / 미국 부분청산 / 듀얼전략 태깅 / NULL 가격.""" +import inspect +from decimal import Decimal +from unittest.mock import AsyncMock, patch + +import pytest + +from app.api.endpoints.portfolio import portfolio_cache +from app.models.models import Portfolio +from app.services import portfolio_cache_bridge as bridge +from app.services.daytrading.position_manager import day_trading_state + + +@pytest.fixture(autouse=True) +def _reset(): + portfolio_cache.holdings = [] + portfolio_cache.summary = {"available_cash": 0, "total_value": 0, "total_investment": 0, + "total_assets": 0, "total_profit_loss": 0, + "total_profit_loss_rate": 0, "total_stocks": 0} + portfolio_cache.recent_sells = {} + day_trading_state.orb_cache = {} + yield + day_trading_state.orb_cache = {} + + +@pytest.mark.asyncio +async def test_remaining_quantity_uses_db_not_stale_cache(): + """캐시가 낡아도(10주) DB(5주) 기준으로 잔량을 기록해야 한다.""" + portfolio_cache.holdings = [Portfolio( + stock_code="005930", stock_name="삼성전자", quantity=10, + average_price=Decimal("70000"), current_price=Decimal("71000"), strategy_type="swing", + )] + q = AsyncMock() + with patch("app.services.db_worker.enqueue_portfolio_upsert", q), \ + patch.object(bridge, "_db_quantity", AsyncMock(return_value=5)): + await bridge.reflect_sell("005930", 5, 71000.0) + assert q.await_args.args[0]["quantity"] == 0, "DB 5주 - 매도 5주 = 0 이어야 함" + + +@pytest.mark.asyncio +async def test_remaining_falls_back_to_cache_when_db_unavailable(): + portfolio_cache.holdings = [Portfolio( + stock_code="005930", stock_name="삼성전자", quantity=10, + average_price=Decimal("70000"), current_price=Decimal("71000"), strategy_type="swing", + )] + q = AsyncMock() + with patch("app.services.db_worker.enqueue_portfolio_upsert", q), \ + patch.object(bridge, "_db_quantity", AsyncMock(return_value=None)): + await bridge.reflect_sell("005930", 4, 71000.0) + assert q.await_args.args[0]["quantity"] == 6 + + +@pytest.mark.asyncio +async def test_orb_cache_roundtrip_through_redis(): + """재기동으로 인메모리가 비어도 당일 ORB 캐시를 복원해야 ORB 전략이 살아난다.""" + from app.services.day_trading_strategy import OpeningRange + + store = {} + + async def _setex(key, ttl, data): + store[key] = data + return True + + async def _get(key): + return store.get(key) + + day_trading_state.orb_cache = { + "005930": OpeningRange(high=71000, low=70000, total_volume=12000, + avg_vol_per_min=400, open_price=70500) + } + with patch("app.core.redis.setex_json", _setex): + assert await day_trading_state.save_orb_cache() is True + + day_trading_state.orb_cache = {} # 재기동 상황 + with patch("app.core.redis.get_json", _get): + assert await day_trading_state.load_orb_cache() == 1 + orb = day_trading_state.get_orb("005930") + assert (orb.high, orb.low, orb.open_price) == (71000, 70000, 70500) + assert orb.avg_vol_per_min == 400 + + +def test_us_partial_exit_updates_portfolio(): + from app.services import us_market_scheduler as us + src = inspect.getsource(us) + i = src.index('"remaining_pct": round(new_remaining, 2)') + assert "reflect_sell" in src[max(0, i - 600):i], "미국 부분청산이 Portfolio 를 갱신하지 않음" + + +def test_dual_strategy_tags_reach_active_trade(): + """§8.4 모멘텀 전용 청산이 실행되려면 ActiveTrade.strategy_name 에 전략이 저장돼야 한다.""" + from app.services.swing import entry_service as es + from app.services import trade_executor as te + src = inspect.getsource(es) + assert '"strategy_name": "MOMENTUM_BREAKOUT"' in src + assert '"strategy_name": "PULLBACK_BUY"' in src + buy = inspect.getsource(te) + assert 'stock.get("strategy_name") or stock.get("strategy_type", "swing")' in buy + + +def test_sell_closes_active_trade_with_real_strategy_name(): + """전량청산이 strategy_type('swing')으로 닫으면 MOMENTUM_BREAKOUT 행이 안 닫힌다.""" + from app.services import trade_executor as te + src = inspect.getsource(te._execute_sell) + assert "_at_row.strategy_name if _at_row else None" in src + assert "enqueue_close_active_trade(stock_code, strategy_name=_at_name)" in src + + +def test_manual_sell_handles_null_current_price(): + from app.api.endpoints import portfolio as pf + src = inspect.getsource(pf.sell_stock) + assert "price or holding.current_price or holding.average_price or 0" in src