Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions config/baskets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 시, 스코어링 전략 점수에 따라 목표 비중을 동적 조정
Expand Down
6 changes: 6 additions & 0 deletions config/risk_params.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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틱이 현실적)
# 양도소득세 (대주주 해당 시에만 적용; 일반 투자자는 해당 없음)
Expand Down
25 changes: 25 additions & 0 deletions core/basket_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
7 changes: 2 additions & 5 deletions core/basket_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 6 additions & 10 deletions core/basket_rebalancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) 모드 여부."""
Expand Down
26 changes: 15 additions & 11 deletions core/operator_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 10 additions & 5 deletions core/order_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
16 changes: 13 additions & 3 deletions core/risk_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,7 @@ def calculate_transaction_costs(
action: str = "BUY",
avg_daily_volume: float = None,
avg_price: float = None,
symbol: str = None,
) -> dict:
"""
거래 비용 계산 (수수료 + 증권거래세 + 슬리피지 + 양도소득세(선택))
Expand All @@ -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(양도소득세, 설정 시),
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
Loading
Loading