사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 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>
1079 lines
51 KiB
Python
1079 lines
51 KiB
Python
"""유입 잔고 강등(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}"
|