Files
auto-stock/backend/tests/test_bugfixes_20260923.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 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
2026-10-10 12:17:49 +09:00

369 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""2026-09-23 로그 기반 버그 수정 회귀 테스트."""
from decimal import Decimal
from pathlib import Path
from unittest.mock import AsyncMock, patch
import numpy as np
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
@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 = {}
yield
@pytest.mark.asyncio
async def test_dt_full_exit_does_not_zero_swing_shares():
"""DT 전량청산(잔량 0)이 같은 종목의 스윙 보유분까지 0으로 만들면 안 된다.
2026-09-23: LG씨엔에스 스윙 8 + DT 7 = 15주. DT 전량청산 7주에
remaining_qty=0이 전달돼 Portfolio 행 전체가 0이 됐음(실제 잔고 8주).
"""
portfolio_cache.holdings = [Portfolio(
stock_code="064400", stock_name="LG씨엔에스", quantity=15,
average_price=Decimal("70400"), current_price=Decimal("70300"),
strategy_type="swing",
)]
q = AsyncMock()
# DB 조회는 목: 실제 AsyncSessionLocal(소켓)을 열지 않는다. None = 조회 실패 → 캐시 수량 기준.
db_q = AsyncMock(return_value=None)
with patch("app.services.db_worker.enqueue_portfolio_upsert", q), \
patch.object(bridge, "_db_quantity", db_q):
await bridge.reflect_sell("064400", 7, 70300.0, remaining_qty=0)
db_q.assert_awaited_once_with("064400")
assert portfolio_cache.holdings[0].quantity == 8, "스윙 보유분이 남아야 함"
assert q.await_args.args[0]["quantity"] == 8
def test_lstm_checkpoint_roundtrip_with_weights_only(tmp_path: Path):
"""LSTM 체크포인트가 weights_only=True 로 로드 가능해야 한다(앙상블 무력화 방지).
[2026-09-27 수정] 선재 결함: 이 테스트는 `ml_ensemble.LSTMPredictor` 를 임포트했는데
그 이름은 git 이력에 존재한 적이 없다(ImportError 로 항상 실패 = 이슈5 불변식이
사실상 미검증 상태였음). 실제 래퍼 클래스는 `ml_ensemble.StockLSTM`
(app/services/ml_ensemble.py:48, save():163 / load():185).
불변식은 그대로 유지·강화한다:
1) save() 산출물이 `torch.load(..., weights_only=True)` 로 **그대로** 읽혀야 한다
(numpy 배열 저장 금지 → list 로 저장, ml_ensemble.py:165 주석).
2) load() 는 그 체크포인트를 복원하고 정규화 파라미터를 ndarray 로 되돌려야 한다.
tmp_path 를 쓰는 것이 중요하다: load() 는 MODEL_DIR 내부 파일에 대해서만
`weights_only=False` 폴백을 허용하므로(ml_ensemble.py:194-206), MODEL_DIR 밖에서
검증해야 폴백에 가려지지 않고 순수 weights_only 경로가 증명된다.
"""
torch = pytest.importorskip("torch")
from app.services.ml_ensemble import MODEL_DIR, StockLSTM
m = StockLSTM(input_size=4)
if m.model is None:
pytest.skip("torch 모델 초기화 불가")
m.seq_mean = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32)
m.seq_std = np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float32)
path = tmp_path / "lstm.pt"
# MODEL_DIR 밖이어야 load() 의 weights_only=False 폴백이 적용되지 않는다
assert not path.resolve().is_relative_to(MODEL_DIR.resolve())
m.save(path)
assert path.exists(), "save() 가 체크포인트를 만들지 않았다"
# (1) 폴백 없이 weights_only=True 로 바로 읽혀야 한다
ckpt = torch.load(path, map_location="cpu", weights_only=True)
assert ckpt["seq_mean"] == [1.0, 2.0, 3.0, 4.0]
assert ckpt["seq_std"] == [1.0, 1.0, 1.0, 1.0]
assert isinstance(ckpt["seq_mean"], list), "numpy 배열 저장 시 weights_only 로드가 거부된다"
assert ckpt["config"]["input_size"] == 4
# (2) load() 왕복
m2 = StockLSTM(input_size=4)
assert m2.load(path) is True
assert isinstance(m2.seq_mean, np.ndarray)
np.testing.assert_allclose(m2.seq_mean, m.seq_mean)
np.testing.assert_allclose(m2.seq_std, m.seq_std)
# (3) 가중치도 동일하게 복원됐는지 (state_dict 왕복 확인)
sd1, sd2 = m.model.state_dict(), m2.model.state_dict()
assert sd1.keys() == sd2.keys()
for k in sd1:
assert torch.allclose(sd1[k].cpu(), sd2[k].cpu()), f"가중치 복원 불일치: {k}"
@pytest.mark.asyncio
async def test_dt_partial_exit_updates_active_trade_quantity():
"""DT 분할매도 후 ActiveTrade.quantity가 줄어야 재기동 복원 시 과다 매도가 없다.
2026-09-23: 7주 중 3주 분할매도 후에도 DB는 7주 → 재기동 복원이 7주로 되살아나
잔량 4주인데 7주 매도(스윙 3주 침범).
"""
from app.services.daytrading import exit_service as es
from app.services.daytrading.position_manager import day_trading_state
day_trading_state.positions["064400"] = {
"stock_name": "LG씨엔에스", "strategy": "day_trade", "entry_price": 70800.0,
"stop_loss": 69000.0, "target_price": 73000.0, "quantity": 7,
"highest_price": 71200.0, "entry_time": es._kst_now(), "reason": "", "order_id": "",
}
upserts = []
async def _capture_at(data):
upserts.append(data)
with patch("app.services.trade_executor.safe_place_order",
AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "1"}})), \
patch.object(es, "enqueue_trade_history", AsyncMock()), \
patch("app.services.db_worker.enqueue_active_trade_upsert", _capture_at), \
patch("app.services.portfolio_cache_bridge.reflect_sell", AsyncMock()):
out = await es._execute_exit("064400", day_trading_state.positions["064400"],
71200.0, reason="목표가 달성", sell_pct=0.5)
assert out is not None
assert day_trading_state.positions["064400"]["quantity"] == 4 # 인메모리
at = [u for u in upserts if u.get("stock_code") == "064400"]
assert at and at[-1]["quantity"] == 4, f"ActiveTrade 잔량 갱신 누락: {upserts}"
day_trading_state.positions.clear()
@pytest.mark.asyncio
async def test_swing_partial_exit_persists_remaining_quantity():
"""스윙 분할매도도 ActiveTrade.quantity를 갱신해야 한다(DT와 동일 패턴)."""
import inspect
from app.services.swing import exit_service as ses
src = inspect.getsource(ses)
block = src[src.index("분할 매도 후 remaining_pct 갱신"):]
block = block[:block.index("await enqueue_active_trade_upsert(_at_data)")]
# [하드닝 R3 2026-10-09] 잔량 = 상한 적용 전 스윙 보유(_swing_basis) − 매도분(_swing_left).
# 동작 검증은 tests/test_swing_hardening_r3_cap_20261009.py.
assert '"quantity": _swing_left' in block, "스윙 분할매도에 잔량 갱신 누락"
assert "_swing_left = max(0, _swing_basis - sell_qty)" in src
def test_us_partial_exit_persists_remaining_quantity():
"""미국 스윙도 동일 — total_qty = trade.quantity 를 읽으므로 갱신이 없으면 과다매도."""
import inspect
from app.services import us_market_scheduler as us
src = inspect.getsource(us)
i = src.index('"strategy_name": "us_mean_reversion",\n "remaining_pct"')
block = src[i:i + 500]
assert '"quantity": max(0, total_qty - sell_qty)' in block, "미국 분할매도에 잔량 갱신 누락"
def test_domestic_executor_partial_exit_persists_quantity():
import inspect
from app.services import trade_executor as te
src = inspect.getsource(te)
i = src.index('"remaining_pct": new_remaining,\n "quantity"')
assert 'max(0, total_qty - quantity)' in src[i:i + 200]
def test_manual_sell_closes_active_trade():
"""수동 전량 매도는 ActiveTrade를 closed 로 만들어야 유령 행이 남지 않는다."""
import inspect
from app.api.endpoints import portfolio as pf
src = inspect.getsource(pf.sell_stock)
# [M-5 2026-10-02] close/upsert 는 manual_sell_service 로 이관(전략명 필터 명시).
# 동작 검증은 tests/test_guard_bypass_endpoints_20261002.py 의 payload 캡처 테스트.
assert "sync_active_trade_after_manual_sell_v2(" in src
assert "max(0, original_holding_qty - quantity)" in src
class _StubActiveTrade:
"""ActiveTrade ORM 대역 — 정합 함수가 실제로 수정하는 필드만 갖는다."""
def __init__(self, stock_code, quantity, strategy_name="MEAN_REVERSION"):
self.stock_code = stock_code
self.quantity = quantity
self.strategy_name = strategy_name
self.status = "active"
def test_sync_reconciles_active_trade_quantity():
"""동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다.
[2026-09-30 M-5] 종전에는 `inspect.getsource()` + 문자열 매칭이라 정합 루프를
리팩터링하면 빗나갔다(실제로 빗나갔다). 이제 **불일치 상태를 주입하고 정합
함수를 실행해 교정 결과를 확인**한다 — 프로덕션 함수를 그대로 호출한다.
"""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
# 스윙 단일 행이 3주로 기록돼 있는데 실제 보유는 5주 → 5주로 교정돼야 한다
row = _StubActiveTrade("000270", 3)
acts = _reconcile_active_trade_quantities(
{"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
)
assert row.quantity == 5, "잔량 교정이 동작해야 한다 (2026-09-23 픽스의 본래 대상)"
assert row.status == "active"
assert acts == [{"action": "corrected", "stock_code": "000270", "from": 3, "to": 5}]
# 일치하면 아무 것도 하지 않는다
row2 = _StubActiveTrade("000270", 5)
assert _reconcile_active_trade_quantities(
{"000270": [row2]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
) == []
assert row2.quantity == 5
def test_sync_closes_active_trade_without_holding():
"""보유가 없으면 active 행을 closed 로 정리한다 (유령 행 누적 방지)."""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
for tag in ("swing", "daytrading", "manual", "external"):
row = _StubActiveTrade("000270", 3)
acts = _reconcile_active_trade_quantities(
{"000270": [row]}, {}, {"000270": tag}, {"000270": 3}
)
assert row.status == "closed", f"{tag}: 유령 행 정리는 태그와 무관하게 수행"
assert row.quantity == 3, "정리 경로에서 수량은 건드리지 않는다"
assert acts[0]["action"] == "closed"
def test_sync_skips_reconcile_for_multiple_active_rows():
"""스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함 (2026-09-23 원래 규칙)."""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
rows = [_StubActiveTrade("000270", 3), _StubActiveTrade("000270", 2, "day_trade")]
assert _reconcile_active_trade_quantities(
{"000270": rows}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
) == []
assert [r.quantity for r in rows] == [3, 2]
def test_reconcile_skips_external_rows_but_keeps_swing_behaviour():
"""[C-1] external 행만 교정에서 빠진다 — 스윙 교정은 그대로 살아 있다.
external 행 수량에는 시스템이 소유하지 않은 유입분이 섞여 있어, 교정하면 그 값이
다음 sync 의 '시스템 근거 수량' 이 되어 유입분이 swing 으로 승격된다(고착).
"""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
ext = _StubActiveTrade("105560", 3, "day_trade")
acts = _reconcile_active_trade_quantities(
{"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3}
)
assert ext.quantity == 3, "external 행은 교정하지 않는다"
assert acts[0]["action"] == "skipped_unattributable"
assert acts[0]["from"] == 3 and acts[0]["to"] == 6
# [M-3] 태그가 swing 인 혼재 행도 교정하지 않는다 — 여기가 실제 주문 사고 경로다
mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION")
acts = _reconcile_active_trade_quantities(
{"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
)
assert mixed.quantity == 3, "유입분이 섞인 swing 행도 부풀리지 않는다"
assert acts[0]["action"] == "skipped_unattributable"
def test_c1_guard_does_not_weaken_the_20260923_intent():
"""의도 보존 검증 — C-1 가드를 **C-1 이전 규칙으로 되돌려도** 위 의도는 통과한다.
즉 이 테스트 묶음이 깨진다면 그것은 2026-09-23 픽스가 깨진 것이고, C-1 가드
때문이 아니다. 반대로 C-1 회귀(2회 sync 유지)는 가드가 없으면 실패한다
(`tests/test_external_demotion_race.py::test_c1_would_have_regressed_...`).
"""
from unittest.mock import patch
from app.api.endpoints import portfolio as pf
def _pre_c1_rule(strategy_type, row_count, actual, system_quantity=None):
# C-1/M-3 이전 규칙: 단일 행 + 보유 있음이면 태그·근거수량 무관 교정
return int(row_count) == 1 and int(actual) > 0
with patch.object(pf, "_should_correct_active_trade_quantity", _pre_c1_rule):
row = _StubActiveTrade("000270", 3)
pf._reconcile_active_trade_quantities(
{"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
)
assert row.quantity == 5, "스윙 잔량 교정은 C-1/M-3 가드와 무관하게 성립한다"
ghost = _StubActiveTrade("000270", 3)
pf._reconcile_active_trade_quantities(
{"000270": [ghost]}, {}, {"000270": "swing"}, {"000270": 3}
)
assert ghost.status == "closed"
# 그리고 이 규칙에서는 유입분이 섞인 행이 부풀려진다 = C-1/M-3 의 인과
ext = _StubActiveTrade("105560", 3, "day_trade")
pf._reconcile_active_trade_quantities(
{"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3}
)
assert ext.quantity == 6, "가드가 없으면 C-1 이 재발한다"
mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION")
pf._reconcile_active_trade_quantities(
{"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
)
assert mixed.quantity == 6, "가드가 없으면 M-3 이 재발한다(유입분 과다매도)"
def test_active_trade_patch_supports_quantity():
import inspect
from app.api.endpoints import portfolio as pf
assert "quantity" in pf.ActiveTradeStatusUpdateRequest.model_fields
src = inspect.getsource(pf.update_active_trade_status)
assert "active_trade.quantity = request.quantity" in src
def test_sync_seeds_active_trade_for_external_positions():
"""외부 유입 보유 종목(ActiveTrade 없음)은 동기화 시 자동 등록되어야 손절 관리를 받는다.
과거 버그(2026-09-22 카카오뱅크 -13%): 다른 인스턴스·증권사 앱에서 매수된
포지션은 ActiveTrade 행이 없어 손절·분할익절·트레일링 관리를 전혀 받지 못했고,
잔량 추적이 불가해 부분매도가 반복되거나 차단됐다. → sync 시 시딩이 불변식.
[2026-09-27] 시딩 구현이 `db.add(ActiveTrade(entry_price=Decimal(str(_avg))))`
직접 쓰기 → **db_worker 큐 경유**(`enqueue_active_trade_upsert`)로 바뀌었다
(CLAUDE.md §3: sync_portfolio 직접 쓰기 예외는 Portfolio 까지만).
payload 는 DLQ JSON 직렬화 때문에 Decimal 이 아닌 순수 float/int 를 담고,
`_seed_payloads` 에 모은 뒤 commit 성공 후에 큐잉한다.
따라서 검사 대상을 'Decimal 생성자 리터럴' → '큐 경유 + payload 필수 키 +
commit 이후 큐잉' 으로 갱신했다(불변식에 더 충실). 다음 사람은 문자열만 보고
고치지 말고 시딩 동작이 유지되는지를 확인할 것.
"""
import inspect
import re
from app.api.endpoints import portfolio as pf
src = inspect.getsource(pf.sync_portfolio)
assert "외부 유입 포지션 — 동기화 시 자동 등록" in src
# 데이트레이딩 태깅 종목은 제외 (엔진이 당일 청산 대상으로 복원해버림)
assert '(_h.strategy_type or "swing") == "daytrading"' in src
# 손절가는 STOP_LOSS_PERCENT 기준
assert "_cfg.STOP_LOSS_PERCENT" in src
# ActiveTrade 쓰기는 db_worker 큐 경유 (직접 INSERT 부활 금지)
assert "enqueue_active_trade_upsert" in src, "시딩 경로 소실 — 외부 유입 포지션 무관리"
# 주석에 남은 이력 문구는 제외하고 실제 코드만 본다
_code = "\n".join(l.split("#")[0] for l in src.splitlines())
assert "db.add(ActiveTrade(" not in _code, "ActiveTrade 직접 INSERT 부활 (§3 위반)"
# 시딩 payload: 진입가(=평균단가)·잔량·손절가 등 필수 키 + JSON 직렬화 가능 타입
m = re.search(r"_seed_payload = \{(.*?)\n\s*\}", src, re.S)
assert m, "시딩 payload 리터럴을 찾지 못함"
body = m.group(1)
for key in ("stock_code", "quantity", "entry_price", "stop_loss", "status", "remaining_pct"):
assert f'"{key}"' in body, f"시딩 payload 에 {key} 누락"
assert "_avg" in body, "진입가가 평균단가(_avg) 기준이 아님"
assert "Decimal" not in body, "payload 는 순수 float/int (DLQ JSON 직렬화 불가)"
# 큐잉은 commit 성공 후 — 롤백된 Portfolio 에 ActiveTrade 가 생기면 안 된다
assert src.index("await db.commit()") < src.index("_enq_seed_at("), \
"commit 전에 시딩 큐잉 — 롤백 시 유령 ActiveTrade"
class _DbBlocked(BaseException):
"""AsyncSessionLocal 실사용 차단용(프로덕션 except Exception 에 삼켜지지 않도록 BaseException)."""
def test_bridge_db_quantity_uses_blocked_session_guard():
"""회귀: 목 없이 AsyncSessionLocal 을 열면 즉시 실패해야 한다(소켓 차단 증거)."""
import asyncio
with patch("app.core.database.AsyncSessionLocal", side_effect=_DbBlocked("db blocked")):
with pytest.raises(_DbBlocked):
asyncio.run(bridge._db_quantity("064400"))