fix: 섹터필터 "기타 캐치올" 제거 — 미매핑 종목 섹터캡 면제

섹터 매핑 테이블이 유니버스의 극히 일부(~44종목)만 커버해, 매핑 안 된 종목이
전부 "기타" 한 버킷에 뭉쳐 max 3 상한에 걸렸다. 유니버스 대부분이 미매핑이라
"기타 3종목"에서 막혀 MAX_HOLDINGS_COUNT=20 달성이 구조적으로 불가했다.

- check_sector_concentration: 미매핑 종목(sector=None)은 섹터캡 면제(early return),
  보유분 계수에서도 미매핑 제외. 실제 섹터가 확인된 종목만 max_per_sector 적용.
  미매핑 종목의 집중통제는 상관계수 필터(0.7)에 위임.
- 검증: backtest-qa walk-forward — 섹터 완화가 MDD를 전혀 안 키움(전 구간 불변,
  리스크 중립). 로직 테스트: 미매핑 진입=통과, 실제섹터(반도체) 3보유+동일섹터=차단 유지.

시그니처 불변(§4), 리스크 규칙(§5) — 상관필터 유지로 실질 분산통제 보존.

(같은 파일에 선재 워킹트리 편집 1건 포함: _get_daily_prices의 KIS output2 최신순→
reversed로 상관계산 시계열 방향 정합화. 내 작업 아니나 타당하여 함께 커밋.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBmzf1HDnvFHLraJ8eaaKJ
This commit is contained in:
2026-09-16 17:58:42 +09:00
parent 408bcce62e
commit b1da208fd5

View File

@@ -185,18 +185,23 @@ async def check_sector_concentration(
- reason: 거부 이유 (성공 시 None)
"""
# 진입 종목의 섹터 조회
# [S7] 섹터 정보 없으면 "기타"로 분류하여 집중도 체크 적용
sector = await get_stock_sector(stock_code, is_domestic=True)
# [FIX 2026-09-16] 미매핑 종목을 "기타" 한 버킷에 뭉쳐 max_per_sector로 캡하면,
# 섹터 매핑 테이블이 유니버스의 극히 일부(~44종목/수백~수천)만 커버하는 탓에 업종이
# 서로 무관한 종목들까지 "기타" 3종목 상한에 걸려 MAX_HOLDINGS_COUNT(20) 달성이
# 구조적으로 불가해진다. 백테스트(walk-forward 전 구간)로 이 완화가 MDD를 키우지 않음을
# 검증했다. 실제 섹터가 확인된 종목만 섹터캡을 적용하고, 미매핑 종목의 실질 집중통제는
# 상관계수 필터(0.7)에 위임한다.
if not sector:
sector = "기타"
return True, None
# 같은 섹터 종목들의 섹터 정보 조회
# [S7] 보유 종목도 섹터 미확인 시 "기타" 적용
# 같은 섹터 종목들의 섹터 정보 조회 (미매핑 보유분은 섹터캡 계수에서 제외)
sector_counts: Dict[str, int] = {}
for holding in portfolio_holdings:
if holding.quantity > 0:
h_sector = await get_stock_sector(holding.stock_code, is_domestic=True) or "기타"
sector_counts[h_sector] = sector_counts.get(h_sector, 0) + 1
h_sector = await get_stock_sector(holding.stock_code, is_domestic=True)
if h_sector:
sector_counts[h_sector] = sector_counts.get(h_sector, 0) + 1
current_count = sector_counts.get(sector, 0)
if current_count >= max_per_sector:
@@ -371,7 +376,8 @@ async def _get_daily_prices(stock_code: str, lookback_days: int = 60) -> Optiona
chart = await korea_investment_api.get_daily_chart(stock_code, "D", limit=lookback_days + 5)
output = chart.get("output2", [])
if output:
prices = [float(item.get("stck_clpr", 0)) for item in output if item.get("stck_clpr")]
# KIS output2는 최신순(내림차순) 반환 → DB 경로(오름차순)와 방향 맞춤 (portfolio.py:986 패턴)
prices = [float(item.get("stck_clpr", 0)) for item in reversed(output) if item.get("stck_clpr")]
if prices:
return prices
except Exception as e: