fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류
## 재기동 내구성 - scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그 (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다) - 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화 날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합 (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다) - db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은 DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다) - 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단 - swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning ## DT 진입 복구 - 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어 5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전 발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리 (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경) - 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽) 임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐 - 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아 개장 직후 게이트가 자동 통과하는 것을 방지 - 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그 - watchlist 30분 갱신 시 ORB 보유 종목 우선 유지 ## 유입 잔고 격리 - sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로 편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리 (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요) - 격리분 손절선 이탈은 집행 없이 warning + summary 노출 - 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운 ## 4% 상한 - market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프 - 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증) ## 프론트 - 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시 - cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제 테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음) 전략 임계값 변경 0건 ⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어 미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후 "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
267
backend/tests/test_external_inflow_isolation.py
Normal file
267
backend/tests/test_external_inflow_isolation.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""유입(external) 잔고 격리 — strategy_type='external' 불변식.
|
||||
|
||||
배경(2026-09-28 조사): 모의계좌 교체 시 기존 잔고가 계좌에 그대로 편입됐고,
|
||||
`sync_portfolio()` 가 시스템 매수 여부를 검사하지 않고 전부 'swing' 으로 태깅해
|
||||
`get_swing_holdings()`(strategy_type=='swing')가 곧바로 손절 대상으로 잡았다.
|
||||
→ 시스템이 매수한 적 없는 잔고를 전략 손절로 처분(고아 청산 9건 / -153,661원).
|
||||
|
||||
사용자 결정: 유입 잔고는 'external' 로 격리하고 자동 손절·익절 대상에서 제외한다
|
||||
(그 수량은 손절이 걸리지 않으며 운영자가 직접 판단한다).
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.endpoints import portfolio as pf
|
||||
from app.api.endpoints.portfolio import (
|
||||
EXTERNAL_STRATEGY_TYPE,
|
||||
_classify_holding_strategy_type as _classify,
|
||||
)
|
||||
|
||||
|
||||
# ── 분류 규칙 (순수 함수) ───────────────────────────────────────────────────
|
||||
|
||||
def test_no_system_buy_evidence_is_external():
|
||||
assert _classify("000100", 8, {}, set()) == EXTERNAL_STRATEGY_TYPE
|
||||
|
||||
|
||||
def test_system_bought_is_swing():
|
||||
assert _classify("000100", 8, {}, {"000100"}) == "swing"
|
||||
|
||||
|
||||
def test_full_dt_quantity_is_daytrading():
|
||||
assert _classify("000100", 3, {"000100": 3}, {"000100"}) == "daytrading"
|
||||
|
||||
|
||||
def test_mixed_dt_and_extra_quantity_stays_swing():
|
||||
"""DT 3주 + 스윙 5주 = 8주 → DT 태깅하면 스윙 5주가 청산 관리를 잃는다."""
|
||||
assert _classify("000100", 8, {"000100": 3}, {"000100"}) == "swing"
|
||||
|
||||
|
||||
def test_dt_position_without_system_evidence_still_external():
|
||||
"""DT 인메모리가 비어 있고 근거도 없으면 external — '무조건 swing' 폴백 금지."""
|
||||
assert _classify("000100", 8, {"000100": 0}, set()) == EXTERNAL_STRATEGY_TYPE
|
||||
|
||||
|
||||
# ── sync_portfolio 불변식 (소스 수준) ──────────────────────────────────────
|
||||
|
||||
def test_sync_collects_system_buy_evidence_without_time_window():
|
||||
"""근거 조회는 기간 제한이 없어야 한다 — 당일만 보면 자정 후 재편입된다."""
|
||||
src = inspect.getsource(pf.sync_portfolio)
|
||||
i = src.index("유입(external) 잔고 격리용")
|
||||
block = src[i:i + 2500]
|
||||
assert 'TradeHistory.order_type == "BUY"' in block
|
||||
assert "recent_point" not in block, "유입 판정에 당일 시간창을 쓰면 안 된다"
|
||||
assert "_SEEDED_AT_STRATEGY_NAMES" in block, "시딩 ActiveTrade 를 근거에서 제외해야 한다"
|
||||
|
||||
|
||||
def test_sync_never_demotes_managed_rows_to_external():
|
||||
"""운용 중인 swing/daytrading 행을 external 로 강등하지 않는다(승격만 허용)."""
|
||||
src = inspect.getsource(pf.sync_portfolio)
|
||||
assert '== EXTERNAL_STRATEGY_TYPE and _classified == "swing"' in src
|
||||
# 강등 코드가 들어오면 (h.strategy_type = external) 패턴으로 나타난다
|
||||
assert "h.strategy_type = EXTERNAL_STRATEGY_TYPE" not in src
|
||||
|
||||
|
||||
def test_sync_skips_active_trade_seeding_for_external():
|
||||
"""격리분에 ActiveTrade 를 시딩하면 자동 손절 관리가 되살아난다."""
|
||||
src = inspect.getsource(pf.sync_portfolio)
|
||||
assert '(_h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE' in src
|
||||
|
||||
|
||||
def test_sync_logs_external_holdings_as_warning():
|
||||
"""격리 잔고는 조용히 방치되면 안 된다 — warning 으로 노출(debug 금지, §4)."""
|
||||
src = inspect.getsource(pf.sync_portfolio)
|
||||
assert "유입 격리 보유" in src
|
||||
i = src.index("유입 격리 보유")
|
||||
assert "logger.warning" in src[max(0, i - 200):i]
|
||||
|
||||
|
||||
def test_portfolio_response_exposes_strategy_type():
|
||||
"""프론트가 격리분을 구분할 수 있어야 한다(필드 노출)."""
|
||||
assert "strategy_type" in pf.PortfolioResponse.model_fields
|
||||
|
||||
|
||||
def test_summary_exposes_external_aggregate():
|
||||
src = inspect.getsource(pf.PortfolioCache.cache_summary)
|
||||
assert '"external_count"' in src and '"external_value"' in src
|
||||
|
||||
|
||||
def test_calculate_portfolio_values_counts_external_separately():
|
||||
class _H:
|
||||
def __init__(self, code, qty, price, st):
|
||||
self.stock_code, self.quantity = code, qty
|
||||
self.current_price, self.average_price = price, price
|
||||
self.market, self.strategy_type = "KRX", st
|
||||
|
||||
vals = pf._calculate_portfolio_values([
|
||||
_H("005930", 10, 70_000, "swing"),
|
||||
_H("000100", 8, 50_000, "external"),
|
||||
])
|
||||
assert vals["external_count"] == 1
|
||||
assert vals["external_value"] == pytest.approx(400_000)
|
||||
# 총자산·평가액에서 빼지 않는다 — 계좌 전체 리스크는 유입분을 포함해야 한다
|
||||
assert vals["domestic_value"] == pytest.approx(700_000 + 400_000)
|
||||
|
||||
|
||||
# ── 스윙 청산 대상에서 자동 제외되는지 (코드 변경 없이) ────────────────────
|
||||
|
||||
def test_swing_holdings_query_filters_by_swing_only():
|
||||
from app.services.swing import position_manager as pm
|
||||
src = inspect.getsource(pm.get_swing_holdings)
|
||||
assert "Portfolio.strategy_type == STRATEGY_TYPE" in src
|
||||
assert pm.STRATEGY_TYPE == "swing"
|
||||
|
||||
|
||||
# ── R2: 수동 청산의 연패 기록 ──────────────────────────────────────────────
|
||||
|
||||
def test_manual_sell_records_consecutive_loss():
|
||||
src = inspect.getsource(pf.sell_stock)
|
||||
assert "record_trade_result_safe" in src, "수동 청산이 연패 카운트에서 빠진다"
|
||||
i = src.index("record_trade_result_safe")
|
||||
assert "save_to_db" in src[i:i + 400], "record 후 save_to_db 누락 (§8.1-7)"
|
||||
# 전량 청산 시에만 + external 제외
|
||||
assert "quantity >= original_holding_qty and original_strategy_type != EXTERNAL_STRATEGY_TYPE" in src
|
||||
|
||||
|
||||
def test_manual_sell_v2_records_consecutive_loss():
|
||||
from app.api.endpoints import portfolio_v2 as pv2
|
||||
src = inspect.getsource(pv2.sell_stock_v2)
|
||||
assert "record_trade_result_safe" in src
|
||||
i = src.index("record_trade_result_safe")
|
||||
assert "save_to_db" in src[i:i + 500]
|
||||
assert 'original_strategy_type != "external"' in src
|
||||
|
||||
|
||||
# ── R3: 스윙 손절 종목의 당일 DT 재진입 차단 ───────────────────────────────
|
||||
|
||||
# ── 손절선 이탈 가시화 (집행 없음) ─────────────────────────────────────────
|
||||
|
||||
class _H:
|
||||
def __init__(self, code, qty, avg, cur, st="external", name=None):
|
||||
self.stock_code, self.quantity = code, qty
|
||||
self.average_price, self.current_price = avg, cur
|
||||
self.strategy_type, self.stock_name = st, name or code
|
||||
self.market = "KRX"
|
||||
|
||||
|
||||
def test_external_breach_detected_beyond_base_stop():
|
||||
from app.core.config import settings
|
||||
|
||||
_sl = abs(settings.STOP_LOSS_PERCENT)
|
||||
rows = pf._external_stop_breaches([
|
||||
_H("000100", 8, 10_000, 10_000 * (1 - (_sl + 3) / 100)), # 이탈
|
||||
_H("009830", 5, 10_000, 10_000 * (1 - (_sl - 2) / 100)), # 미이탈
|
||||
])
|
||||
assert [r["stock_code"] for r in rows] == ["000100"]
|
||||
assert rows[0]["profit_loss_rate"] == pytest.approx(-(_sl + 3), abs=0.01)
|
||||
assert rows[0]["breach_pct"] < 0
|
||||
|
||||
|
||||
def test_non_external_rows_are_not_reported():
|
||||
"""스윙 행은 실제 손절 경로가 처리한다 — 이 경고는 격리분 전용."""
|
||||
assert pf._external_stop_breaches([_H("005930", 10, 10_000, 1_000, st="swing")]) == []
|
||||
|
||||
|
||||
def test_breach_ignores_missing_prices():
|
||||
assert pf._external_stop_breaches([
|
||||
_H("000100", 8, 0, 5_000),
|
||||
_H("009830", 8, 10_000, 0),
|
||||
_H("241560", 0, 10_000, 1_000),
|
||||
]) == []
|
||||
|
||||
|
||||
def test_sync_logs_breach_without_executing():
|
||||
src = inspect.getsource(pf.sync_portfolio)
|
||||
assert "_external_stop_breaches(" in src
|
||||
i = src.index("유입 격리분 손절선 이탈")
|
||||
assert "logger.warning" in src[max(0, i - 200):i], "§4: 이탈 경고는 debug 금지"
|
||||
# 경고 블록 안에서 매도 주문을 내지 않는다
|
||||
block = src[i:i + 600]
|
||||
assert "safe_place_order" not in block and "place_order" not in block
|
||||
|
||||
|
||||
def test_summary_exposes_breach_list():
|
||||
src = inspect.getsource(pf.PortfolioCache.cache_summary)
|
||||
assert '"external_stop_breaches"' in src
|
||||
|
||||
|
||||
def _external_row():
|
||||
from app.models.models import Portfolio
|
||||
return Portfolio(
|
||||
stock_code="000100", stock_name="유한양행", quantity=8,
|
||||
average_price=10_000, current_price=10_000, market="KRX",
|
||||
strategy_type=EXTERNAL_STRATEGY_TYPE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_buy_promotes_external_row_on_swing_buy():
|
||||
"""격리 행에 스윙 시스템 매수가 들어오면 승격 — 다음 sync 까지 무관리 방지."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.services import portfolio_cache_bridge as bridge
|
||||
|
||||
row = _external_row()
|
||||
|
||||
class _Cache:
|
||||
holdings = [row]
|
||||
apply_buy = AsyncMock()
|
||||
|
||||
_enq = AsyncMock()
|
||||
with patch.object(bridge, "_cache", return_value=_Cache()), \
|
||||
patch("app.services.db_worker.enqueue_portfolio_upsert", _enq):
|
||||
await bridge.reflect_buy("000100", "유한양행", 5, 11_000, strategy_type="swing")
|
||||
|
||||
assert row.strategy_type == "swing"
|
||||
assert _enq.await_args.args[0]["strategy_type"] == "swing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_buy_keeps_external_on_daytrading_buy():
|
||||
"""DT 매수는 격리 태그를 바꾸지 않는다(격리 수량이 external 표시에서 사라짐 방지)."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.services import portfolio_cache_bridge as bridge
|
||||
|
||||
row = _external_row()
|
||||
|
||||
class _Cache:
|
||||
holdings = [row]
|
||||
apply_buy = AsyncMock()
|
||||
|
||||
_enq = AsyncMock()
|
||||
with patch.object(bridge, "_cache", return_value=_Cache()), \
|
||||
patch("app.services.db_worker.enqueue_portfolio_upsert", _enq):
|
||||
await bridge.reflect_buy("000100", "유한양행", 3, 11_000, strategy_type="daytrading")
|
||||
|
||||
assert row.strategy_type == EXTERNAL_STRATEGY_TYPE
|
||||
assert "strategy_type" not in _enq.await_args.args[0]
|
||||
|
||||
|
||||
# ── MDD 강제 축소는 격리분도 대상 (계좌 생존 장치) ─────────────────────────
|
||||
|
||||
def test_mdd_reduction_does_not_exclude_external():
|
||||
"""MDD -10%/-20% 축소는 계좌 전체 리스크 감축이므로 격리분도 매도 대상이다.
|
||||
|
||||
평시 전략 손절만 제외한다. `execute_reduction()` 은 KIS 잔고(output1)를 직접
|
||||
순회하므로 strategy_type 필터가 없어야 축소 목표(30%/50%)를 채울 수 있다.
|
||||
여기에 strategy_type 제외가 들어오면 -20% 국면에서 줄일 수 있는 포지션이
|
||||
장부상 줄어들어 리스크 감축이 약해진다.
|
||||
"""
|
||||
from app.services import mdd_manager as mm
|
||||
|
||||
src = inspect.getsource(mm.MDDManager.execute_reduction)
|
||||
assert 'balance.get("output1"' in src, "축소 대상 소스가 KIS 잔고가 아니다"
|
||||
# 'strategy_type' 자체는 TradeHistory 태그("mdd_reduction")로 등장한다 —
|
||||
# 금지 대상은 격리분 제외 필터다.
|
||||
assert EXTERNAL_STRATEGY_TYPE not in src, "MDD 축소에서 격리분을 제외하고 있다"
|
||||
assert "select(Portfolio" not in src, "축소 대상을 Portfolio(전략 태그) 기준으로 좁히고 있다"
|
||||
|
||||
|
||||
def test_swing_full_exit_blocks_same_day_dt_reentry():
|
||||
from app.services.swing import exit_service as ses
|
||||
src = inspect.getsource(ses.check_and_execute_exit_v2)
|
||||
assert "closed_today.add(stock_code)" in src, "전략 간 당일 쿨다운 누락"
|
||||
i = src.index("closed_today.add(stock_code)")
|
||||
assert "trade_lock" in src[max(0, i - 300):i], "closed_today 변경은 trade_lock 규약"
|
||||
Reference in New Issue
Block a user