From 70def8fe0beda6740878fcd1e8adcd313026d431 Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 10:49:30 +0900 Subject: [PATCH] =?UTF-8?q?fix(P0):=20=EC=9D=B8=EC=A6=9D=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=C2=B7=EB=A0=88=EA=B1=B0=EC=8B=9C=20DT=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EB=B3=91=EC=A1=B4=C2=B7=ED=98=BC=EC=9E=AC=20?= =?UTF-8?q?=EC=A2=85=EB=AA=A9=20=EC=9E=AC=ED=83=9C=EA=B9=85=C2=B7=EC=9E=90?= =?UTF-8?q?=EC=A0=95=20=ED=9B=84=20=EC=8A=A4=ED=85=8C=EC=9D=BC=20=EC=B2=AD?= =?UTF-8?q?=EC=82=B0=20=EB=AC=B4=EB=A0=A5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 감사 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) --- backend/app/api/endpoints/day_trading.py | 55 ++++++++---- backend/app/main.py | 8 +- .../app/services/daytrading/exit_service.py | 48 +++++++++-- .../app/services/portfolio_cache_bridge.py | 15 ++-- .../test_daytrading_stale_position_guard.py | 35 ++++++++ backend/tests/test_p0_audit_fixes.py | 83 +++++++++++++++++++ 6 files changed, 214 insertions(+), 30 deletions(-) create mode 100644 backend/tests/test_p0_audit_fixes.py diff --git a/backend/app/api/endpoints/day_trading.py b/backend/app/api/endpoints/day_trading.py index 2e1c096..ca3c557 100644 --- a/backend/app/api/endpoints/day_trading.py +++ b/backend/app/api/endpoints/day_trading.py @@ -51,6 +51,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select # MCP risk assessment — 모듈 최상단에서 import (순환 참조 시 별도 util 모듈 분리 고려) from app.api.endpoints.mcp import request_mcp_risk_assessment +from app.core.security import require_api_key router = APIRouter() logger = logging.getLogger(__name__) @@ -804,7 +805,7 @@ async def get_watchlist(): } -@router.post("/watchlist") +@router.post("/watchlist", dependencies=[Depends(require_api_key)]) async def set_watchlist(req: WatchlistRequest): """감시 종목 설정 (최대 20종목) — 실제 엔진과 이 모듈 양쪽에 반영 @@ -860,9 +861,22 @@ async def get_current_signals(): } -@router.post("/execute") +@router.post("/execute", dependencies=[Depends(require_api_key)]) async def execute_day_trade(req: ExecuteRequest): - """특정 종목 데이트레이딩 신호 스캔 후 진입""" + """특정 종목 데이트레이딩 신호 스캔 후 진입. + + [2026-09-23] 실매수(dry_run=False)는 비활성화. 이 경로로 진입한 포지션은 실제 엔진의 + `day_trading_state`에 등록되지 않아 손절·트레일링·EOD 청산을 전혀 받지 못한다. + 신호 확인용 dry_run 만 허용한다. + """ + if not req.dry_run: + raise HTTPException( + status_code=410, + detail=( + "이 엔드포인트의 실매수는 비활성화되었습니다(엔진 미관리 포지션 방지). " + "자동 진입은 스케줄러가, 수동 매수는 /portfolio/buy 를 사용하세요." + ), + ) _reset_if_new_day() code = req.stock_code name = _watchlist.get(code, code) @@ -975,24 +989,37 @@ async def get_positions(): } -@router.post("/close-all") +@router.post("/close-all", dependencies=[Depends(require_api_key)]) async def close_all_positions(): - """전체 포지션 수동 강제 청산 (EOD 대비 수동 실행)""" - _reset_if_new_day() - await _execute_eod_flat() + """전체 포지션 수동 강제 청산 — 실제 엔진(services/daytrading)에 위임. + + [2026-09-23] 이 모듈의 레거시 인메모리 상태로 청산하면 엔진 상태가 그대로 남아 + 30초 뒤 같은 물량을 다시 매도한다(이중 매도). 엔진 경로만 사용한다. + """ + from app.services.daytrading.exit_service import force_close_all + _es, _st = _engine() + _st.reset_if_new_day() + if not _st.positions: + return {"status": "ok", "message": "활성 포지션 없음", "exits": [], "daily_pnl": round(_st.daily_pnl, 0)} + exits = await force_close_all(reason="수동 전량 청산 (API)") return { "status": "ok", - "message": "전체 데이트레이딩 포지션 청산 완료", - "daily_pnl": round(_daily_pnl, 0), + "message": f"데이트레이딩 포지션 {len(exits)}건 청산 완료", + "exits": exits, + "daily_pnl": round(_st.daily_pnl, 0), } -@router.post("/cycle") +@router.post("/cycle", dependencies=[Depends(require_api_key)]) async def manual_cycle(): - """1분 사이클 수동 실행 (테스트/디버그용)""" - _reset_if_new_day() - result = await run_day_trading_cycle() - return result + """1 사이클 수동 실행 — 실제 엔진의 청산·진입 로직 호출 (테스트/디버그용).""" + from app.services.daytrading.exit_service import check_and_exit_positions + from app.services.daytrading.entry_service import scan_and_enter + _es, _st = _engine() + _st.reset_if_new_day() + exits = await check_and_exit_positions() + entries = await scan_and_enter() + return {"exits": exits, "entries": entries, "positions": _st.position_count()} @router.get("/status") diff --git a/backend/app/main.py b/backend/app/main.py index 43e5670..023386f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -215,11 +215,9 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"⚠️ [KRX] 초기 데이터 확인 실패 (non-critical): {e}") - try: - from app.api.endpoints.day_trading import restore_positions_from_db - await restore_positions_from_db() - except Exception as e: - logger.warning(f"⚠️ 데이트레이딩 포지션 복구 실패 (non-critical): {e}") + # [2026-09-23] 레거시 day_trading._positions 복원 제거. + # services/daytrading/scheduler 가 day_trading_state 로 복원하므로, 둘 다 복원하면 + # 같은 포지션이 두 엔진에 존재해 이중 매도가 발생한다. # ── httpx 싱글톤 클라이언트 초기화 ────────────────────────────────────── from app.api.endpoints.mcp import init_http_client, close_http_client diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index 2f57454..8fb83a4 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -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: diff --git a/backend/app/services/portfolio_cache_bridge.py b/backend/app/services/portfolio_cache_bridge.py index 76b92c8..dad65cd 100644 --- a/backend/app/services/portfolio_cache_bridge.py +++ b/backend/app/services/portfolio_cache_bridge.py @@ -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}") diff --git a/backend/tests/test_daytrading_stale_position_guard.py b/backend/tests/test_daytrading_stale_position_guard.py index e1b26c9..7723b30 100644 --- a/backend/tests/test_daytrading_stale_position_guard.py +++ b/backend/tests/test_daytrading_stale_position_guard.py @@ -57,6 +57,41 @@ async def test_today_position_untouched(): exec_mock.assert_not_awaited() +@pytest.mark.asyncio +async def test_stale_position_found_from_db_after_midnight_reset(): + """자정 reset 으로 인메모리가 비어도 DB(ActiveTrade)에서 전일 포지션을 찾아 청산한다.""" + from types import SimpleNamespace + from datetime import datetime as _dt + + day_trading_state.positions = {} + row = SimpleNamespace( + stock_code="064400", stock_name="LG씨엔에스", entry_price=70800.0, quantity=4, + highest_since_entry=71200.0, created_at=_dt.now().replace(microsecond=0) - timedelta(days=1), + order_id="1", strategy_name="day_trade", status="active", + ) + + class _Result: + def scalars(self): + return SimpleNamespace(all=lambda: [row]) + + class _Sess: + async def __aenter__(self): return self + async def __aexit__(self, *a): return False + async def execute(self, *a, **k): return _Result() + + exec_mock = AsyncMock(return_value={"stock_code": "064400", "quantity": 4}) + with patch.object(es, "is_market_open", return_value=True), \ + patch("app.core.database.AsyncSessionLocal", _Sess), \ + patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "70300"}})), \ + patch.object(es, "_execute_exit", exec_mock): + out = await es.close_stale_positions() + + assert len(out) == 1 + assert exec_mock.await_args.args[1]["quantity"] == 4 + assert exec_mock.await_args.kwargs["sell_pct"] == 1.0 + + @pytest.mark.asyncio async def test_no_action_when_market_closed(): day_trading_state.positions["064400"] = _pos(es._kst_now() - timedelta(days=1)) diff --git a/backend/tests/test_p0_audit_fixes.py b/backend/tests/test_p0_audit_fixes.py new file mode 100644 index 0000000..4b1e3f3 --- /dev/null +++ b/backend/tests/test_p0_audit_fixes.py @@ -0,0 +1,83 @@ +"""감사 P0 수정 검증: 인증 누락 / 레거시 엔진 병존 / 혼재 종목 재태깅.""" +import inspect +from decimal import Decimal +from unittest.mock import AsyncMock, patch + +import pytest + +from app.api.endpoints import day_trading as dt +from app.api.endpoints.portfolio import portfolio_cache +from app.models.models import Portfolio +from app.services import portfolio_cache_bridge as bridge + + +def _deps(fn_name: str): + """해당 라우트에 걸린 의존성 이름 목록 (중첩 포함).""" + for r in dt.router.routes: + if getattr(r, "endpoint", None) is not None and r.endpoint.__name__ == fn_name: + names, stack = [], list(r.dependant.dependencies) + while stack: + d = stack.pop() + if d.call is not None: + names.append(getattr(d.call, "__name__", str(d.call))) + stack.extend(d.dependencies) + return names + raise AssertionError(f"route not found: {fn_name}") + + +@pytest.mark.parametrize("fn", ["set_watchlist", "execute_day_trade", + "close_all_positions", "manual_cycle"]) +def test_write_endpoints_require_api_key(fn): + """CLAUDE.md §4: 쓰기 엔드포인트는 require_api_key 필수 (인증 없이 전량청산 가능했음).""" + assert "require_api_key" in _deps(fn), f"{fn}: 인증 누락" + + +def test_legacy_execute_blocks_real_orders(): + src = inspect.getsource(dt.execute_day_trade) + assert "if not req.dry_run:" in src and "410" in src + + +def test_close_all_delegates_to_engine(): + """레거시 인메모리로 청산하면 엔진이 같은 물량을 30초 뒤 재매도한다.""" + src = inspect.getsource(dt.close_all_positions) + assert "force_close_all" in src and "_execute_eod_flat" not in src + + +def test_startup_does_not_restore_legacy_positions(): + import app.main as m + src = inspect.getsource(m) + assert "restore_positions_from_db()" not in src, "레거시 복원이 남아 있으면 이중 매도" + + +@pytest.mark.asyncio +async def test_dt_buy_does_not_retag_existing_swing_row(): + """스윙 보유 종목을 DT가 추가 매수해도 행 전략 태깅을 바꾸면 안 된다. + + 바꾸면 get_swing_holdings 에서 빠져 스윙 보유분의 손절·익절이 완전히 멈춘다. + """ + portfolio_cache.holdings = [Portfolio( + stock_code="064400", stock_name="LG씨엔에스", quantity=8, + average_price=Decimal("70148"), current_price=Decimal("70800"), + strategy_type="swing", + )] + portfolio_cache.summary = {"available_cash": 1_000_000, "total_value": 0, "total_investment": 0, + "total_assets": 1_000_000, "total_profit_loss": 0, + "total_profit_loss_rate": 0, "total_stocks": 1} + q = AsyncMock() + with patch("app.services.db_worker.enqueue_portfolio_upsert", q): + await bridge.reflect_buy("064400", "LG씨엔에스", 4, 70800.0, strategy_type="daytrading") + + assert portfolio_cache.holdings[0].strategy_type == "swing" + assert portfolio_cache.holdings[0].quantity == 12 + assert "strategy_type" not in q.await_args.args[0], "기존 행에 전략 태깅을 덮어쓰면 안 됨" + + +@pytest.mark.asyncio +async def test_new_row_keeps_strategy_tag(): + portfolio_cache.holdings = [] + portfolio_cache.summary = None + q = AsyncMock() + with patch("app.services.db_worker.enqueue_portfolio_upsert", q): + await bridge.reflect_buy("005930", "삼성전자", 3, 70000.0, strategy_type="daytrading") + assert q.await_args.args[0]["strategy_type"] == "daytrading" + assert portfolio_cache.holdings[0].strategy_type == "daytrading"