Merge branch 'dev' into main — P0 감사 수정
This commit is contained in:
@@ -51,6 +51,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
# MCP risk assessment — 모듈 최상단에서 import (순환 참조 시 별도 util 모듈 분리 고려)
|
# MCP risk assessment — 모듈 최상단에서 import (순환 참조 시 별도 util 모듈 분리 고려)
|
||||||
from app.api.endpoints.mcp import request_mcp_risk_assessment
|
from app.api.endpoints.mcp import request_mcp_risk_assessment
|
||||||
|
from app.core.security import require_api_key
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
async def set_watchlist(req: WatchlistRequest):
|
||||||
"""감시 종목 설정 (최대 20종목) — 실제 엔진과 이 모듈 양쪽에 반영
|
"""감시 종목 설정 (최대 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):
|
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()
|
_reset_if_new_day()
|
||||||
code = req.stock_code
|
code = req.stock_code
|
||||||
name = _watchlist.get(code, 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():
|
async def close_all_positions():
|
||||||
"""전체 포지션 수동 강제 청산 (EOD 대비 수동 실행)"""
|
"""전체 포지션 수동 강제 청산 — 실제 엔진(services/daytrading)에 위임.
|
||||||
_reset_if_new_day()
|
|
||||||
await _execute_eod_flat()
|
[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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"message": "전체 데이트레이딩 포지션 청산 완료",
|
"message": f"데이트레이딩 포지션 {len(exits)}건 청산 완료",
|
||||||
"daily_pnl": round(_daily_pnl, 0),
|
"exits": exits,
|
||||||
|
"daily_pnl": round(_st.daily_pnl, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cycle")
|
@router.post("/cycle", dependencies=[Depends(require_api_key)])
|
||||||
async def manual_cycle():
|
async def manual_cycle():
|
||||||
"""1분 사이클 수동 실행 (테스트/디버그용)"""
|
"""1 사이클 수동 실행 — 실제 엔진의 청산·진입 로직 호출 (테스트/디버그용)."""
|
||||||
_reset_if_new_day()
|
from app.services.daytrading.exit_service import check_and_exit_positions
|
||||||
result = await run_day_trading_cycle()
|
from app.services.daytrading.entry_service import scan_and_enter
|
||||||
return result
|
_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")
|
@router.get("/status")
|
||||||
|
|||||||
@@ -215,11 +215,9 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ [KRX] 초기 데이터 확인 실패 (non-critical): {e}")
|
logger.warning(f"⚠️ [KRX] 초기 데이터 확인 실패 (non-critical): {e}")
|
||||||
|
|
||||||
try:
|
# [2026-09-23] 레거시 day_trading._positions 복원 제거.
|
||||||
from app.api.endpoints.day_trading import restore_positions_from_db
|
# services/daytrading/scheduler 가 day_trading_state 로 복원하므로, 둘 다 복원하면
|
||||||
await restore_positions_from_db()
|
# 같은 포지션이 두 엔진에 존재해 이중 매도가 발생한다.
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"⚠️ 데이트레이딩 포지션 복구 실패 (non-critical): {e}")
|
|
||||||
|
|
||||||
# ── httpx 싱글톤 클라이언트 초기화 ──────────────────────────────────────
|
# ── httpx 싱글톤 클라이언트 초기화 ──────────────────────────────────────
|
||||||
from app.api.endpoints.mcp import init_http_client, close_http_client
|
from app.api.endpoints.mcp import init_http_client, close_http_client
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ async def check_and_exit_positions() -> list[dict]:
|
|||||||
exits = []
|
exits = []
|
||||||
|
|
||||||
if not day_trading_state.positions:
|
if not day_trading_state.positions:
|
||||||
return exits
|
# 인메모리가 비어도 DB에 전일 미청산 포지션이 남아 있을 수 있다(자정 reset 이후)
|
||||||
|
return await close_stale_positions()
|
||||||
|
|
||||||
# 일일 손실 한도 초과 시 전체 강제 청산
|
# 일일 손실 한도 초과 시 전체 강제 청산
|
||||||
# [S5] 미실현 손익도 포함하여 일일 손실 한도 판단
|
# [S5] 미실현 손익도 포함하여 일일 손실 한도 판단
|
||||||
@@ -330,16 +331,51 @@ async def close_stale_positions() -> list[dict]:
|
|||||||
today = _kst_now().date()
|
today = _kst_now().date()
|
||||||
results: list[dict] = []
|
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()):
|
for stock_code in list(day_trading_state.positions.keys()):
|
||||||
pos = day_trading_state.get_position(stock_code)
|
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
|
continue
|
||||||
entry_time = pos.get("entry_time")
|
entry_time = pos.get("entry_time")
|
||||||
if entry_time is None:
|
|
||||||
continue
|
|
||||||
entry_date = entry_time.date() if hasattr(entry_time, "date") else None
|
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
|
price = 0.0
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ async def reflect_buy(
|
|||||||
existing.quantity = new_qty
|
existing.quantity = new_qty
|
||||||
existing.average_price = Decimal(str(round(new_avg, 2)))
|
existing.average_price = Decimal(str(round(new_avg, 2)))
|
||||||
existing.current_price = Decimal(str(price))
|
existing.current_price = Decimal(str(price))
|
||||||
if strategy_type == "daytrading":
|
# [2026-09-23] 기존 행의 strategy_type 은 변경하지 않는다.
|
||||||
existing.strategy_type = "daytrading"
|
# 스윙 8주 보유 중 DT 4주를 사면 행(12주)이 daytrading 이 되어
|
||||||
|
# get_swing_holdings 에서 빠지고 스윙 8주의 손절·익절이 완전히 멈춘다.
|
||||||
|
# 화면의 DT 그룹은 엔진 포지션(/daytrading/positions)으로 별도 표시한다.
|
||||||
avg_for_db, qty_for_db = new_avg, new_qty
|
avg_for_db, qty_for_db = new_avg, new_qty
|
||||||
else:
|
else:
|
||||||
cache.holdings.append(Portfolio(
|
cache.holdings.append(Portfolio(
|
||||||
@@ -55,15 +57,18 @@ async def reflect_buy(
|
|||||||
))
|
))
|
||||||
avg_for_db, qty_for_db = price, quantity
|
avg_for_db, qty_for_db = price, quantity
|
||||||
|
|
||||||
await enqueue_portfolio_upsert({
|
_upsert = {
|
||||||
"stock_code": stock_code,
|
"stock_code": stock_code,
|
||||||
"stock_name": stock_name,
|
"stock_name": stock_name,
|
||||||
"quantity": qty_for_db,
|
"quantity": qty_for_db,
|
||||||
"average_price": round(avg_for_db, 2),
|
"average_price": round(avg_for_db, 2),
|
||||||
"current_price": price,
|
"current_price": price,
|
||||||
"market": market,
|
"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)
|
await cache.apply_buy(stock_code, stock_name, quantity, price)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[cache_bridge] reflect_buy 실패 ({stock_code}): {e}")
|
logger.warning(f"[cache_bridge] reflect_buy 실패 ({stock_code}): {e}")
|
||||||
|
|||||||
@@ -57,6 +57,41 @@ async def test_today_position_untouched():
|
|||||||
exec_mock.assert_not_awaited()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_no_action_when_market_closed():
|
async def test_no_action_when_market_closed():
|
||||||
day_trading_state.positions["064400"] = _pos(es._kst_now() - timedelta(days=1))
|
day_trading_state.positions["064400"] = _pos(es._kst_now() - timedelta(days=1))
|
||||||
|
|||||||
83
backend/tests/test_p0_audit_fixes.py
Normal file
83
backend/tests/test_p0_audit_fixes.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user