Skip to content

Commit f24e29a

Browse files
authored
Merge pull request #426 from easygap/fix/evaluator-per-basket-capital
fix: 자기검토 2라운드 — 평가 자본 정합(HIGH)·백업 스킵 가시화·min_trade 침묵 슬롯
2 parents e4d7ca6 + d50e437 commit f24e29a

4 files changed

Lines changed: 72 additions & 6 deletions

File tree

core/basket_evaluation.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,8 @@ def collect_basket_paper_evaluation(
182182
basket_name = enabled[0]
183183
basket_key = rebalance_live_strategy_id(basket_name)
184184

185+
basket_cfg = BasketRebalancer._load_baskets_config().get(basket_name) or {}
185186
if min_days is None:
186-
basket_cfg = BasketRebalancer._load_baskets_config().get(basket_name) or {}
187187
min_days = int((basket_cfg.get("promotion") or {}).get("min_trading_days", 60))
188188

189189
session = get_session()
@@ -230,10 +230,19 @@ def _d(v):
230230
total_costs = sum(
231231
float(t.commission or 0) + float(t.tax or 0) + float(t.slippage or 0) for t in trades
232232
)
233+
# 자본 해석은 운영(BasketRebalancer→PortfolioManager)과 동일해야 한다 — 바스켓별
234+
# initial_capital(레버)이 설정됐는데 평가가 전역 자본으로 나누면 비용 드래그가
235+
# 수 배 과대(예: 30M 매입 비용 / 10M)되어 거짓 FAIL_REVIEW, NAV 수익률도 왜곡된다.
236+
# (자기검토 2라운드 HIGH — 레버와 같은 날 정합 수정)
237+
basket_capital = basket_cfg.get("initial_capital")
233238
initial_capital = float(
234-
(config.risk_params.get("position_sizing") or {}).get("initial_capital")
235-
or config.trading.get("initial_capital")
236-
or 10_000_000
239+
basket_capital
240+
if basket_capital is not None
241+
else (
242+
(config.risk_params.get("position_sizing") or {}).get("initial_capital")
243+
or config.trading.get("initial_capital")
244+
or 10_000_000
245+
)
237246
)
238247

239248
nav_return_pct = None

core/basket_rebalancer.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,15 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder
384384
drift = target_w - actual_w
385385
trade_value = abs(investable * target_w - investable * actual_w)
386386
if trade_value < min_trade:
387+
if drift > 0 and actual_w <= 0:
388+
# 미보유 슬롯의 목표 금액이 min_trade_amount보다 작으면 이 분기에서
389+
# 영원히 침묵 스킵된다 — 채움 불가(아래 quantity<=0)와 같은 부류의
390+
# 구조 문제이므로 동일하게 드러낸다(작은 비중·낮은 자본 조합에서 발생).
391+
logger.warning(
392+
"종목 {} 채움 불가: 목표 거래금액 {:,.0f}원 < 최소 거래금액 {:,.0f}원 "
393+
"— 비중/자본/min_trade_amount 조정 필요 (현재 미보유 비중 {:.1%})",
394+
symbol, trade_value, min_trade, drift,
395+
)
387396
continue
388397
price = prices.get(symbol, 0)
389398
if price <= 0:

database/backup.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,12 @@ def run_daily_backup(config=None) -> bool:
6060
import os
6161
db_path_override = os.environ.get("QUANT_DB_PATH")
6262
if db_path_override and not os.environ.get("QUANT_BACKUP_PATH"):
63-
logger.debug(
64-
"DB 백업 스킵: QUANT_DB_PATH(격리 DB) 환경 — 운영 backup_path 오염 방지"
63+
# warning 레벨 — QUANT_DB_PATH는 '배포 환경별 DB 분리'라는 정당한 운영 용도도
64+
# 있어(config_loader 주석), 그 배포에서 백업이 조용히 꺼지면 운영자가 주간
65+
# 복원 리허설 전까지 모른다. 운영 배포라면 QUANT_BACKUP_PATH도 함께 설정할 것.
66+
logger.warning(
67+
"DB 백업 스킵: QUANT_DB_PATH 설정 환경 — 운영 backup_path 오염 방지. "
68+
"운영 배포라면 QUANT_BACKUP_PATH도 함께 설정하세요."
6569
)
6670
return False
6771
if os.environ.get("QUANT_BACKUP_PATH"):

tests/test_basket_evaluation.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,47 @@ def test_snapshot_date_override_and_upsert(self):
173173
assert len(rows) == 1
174174
assert rows[0].date == datetime(2026, 6, 5)
175175
assert float(rows[0].total_value) == 2_000.0
176+
177+
178+
class TestEvaluatorPerBasketCapital:
179+
"""평가 수집기의 자본 해석이 운영(바스켓별 initial_capital 레버)과 일치하는지.
180+
181+
자기검토 2라운드 HIGH: 레버 설정 시 평가가 전역 자본으로 나누면 비용 드래그가
182+
수 배 과대 → 거짓 FAIL_REVIEW, NAV 수익률 왜곡 — 게이트 verdict 자체가 틀어진다.
183+
"""
184+
185+
def test_collector_uses_basket_initial_capital(self):
186+
from unittest.mock import patch
187+
from core.basket_rebalancer import BasketRebalancer
188+
from core.basket_evaluation import collect_basket_paper_evaluation
189+
190+
baskets = {
191+
"cap_t": {
192+
"enabled": True,
193+
"holdings": {"005930": 1.0},
194+
"initial_capital": 30_000_000,
195+
},
196+
}
197+
with patch.object(BasketRebalancer, "_load_baskets_config",
198+
return_value=baskets):
199+
result, _ = collect_basket_paper_evaluation(
200+
basket_name="cap_t", include_benchmark=False,
201+
)
202+
assert result["metrics"]["initial_capital"] == 30_000_000
203+
204+
def test_collector_falls_back_to_global_capital(self):
205+
from unittest.mock import patch
206+
from core.basket_rebalancer import BasketRebalancer
207+
from core.basket_evaluation import collect_basket_paper_evaluation
208+
from config.config_loader import Config
209+
210+
baskets = {"cap_g": {"enabled": True, "holdings": {"005930": 1.0}}}
211+
global_cap = float(
212+
Config.get().risk_params.get("position_sizing", {}).get("initial_capital", 10_000_000)
213+
)
214+
with patch.object(BasketRebalancer, "_load_baskets_config",
215+
return_value=baskets):
216+
result, _ = collect_basket_paper_evaluation(
217+
basket_name="cap_g", include_benchmark=False,
218+
)
219+
assert result["metrics"]["initial_capital"] == global_cap

0 commit comments

Comments
 (0)