사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 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>
331 lines
14 KiB
Python
331 lines
14 KiB
Python
"""유입(external) 잔고 격리 — strategy_type='external' 불변식.
|
|
|
|
배경(2026-09-28 조사): 모의계좌 교체 시 기존 잔고가 계좌에 그대로 편입됐고,
|
|
`sync_portfolio()` 가 시스템 매수 여부를 검사하지 않고 전부 'swing' 으로 태깅해
|
|
`get_swing_holdings()`(strategy_type=='swing')가 곧바로 손절 대상으로 잡았다.
|
|
→ 시스템이 매수한 적 없는 잔고를 전략 손절로 처분(고아 청산 9건 / -153,661원).
|
|
|
|
사용자 결정: 유입 잔고는 'external' 로 격리하고 자동 손절·익절 대상에서 제외한다
|
|
(그 수량은 손절이 걸리지 않으며 운영자가 직접 판단한다).
|
|
"""
|
|
import inspect
|
|
|
|
import pytest
|
|
|
|
from app.api.endpoints import portfolio as pf
|
|
from app.api.endpoints.portfolio import (
|
|
EXTERNAL_STRATEGY_TYPE,
|
|
_classify_holding_strategy_type as _classify,
|
|
)
|
|
|
|
|
|
# ── 분류 규칙 (순수 함수) ───────────────────────────────────────────────────
|
|
|
|
def test_no_system_buy_evidence_is_external():
|
|
assert _classify("000100", 8, {}, set()) == EXTERNAL_STRATEGY_TYPE
|
|
|
|
|
|
def test_system_bought_is_swing():
|
|
assert _classify("000100", 8, {}, {"000100"}) == "swing"
|
|
|
|
|
|
def test_full_dt_quantity_is_daytrading():
|
|
assert _classify("000100", 3, {"000100": 3}, {"000100"}) == "daytrading"
|
|
|
|
|
|
def test_mixed_dt_and_extra_quantity_stays_swing():
|
|
"""DT 3주 + 스윙 5주 = 8주 → DT 태깅하면 스윙 5주가 청산 관리를 잃는다."""
|
|
assert _classify("000100", 8, {"000100": 3}, {"000100"}) == "swing"
|
|
|
|
|
|
def test_dt_position_without_system_evidence_still_external():
|
|
"""DT 인메모리가 비어 있고 근거도 없으면 external — '무조건 swing' 폴백 금지."""
|
|
assert _classify("000100", 8, {"000100": 0}, set()) == EXTERNAL_STRATEGY_TYPE
|
|
|
|
|
|
# ── sync_portfolio 불변식 (소스 수준) ──────────────────────────────────────
|
|
|
|
def _compiled(stmt):
|
|
from sqlalchemy.dialects import postgresql
|
|
return str(stmt.compile(dialect=postgresql.dialect(),
|
|
compile_kwargs={"literal_binds": True}))
|
|
|
|
|
|
def test_sync_collects_system_buy_evidence_without_time_window():
|
|
"""근거 조회는 기간 제한이 없어야 한다 — 당일만 보면 자정 후 재편입된다.
|
|
|
|
[2026-09-30 M-5] 소스 문자열이 아니라 **컴파일된 SQL** 을 본다.
|
|
"""
|
|
sql = _compiled(pf._system_buy_codes_stmt())
|
|
assert "trade_history.order_type = 'BUY'" in sql
|
|
assert "created_at" not in sql and "executed_at" not in sql, (
|
|
"유입 판정에 당일 시간창을 쓰면 안 된다"
|
|
)
|
|
|
|
|
|
def test_sync_demotes_only_through_guarded_helper():
|
|
"""[2026-09-30 사용자 결정] 강등을 허용하되 **반드시 레이스 가드를 거친다**.
|
|
|
|
"스윙으로 편입하는 건 없어야 해" → swing → external 강등이 필요하지만,
|
|
조건 없이 강등하면 매수 직후(근거 DB 반영 전) 정상 포지션이 리스크 관리 밖으로
|
|
떨어진다. 그래서 강등은 `_should_demote_to_external()` 단일 관문만 통과한다.
|
|
"""
|
|
src = inspect.getsource(pf.sync_portfolio)
|
|
assert "_classify_durable(stock_code, quantity) == \"swing\"" in src, (
|
|
"승격 경로 유지 — 단 durable 근거(레지스트리 제외)만 본다 (M-2)"
|
|
)
|
|
# 국내·해외 두 경로 모두 강등한다
|
|
assert src.count("h.strategy_type = EXTERNAL_STRATEGY_TYPE") == 2
|
|
# 무조건 강등 금지 — 가드 함수를 거친 분기에서만 대입된다
|
|
for seg in src.split("h.strategy_type = EXTERNAL_STRATEGY_TYPE")[:-1]:
|
|
assert "_should_demote_to_external(" in seg[-400:], (
|
|
"강등 대입 직전 분기가 _should_demote_to_external() 이어야 한다"
|
|
)
|
|
|
|
|
|
def test_sync_excludes_legacy_seeded_active_trade_from_evidence():
|
|
"""2026-09-29 이전 시딩 행(strategy_name='swing')은 entry_reason 으로 걸러낸다.
|
|
|
|
걸러내지 않으면 유입 잔고가 자기 시딩 행을 근거로 삼아 격리가 스스로 풀린다.
|
|
"""
|
|
sql = _compiled(pf._evidence_active_trade_codes_stmt())
|
|
assert "NOT IN ('synced')" in sql
|
|
assert "NOT LIKE '외부 유입 포지션" in sql
|
|
|
|
|
|
def test_sync_skips_active_trade_seeding_for_external():
|
|
"""격리분에 ActiveTrade 를 시딩하면 자동 손절 관리가 되살아난다."""
|
|
src = inspect.getsource(pf.sync_portfolio)
|
|
assert '(_h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE' in src
|
|
|
|
|
|
def test_sync_logs_external_holdings_as_warning():
|
|
"""격리 잔고는 조용히 방치되면 안 된다 — warning 으로 노출(debug 금지, §4)."""
|
|
src = inspect.getsource(pf.sync_portfolio)
|
|
assert "유입 격리 보유" in src
|
|
i = src.index("유입 격리 보유")
|
|
assert "logger.warning" in src[max(0, i - 200):i]
|
|
|
|
|
|
def test_portfolio_response_exposes_strategy_type():
|
|
"""프론트가 격리분을 구분할 수 있어야 한다(필드 노출)."""
|
|
assert "strategy_type" in pf.PortfolioResponse.model_fields
|
|
|
|
|
|
def test_summary_exposes_external_aggregate():
|
|
src = inspect.getsource(pf.PortfolioCache.cache_summary)
|
|
assert '"external_count"' in src and '"external_value"' in src
|
|
|
|
|
|
def test_calculate_portfolio_values_counts_external_separately():
|
|
class _H:
|
|
def __init__(self, code, qty, price, st):
|
|
self.stock_code, self.quantity = code, qty
|
|
self.current_price, self.average_price = price, price
|
|
self.market, self.strategy_type = "KRX", st
|
|
|
|
vals = pf._calculate_portfolio_values([
|
|
_H("005930", 10, 70_000, "swing"),
|
|
_H("000100", 8, 50_000, "external"),
|
|
])
|
|
assert vals["external_count"] == 1
|
|
assert vals["external_value"] == pytest.approx(400_000)
|
|
# 총자산·평가액에서 빼지 않는다 — 계좌 전체 리스크는 유입분을 포함해야 한다
|
|
assert vals["domestic_value"] == pytest.approx(700_000 + 400_000)
|
|
|
|
|
|
# ── 스윙 청산 대상에서 자동 제외되는지 (코드 변경 없이) ────────────────────
|
|
|
|
def test_swing_holdings_query_filters_by_swing_only():
|
|
from app.services.swing import position_manager as pm
|
|
src = inspect.getsource(pm.get_swing_holdings)
|
|
assert "Portfolio.strategy_type == STRATEGY_TYPE" in src
|
|
assert pm.STRATEGY_TYPE == "swing"
|
|
|
|
|
|
# ── R2: 수동 청산의 연패 기록 ──────────────────────────────────────────────
|
|
|
|
def test_manual_sell_records_consecutive_loss():
|
|
src = inspect.getsource(pf.sell_stock)
|
|
assert "record_trade_result_safe" in src, "수동 청산이 연패 카운트에서 빠진다"
|
|
i = src.index("record_trade_result_safe")
|
|
assert "save_to_db" in src[i:i + 400], "record 후 save_to_db 누락 (§8.1-7)"
|
|
# 전량 청산 시에만 + external 제외
|
|
assert "quantity >= original_holding_qty and original_strategy_type != EXTERNAL_STRATEGY_TYPE" in src
|
|
|
|
|
|
def test_manual_sell_v2_records_consecutive_loss():
|
|
from app.api.endpoints import portfolio_v2 as pv2
|
|
src = inspect.getsource(pv2.sell_stock_v2)
|
|
assert "record_trade_result_safe" in src
|
|
i = src.index("record_trade_result_safe")
|
|
assert "save_to_db" in src[i:i + 500]
|
|
assert 'original_strategy_type != "external"' in src
|
|
|
|
|
|
# ── R3: 스윙 손절 종목의 당일 DT 재진입 차단 ───────────────────────────────
|
|
|
|
# ── 손절선 이탈 가시화 (집행 없음) ─────────────────────────────────────────
|
|
|
|
class _H:
|
|
def __init__(self, code, qty, avg, cur, st="external", name=None):
|
|
self.stock_code, self.quantity = code, qty
|
|
self.average_price, self.current_price = avg, cur
|
|
self.strategy_type, self.stock_name = st, name or code
|
|
self.market = "KRX"
|
|
|
|
|
|
def test_external_breach_detected_beyond_base_stop():
|
|
from app.core.config import settings
|
|
|
|
_sl = abs(settings.STOP_LOSS_PERCENT)
|
|
rows = pf._external_stop_breaches([
|
|
_H("000100", 8, 10_000, 10_000 * (1 - (_sl + 3) / 100)), # 이탈
|
|
_H("009830", 5, 10_000, 10_000 * (1 - (_sl - 2) / 100)), # 미이탈
|
|
])
|
|
assert [r["stock_code"] for r in rows] == ["000100"]
|
|
assert rows[0]["profit_loss_rate"] == pytest.approx(-(_sl + 3), abs=0.01)
|
|
assert rows[0]["breach_pct"] < 0
|
|
|
|
|
|
def test_non_external_rows_are_not_reported():
|
|
"""스윙 행은 실제 손절 경로가 처리한다 — 이 경고는 격리분 전용."""
|
|
assert pf._external_stop_breaches([_H("005930", 10, 10_000, 1_000, st="swing")]) == []
|
|
|
|
|
|
def test_breach_ignores_missing_prices():
|
|
assert pf._external_stop_breaches([
|
|
_H("000100", 8, 0, 5_000),
|
|
_H("009830", 8, 10_000, 0),
|
|
_H("241560", 0, 10_000, 1_000),
|
|
]) == []
|
|
|
|
|
|
def test_sync_logs_breach_without_executing():
|
|
src = inspect.getsource(pf.sync_portfolio)
|
|
assert "_external_stop_breaches(" in src
|
|
i = src.index("유입 격리분 손절선 이탈")
|
|
assert "logger.warning" in src[max(0, i - 200):i], "§4: 이탈 경고는 debug 금지"
|
|
# 경고 블록 안에서 매도 주문을 내지 않는다
|
|
block = src[i:i + 600]
|
|
assert "safe_place_order" not in block and "place_order" not in block
|
|
|
|
|
|
def test_summary_exposes_breach_list():
|
|
src = inspect.getsource(pf.PortfolioCache.cache_summary)
|
|
assert '"external_stop_breaches"' in src
|
|
|
|
|
|
def _external_row():
|
|
from app.models.models import Portfolio
|
|
return Portfolio(
|
|
stock_code="000100", stock_name="유한양행", quantity=8,
|
|
average_price=10_000, current_price=10_000, market="KRX",
|
|
strategy_type=EXTERNAL_STRATEGY_TYPE,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_buy_refuses_partial_mix_promotion_on_swing_buy():
|
|
"""[2026-09-30 정정] 수량이 남아 있는 격리 행은 스윙 매수로도 승격하지 않는다.
|
|
|
|
종전 동작(무조건 승격)은 **유입분 세탁 경로**였다: Portfolio 는 stock_code
|
|
UNIQUE 로 수량이 합산되므로(§3.5) 승격하면 유입 8주까지 스윙 자동 손절 대상이
|
|
된다. 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주
|
|
→ swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 위반이다.
|
|
"""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from app.services import portfolio_cache_bridge as bridge
|
|
|
|
row = _external_row() # external 8주
|
|
|
|
class _Cache:
|
|
holdings = [row]
|
|
apply_buy = AsyncMock()
|
|
|
|
_enq = AsyncMock()
|
|
with patch.object(bridge, "_cache", return_value=_Cache()), \
|
|
patch("app.services.db_worker.enqueue_portfolio_upsert", _enq):
|
|
await bridge.reflect_buy("000100", "유한양행", 5, 11_000, strategy_type="swing")
|
|
|
|
assert row.strategy_type == EXTERNAL_STRATEGY_TYPE, "부분 혼재는 승격 금지"
|
|
assert "strategy_type" not in _enq.await_args.args[0], "DB 태그도 바꾸지 않는다"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_buy_promotes_only_when_no_inflow_quantity_remains():
|
|
"""유입 잔여 수량이 0 인 행은 승격한다 — 관리 공백을 만들지 않기 위해."""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from app.models.models import Portfolio
|
|
from app.services import portfolio_cache_bridge as bridge
|
|
|
|
row = Portfolio(
|
|
stock_code="000100", stock_name="유한양행", quantity=0,
|
|
average_price=10_000, current_price=10_000, market="KRX",
|
|
strategy_type=EXTERNAL_STRATEGY_TYPE,
|
|
)
|
|
|
|
class _Cache:
|
|
holdings = [row]
|
|
apply_buy = AsyncMock()
|
|
|
|
_enq = AsyncMock()
|
|
with patch.object(bridge, "_cache", return_value=_Cache()), \
|
|
patch("app.services.db_worker.enqueue_portfolio_upsert", _enq):
|
|
await bridge.reflect_buy("000100", "유한양행", 5, 11_000, strategy_type="swing")
|
|
|
|
assert row.strategy_type == "swing"
|
|
assert _enq.await_args.args[0]["strategy_type"] == "swing"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_buy_keeps_external_on_daytrading_buy():
|
|
"""DT 매수는 격리 태그를 바꾸지 않는다(격리 수량이 external 표시에서 사라짐 방지)."""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from app.services import portfolio_cache_bridge as bridge
|
|
|
|
row = _external_row()
|
|
|
|
class _Cache:
|
|
holdings = [row]
|
|
apply_buy = AsyncMock()
|
|
|
|
_enq = AsyncMock()
|
|
with patch.object(bridge, "_cache", return_value=_Cache()), \
|
|
patch("app.services.db_worker.enqueue_portfolio_upsert", _enq):
|
|
await bridge.reflect_buy("000100", "유한양행", 3, 11_000, strategy_type="daytrading")
|
|
|
|
assert row.strategy_type == EXTERNAL_STRATEGY_TYPE
|
|
assert "strategy_type" not in _enq.await_args.args[0]
|
|
|
|
|
|
# ── MDD 강제 축소는 격리분도 대상 (계좌 생존 장치) ─────────────────────────
|
|
|
|
def test_mdd_reduction_does_not_exclude_external():
|
|
"""MDD -10%/-20% 축소는 계좌 전체 리스크 감축이므로 격리분도 매도 대상이다.
|
|
|
|
평시 전략 손절만 제외한다. `execute_reduction()` 은 KIS 잔고(output1)를 직접
|
|
순회하므로 strategy_type 필터가 없어야 축소 목표(30%/50%)를 채울 수 있다.
|
|
여기에 strategy_type 제외가 들어오면 -20% 국면에서 줄일 수 있는 포지션이
|
|
장부상 줄어들어 리스크 감축이 약해진다.
|
|
"""
|
|
from app.services import mdd_manager as mm
|
|
|
|
src = inspect.getsource(mm.MDDManager.execute_reduction)
|
|
assert 'balance.get("output1"' in src, "축소 대상 소스가 KIS 잔고가 아니다"
|
|
# 'strategy_type' 자체는 TradeHistory 태그("mdd_reduction")로 등장한다 —
|
|
# 금지 대상은 격리분 제외 필터다.
|
|
assert EXTERNAL_STRATEGY_TYPE not in src, "MDD 축소에서 격리분을 제외하고 있다"
|
|
assert "select(Portfolio" not in src, "축소 대상을 Portfolio(전략 태그) 기준으로 좁히고 있다"
|
|
|
|
|
|
def test_swing_full_exit_blocks_same_day_dt_reentry():
|
|
from app.services.swing import exit_service as ses
|
|
src = inspect.getsource(ses.check_and_execute_exit_v2)
|
|
assert "closed_today.add(stock_code)" in src, "전략 간 당일 쿨다운 누락"
|
|
i = src.index("closed_today.add(stock_code)")
|
|
assert "trade_lock" in src[max(0, i - 300):i], "closed_today 변경은 trade_lock 규약"
|