- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
137 lines
5.6 KiB
Python
137 lines
5.6 KiB
Python
"""감사 잔여 항목: 잔량 출처 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)
|
|
# [하드닝 R8] 역추적이 _resolve_sell_active_trade_target_v2 로 분리됨(불변식 동일)
|
|
assert "_resolve_sell_active_trade_target_v2(" in src
|
|
assert "_row.strategy_name if _row else None" in inspect.getsource(
|
|
te._resolve_sell_active_trade_target_v2
|
|
)
|
|
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
|
|
|
|
|
|
def test_available_cash_skips_negative_deposit():
|
|
"""예수금이 D+2 기준으로 음수(-913,965)여도 주문가능/정산 금액을 우선 사용해야 한다."""
|
|
from app.api.endpoints.portfolio import _extract_balance_data
|
|
|
|
out = _extract_balance_data({"output2": [{
|
|
"dnca_tot_amt": "-913965", # D+2 예수금 (당일 매수 반영으로 음수)
|
|
"nxdy_excc_amt": "4524276",
|
|
"prvs_rcdl_excc_amt": "5224855",
|
|
"tot_evlu_amt": "9825565",
|
|
}]})
|
|
assert out["available_cash"] == 5224855.0
|
|
|
|
# 주문가능현금이 주입되면 그 값이 최우선
|
|
out2 = _extract_balance_data({"output2": [{
|
|
"_ord_psbl_cash": 6000000.0,
|
|
"dnca_tot_amt": "-913965",
|
|
"prvs_rcdl_excc_amt": "5224855",
|
|
}]})
|
|
assert out2["available_cash"] == 6000000.0
|