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:
@@ -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():
|
||||
|
||||
111
backend/tests/test_p3_audit_fixes.py
Normal file
111
backend/tests/test_p3_audit_fixes.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user