diff --git a/config/baskets.yaml b/config/baskets.yaml index 62c719d7..65315ae4 100644 --- a/config/baskets.yaml +++ b/config/baskets.yaml @@ -87,24 +87,34 @@ baskets: # --- 소액 적립 트랙 (용돈 엔진) — docs/POCKET_TRACK_PLAN.md --- # 소액(30만 시작 + 월 10만 적립)의 유일한 분산 수단은 ETF다: KODEX 200 1주가 - # 그 자체로 200종목 분산이고, 국내 주식형 ETF는 매도 거래세 면제라 비용도 유리. - # 주식 50/현금 50으로 낙폭을 절반으로 누른다(STATIC_ALLOCATION.md). + # 그 자체로 200종목 분산이고, 국내 주식형 ETF는 매도 거래세 면제라 비용도 유리 + # (transaction_costs.tax_exempt_symbols에 등록 — 체결 모델도 면제 반영). + # + # v2 (2026-07-07): 위험자산 절반 원칙은 유지하되 "나머지 절반"을 무수익 현금 대신 + # CD금리 파킹 ETF로 보유한다. 종전 50/50에서 현금 50%는 이자 0으로 잠들어 있었는데, + # 357870은 CD 91일물 금리를 일할 누적하는 무분배(TR형) ETF라 가격 변동이 사실상 + # 없이 연 3%대가 쌓인다 — 위험은 거의 그대로, 유휴분만 회수(연 +1%대 기대). + # - 주가지수(069500) 47.5% + 파킹(357870) 47.5% + 현금 5%(수수료 여유) + # - min_cash_ratio 0.05: 전역 20% 하한은 개별 주식 바스켓용 안전판 — 보유분 + # 절반이 현금성인 이 바스켓엔 과잉이라 바스켓별 오버라이드로 낮춘다. + # - 한계 고지: KODEX200 분배금은 페이퍼가 못 받는다(연 ~1.5-2% × 47.5% 보수적 + # 편향). 357870은 무분배라 누락 없음. 합성 ETF의 스왑 상대방 위험은 미미. # 파라미터 주의(소액 특화): - # - max_turnover_ratio 0.6: 15%면 회전 한도(30만×15%=4.5만)가 ETF 1주가(약 12.6만)보다 - # 작아 영원히 매수 불가. 0.6이면 첫 사이클에 주식 슬리브(50%)를 채울 수 있다. - # - min_trade_amount 50000: 기본 20만이면 슬롯(15만)이 최소거래 미달로 미체결 판정. - # 월 적립은 tools/record_deposit.py --basket kr_pocket --amount 100000 으로 기록 - # (입금은 TWR이 중화 — 수익률 왜곡 없음). 다음 사이클이 새 현금을 흡수한다. + # - max_turnover_ratio 0.6: 15%면 회전 한도(30만×15%=4.5만)가 ETF 1주가(약 12.4만)보다 + # 작아 영원히 매수 불가. 0.6이면 첫 사이클에 큰 슬롯부터 채운다(잔여는 다음 날). + # - min_trade_amount 50000: 기본 20만이면 슬롯(약 14만)이 최소거래 미달로 미체결 판정. + # 월 적립은 웹 대시보드 입금 버튼 또는 tools/record_deposit.py --basket kr_pocket + # --amount 100000 으로 기록(입금은 TWR이 중화 — 수익률 왜곡 없음). 다음 사이클이 흡수한다. kr_pocket: - name: "소액 적립 (KODEX200 50/50)" + name: "소액 적립 (지수+CD금리 파킹)" enabled: true initial_capital: 300000 # 시작 자본 30만원 (paper — live 전환 시 실자본과 일치) - target_stock_weight: 0.5 # 주식 50% / 현금 50% — 낙폭 절반이 목적 - # 소액 구조적 절사: ETF 1주(약 12.8만) 단위라 배치율 미달이 상시 존재하고, 적립 - # 직후에는 '부족분 < 1주'로 매수가 보류돼 미달이 최대 1주가/총액(잔고 40만 시점 - # 32%p)까지 벌어진다(docs/POCKET_TRACK_PLAN.md §3 — 정상, 적립 누적 시 수렴). - # 이 값은 '하한(floor)'이고, 헬스가 구조 하한(1주가/총액)과의 max로 자동 완화· - # 조임한다 — 잔고가 커지면 이 값이 다시 지배한다. + target_stock_weight: 0.95 # 투자 95% (지수 47.5 + 파킹 47.5) / 현금 5% + min_cash_ratio: 0.05 # 바스켓별 현금 하한 오버라이드 (전역 0.20 대체) + # 소액 구조적 절사: ETF 1주(약 12.4만) 단위라 배치율 미달이 상시 존재하고, 적립 + # 직후에는 '부족분 < 1주'로 매수가 보류돼 미달이 벌어진다(docs/POCKET_TRACK_PLAN.md + # §3 — 정상, 적립 누적 시 수렴). 이 값은 '하한(floor)'이고, 헬스가 구조 하한 + # (1주가/총액)과의 max로 자동 완화·조임한다 — 잔고가 커지면 이 값이 다시 지배한다. monitoring: deployment_tolerance: 0.10 rebalance: @@ -113,7 +123,8 @@ baskets: min_trade_amount: 50000 max_turnover_ratio: 0.6 holdings: - "069500": 1.0 # KODEX 200 — 주식 슬리브 전체(슬리브 자체가 총자산의 50%) + "069500": 0.5 # KODEX 200 — 위험자산 슬리브(총자산의 47.5%) + "357870": 0.5 # TIGER CD금리투자KIS(합성) — 파킹 슬리브(총자산의 47.5%) # --- 예시 2: 신호 가중 리밸런싱 바스켓 --- # signal_weighted: true 시, 스코어링 전략 점수에 따라 목표 비중을 동적 조정 diff --git a/config/risk_params.yaml b/config/risk_params.yaml index 1b1e2619..16dc4361 100644 --- a/config/risk_params.yaml +++ b/config/risk_params.yaml @@ -158,6 +158,12 @@ paper_backtest_compare: transaction_costs: commission_rate: 0.00015 # 편도 수수료 (0.015%) tax_rate: 0.0020 # 증권거래세+농특세: 매도 금액의 0.20% (2026년~ 코스피·코스닥 동일) + # 국내 상장 ETF는 증권거래세 비과세 — 개별 주식 세율을 일괄 적용하면 ETF 전용 바스켓 + # (kr_pocket)의 매도 비용이 0.20%p 과대계상돼 승격 게이트의 비용 상한(연 1%) 판정까지 + # 왜곡된다. 여기 등록된 종목은 매도세 0으로 계산한다(수수료·슬리피지는 동일 적용). + tax_exempt_symbols: + - "069500" # KODEX 200 + - "357870" # TIGER CD금리투자KIS(합성) slippage: 0.0005 # 슬리피지 기본 비율 (0.05%). 동적 슬리피지 배수 적용 시 이 값 위에 곱해짐 slippage_ticks: 1 # 호가 단위 기준 슬리피지 틱 수 (지정가 주문 기준 1틱이 현실적) # 양도소득세 (대주주 해당 시에만 적용; 일반 투자자는 해당 없음) diff --git a/core/basket_deploy.py b/core/basket_deploy.py index 371e15fb..dcdebf4f 100644 --- a/core/basket_deploy.py +++ b/core/basket_deploy.py @@ -15,6 +15,31 @@ DEFAULT_COMMISSION_RATE = 0.00015 # 0.015% 양방향 DEFAULT_TAX_RATE = 0.0020 # 0.20% 매도만 +DEFAULT_MIN_CASH_RATIO = 0.20 # diversification.min_cash_ratio 기본값과 동일 + + +def effective_stock_fraction(basket_cfg: dict[str, Any], risk_params: dict[str, Any]) -> float: + """바스켓의 유효 투자 비중(총자산 대비). 리밸런서·평가·헬스가 같은 규칙을 써야 + '설계 비중'이 서로 어긋나지 않으므로 여기 한 곳에 둔다. + + - target_stock_weight: 바스켓이 명시한 투자 목표 비중. 미지정이면 (1 - 현금 하한). + - min_cash_ratio: 전역(diversification.min_cash_ratio, 기본 20%)이 하한이지만 + 바스켓별로 오버라이드할 수 있다. 전역 20%는 개별 주식 바스켓의 안전판인데, + 보유분 절반이 현금성(CD금리 파킹 ETF)인 바스켓에는 과잉이라 유휴 현금만 남긴다. + """ + div_cfg = (risk_params or {}).get("diversification", {}) or {} + mcr_raw = basket_cfg.get("min_cash_ratio", div_cfg.get("min_cash_ratio", DEFAULT_MIN_CASH_RATIO)) + try: + mcr = float(mcr_raw) + except (TypeError, ValueError): + mcr = float(div_cfg.get("min_cash_ratio", DEFAULT_MIN_CASH_RATIO)) + mcr = max(0.0, min(1.0, mcr)) + max_stock = 1.0 - mcr + tsw = basket_cfg.get("target_stock_weight") + if tsw is None: + return max_stock + return max(0.0, min(float(tsw), max_stock)) + def estimate_order_costs( orders: list[Any], diff --git a/core/basket_evaluation.py b/core/basket_evaluation.py index 70861160..85760945 100644 --- a/core/basket_evaluation.py +++ b/core/basket_evaluation.py @@ -494,11 +494,8 @@ def _d(v): # 성과 귀속(실행 격차/구성 격차) — 종목별 조회(네트워크)라 기본 off. # 일일 사이클(리포트 부가필드)은 호출하지 않고, CLI 평가 도구에서만 켠다. if include_attribution: - max_stock = 1.0 - ( - config.risk_params.get("diversification", {}).get("min_cash_ratio", 0.20) - ) - tsw = basket_cfg.get("target_stock_weight") - design_fraction = max_stock if tsw is None else max(0.0, min(float(tsw), max_stock)) + from core.basket_deploy import effective_stock_fraction + design_fraction = effective_stock_fraction(basket_cfg, config.risk_params) holdings = basket_cfg.get("holdings") or {} design_return_pct = None if snaps and holdings: diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index e90cc5e1..2e7ab8de 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -129,13 +129,11 @@ def __init__( self.data_collector = DataCollector(self.config) self._risk_params = self.config.risk_params - self._min_cash_ratio = ( - self._risk_params.get("diversification", {}).get("min_cash_ratio", 0.20) - ) # target_stock_weight: 바스켓이 명시하는 주식 목표 비중(총자산 대비). 나머지는 현금으로 # 보유한다. 미지정(None)이면 기존 동작(min_cash_ratio만 현금 유보)을 유지한다. # 정적 자산배분(docs/STATIC_ALLOCATION.md) 결론을 실행 기능으로 구현: 예) 0.5면 주식 50% - # 보유 + 50% 현금 → 낙폭을 절반으로 줄인다. min_cash_ratio가 더 큰 현금을 요구하면 그쪽을 따른다. + # 보유 + 50% 현금 → 낙폭을 절반으로 줄인다. min_cash_ratio가 더 큰 현금을 요구하면 그쪽을 + # 따르되, 하한 자체는 바스켓별 오버라이드 가능(effective_stock_fraction 참고). tsw = self.basket.get("target_stock_weight") self._target_stock_weight = float(tsw) if tsw is not None else None @@ -146,12 +144,10 @@ def __init__( ) def _stock_fraction(self) -> float: - """총자산 중 주식에 배정할 비중. target_stock_weight가 있으면 그것을(단, min_cash_ratio - 가 요구하는 현금 하한은 항상 지킴), 없으면 (1 - min_cash_ratio).""" - max_stock = 1.0 - self._min_cash_ratio - if self._target_stock_weight is None: - return max_stock - return max(0.0, min(self._target_stock_weight, max_stock)) + """총자산 중 주식에 배정할 비중 — 규칙은 effective_stock_fraction 한 곳에만 둔다 + (리밸런서·평가·헬스가 각자 계산하면 '설계 비중'이 서로 어긋난다).""" + from core.basket_deploy import effective_stock_fraction + return effective_stock_fraction(self.basket, self._risk_params) def _is_live(self) -> bool: """실전(live) 모드 여부.""" diff --git a/core/operator_health.py b/core/operator_health.py index fc4465e6..c5e0bdb8 100644 --- a/core/operator_health.py +++ b/core/operator_health.py @@ -135,28 +135,32 @@ def summarize_blockers(blockers: dict[str, Any] | None) -> dict[str, Any]: def structural_deployment_tolerance( - max_share_price: float | None, + truncation_unit_value: float | None, total_value: float | None, floor_tolerance: float = 0.05, ) -> float: """배치율 허용 오차의 구조 하한 — 정수 주식 절사가 만드는 불가피한 미달을 반영(순수). - 적립 직후에는 '부족분 < 1주 가격'인 동안 매수가 보류되므로(설계), 미달폭이 최대 - 1주 가격/총액까지 벌어진다(예: 잔고 40만·ETF 1주 12.8만 → 32%p). 이 구간을 고정 - 허용치로 재면 정상 적립 과정이 매일 ATTENTION을 울린다(경보 피로). 반대로 잔고가 - 커지면 구조 하한이 저절로 조여져(200만 → 6.4%p) floor가 다시 지배한다 — - 진짜 이상(예: 매수 자체 불능 → 미달 50%p)은 어느 시점에도 잡힌다. - - 반환: max(floor_tolerance, max_share_price/total_value). 입력 불충분 시 floor. + 적립 직후에는 '부족분 < 1주 가격'인 동안 매수가 보류되므로(설계), 미달폭이 + 슬롯마다 최대 1주 가격까지 벌어진다. truncation_unit_value는 그 상한의 합 — + **보유 슬롯별 1주 가격의 합**이다(예: 잔고 40만·ETF 1주 12.8만 단일 슬롯 → 32%p; + 지수 12.4만+파킹 5.8만 2슬롯 잔고 70만 → 26%p). 최고가 1주만 재면 다중 슬롯 + 바스켓이 정상 적립 중(모든 슬롯이 부족분 < 1주)에도 허용을 초과해 몇 주씩 + 거짓 ATTENTION이 울린다. 잔고가 커지면 구조 하한이 저절로 조여져 floor가 다시 + 지배한다 — 진짜 이상(미달이 절사 상한을 초과)은 규모가 커지는 즉시 잡힌다. + 완전히 빈 슬롯은 절사가 아니라 실패이므로 합산에 넣지 않는다(호출부는 '보유' + 포지션의 1주 가격만 합산할 것). + + 반환: max(floor_tolerance, truncation_unit_value/total_value). 입력 불충분 시 floor. """ try: - price = float(max_share_price or 0) + unit = float(truncation_unit_value or 0) total = float(total_value or 0) except (TypeError, ValueError): return float(floor_tolerance) - if price <= 0 or total <= 0: + if unit <= 0 or total <= 0: return float(floor_tolerance) - return max(float(floor_tolerance), price / total) + return max(float(floor_tolerance), unit / total) def summarize_deployment( diff --git a/core/order_executor.py b/core/order_executor.py index ceef8956..fe89bb95 100644 --- a/core/order_executor.py +++ b/core/order_executor.py @@ -779,6 +779,7 @@ def _execute_buy_impl( 1, "BUY", avg_daily_volume=avg_daily_volume, + symbol=symbol, ) sizing_entry_price = float(sizing_costs.get("execution_price", price) or price) @@ -867,7 +868,9 @@ def _execute_buy_impl( return {"success": False, "reason": perf_check.get("reason", "성과 열화로 매수 중단")} # 거래 비용 계산 (avg_daily_volume 있으면 거래량 기반 동적 슬리피지 적용) - costs = self.risk_manager.calculate_transaction_costs(price, quantity, "BUY", avg_daily_volume=avg_daily_volume) + costs = self.risk_manager.calculate_transaction_costs( + price, quantity, "BUY", avg_daily_volume=avg_daily_volume, symbol=symbol, + ) available_cash = capital if available_cash is None else available_cash estimated_fill_price = float(costs.get("execution_price", price) or price) @@ -984,7 +987,7 @@ def _execute_buy_impl( order.transition(OrderStatus.ACKED) # Paper에서는 슬리피지 적용 후 즉시 FILLED (simulated fill) costs = self.risk_manager.calculate_transaction_costs( - expected_price, quantity, "BUY", avg_daily_volume=avg_daily_volume, + expected_price, quantity, "BUY", avg_daily_volume=avg_daily_volume, symbol=symbol, ) fill_price = costs["execution_price"] order.transition(OrderStatus.FILLED, fill_qty=quantity, fill_price=fill_price) @@ -994,7 +997,7 @@ def _execute_buy_impl( if self.mode == "live": costs = self.risk_manager.calculate_transaction_costs( - fill_price, quantity, "BUY", avg_daily_volume=avg_daily_volume, + fill_price, quantity, "BUY", avg_daily_volume=avg_daily_volume, symbol=symbol, ) stop_loss = self.risk_manager.calculate_stop_loss( @@ -1128,7 +1131,7 @@ def _execute_buy_quantity_impl( return {"success": False, "reason": "장 초반/마감 진입 차단 시간대"} costs = self.risk_manager.calculate_transaction_costs( - price, quantity, "BUY", avg_daily_volume=avg_daily_volume, + price, quantity, "BUY", avg_daily_volume=avg_daily_volume, symbol=symbol, ) expected_price = float(price) fill_price = float(costs["execution_price"]) @@ -1232,7 +1235,7 @@ def _execute_buy_quantity_impl( OrderGuard.clear(symbol) # 체결가 기준 비용 재계산 costs = self.risk_manager.calculate_transaction_costs( - fill_price, quantity, "BUY", avg_daily_volume=avg_daily_volume, + fill_price, quantity, "BUY", avg_daily_volume=avg_daily_volume, symbol=symbol, ) else: order.transition(OrderStatus.SUBMITTED) @@ -1464,6 +1467,7 @@ def _execute_sell_impl( "SELL", avg_daily_volume=avg_daily_volume, avg_price=float(position.avg_price), + symbol=symbol, ) fill_price = float(costs["execution_price"]) order.transition(OrderStatus.SUBMITTED) @@ -1480,6 +1484,7 @@ def _execute_sell_impl( "SELL", avg_daily_volume=avg_daily_volume, avg_price=float(position.avg_price), + symbol=symbol, ) total_tax = costs["tax"] + costs.get("capital_gains_tax", 0) pnl = (fill_price - position.avg_price) * sell_qty - costs["commission"] - total_tax diff --git a/core/risk_manager.py b/core/risk_manager.py index 6f099c77..afde1801 100644 --- a/core/risk_manager.py +++ b/core/risk_manager.py @@ -667,6 +667,7 @@ def calculate_transaction_costs( action: str = "BUY", avg_daily_volume: float = None, avg_price: float = None, + symbol: str = None, ) -> dict: """ 거래 비용 계산 (수수료 + 증권거래세 + 슬리피지 + 양도소득세(선택)) @@ -677,6 +678,10 @@ def calculate_transaction_costs( action: "BUY" 또는 "SELL" avg_daily_volume: 일평균 거래량 (동적 슬리피지용) avg_price: 매도 시 평균 매입 단가 (양도소득세 계산용; 대주주 해당 시) + symbol: 종목코드 (선택). transaction_costs.tax_exempt_symbols에 있으면 + 매도세를 면제한다 — 국내 상장 ETF는 증권거래세 비과세인데 개별 주식 + 세율을 일괄 적용하면 ETF 바스켓의 비용이 과대계상된다(승격 게이트의 + 비용 상한 판정까지 왜곡). 미전달 시 기존 동작(일괄 과세) 유지. Returns: commission(수수료), tax(증권거래세+농특세 매도 시 0.20%), capital_gains_tax(양도소득세, 설정 시), @@ -685,6 +690,11 @@ def calculate_transaction_costs( costs = self.risk_params.get("transaction_costs", {}) amount = price * quantity + sell_tax_rate = costs.get("tax_rate", 0.0020) + exempt = costs.get("tax_exempt_symbols") or [] + if symbol is not None and str(symbol) in {str(s) for s in exempt}: + sell_tax_rate = 0.0 + commission = amount * costs.get("commission_rate", 0.00015) dynamic = costs.get("dynamic_slippage", {}) slippage_rate_fixed = costs.get("slippage", 0.0005) @@ -713,10 +723,10 @@ def calculate_transaction_costs( slippage = slippage_per_share * quantity slippage_rate_effective = slippage_per_share / price if price > 0 else 0 - # 증권거래세+농특세: 매도 금액의 0.20% (2026년~ 코스피·코스닥 동일) + # 증권거래세+농특세: 매도 금액의 0.20% (2026년~ 코스피·코스닥 동일; ETF는 면제) tax = 0 if action.upper() == "SELL": - tax = amount * costs.get("tax_rate", 0.0020) + tax = amount * sell_tax_rate # 양도소득세 (대주주 해당 시만; enabled 시 실현 이익에 대해 부과) capital_gains_tax = 0 @@ -735,7 +745,7 @@ def calculate_transaction_costs( execution_price = price + slippage_per_share else: effective_price = price * ( - 1 - costs.get("commission_rate", 0) - slippage_rate_effective - costs.get("tax_rate", 0) + 1 - costs.get("commission_rate", 0) - slippage_rate_effective - sell_tax_rate ) execution_price = max(0, price - slippage_per_share) diff --git a/docs/POCKET_TRACK_PLAN.md b/docs/POCKET_TRACK_PLAN.md index e4f6159b..af63cd20 100644 --- a/docs/POCKET_TRACK_PLAN.md +++ b/docs/POCKET_TRACK_PLAN.md @@ -39,14 +39,40 @@ ## 3. 트랙 1 — `kr_pocket` 바스켓 (용돈 엔진) +### v2 (2026-07-07): 유휴 현금 50%를 CD금리 파킹 ETF로 — "최대 이득 + 안전"의 실제 레버 + +v1(지수 50 / 현금 50)의 현금 절반은 이자 0으로 잠들어 있었다. 알파가 없다는 결론 +아래에서 수익을 더 끌어올 수 있는 정직한 원천은 예측이 아니라 **캐시 드래그 제거**다: + +- **구성: KODEX 200 (069500) 47.5% + TIGER CD금리투자KIS(합성) (357870) 47.5% + 현금 5%** + (`target_stock_weight: 0.95`, holdings 0.5/0.5, 바스켓별 `min_cash_ratio: 0.05`). +- 357870은 CD 91일물 금리를 일할 누적하는 무분배(TR형) 파킹 ETF — 가격이 사실상 + 단조 증가(일 변동 ±수원), 시장 위험 없이 연 ~3%대. **위험자산 노출은 종전 절반 + 원칙 그대로**(50%→47.5%)이고, 무수익이던 절반만 이자 자산이 됐다. 총자산 기준 + 기대 상승분 연 +1%대 — 30만 원에선 커피값이지만 적립이 쌓일수록 같이 커진다. +- 비용 정합: 두 ETF 모두 **매도 거래세 면제**를 체결 모델에도 반영 + (`transaction_costs.tax_exempt_symbols`) — 종전엔 개별주 세율 0.20%가 일괄 + 적용돼 비용이 과대계상됐다(승격 게이트의 비용 상한 판정 왜곡 요인). +- 한계 고지(정직): KODEX 200 분배금(연 ~1.5-2%)은 페이퍼가 수령하지 못한다 — + 실계좌 대비 **보수적** 편향이라 승격 판단을 부풀리지 않는다. 357870은 무분배라 + 누락 없음. 합성 ETF 스왑 상대방 위험은 CD금리 상품에선 미미. +- E2E 검증(격리 DB, 실가격): 사이클1 지수 1주 매수(회전상한 60% 내 큰 슬롯 우선) + → 사이클2 파킹 2주 매수 → 사이클3 드리프트 9.4%p 감지되나 '부족분 < 1주'로 + 0주문(조용) — 정상 정착 상태. 30만 시점 배치율 ~80%(정수 절사), 적립 시 수렴. +- 트랙레코드는 v2 구성으로 재시작(0/60, `tools/restart_basket_track_record.py`, + 가역) — 실돈을 정당화할 기록은 실제 굴릴 구성으로 측정해야 한다. + +### v1 원 설계 (참고 보존) + - **구성: KODEX 200 (069500) 단일 × `target_stock_weight: 0.5`** — 나머지 50% 현금. - 왜 ETF: ① 1주로 200종목 분산(소액에서 유일한 분산 수단) ② 국내 주식형 ETF는 **매도 거래세 면제**(개별주 0.15~0.2% 대비 비용 우위) ③ 우리 결론(베타가 답)의 가장 순수한 구현체. 데이터 경로 확인 완료(FDR, 현재가 126,470원). - 30만원 시뮬레이션: 주식 슬리브 15만 → 1주(12.6만) = 슬리브의 84% 채움. 월 10만 적립이 쌓이면 1주씩 추가 매수되는 구조(적립과 정수 주식이 자연스럽게 맞물린다). - - 왜 단일 종목인가: 30만 규모에서 2개 이상으로 쪼개면 슬롯이 최소거래·1주 가격에 - 걸려 미체결만 늘어난다. 단순함이 이 규모의 최적 분산이다. + - 왜 단일 종목인가(v1 당시): 30만 규모에서 개별주 2개 이상으로 쪼개면 슬롯이 + 최소거래·1주 가격에 걸려 미체결만 늘어난다. v2의 파킹 ETF는 1주 5.8만이라 + 이 제약에 걸리지 않는다(2주 = 슬롯의 81%). - 드리프트 8%p·저회전 유지. 리밸런싱보다 **적립 매수가 주된 거래**가 된다. - 페이퍼로 시작 → 기존 60일 게이트·일일/주간 리포트·관측성 전부 재사용(계정 키 `basket_rebalance:kr_pocket` 격리). live 전환도 기존 런북 그대로. diff --git a/main.py b/main.py index 6efd93da..ff669196 100644 --- a/main.py +++ b/main.py @@ -1689,17 +1689,11 @@ def run_health_check() -> int: config = Config.get() enabled_baskets = BasketRebalancer.get_enabled_baskets() baskets_cfg = BasketRebalancer._load_baskets_config() - min_cash_ratio = ( - config.risk_params.get("diversification", {}).get("min_cash_ratio", 0.20) - ) def _design_fraction(cfg: dict) -> float: """BasketRebalancer._stock_fraction과 동일 규칙(인스턴스 없이 계산).""" - max_stock = 1.0 - min_cash_ratio - tsw = cfg.get("target_stock_weight") - if tsw is None: - return max_stock - return max(0.0, min(float(tsw), max_stock)) + from core.basket_deploy import effective_stock_fraction + return effective_stock_fraction(cfg, config.risk_params) # 바스켓별 전용 계정 키(basket_rebalance:) 기준으로 조회한다. # 복수 바스켓이면 '가장 오래된 최신 스냅샷'을 기준으로(가장 뒤처진 사이클 감시). @@ -1743,11 +1737,14 @@ def _design_fraction(cfg: dict) -> float: floor_tol = float( (cfg_b.get("monitoring") or {}).get("deployment_tolerance", 0.05) ) - max_price = max( - (float(p.avg_price or 0) for p in positions_b), default=0.0, + # 보유 슬롯별 1주 가격의 합 — 슬롯마다 '부족분 < 1주' 절사가 + # 동시에 존재할 수 있으므로 최고가 1주만 재면 다중 슬롯 바스켓이 + # 정상 적립 중에 거짓 ATTENTION을 울린다. 빈 슬롯은 실패이므로 제외. + truncation_unit = sum( + float(p.avg_price or 0) for p in positions_b ) tol_b = structural_deployment_tolerance( - max_price, float(snap.total_value), floor_tol, + truncation_unit, float(snap.total_value), floor_tol, ) violation = (design - dep_ratio) - tol_b if worst_shortfall is None or violation > worst_shortfall: diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py index 0ac582bb..ee78eec0 100644 --- a/monitoring/web_dashboard.py +++ b/monitoring/web_dashboard.py @@ -102,7 +102,6 @@ def get_baskets_json() -> dict: config = Config.get() baskets_cfg = BasketRebalancer._load_baskets_config() - min_cash = (config.risk_params.get("diversification") or {}).get("min_cash_ratio", 0.20) global_capital = (config.risk_params.get("position_sizing") or {}).get( "initial_capital", 10_000_000 ) @@ -141,9 +140,10 @@ def get_baskets_json() -> dict: finally: session.close() - max_stock = 1.0 - float(min_cash) - tsw = cfg.get("target_stock_weight") - design_fraction = max_stock if tsw is None else max(0.0, min(float(tsw), max_stock)) + # 설계 비중은 리밸런서·평가·헬스와 같은 단일 규칙을 쓴다 — 여기만 다르게 + # 계산하면 대시보드와 디스코드 카드가 서로 다른 '설계 %'를 보여준다. + from core.basket_deploy import effective_stock_fraction + design_fraction = effective_stock_fraction(cfg, config.risk_params) positions = [ { diff --git a/tests/test_basket_deploy.py b/tests/test_basket_deploy.py index 3ac596b0..7acb885a 100644 --- a/tests/test_basket_deploy.py +++ b/tests/test_basket_deploy.py @@ -2,7 +2,10 @@ from types import SimpleNamespace +import pytest + from core.basket_deploy import ( + effective_stock_fraction, estimate_order_costs, summarize_basket_deployment, DEFAULT_COMMISSION_RATE, @@ -107,3 +110,41 @@ def test_next_steps_always_include_dryrun_and_live(self): joined = " ".join(out["next_steps"]) assert "--dry-run" in joined assert "live" in joined.lower() + + +class TestEffectiveStockFraction: + """유효 투자 비중 — 리밸런서·평가·헬스가 공유하는 단일 규칙.""" + + RISK = {"diversification": {"min_cash_ratio": 0.20}} + + def test_no_target_uses_global_cash_floor(self): + assert effective_stock_fraction({}, self.RISK) == pytest.approx(0.80) + + def test_target_clamped_by_global_floor(self): + # 오버라이드 없으면 전역 20% 하한이 그대로 상한을 만든다 (기존 동작 회귀 pin) + cfg = {"target_stock_weight": 0.95} + assert effective_stock_fraction(cfg, self.RISK) == pytest.approx(0.80) + + def test_legacy_5050_unchanged(self): + cfg = {"target_stock_weight": 0.5} + assert effective_stock_fraction(cfg, self.RISK) == pytest.approx(0.5) + + def test_basket_override_allows_higher_deployment(self): + # kr_pocket v2: 보유분 절반이 현금성(파킹 ETF)이라 현금 하한 5%로 낮춰 95% 배치 + cfg = {"target_stock_weight": 0.95, "min_cash_ratio": 0.05} + assert effective_stock_fraction(cfg, self.RISK) == pytest.approx(0.95) + + def test_override_still_floors_target(self): + cfg = {"target_stock_weight": 0.99, "min_cash_ratio": 0.05} + assert effective_stock_fraction(cfg, self.RISK) == pytest.approx(0.95) + + def test_invalid_override_falls_back_to_global(self): + cfg = {"target_stock_weight": 0.95, "min_cash_ratio": "잘못된값"} + assert effective_stock_fraction(cfg, self.RISK) == pytest.approx(0.80) + + def test_out_of_range_override_clamped(self): + assert effective_stock_fraction({"min_cash_ratio": -0.5}, self.RISK) == pytest.approx(1.0) + assert effective_stock_fraction({"min_cash_ratio": 1.5}, self.RISK) == pytest.approx(0.0) + + def test_empty_risk_params_uses_default(self): + assert effective_stock_fraction({}, {}) == pytest.approx(0.80) diff --git a/tests/test_basket_rebalancer.py b/tests/test_basket_rebalancer.py index 78fa5149..743bd2c0 100644 --- a/tests/test_basket_rebalancer.py +++ b/tests/test_basket_rebalancer.py @@ -343,8 +343,11 @@ def test_pocket_basket_small_capital_invariants(self): """소액 적립 바스켓(docs/POCKET_TRACK_PLAN.md §3) — 소액 특화 불변식. 이 값들이 '일반' 기본값으로 되돌아가면 바스켓이 조용히 죽는다: - min_trade 20만이면 슬롯(15만)이 미체결 판정, 회전상한 15%면 한도(4.5만)가 - ETF 1주가(약 12.6만)보다 작아 영원히 매수 불가. + min_trade 20만이면 슬롯(약 14만)이 미체결 판정, 회전상한 15%면 한도(4.5만)가 + ETF 1주가(약 12.4만)보다 작아 영원히 매수 불가. + + v2 (2026-07-07): 위험자산 절반 원칙은 유지하되 나머지 절반을 무수익 현금 + 대신 CD금리 파킹 ETF(357870)로 — 지수 47.5 / 파킹 47.5 / 현금 5. """ baskets = self._load() assert "kr_pocket" in baskets, "소액 적립 바스켓 누락" @@ -353,16 +356,26 @@ def test_pocket_basket_small_capital_invariants(self): capital = float(b["initial_capital"]) assert capital == 300_000 tsw = float(b["target_stock_weight"]) - assert tsw == 0.5 + assert tsw == 0.95 + # 파킹 ETF 바스켓은 전역 현금 하한(20%)이 과잉 — 바스켓별 5% 오버라이드. + # 이 키가 사라지면 배치 목표가 80%로 조용히 깎여 파킹 슬롯이 못 찬다. + assert float(b["min_cash_ratio"]) == 0.05 rb = b["rebalance"] - slot = capital * tsw # 단일 종목이므로 슬리브 전체가 한 슬롯 + # 지수/파킹 각 슬롯(총자산의 47.5%)이 최소거래 이상이어야 미체결 판정을 피한다 + slot = capital * tsw * 0.5 assert float(rb["min_trade_amount"]) <= slot, "최소거래가 슬롯보다 크면 미체결" - # 첫 사이클에 주식 슬리브를 채울 수 있어야 한다(회전 한도 ≥ 슬리브 비중) - assert float(rb["max_turnover_ratio"]) >= tsw, "회전 한도 < 슬리브면 초기 매수 불가" - # ETF 단일 구성(1주 = 200종목 분산) - assert list(b["holdings"].keys()) == ["069500"] - # 소액 구조적 절사(1주 단위 -7.3%p) 허용 — 기본 5%p면 매일 무의미 ATTENTION + # 첫 사이클에 최소 큰 슬롯 하나는 채울 수 있어야 한다(회전 한도 ≥ 슬롯 비중) + assert float(rb["max_turnover_ratio"]) >= tsw * 0.5, "회전 한도 < 슬롯이면 초기 매수 불가" + # 지수(위험자산) + CD금리 파킹 반반 — 위험 노출은 종전 50/50 설계와 동일 수준 + assert b["holdings"] == {"069500": 0.5, "357870": 0.5} + # 소액 구조적 절사(1주 단위) 허용 — 기본 5%p면 매일 무의미 ATTENTION assert float(b["monitoring"]["deployment_tolerance"]) == 0.10 + # 두 ETF 모두 매도 거래세 면제 목록에 있어야 비용이 과대계상되지 않는다 + from config.config_loader import Config + exempt = {str(s) for s in ( + Config.get().risk_params.get("transaction_costs", {}).get("tax_exempt_symbols") or [] + )} + assert {"069500", "357870"} <= exempt def test_observation_track_deployment_alarm_disabled(self): """관찰용 강등(kr_diversified_hold): 종결된 자본 결정의 잔상인 배치율 미달이 diff --git a/tests/test_operator_health.py b/tests/test_operator_health.py index c3125e6e..bb3007e9 100644 --- a/tests/test_operator_health.py +++ b/tests/test_operator_health.py @@ -273,6 +273,25 @@ def test_floor_when_inputs_missing(self): assert self._f(128_000, 0) == pytest.approx(0.10) assert self._f("x", 400_000) == pytest.approx(0.10) + def test_multi_slot_accumulation_not_alarmed(self): + # kr_pocket v2(지수+파킹 2슬롯) 적립 4개월차: 지수 2주+파킹 5주 보유, + # 잔고 70만, 양쪽 슬롯 모두 '부족분 < 1주'로 매수 보류(정상) — 미달 18.4%p. + # 절사 상한은 슬롯당 1주씩 쌓이므로 '보유 슬롯별 1주 가격의 합'으로 재야 한다: + # 최고가 1주(12.4만)만 재면 17.7% < 18.4%p → 정상 적립이 몇 주간 거짓 ATTENTION. + from core.operator_health import summarize_deployment + tol = self._f(123_710 + 57_715, 700_000) # 합산 = 25.9% + assert tol == pytest.approx(0.2592, abs=1e-3) + out = summarize_deployment(0.95 - 0.1843, 0.95, tolerance=tol) + assert out["verdict"] == "OK" + + def test_multi_slot_whole_slot_missing_still_caught(self): + # 파킹 슬롯(47.5%)이 통째로 미체결이면 절사가 아니라 실패 — 빈 슬롯은 + # 합산에서 빠지므로(보유 1주 가격만 합산) 미달 47.5%p > 하한이 잡는다. + from core.operator_health import summarize_deployment + tol = self._f(123_710, 700_000) # 보유는 지수뿐 → 17.7% + out = summarize_deployment(0.95 - 0.475, 0.95, tolerance=tol) + assert out["verdict"] == "ATTENTION" + class TestSummarizeDeployment: """집계 배치율 미달 판정(순수 함수).""" diff --git a/tests/test_order_executor_paper.py b/tests/test_order_executor_paper.py index f22b554e..0e89ea9b 100644 --- a/tests/test_order_executor_paper.py +++ b/tests/test_order_executor_paper.py @@ -220,7 +220,7 @@ def test_paper_buy_blocks_when_earnings_lookup_unknown(fresh_db, monkeypatch): monkeypatch.setattr( executor.risk_manager, "calculate_transaction_costs", - lambda price, quantity, action, avg_daily_volume=None: { + lambda price, quantity, action, avg_daily_volume=None, symbol=None: { "execution_price": float(price), "commission": 0.0, "tax": 0.0, diff --git a/tests/test_risk_manager.py b/tests/test_risk_manager.py index 7b821830..ddf5f376 100644 --- a/tests/test_risk_manager.py +++ b/tests/test_risk_manager.py @@ -249,3 +249,55 @@ def fetch_stock(self, symbol): assert result["blocked"] is True assert result["scale"] == 0.0 assert result["missing_symbols"] == ["000660"] + + +class TestEtfSellTaxExemption: + """국내 상장 ETF 매도세 면제 — tax_exempt_symbols 등록 종목은 매도세 0. + + 개별 주식 세율(0.20%)을 ETF에 일괄 적용하면 kr_pocket 같은 ETF 전용 바스켓의 + 매도 비용이 과대계상돼 승격 게이트의 비용 상한(연 1%) 판정까지 왜곡된다. + """ + + @pytest.fixture + def rm(self): + class _Cfg: + risk_params = { + "transaction_costs": { + "commission_rate": 0.00015, + "tax_rate": 0.0020, + # yaml에서 숫자로 적혀도(따옴표 누락) 문자열 비교로 매칭돼야 한다 + "tax_exempt_symbols": ["069500", 357870], + "slippage": 0.0005, + "slippage_ticks": 1, + "dynamic_slippage": {"enabled": False}, + }, + } + return RiskManager(_Cfg()) + + def test_exempt_symbol_sell_has_no_tax(self, rm): + out = rm.calculate_transaction_costs(57715, 2, "SELL", symbol="069500") + assert out["tax"] == 0 + + def test_int_coded_yaml_entry_still_matches(self, rm): + out = rm.calculate_transaction_costs(57715, 2, "SELL", symbol="357870") + assert out["tax"] == 0 + + def test_non_exempt_symbol_keeps_tax(self, rm): + out = rm.calculate_transaction_costs(70000, 10, "SELL", symbol="005930") + assert out["tax"] == round(700000 * 0.0020, 0) + + def test_no_symbol_keeps_legacy_behavior(self, rm): + # symbol 미전달 호출부(백테스트·스크립트)는 기존 일괄 과세 유지 + out = rm.calculate_transaction_costs(70000, 10, "SELL") + assert out["tax"] == round(700000 * 0.0020, 0) + + def test_exempt_sell_effective_price_excludes_tax(self, rm): + exempt = rm.calculate_transaction_costs(57715, 2, "SELL", symbol="069500") + taxed = rm.calculate_transaction_costs(57715, 2, "SELL", symbol="005930") + assert exempt["effective_price"] > taxed["effective_price"] + + def test_buy_unaffected_by_exemption(self, rm): + etf = rm.calculate_transaction_costs(57715, 2, "BUY", symbol="069500") + stock = rm.calculate_transaction_costs(57715, 2, "BUY", symbol="005930") + assert etf["tax"] == 0 and stock["tax"] == 0 + assert etf["total_cost"] == stock["total_cost"] diff --git a/tools/capital_slot_simulator.py b/tools/capital_slot_simulator.py index 437dbf45..03a35fbc 100644 --- a/tools/capital_slot_simulator.py +++ b/tools/capital_slot_simulator.py @@ -142,10 +142,10 @@ def main() -> int: holdings = basket_cfg.get("holdings") or {} min_trade = float((basket_cfg.get("rebalance") or {}).get("min_trade_amount", 0) or 0) - min_cash = (config.risk_params.get("diversification") or {}).get("min_cash_ratio", 0.20) - max_stock = 1.0 - float(min_cash) - tsw = basket_cfg.get("target_stock_weight") - stock_fraction = max_stock if tsw is None else max(0.0, min(float(tsw), max_stock)) + # 실제 사이클과 같은 단일 규칙(바스켓별 min_cash_ratio 오버라이드 포함) — 여기만 + # 다르면 '이 자본으로 슬롯이 차는가'라는 이 도구의 답 자체가 틀린다. + from core.basket_deploy import effective_stock_fraction + stock_fraction = effective_stock_fraction(basket_cfg, config.risk_params) # 현재가 조회 (읽기 전용) — 실제 사이클과 동일 소스(BasketRebalancer 시세 경로) 재사용. rebalancer = BasketRebalancer(basket_name=basket_name, config=config)