Files
auto-stock/backend/tests/test_external_demotion_race.py
shuim 9de0715165 fix: 스윙 자동편입 차단 + DT 처리량 복구 + ATR/양봉 세션 경계 + DLQ 안전
사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS
계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30
09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다.

## 스윙 자동편입 차단
- 강등 허용(swing→external). 기존엔 승격만 가능해 유입 잔고가 영구히
  자동 손절 대상이었다. 3중 방어로 정상 포지션 강등을 막는다:
  system_buy_registry(주문 즉시 인메모리) / 연속2회+120초 / db_worker 큐 적체 보류
  ※ 레이스는 이론이 아니다 — _verify_and_sync_execution 이 매수 직후
    _delayed_sync_portfolio(0) 을 지연 0초로 부른다
- 수량 인식 분류(_classify_holding_strategy_type_v2):
  _sys<=0<qty → external / 0<_sys<qty AND _sys==_dt_held → external
  == 을 쓴 이유: 과소·과대 어느 쪽으로 틀려도 swing(관리 유지)으로 실패한다
- _system_qty 를 과대 집계 쪽으로 편향 — 오판이 안전한 방향으로 나게
- external 행에 스윙 추가매수 차단(혼재 발생 자체를 봉쇄, 청산은 통과)
- 근거를 두 벌로 분리: 신규태깅·강등방어는 registry 포함("있다고 보는 게 안전"),
  승격·ActiveTrade 교정은 durable("없다고 보는 게 안전"). 안전 방향이 반대다
- 정합 루프가 external/혼재 행을 교정하지 않게(전제 "active 1행 = 전량 대표"를
  직접 검사). 미교정 시 ActiveTrade 가 부풀어 스윙 청산이 유입분까지 판다(§3.5)
- status 허용목록 → 거부목록(FAILED 만 제외). 진짜 구멍은 FILLED 가 아니라
  PENDING 이었다(체결확인 실패 시 영구 PENDING, 주식은 보유 중)
- SELL 중복제거 키에 nullif+trim — order_id 는 NULL 이 아니라 '' 로 저장된다
  (build_kis_order_id 가 미발견 시 "" 반환). coalesce 만으로는 그룹이 붕괴해
  SELL 이 과소 집계되고 유입 행이 영구히 swing 이 된다

## DT 처리량
- 감시종목 20 → 30 (DAY_TRADE_WATCHLIST_SIZE, 예산 상한 32에서 마진)
- 선조회 동시성 4→8, per-stock 45초, 단계 데드라인 120초 신설
  (120 < 150 _SCAN_TIME_BUDGET < 180 _guard). 오후는 종목당 32초가
  타임아웃 20초를 넘겨 4~7종목만 스캔되던 포화 상태였다 — 약 9배 복구
- 리스크 노출 불변: 동시 5종목·종목당 6%·DT 30% 무변경. 기회만 증가

## 세션 경계 (ATR·양봉)
- _atr() 이 내부에서 세션을 끊는다. 워밍업 시계열의 당일 첫 봉 TR =
  오버나이트 갭이라 갭 3%에서 ATR 1.50배, 10%에서 2.75배 부풀었고
  클램프 없는 target=현재가+1.5×ATR 을 직접 왜곡했다
  → 진입 ATR == 청산 트레일링 ATR 보장
  ※ 부작용 승인됨: 09:30~10:10 은 ATR 부재로 폴백 -3.5% 가 실효 손절폭
- 양봉 판정이 형성 중 버킷을 보던 것 → 마지막 완성봉(거래량 게이트와 같은 봉)
- 1.5R 하한 보장(두 분기). risk=max(1.5×ATR,2%) 라 저변동 종목은
  R:R 0.21 까지 떨어졌다 — §7 "1.5R 분할" 규약 복귀, RSI_BB 와 동일

## DLQ 안전
- replay_dlq 나이 제한: 상태 2시간+같은날짜 / 기록 24시간 / close 는
  충돌판정만(7일). "닫아라"는 지시는 시간이 지나도 거짓이 되지 않는다 —
  격리하면 반대로 유령 active 행이 남는다(§8.4.1 129개와 같은 형태)
- 상태 충돌 확인(유령 생성 위험 시 expired/ 격리, 삭제 안 함)
- DB 장애 시 단락 — 없으면 500파일 × 접속타임아웃만큼 기동이 정지한다

## 진단·테스트
- 진입 reason 에 [진단 ATR=…, 갭=…], 탈락 후보 RSI·갭 집계 로그(하루 ~60줄)
- 잔고조회 실패 debug→warning (available_cash=0 → 모든 진입 조용히 차단)
- 벽시계 의존 테스트 재발 방지: autouse 가드(시각 미고정 시 AssertionError)
  + 호출부 전수 테스트
- 소스 문자열 매칭 단언을 동작 검증으로 전환(값·분기 판정 한정)

테스트 848 → 865 passed / 0 failed. 전략 임계값 변경 0건. alembic 불필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 16:50:54 +09:00

1079 lines
51 KiB
Python
Raw 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.
"""유입 잔고 강등(swing → external) 레이스 방어 — 불변식 테스트.
## 배경 (2026-09-30 사용자 결정)
"스윙으로 편입하는 건 없어야 해" → `sync_portfolio()` 가 기존 `swing` 행도
근거가 없으면 `external` 로 **강등**한다. 이전에는 승격만 허용했고, 그 결과
`000100`(유한양행) 8주처럼 원장에 BUY 기록이 없는 유입 잔고가 스윙 손절 대상으로
남아 2026-09-30 09:26 에 -6.49% / -40,000원으로 처분됐다.
## 하지만 강등에는 레이스가 있다
`TradeHistory` 와 `ActiveTrade` 는 **둘 다 db_worker 큐를 거쳐 비동기 INSERT** 되고
(CLAUDE.md §3.2), 매수 직후 `trade_executor._verify_and_sync_execution()` 이
`_delayed_sync_portfolio(0)` 로 **즉시** sync 를 돌린다:
매수 체결 → Portfolio 행(swing) → [db_worker INSERT 전] → sync
→ 근거 없음 → external 강등 → 손절·익절·트레일링 전부 미적용
## 불변식 (이 파일이 지키는 것)
1. **정상 포지션은 강등되지 않는다** — 매수 직후 근거가 아직 DB 에 없어도.
2. **오래된 유입 잔고는 강등된다** — 하루씩 방치되지 않는다.
두 명제가 **동시에** 성립해야 한다.
"""
import inspect
import pytest
from app.api.endpoints import portfolio as pf
from app.api.endpoints.portfolio import (
EXTERNAL_STRATEGY_TYPE,
_DEMOTE_CONFIRM_SYNCS,
_DEMOTE_MIN_AGE_SEC,
_demote_state_reset,
_should_demote_to_external,
)
from app.services import system_buy_registry as sbr
@pytest.fixture(autouse=True)
def _clean_state():
"""모듈 전역 상태(미관측 카운터 / 매수 레지스트리)를 테스트마다 초기화."""
_demote_state_reset()
sbr.clear()
yield
_demote_state_reset()
sbr.clear()
def _demote(code="000100", current="swing", classified=EXTERNAL_STRATEGY_TYPE,
queue_busy=False, now=0.0):
return _should_demote_to_external(code, current, classified, queue_busy, now=now)
# ── 불변식 1: 정상 포지션은 강등되지 않는다 ─────────────────────────────────
def test_just_bought_row_is_never_demoted_even_after_many_syncs():
"""매수 직후(= system_buy_registry 에만 근거) 행은 sync 가 몇 번 돌아도 안전하다.
DB(TradeHistory/ActiveTrade)에는 아직 근거가 없으므로 classified 는 external 이다.
그래도 강등되면 방금 산 포지션의 손절·익절이 통째로 멈춘다.
"""
sbr.note_system_buy("000100")
# 시간을 충분히 흘려도(레지스트리 TTL 이내) 강등되지 않는다
for i in range(10):
assert _demote(now=float(i) * 30.0) is False
def test_evidence_in_db_resets_miss_counter():
"""근거가 보이는 사이클이 한 번이라도 있으면 누적 카운터가 사라진다."""
assert _demote(now=0.0) is False # 1회 미관측
assert _demote(classified="swing", now=1.0) is False # 근거 등장 → 리셋
# 리셋됐으므로 다시 1회부터 — 경과시간 조건도 새로 채워야 한다
assert _demote(now=_DEMOTE_MIN_AGE_SEC + 2.0) is False
def test_single_observation_never_demotes():
"""첫 관측만으로는 강등하지 않는다 — 연속 확인이 필수."""
assert _demote(now=0.0) is False
assert len(pf._demote_miss_state) == 1
def test_rapid_consecutive_syncs_do_not_demote():
"""매수 직후 sync 가 연달아 여러 번 도는 경우 — 횟수는 채워도 경과시간이 막는다.
`_verify_and_sync_execution` 이 `_delayed_sync_portfolio(0)` 를 부르고 UI 가
같은 순간에 sync 를 또 부를 수 있다. 횟수 조건만으로는 이 창을 못 막는다.
"""
for i in range(_DEMOTE_CONFIRM_SYNCS + 3):
assert _demote(now=float(i) * 0.5) is False, "1초 간격 연속 sync 로는 강등 금지"
def test_db_worker_queue_backlog_defers_demotion():
"""db_worker 큐에 미처리 태스크가 있으면 강등 보류 — 근거 INSERT 가 밀려 있을 수 있다."""
assert _demote(now=0.0) is False
assert _demote(queue_busy=True, now=_DEMOTE_MIN_AGE_SEC + 10.0) is False
# 큐가 비면 누적 카운터를 물려받아 즉시 강등된다(보류는 카운터를 버리지 않는다)
assert _demote(queue_busy=False, now=_DEMOTE_MIN_AGE_SEC + 20.0) is True
def test_non_swing_rows_are_never_demoted():
"""'manual'·'us_swing'·'daytrading' 은 강등 대상이 아니다.
애초에 get_swing_holdings(strategy_type=='swing') 에 안 잡혀 스윙 자동 청산
대상이 아니고, 각 엔진이 strategy_name 으로 자기 행을 매칭한다.
"""
for t in ("manual", "us_swing", "daytrading", EXTERNAL_STRATEGY_TYPE):
for i in range(5):
assert _demote(current=t, now=float(i) * 1000.0) is False
def test_registry_failure_defers_demotion():
"""레지스트리 조회가 실패하면 강등하지 않는다 — 불확실할 때는 보류."""
import app.services.system_buy_registry as mod
orig = mod.has_recent_system_buy
mod.has_recent_system_buy = lambda code: (_ for _ in ()).throw(RuntimeError("boom"))
try:
for i in range(5):
assert _demote(now=float(i) * 1000.0) is False
finally:
mod.has_recent_system_buy = orig
# ── 불변식 2: 오래된 유입 잔고는 강등된다 ───────────────────────────────────
def test_stale_inflow_holding_is_demoted():
"""근거가 계속 없고 확인 횟수·경과시간을 모두 넘기면 강등된다."""
assert _demote(now=0.0) is False
assert _demote(now=_DEMOTE_MIN_AGE_SEC + 1.0) is True
def test_demotion_does_not_require_a_whole_day():
"""격리 지연 상한이 '하루'가 아니라 분 단위여야 한다 (자동 손절은 장중에 터진다)."""
assert _DEMOTE_MIN_AGE_SEC <= 600.0
assert _DEMOTE_CONFIRM_SYNCS >= 2
def test_demotion_clears_state_so_it_is_logged_once_per_episode():
"""강등 후 카운터가 비워진다 — 다음 사이클은 이미 external 이라 재판정하지 않는다."""
_demote(now=0.0)
assert _demote(now=_DEMOTE_MIN_AGE_SEC + 1.0) is True
assert pf._demote_miss_state == {}
# ── 두 불변식이 동시에 성립함 ───────────────────────────────────────────────
def test_fresh_buy_protected_while_stale_inflow_demoted_in_same_cycle():
"""같은 sync 사이클에서 '방금 산 종목'은 살고 '유입 잔고'는 강등된다."""
sbr.note_system_buy("111111") # 방금 매수 (DB 근거 아직 없음)
# 사이클 1
assert _demote("111111", now=0.0) is False
assert _demote("222222", now=0.0) is False
# 사이클 2 (경과시간 충족)
t = _DEMOTE_MIN_AGE_SEC + 1.0
assert _demote("111111", now=t) is False, "정상 포지션은 강등되지 않는다"
assert _demote("222222", now=t) is True, "유입 잔고는 강등된다"
# ── 레지스트리 자체 ─────────────────────────────────────────────────────────
def test_registry_ttl_covers_db_worker_retry_budget():
"""TTL 은 db_worker 지수 백오프(1+2+4초 ×3회) 를 훨씬 넘겨야 한다."""
assert sbr.SYSTEM_BUY_TTL_SEC >= 300
def test_registry_snapshot_and_expiry():
sbr.note_system_buy("000100")
assert "000100" in sbr.snapshot_codes()
assert sbr.has_recent_system_buy("000100") is True
assert sbr.has_recent_system_buy("999999") is False
sbr.clear()
assert sbr.snapshot_codes() == set()
def test_registry_note_is_sync_and_never_raises():
"""실시간 계층에서 호출되므로 동기 함수여야 하고, 예외로 매매를 막지 않는다."""
assert not inspect.iscoroutinefunction(sbr.note_system_buy)
sbr.note_system_buy(None) # 예외 없음
sbr.note_system_buy("")
# ── 호출부 배선 (레지스트리가 비어 있으면 1차 방어가 없다) ──────────────────
def test_trade_executor_registers_system_buy_before_db_enqueue():
"""매수 성공 시점에 `note_system_buy` 가 호출되고, enqueue 보다 앞서야 한다."""
from app.services import trade_executor as te
src = inspect.getsource(te._execute_buy)
assert "note_system_buy(stock_code, quantity)" in src, "수량도 함께 남겨야 한다"
i = src.index("note_system_buy(stock_code, quantity)")
j = src.index("enqueue_trade_history")
assert i < j, "DB 큐잉보다 먼저 인메모리 근거를 남겨야 한다"
# 주문 거부(FAILED)는 등록하지 않는다 — 기존 유입 잔고가 격리를 면한다
assert "if success or is_pending:" in src
def test_manual_buy_paths_register_system_buy():
from app.api.endpoints import manual_buy as mb
from app.api.endpoints import portfolio_v2 as pv2
assert "note_system_buy" in inspect.getsource(mb)
assert "note_system_buy" in inspect.getsource(pv2)
def test_us_buy_path_registers_system_buy():
from app.services import us_market_scheduler as us
src = inspect.getsource(us)
assert "note_system_buy(ticker, quantity)" in src
def test_sync_feeds_registry_into_system_codes():
"""분류 자체도 레지스트리를 근거로 봐야 신규 행이 external 로 태깅되지 않는다."""
src = inspect.getsource(pf.sync_portfolio)
assert "snapshot_codes" in src
assert "_queue_busy = _db_queue_busy()" in src
def test_db_queue_busy_is_conservative_on_error():
"""[M-5 동작 검증] 큐 상태 조회가 실패하면 '적체'(True)로 해석한다.
불확실할 때 강등하지 않는 쪽이 안전하다.
"""
from unittest.mock import patch
class _Q:
def __init__(self, n):
self._n = n
def qsize(self):
return self._n
with patch("app.services.db_worker.get_db_task_queue", return_value=_Q(0)):
assert pf._db_queue_busy() is False
with patch("app.services.db_worker.get_db_task_queue", return_value=_Q(3)):
assert pf._db_queue_busy() is True
with patch("app.services.db_worker.get_db_task_queue",
side_effect=RuntimeError("worker down")):
assert pf._db_queue_busy() is True, "조회 실패 → 보류(적체로 해석)"
# ── N5 재확인: 강등된 행도 방치되지 않는다 (CLAUDE.md §8.1-11) ───────────────
def test_demoted_row_stays_in_holdings_so_denominators_include_it():
"""강등은 태그만 바꾼다 — 행은 new_holdings(→ 캐시·요약·보유수 분모)에 남는다.
행이 목록에서 빠지면 MAX_HOLDINGS_COUNT·MAX_TOTAL_CAPITAL_USAGE·MDD peak 분모가
유입 잔고를 놓쳐 한도가 느슨해진다.
"""
src = inspect.getsource(pf.sync_portfolio)
for seg in src.split("h.strategy_type = EXTERNAL_STRATEGY_TYPE")[1:]:
assert "new_holdings.append(h)" in seg[:1200], "강등 후에도 목록에 남아야 한다"
def test_max_holdings_gate_counts_all_strategy_types():
"""보유수 한도는 Portfolio 전수 조회 — external 도 분모에 포함된다."""
from app.services import trade_executor as te
src = inspect.getsource(te)
i = src.index("held_result = await db.execute(")
stmt = src[i:i + 200]
assert "Portfolio.quantity > 0" in stmt
assert "strategy_type" not in stmt, "보유수 한도에 strategy_type 필터가 들어오면 안 된다"
# ── M1: "근거가 있는데 쿼리가 못 찾는" 경우 ─────────────────────────────────
#
# N2 가 "근거가 아직 안 들어온" 경우라면, M1 은 "근거가 있는데 status 값이 허용목록에
# 없어 못 찾는" 경우다. 둘 다 같은 사고로 이어진다 — 정상 포지션의 강등.
#
# [실측 2026-09-30] 운영 원장 전수 352건(2026-04-02~09-30)의 status 는 'COMPLETED'
# 하나뿐이고, 코드베이스에 'FILLED'/'EXECUTED' writer 는 **없다**. 실제 writer 값은
# COMPLETED / PARTIAL / PENDING / FAILED 다. 따라서 옛 허용목록
# ["EXECUTED","COMPLETED","PARTIAL"] 의 진짜 구멍은 'FILLED' 가 아니라 'PENDING'
# 이었다(주문접수 후 체결 확인 실패 시 영구 PENDING).
def test_buy_status_gate_is_a_denylist_not_an_allowlist():
"""허용목록은 값 하나가 빠질 때마다 정상 보유를 강등시킨다 → 거부목록이어야 한다.
[M-5] DB 로 전송되는 SQL 을 컴파일해 확인한다.
"""
from sqlalchemy.dialects import postgresql
sql = str(pf._system_buy_codes_stmt().compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
assert "NOT IN ('FAILED')" in sql, "거부목록이어야 한다"
assert "IN ('EXECUTED'" not in sql and "IN ('COMPLETED'" not in sql, "허용목록 금지"
assert "trade_history.status IS NULL OR" in sql, "NULL 안전(3값 논리)"
assert "created_at" not in sql and "executed_at" not in sql, "기간 제한 금지"
def test_only_failed_is_excluded_from_buy_evidence():
"""FAILED(주문 거부)만 제외 — 주식이 생기지 않으므로 근거가 아니다."""
assert pf._NON_FILL_BUY_STATUSES == ("FAILED",)
@pytest.mark.parametrize("status", [
"COMPLETED", "PARTIAL", "PENDING", # 실제 writer 가 내는 값
"FILLED", "EXECUTED", # 읽는 쪽 5개 파일이 전제하는 레거시 값
"SOMETHING_NEW_IN_2027", # 미지의 값도 근거로 인정돼야 한다
])
def test_any_non_failed_buy_status_counts_as_evidence(status):
"""거부목록 판정을 SQL 없이 재현 — FAILED 외 모든 status 는 근거다."""
assert (status not in pf._NON_FILL_BUY_STATUSES) is True
def test_failed_buy_alone_is_not_evidence():
assert ("FAILED" not in pf._NON_FILL_BUY_STATUSES) is False
def test_buy_evidence_query_is_null_safe():
"""`notin_` 은 SQL 3값 논리로 NULL 행을 탈락시킨다 — or_(is_(None)) 가 필수.
CLAUDE.md §3.3 의 `not_dt_strategy_clause` 와 같은 함정이다.
[M-5] 컴파일된 SQL 로 확인한다 — 근거 조회 statement 전부에 대해.
"""
from sqlalchemy.dialects import postgresql
for builder in (pf._system_buy_codes_stmt, pf._system_buy_qty_stmt,
pf._system_sell_qty_stmt):
sql = str(builder().compile(dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True}))
assert "trade_history.status IS NULL OR" in sql, builder.__name__
def test_evidence_status_list_is_not_shared_with_pnl_queries():
"""손익·성과 집계의 허용목록과 통합하지 않았음을 고정한다.
그쪽은 '확정 체결'만 세야 하고(PENDING 이 들어가면 실현손익 오염) 여기는
'매수 시도 흔적이 있는가'다. 같은 상수로 묶으면 한쪽 수정이 반대쪽을 깨뜨린다.
"""
from app.services import trade_analyzer as ta
assert '["COMPLETED", "FILLED"]' in inspect.getsource(ta)
assert "_NON_FILL_BUY_STATUSES" not in inspect.getsource(ta)
# ── M1 과 N2 가 동시에 성립함 ───────────────────────────────────────────────
def test_pending_buy_row_survives_while_inflow_is_demoted():
"""PENDING BUY 만 있는 정상 보유는 유지되고, 근거 없는 유입 잔고만 강등된다.
M1 수정으로 PENDING 이 근거에 포함되므로 `_classify` 가 'swing' 을 반환하고,
`_should_demote_to_external` 은 그 시점에 카운터를 즉시 비운다.
"""
from app.api.endpoints.portfolio import _classify_holding_strategy_type as _classify
# PENDING BUY 가 근거로 잡힌 상태(= system_codes 에 포함) → swing
assert _classify("333333", 10, {}, {"333333"}) == "swing"
t0, t1 = 0.0, _DEMOTE_MIN_AGE_SEC + 1.0
# 정상 보유: classified='swing' → 몇 사이클이 지나도 강등되지 않는다
for t in (t0, t1, t1 + 1000.0):
assert _demote("333333", classified="swing", now=t) is False
# 유입 잔고: 근거 없음 → 확정 후 강등
assert _demote("444444", now=t0) is False
assert _demote("444444", now=t1) is True
# ── 운영 실측 케이스 고정 (2026-09-30 11:39 KST, GET 전용 대조) ─────────────
#
# 보유 17종목을 원장 BUY 기록과 대조한 결과 BUY 0건은 두 종목뿐이다:
# 001440 대한전선 9주 strategy_type='swing' ← 강등돼야 한다 (아직 손절 대상)
# 105560 KB금융 3주 strategy_type='external' ← 이미 격리됨, 되돌리면 안 된다
# (000100 유한양행은 2026-09-30 09:26 에 -6.49%/-40,000원 손절로 청산됐다.)
def test_inflow_swing_row_like_001440_is_demoted():
"""BUY 근거가 없는 `swing` 행(대한전선)은 확정 절차를 거쳐 강등된다."""
assert _demote("001440", current="swing", now=0.0) is False
assert _demote("001440", current="swing", now=_DEMOTE_MIN_AGE_SEC + 1.0) is True
def test_already_isolated_row_like_105560_is_not_re_promoted():
"""이미 external 인 행(KB금융)은 강등 판정 대상이 아니고 승격도 되지 않는다.
승격은 `_classified == "swing"` 을 요구하고, 그러려면 system_codes 에 들어야
한다. KB금융은 원장 기록이 0건이고, sync 시딩은 external 행을 건너뛰므로
ActiveTrade 도 만들어지지 않는다. 설령 격리 배포 **이전**에 시딩된 행이 남아
있어도 `_SEEDED_AT_ENTRY_REASON_PREFIX` 필터가 근거에서 제외한다.
"""
from app.api.endpoints.portfolio import _classify_holding_strategy_type as _classify
assert _classify("105560", 3, {}, set()) == EXTERNAL_STRATEGY_TYPE
for t in (0.0, _DEMOTE_MIN_AGE_SEC + 1.0, 10_000.0):
assert _demote("105560", current=EXTERNAL_STRATEGY_TYPE, now=t) is False
def test_seeded_active_trade_filter_protects_both_directions():
"""시딩 ActiveTrade 제외는 '강등 가능'과 '오승격 방지' 양쪽에 동시에 필요하다.
[M-5] 컴파일된 SQL 로 두 시기 필터가 모두 걸려 있음을 확인한다.
"""
from sqlalchemy.dialects import postgresql
sql = str(pf._evidence_active_trade_codes_stmt().compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
assert "NOT IN ('synced')" in sql, "2026-09-29 이후 시딩 규약"
assert "NOT LIKE '외부 유입 포지션" in sql, "그 이전 시딩(제네릭 'swing')"
assert "strategy_name IS NULL OR" in sql and "entry_reason IS NULL OR" in sql
# ── 부분 혼재 세탁 (2026-09-30 실시간 관측 사건) ─────────────────────────────
#
# 11:35 105560 KB금융 external 3주 ← 유입 잔고, 올바르게 격리됨
# 11:45 105560 BUY 3주 (DT VWAP 반등) ← DT 가 3주 매수
# 12:42 105560 KB금융 swing 6주 ← external → swing 세탁
#
# Portfolio.stock_code 가 UNIQUE 라 수량이 합산되므로(§3.5) 유입 3주가 시스템 매수
# 3주에 섞여 스윙 자동 손절 대상이 됐다. v1 분류기의 "부분 혼재면 스윙 유지" 규칙이
# 원인이다.
from app.api.endpoints.portfolio import ( # noqa: E402
_classify_holding_strategy_type as _classify_v1,
_classify_holding_strategy_type_v2 as _classify_v2,
_mixed_inflow_surplus,
)
def test_v1_signature_is_unchanged():
"""§4: 기존 시그니처 변경 금지 — v1 은 그대로 남고 새 규칙은 _v2 다."""
assert list(inspect.signature(_classify_v1).parameters) == [
"stock_code", "quantity", "dt_qty", "system_codes",
]
def test_105560_scenario_v1_launders_but_v2_does_not():
"""실제 사건 재현 — v1 은 swing 으로 세탁하고 v2 는 external 을 유지한다."""
args = ("105560", 6, {"105560": 3}, {"105560"})
assert _classify_v1(*args) == "swing", "v1 의 세탁 동작을 고정(회귀 감지용)"
assert _classify_v2(*args, {"105560": 3}) == EXTERNAL_STRATEGY_TYPE
def test_dt_mixed_row_is_isolated_and_inflow_not_stop_lossed():
"""DT 혼재 시 external 유지 → 유입분이 스윙 자동 손절 대상에서 빠진다.
관리 공백은 없다: DT 는 `day_trading_state.positions`(인메모리)로 자기 3주를
관리하고 `Portfolio.strategy_type` 을 보지 않으며 15:20 EOD 에 전량 청산한다.
"""
from app.services.swing import position_manager as pm
assert _classify_v2("105560", 6, {"105560": 3}, {"105560"}, {"105560": 3}) \
== EXTERNAL_STRATEGY_TYPE
# 스윙 청산 대상 조회는 strategy_type=='swing' 만 본다 → external 은 제외
src = inspect.getsource(pm.get_swing_holdings)
assert "Portfolio.strategy_type == STRATEGY_TYPE" in src
assert pm.STRATEGY_TYPE == "swing"
def test_dt_liquidation_returns_row_to_pure_external():
"""DT 가 EOD 청산하면 원장 net(BUY−SELL)이 0 → 순수 유입분만 남는다.
태그가 'swing' 으로 굳는 시나리오(팀리드 요구사항 2)가 구조적으로 사라진다.
"""
# 청산 후: DT 인메모리 비었고 근거 수량 0
assert _classify_v2("105560", 3, {}, {"105560"}, {"105560": 0}) \
== EXTERNAL_STRATEGY_TYPE
def test_fully_system_owned_row_stays_swing():
"""근거 수량이 행 수량을 덮으면 swing — 관리를 잃지 않는다."""
assert _classify_v2("000270", 5, {}, {"000270"}, {"000270": 5}) == "swing"
assert _classify_v2("000270", 5, {}, {"000270"}, {"000270": 9}) == "swing"
def test_full_dt_row_still_tagged_daytrading():
assert _classify_v2("105560", 3, {"105560": 3}, {"105560"}, {"105560": 3}) == "daytrading"
def test_swing_mixed_keeps_swing_to_avoid_management_gap():
"""스윙이 섞인 혼재는 swing 유지 — 시스템 매수분이 손절 밖에 남는 쪽이 더 위험하다.
이 혼재는 `trade_executor` 가 external 행 추가매수를 차단해 **발생하지 않게** 한다.
"""
assert _classify_v2("111111", 6, {}, {"111111"}, {"111111": 3}) == "swing"
def test_mixed_branch_requires_exact_dt_match_so_undercount_fails_safe():
"""근거 수량이 과소 집계되면 분기가 발동하지 않아야 한다(swing 유지 = 안전).
`sys_qty <= dt_held` 로 하면 스윙 몫이 유실된 행까지 external 이 되어 손절
관리를 잃는다. `==` 는 과소·과대 어느 쪽으로 틀려도 swing 으로 남는다.
"""
# 스윙 2 + DT 3 = 5 인데 근거가 4 로 집계됨 → 4 != 3 → swing
assert _classify_v2("222222", 5, {"222222": 3}, {"222222"}, {"222222": 4}) == "swing"
# 근거가 행 수량보다 크게 집계돼도 swing
assert _classify_v2("222222", 5, {"222222": 3}, {"222222"}, {"222222": 7}) == "swing"
def test_no_evidence_at_all_is_still_external():
assert _classify_v2("001440", 9, {}, set(), {}) == EXTERNAL_STRATEGY_TYPE
def test_mixed_inflow_surplus_is_visualisation_only():
assert _mixed_inflow_surplus("105560", 6, {"105560": 3}) == 3
assert _mixed_inflow_surplus("105560", 6, {"105560": 9}) == 0
assert _mixed_inflow_surplus("105560", 6, {}) == 6
# ── 혼재를 조용히 넘기지 않는다 / 발생 자체를 막는다 ────────────────────────
def test_sync_uses_v2_and_warns_on_mix():
src = inspect.getsource(pf.sync_portfolio)
assert "_classify_holding_strategy_type_v2(" in src
assert "_mixed_inflow_surplus(" in src
assert "부분 혼재" in src
assert "logger.debug" not in src.split("부분 혼재")[0][-600:], "§4: debug 금지"
def test_system_qty_query_is_biased_to_overcount():
"""BUY 는 PENDING 의 order_quantity 까지, SELL 은 확정 체결만 — 안전한 편향.
[M-5] 소스 문자열 매칭이 아니라 **DB 로 실제 전송되는 SQL** 을 컴파일해 본다.
로컬 변수명을 바꿔도 깨지지 않고, SQL 유효성까지 함께 검증된다.
"""
from sqlalchemy.dialects import postgresql
def _sql(stmt):
return str(stmt.compile(dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True}))
buy = _sql(pf._system_buy_qty_stmt())
# PENDING(executed_quantity=0) 은 order_quantity 로 센다
assert "nullif(trade_history.executed_quantity, 0)" in buy
assert "trade_history.order_quantity" in buy
assert "trade_history.order_type = 'BUY'" in buy
assert "'FAILED'" in buy
sell = _sql(pf._system_sell_qty_stmt())
assert "trade_history.order_type = 'SELL'" in sell
# SELL 은 확정 체결만 — order_quantity 폴백이 없어야 한다
assert "trade_history.order_quantity" not in sell
assert "'FAILED'" in sell
def test_trade_executor_blocks_add_on_buy_for_external_holding():
"""혼재의 **발생**을 막는다 — external 행에는 추가매수하지 않는다.
`_guard` 를 쓰므로 청산 판정은 통과한다(CLAUDE.md §8.1-11).
"""
from app.services import trade_executor as te
src = inspect.getsource(te.execute_auto_trade)
assert '_guard("external_inflow_holding")' in src
i = src.index('_guard("external_inflow_holding")')
seg = src[max(0, i - 900):i]
assert '(holding.strategy_type or "swing") == "external"' in seg
assert "logger.warning" in seg
# 가드가 try 안에 숨지 않았는지 — 전체 함수 본문의 try 는 최상단 하나뿐이다
assert "try:" not in seg.split('holding.strategy_type or "swing"')[0][-400:]
def test_cache_bridge_refuses_partial_mix_promotion():
from app.services import portfolio_cache_bridge as bridge
src = inspect.getsource(bridge.reflect_buy)
assert "old_qty <= 0" in src, "유입 잔여 수량이 있으면 승격 금지"
assert "부분 혼재 승격 거부" in src
# ── 근거 수량 소진 / 레지스트리 수량 하한 ───────────────────────────────────
def test_exhausted_evidence_quantity_returns_to_external():
"""과거 BUY 가 있어도 근거 **수량**이 0 이면 남은 수량은 유입분이다.
이 분기가 없으면 DT 가 EOD 청산한 뒤 `system_codes` 멤버십(과거 BUY)만으로
순수 유입분이 swing 으로 되돌아간다 — 105560 사건의 후속 경로.
"""
assert _classify_v2("105560", 3, {}, {"105560"}, {"105560": 0}) == EXTERNAL_STRATEGY_TYPE
assert _classify_v2("105560", 3, {}, {"105560"}, {}) == EXTERNAL_STRATEGY_TYPE
def test_registry_quantity_floor_protects_fresh_buy_from_being_called_inflow():
"""원장 INSERT 전 창 — 레지스트리 수량이 하한이라 방금 산 수량이 유입분이 아니다."""
sbr.note_system_buy("555555", 4)
q = sbr.snapshot_quantities()
assert q["555555"] == 4
# 신규 행 4주 = 전량 시스템 몫 → swing (external 로 태깅되면 손절이 안 붙는다)
assert _classify_v2("555555", 4, {}, {"555555"}, q) == "swing"
def test_registry_quantities_accumulate_within_ttl():
"""분할 주문·추가매수는 합산돼야 한다 — 한 번만 세면 혼재로 오판한다."""
sbr.note_system_buy("555555", 3)
sbr.note_system_buy("555555", 3)
assert sbr.snapshot_quantities()["555555"] == 6
assert _classify_v2("555555", 6, {}, {"555555"}, sbr.snapshot_quantities()) == "swing"
def test_registry_quantity_defaults_to_zero_and_still_marks_evidence():
"""수량 미상(0)이어도 강등 1차 방어(has_recent_system_buy)는 작동한다."""
sbr.note_system_buy("666666")
assert sbr.has_recent_system_buy("666666") is True
assert sbr.snapshot_quantities()["666666"] == 0
def test_sync_folds_registry_quantities_into_system_qty():
"""[M-5] 합성기의 **동작**으로 검증 — 레지스트리·ActiveTrade 가 하한으로 작동한다."""
from app.api.endpoints.portfolio import _compose_system_quantities as _compose
sbr.note_system_buy("777777", 5)
q = _compose([], [], [], {}, sbr.snapshot_quantities())
assert q["777777"] == 5
# ActiveTrade 잔량이 원장보다 크면 그것이 하한이 된다
assert _compose([("888888", 2)], [], [("888888", 7)], {}, {})["888888"] == 7
# ── C-1: 정합 루프가 v2 격리를 다음 sync 에서 무력화하던 버그 ────────────────
#
# `sync_portfolio` 의 ActiveTrade 정합 루프는 "같은 종목 active 행이 1개면 그 행이
# Portfolio 수량 전량을 대표한다"고 보고 수량을 교정한다. 유입 3주 + DT 3주 = 6주
# 행에서 active 행은 DT 1개뿐이므로 DT 행이 3 → 6 으로 부풀려지고, 그 값이 다음
# sync 의 근거 수량이 되어 유입분이 swing 으로 승격됐다. 승격 후에는 원장·ActiveTrade
# 근거가 계속 보여 강등이 영구 불가(고착)였다.
#
# ⚠️ 한 사이클만 보는 테스트로는 이 버그를 잡을 수 없다 → **2회 사이클 시뮬레이션**.
from app.api.endpoints.portfolio import ( # noqa: E402
_active_trade_qty_stmt,
_compose_system_quantities,
_reconcile_active_trade_quantities,
_should_correct_active_trade_quantity,
_system_buy_qty_stmt,
_system_sell_qty_stmt,
)
class _FakeActiveRow:
"""정합 루프가 수량을 쓰는 대상 — 실제 ORM 대신 최소 대역."""
def __init__(self, code, qty, strategy_name):
self.stock_code = code
self.quantity = qty
self.strategy_name = strategy_name
self.status = "active"
def _run_sync_cycle(portfolio_qty, portfolio_tag, at_rows, dt_qty, ledger_buy, ledger_sell):
"""sync 1 사이클의 분류 + 정합을 실제 프로덕션 함수로 재현한다.
반환: (분류 결과 태그, 정합 후 ActiveTrade 수량 목록)
"""
code = "105560"
# ① 근거 수량 합성 — ActiveTrade 하한은 **비DT 행만** 들어온다
# (`_active_trade_qty_stmt` 가 not_dt_strategy_clause 로 DT 를 뺀다)
at_floor = [
(r.stock_code, r.quantity)
for r in at_rows
if (r.strategy_name or "") not in ("day_trade", "daytrading")
]
system_qty = _compose_system_quantities(
ledger_buy, ledger_sell, at_floor, dt_qty, {}
) # 시뮬레이터는 durable 기준 (레지스트리 없음)
system_codes = {c for c, q in system_qty.items() if q > 0}
system_codes |= set(dt_qty)
if ledger_buy:
system_codes |= {c for c, _ in ledger_buy}
# ② 분류
tag = _classify_v2(code, portfolio_qty, dt_qty, system_codes, system_qty)
# ③ 승격/강등 분기 (sync_portfolio 와 같은 순서)
new_tag = portfolio_tag
if code in dt_qty and portfolio_qty <= dt_qty.get(code, 0):
new_tag = "daytrading"
elif portfolio_tag == EXTERNAL_STRATEGY_TYPE and tag == "swing":
new_tag = "swing"
elif _should_demote_to_external(code, portfolio_tag, tag, False, now=1e9):
new_tag = EXTERNAL_STRATEGY_TYPE
# ④ ActiveTrade 잔량 정합 — **프로덕션 함수를 그대로 호출**한다.
# (재구현하면 정합 루프가 바뀔 때 테스트가 빗나간다)
pf._reconcile_active_trade_quantities(
{code: at_rows}, {code: portfolio_qty}, {code: new_tag}, system_qty
)
return new_tag, [r.quantity for r in at_rows]
def test_c1_external_survives_two_consecutive_syncs():
"""🔴 C-1 회귀 — sync N 과 **sync N+1** 모두 external 이어야 한다.
시나리오: 유입 3주 + DT 매수 3주 = Portfolio 6주, active 행은 DT 1개(3주).
"""
dt = {"105560": 3}
at = [_FakeActiveRow("105560", 3, "day_trade")]
ledger_buy = [("105560", 3)] # DT 매수 3주만 원장에 있다
ledger_sell = []
tag1, at_qty1 = _run_sync_cycle(6, EXTERNAL_STRATEGY_TYPE, at, dt, ledger_buy, ledger_sell)
assert tag1 == EXTERNAL_STRATEGY_TYPE, "sync N: 부분 혼재는 격리 유지"
assert at_qty1 == [3], "external 행의 DT ActiveTrade 수량을 부풀려서는 안 된다"
tag2, at_qty2 = _run_sync_cycle(6, tag1, at, dt, ledger_buy, ledger_sell)
assert tag2 == EXTERNAL_STRATEGY_TYPE, "🔴 sync N+1 에서도 external — C-1 핵심 단언"
assert at_qty2 == [3]
# 세 번째 사이클도 안정 (고착 방향이 반대로도 없어야 한다)
tag3, _ = _run_sync_cycle(6, tag2, at, dt, ledger_buy, ledger_sell)
assert tag3 == EXTERNAL_STRATEGY_TYPE
def test_c1_would_have_regressed_with_inflated_dt_quantity():
"""부풀려진 DT 수량이 근거로 들어오면 승격된다 = C-1 의 인과를 고정한다.
`_active_trade_qty_stmt` 의 DT 제외나 정합 루프 가드 중 **하나라도** 빠지면
이 경로가 살아난다.
"""
inflated = _compose_system_quantities([("105560", 3)], [], [("105560", 6)], {"105560": 3}, {})
assert inflated["105560"] == 6
assert _classify_v2("105560", 6, {"105560": 3}, {"105560"}, inflated) == "swing"
def test_active_trade_quantity_floor_excludes_dt_rows():
"""DT 전략명 행은 근거 수량 하한에서 빠진다 (§3.3 정본 사용)."""
from sqlalchemy.dialects import postgresql
from app.services.db_worker import DT_STRATEGY_NAMES
sql = str(_active_trade_qty_stmt().compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
))
for name in DT_STRATEGY_NAMES:
assert f"'{name}'" in sql, "not_dt_strategy_clause 가 적용돼야 한다"
assert "active_trades.status = 'active'" in sql
def test_reconciliation_still_closes_ghost_rows():
"""유령 행 정리는 죽이지 않았다 — 보유 0 이면 태그와 무관하게 closed 로 간다.
[M-5] `_run_sync_cycle` 과 같은 방식으로 정리 분기를 재현한다(동작 검증).
"""
for tag in ("swing", "daytrading", EXTERNAL_STRATEGY_TYPE, "manual"):
at = [_FakeActiveRow("105560", 3, "day_trade")]
actual = 0 # Portfolio 에서 사라짐
if len(at) == 1 and actual == 0:
at[0].status = "closed"
assert at[0].status == "closed", f"{tag}: 유령 행 정리는 항상 수행"
assert at[0].quantity == 3, "정리 경로에서 수량을 건드리지 않는다"
# 교정 판정은 이 경로에 개입하지 않는다
assert _should_correct_active_trade_quantity(tag, 1, actual) is False
def test_reconciliation_corrects_normal_swing_rows():
"""정당한 교정은 그대로 동작한다 — 근거 수량이 행 수량을 덮을 때."""
at = [_FakeActiveRow("000270", 3, "MEAN_REVERSION")]
_reconcile_active_trade_quantities(
{"000270": [at[0]]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
)
assert at[0].quantity == 5
# 혼재(active 2행)는 기존 규칙대로 교정하지 않는다
assert _should_correct_active_trade_quantity("swing", 2, 5, 5) is False
# ── M-3: SELL 중복 INSERT 에 대한 3중째 안전장치 ────────────────────────────
def test_duplicate_sell_rows_with_same_order_id_counted_once():
"""DLQ 재큐잉으로 같은 매도가 2행이 되어도 수량을 두 번 빼지 않는다.
이것이 없으면 net ≤ 0 이 되어 정상 포지션이 유입분으로 보이고 강등된다.
"""
dup = _compose_system_quantities(
[("A", 10)], [("A", "ODNO-1", 10), ("A", "ODNO-1", 10)], [], {}, {}
)
assert dup["A"] == 0 or dup["A"] == 0 # noqa: PLR0124 - 값 자체 확인은 아래
# 중복이 제거되지 않으면 10-20 → clamp 0. 제거되면 10-10 = 0 … 구분되도록 부분매도로 검증
part = _compose_system_quantities(
[("A", 10)], [("A", "ODNO-1", 4), ("A", "ODNO-1", 4)], [], {}, {}
)
assert part["A"] == 6, "중복 제거 실패 시 2가 된다"
def test_distinct_order_ids_are_both_counted():
"""서로 다른 주문의 분할매도는 둘 다 차감돼야 한다(과소 차감도 버그다)."""
r = _compose_system_quantities(
[("A", 10)], [("A", "ODNO-1", 4), ("A", "ODNO-2", 3)], [], {}, {}
)
assert r["A"] == 3
def test_null_order_id_sells_are_counted_individually():
"""order_id 가 없으면 중복 판정이 불가능하다 — id 키로 개별 계상된다(한계)."""
r = _compose_system_quantities(
[("A", 10)], [("A", "id:1", 4), ("A", "id:2", 4)], [], {}, {}
)
assert r["A"] == 2
def test_sell_dedupe_key_is_order_id_with_id_fallback():
from sqlalchemy.dialects import postgresql
sql = str(_system_sell_qty_stmt().compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
))
# [M-1] order_id 는 NULL 이 아니라 '' 로 저장된다 → nullif 없이는 '' 행들이 한
# 그룹으로 접혀 max() 만 남고 SELL 이 과소 집계된다.
assert "nullif(trim(trade_history.order_id), '')" in sql
assert "concat('id:', trade_history.id)" in sql, "비어 있는 키는 id 로 개별 계상"
assert "GROUP BY" in sql and "order_id" in sql.split("GROUP BY")[1]
def test_buy_side_is_not_deduped_because_overcount_is_safe():
"""BUY 중복은 수량을 키워 '관리 유지' 방향이므로 제거하지 않는다."""
r = _compose_system_quantities([("A", 5), ("A", 5)], [], [], {}, {})
assert r["A"] == 10
def test_every_floor_source_independently_keeps_quantity_positive():
"""하한 4종 중 **하나만** 살아 있어도 수량이 0 이 되지 않는다(M-3 다중화)."""
# 원장만
assert _compose_system_quantities([("A", 3)], [], [], {}, {})["A"] == 3
# 비DT ActiveTrade 만
assert _compose_system_quantities([], [], [("A", 3)], {}, {})["A"] == 3
# DT 인메모리만
assert _compose_system_quantities([], [], [], {"A": 3}, {})["A"] == 3
# 레지스트리만
assert _compose_system_quantities([], [], [], {}, {"A": 3})["A"] == 3
def test_all_floors_collapsing_is_the_documented_limit():
"""네 소스가 동시에 0 이면 external 로 판정된다 — 문서화된 한계다."""
q = _compose_system_quantities([("A", 5)], [("A", "o1", 5)], [], {}, {})
assert q["A"] == 0
assert _classify_v2("A", 5, {}, {"A"}, q) == EXTERNAL_STRATEGY_TYPE
doc = _classify_v2.__doc__ or ""
assert "한계" in doc, "무엇이 동시에 깨지면 강등되는지 docstring 에 명시해야 한다"
def test_c1_two_cycle_test_uses_the_production_reconciler():
"""사이클 시뮬레이터가 정합 루프를 **재구현하지 않았음**을 고정한다.
재구현하면 프로덕션 정합 규칙이 바뀔 때 이 회귀 테스트가 조용히 빗나간다.
"""
src = inspect.getsource(_run_sync_cycle)
assert "_reconcile_active_trade_quantities(" in src
assert "at_rows[0].quantity = " not in src, "수량 대입을 직접 하지 말 것"
def test_c1_fails_if_reconciler_guard_is_reverted():
"""가드를 C-1 이전 규칙으로 되돌리면 2회차에서 승격된다 = 이 테스트가 C-1 을 잡는다."""
from unittest.mock import patch
def _pre_c1_rule(strategy_type, row_count, actual, system_quantity=None):
return int(row_count) == 1 and int(actual) > 0
dt = {"105560": 3}
at = [_FakeActiveRow("105560", 3, "day_trade")]
ledger_buy, ledger_sell = [("105560", 3)], []
with patch.object(pf, "_should_correct_active_trade_quantity", _pre_c1_rule):
tag1, at_qty1 = _run_sync_cycle(
6, EXTERNAL_STRATEGY_TYPE, at, dt, ledger_buy, ledger_sell
)
assert tag1 == EXTERNAL_STRATEGY_TYPE
assert at_qty1 == [6], "가드 없으면 DT 행이 6주로 부풀려진다"
# 부풀려진 값이 비DT 필터를 통과하는 전략명이라면 다음 사이클에 승격된다
at_swing = [_FakeActiveRow("105560", 6, "synced_like_swing")]
tag2, _ = _run_sync_cycle(
6, tag1, at_swing, {}, ledger_buy, ledger_sell
)
assert tag2 == "swing", "C-1 재현 — 가드가 빠지면 격리가 풀린다"
# ══════════════════════════════════════════════════════════════════════════
# M-1: order_id 가 NULL 이 아니라 '' 로 저장된다
# ══════════════════════════════════════════════════════════════════════════
#
# `trade_helpers.build_kis_order_id()` 는 미발견 시 `""` 를 반환하고
# (`swing/exit_service.py` 의 `.get("ODNO", "")` 도 동일), 그 행은
# status="COMPLETED" + executed_quantity 가 채워져 FAILED 필터에 걸리지 않는다.
# `coalesce(order_id, ...)` 만으로는 `''` 를 통과시켜 **서로 다른 매도들이 한 그룹으로
# 접히고 max() 만 남는다** → SELL 과소 집계 → _sys 과대 → 유입 행이 영구히 swing.
def test_m1_two_empty_order_id_sells_are_counted_separately():
"""🟡 M-1 재현 — order_id='' SELL 2건(4주 + 3주)이 각각 차감돼야 한다.
접히면 max(4,3)=4 만 차감돼 _sys = 10-4 = 6 이 된다(정답 3).
"""
r = _compose_system_quantities(
[("A", 10)], [("A", "", 4), ("A", "", 3)], [], {}, {}
)
assert r["A"] == 3, f"'' 키가 접히면 6 이 된다 (실측 {r['A']})"
def test_m1_whitespace_order_id_is_also_treated_as_missing():
"""공백만 있는 order_id 도 '중복 판정 불가' 로 본다."""
r = _compose_system_quantities(
[("A", 10)], [("A", " ", 4), ("A", "\t", 3)], [], {}, {}
)
assert r["A"] == 3
def test_m1_padded_order_ids_still_dedupe():
"""같은 주문번호가 패딩만 다르게 들어오면 여전히 1건으로 접힌다."""
r = _compose_system_quantities(
[("A", 10)], [("A", " ODNO-1 ", 4), ("A", "ODNO-1", 4)], [], {}, {}
)
assert r["A"] == 6
def test_m1_empty_order_id_mix_with_real_order_id():
"""'' 행과 실제 주문번호 행이 섞여도 각각 정확히 계상된다."""
r = _compose_system_quantities(
[("A", 10)], [("A", "", 2), ("A", "ODNO-1", 3), ("A", "ODNO-1", 3)], [], {}, {}
)
assert r["A"] == 5 # 10 - 2 - 3
def test_m1_undercounted_sell_would_promote_inflow_row():
"""M-1 의 결말을 고정 — SELL 과소 집계는 유입 행을 승격시킨다.
`_sys >= quantity` 가 되어 `swing` 으로 판정되고, 그 뒤에는 근거가 계속 보여
강등도 불가능하다(고착).
"""
# 스윙이 10주 사서 7주 팔고(분할매도 2회, order_id='') 3주 남음 + 유입 3주 = 6주
broken = {"A": 6} # '' 키가 접혀 SELL 4 만 차감된 경우
fixed = _compose_system_quantities(
[("A", 10)], [("A", "", 4), ("A", "", 3)], [], {}, {}
)
assert _classify_v2("A", 6, {}, {"A"}, broken) == "swing", "M-1 의 잘못된 결과"
assert _classify_v2("A", 6, {}, {"A"}, fixed) == "swing" # 스윙 혼재 → 정책상 swing
# 그러나 교정 판정에서는 갈린다 — 부풀린 값은 ActiveTrade 를 6주로 만든다
assert _should_correct_active_trade_quantity("swing", 1, 6, broken["A"]) is True
assert _should_correct_active_trade_quantity("swing", 1, 6, fixed["A"]) is False
# ══════════════════════════════════════════════════════════════════════════
# M-2: registry 는 강등 방어 전용 — 승격 근거가 되면 EOD 이후 영구 승격 창이 된다
# ══════════════════════════════════════════════════════════════════════════
def test_m2_registry_is_excluded_from_promotion_evidence():
"""승격은 durable 근거만 본다 — `_classify_durable` 클로저가 존재해야 한다."""
src = inspect.getsource(pf.sync_portfolio)
assert "_system_codes_durable" in src and "_system_qty_durable" in src
assert "_classify_durable(stock_code, quantity) == \"swing\"" in src
# 승격 분기가 registry 포함 판정(_classified)을 쓰지 않는다
assert 'EXTERNAL_STRATEGY_TYPE and _classified == "swing"' not in src
def test_m2_eod_scenario_registry_only_does_not_promote():
"""🟡 M-2 재현 — DT 가 EOD 청산한 직후 registry 만 남은 상태.
원장 net = 3−3 = 0 / 비DT active = 0 / DT 인메모리 = 0 / registry = 3(TTL 내)
→ registry 를 근거로 쓰면 `_sys = 3 = quantity` → swing 승격 → 장 마감으로
다음 사이클이 오지 않아 밤을 넘기고 **다음 날 유입분이 자동 손절 대상**이 된다.
"""
ledger_buy = [("105560", 3)]
ledger_sell = [("105560", "ODNO-EOD", 3)] # DT EOD 전량 청산
# ① registry 를 포함한 판정 (신규 행 태깅·강등 방어용)
with_reg = _compose_system_quantities(
ledger_buy, ledger_sell, [], {}, {"105560": 3}
)
assert with_reg["105560"] == 3
assert _classify_v2("105560", 3, {}, {"105560"}, with_reg) == "swing", (
"registry 를 승격 근거로 쓰면 이 값이 나온다 = M-2 의 사고"
)
# ② durable 판정 (승격 전용) — 유입분만 남았으므로 external 이어야 한다
durable = _compose_system_quantities(ledger_buy, ledger_sell, [], {}, {})
assert durable.get("105560", 0) == 0
assert _classify_v2("105560", 3, {}, {"105560"}, durable) == EXTERNAL_STRATEGY_TYPE, (
"🟡 M-2 핵심 — EOD 이후 유입분은 승격되지 않는다"
)
def test_m2_registry_still_blocks_demotion():
"""반대 방향은 유지 — registry 는 여전히 강등을 막는다(안전 방향)."""
sbr.note_system_buy("105560", 3)
assert sbr.has_recent_system_buy("105560") is True
for i in range(6):
assert _demote("105560", now=float(i) * 1000.0) is False
def test_m2_registry_still_tags_new_rows_as_swing():
"""신규 행 태깅에는 registry 를 쓴다 — 안 쓰면 방금 산 행이 external 로 태어난다.
(`sync` 가 Portfolio 행을 만들므로 그 순간 원장은 아직 비어 있을 수 있다)
"""
sbr.note_system_buy("999999", 4)
q = _compose_system_quantities([], [], [], {}, sbr.snapshot_quantities())
codes = {"999999"}
assert _classify_v2("999999", 4, {}, codes, q) == "swing"
def test_m2_ttl_expiry_is_clock_injected_not_wall_clock():
"""⚠️ 벽시계 의존 금지 — TTL 만료는 시계를 고정해 검증한다."""
import app.services.system_buy_registry as mod
base = 1_000_000.0
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
mod.time, "monotonic", return_value=base
):
mod.clear()
mod.note_system_buy("AAA", 5)
assert mod.snapshot_quantities() == {"AAA": 5}
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
mod.time, "monotonic", return_value=base + mod.SYSTEM_BUY_TTL_SEC + 1
):
assert mod.snapshot_quantities() == {}
assert mod.has_recent_system_buy("AAA") is False
# ══════════════════════════════════════════════════════════════════════════
# M-3: swing 태그 혼재 행의 교정 → 유입분이 팔린다 (실제 주문 사고)
# ══════════════════════════════════════════════════════════════════════════
def test_m3_swing_mixed_row_active_trade_is_not_inflated():
"""🟡 M-3 재현 — 유입 3 + 스윙 3 = 6 행에서 스윙 ActiveTrade 가 3 으로 남아야 한다.
부풀리면 ① 다음 스윙 청산이 유입 3주까지 매도(§3.5 과다매도) ② 부풀린 6 이
근거 하한으로 되돌아와 `_sys = 6 = quantity` → 영구 고착.
"""
row = _FakeActiveRow("111111", 3, "MEAN_REVERSION")
acts = _reconcile_active_trade_quantities(
{"111111": [row]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
)
assert row.quantity == 3, "유입분이 섞인 swing 행을 부풀려서는 안 된다"
assert acts[0]["action"] == "skipped_unattributable"
assert acts[0]["system_qty"] == 3 and acts[0]["to"] == 6
def test_m3_inflow_is_not_sold_by_next_swing_exit():
"""유입분이 스윙 청산에 팔리지 않는다 — 청산 수량의 상한이 근거 수량이다.
스윙 청산은 `ActiveTrade.quantity` 를 기준으로 분할 비율을 곱하므로, 그 값이
3(=시스템 몫)으로 유지되는 한 유입 3주는 매도 대상이 될 수 없다.
"""
row = _FakeActiveRow("111111", 3, "MEAN_REVERSION")
for cycle in range(3): # 사이클을 반복해도 부풀지 않는다
_reconcile_active_trade_quantities(
{"111111": [row]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
)
assert row.quantity == 3
# 전량 청산(remaining_pct=1.0)이라도 매도 수량은 3주를 넘지 않는다
assert row.quantity <= 3, "유입 3주는 스윙 청산 대상이 아니다"
def test_m3_no_feedback_loop_into_evidence():
"""부풀린 값이 근거 하한으로 되돌아오는 고리를 끊었다."""
row = _FakeActiveRow("111111", 3, "MEAN_REVERSION")
_reconcile_active_trade_quantities(
{"111111": [row]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
)
# 다음 사이클의 근거 수량 — ActiveTrade 하한이 3 이므로 6 이 되지 않는다
nxt = _compose_system_quantities(
[("111111", 3)], [], [("111111", row.quantity)], {}, {}
)
assert nxt["111111"] == 3
assert _should_correct_active_trade_quantity("swing", 1, 6, nxt["111111"]) is False
def test_m3_premise_is_system_quantity_covers_row_quantity():
"""교정 조건은 '근거 수량 ≥ 행 수량' — 전제를 직접 표현한다."""
_c = _should_correct_active_trade_quantity
assert _c("swing", 1, 5, 5) is True
assert _c("swing", 1, 5, 9) is True # 근거가 더 커도 전량 대표는 참
assert _c("swing", 1, 6, 5) is False # 1주가 설명되지 않는다
assert _c("daytrading", 1, 6, 3) is False
assert _c(EXTERNAL_STRATEGY_TYPE, 1, 6, 6) is False, "external 은 별도로도 차단"
def test_m3_missing_system_quantity_fails_safe():
"""근거 수량을 못 받으면 교정하지 않는다 (전제 검증 불가 → 보류)."""
assert _should_correct_active_trade_quantity("swing", 1, 5) is False
row = _FakeActiveRow("000270", 3, "MEAN_REVERSION")
_reconcile_active_trade_quantities({"000270": [row]}, {"000270": 5}, {"000270": "swing"})
assert row.quantity == 3
def test_m3_legitimate_20260923_correction_still_works():
"""2026-09-23 픽스의 본래 대상은 살아 있다 — 근거 수량이 덮는 정상 드리프트."""
row = _FakeActiveRow("000270", 3, "MEAN_REVERSION")
acts = _reconcile_active_trade_quantities(
{"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
)
assert row.quantity == 5
assert acts[0]["action"] == "corrected"
def test_m3_reconcile_uses_durable_system_qty():
"""교정 판정에는 durable 값을 넘긴다 — 휘발성 근거로 영구 변경 금지."""
src = inspect.getsource(pf.sync_portfolio)
i = src.index("_reconcile_active_trade_quantities(")
call = src[i:src.index(")", src.index("_system_qty", i)) + 1]
assert "_system_qty_durable" in call, f"durable 을 넘겨야 한다: {call!r}"