"""유입 잔고 강등(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)는 등록하지 않는다 — 기존 유입 잔고가 격리를 면한다 # [2026-10-02 H-1] rt_cd≠"0" 은 전부 거부(PENDING 분류 폐지) → 접수 성공만 등록. assert "if success:\n from app.services.system_buy_registry import note_system_buy" in src assert "is_pending" not in src.replace("`elif is_pending:`", "") 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}"