Skip to content

Commit 478b683

Browse files
committed
feat: 일일 리포트 v2 — 시장/설계/일정 대비 지표 추가
한 달 운영 리뷰(docs/PAPER_MONTH1_REVIEW_AND_PLAN.md P0-3)의 처방을 구현했다. 기존 일일 카드는 절대 수익만 있어 -5.47%가 전략 탓인지 판단할 수 없었다. 아래 세 축을 카드에 추가해 왜 그런지 한눈에 보이게 한다. 추가 필드: - 📊 vs KS11: NAV vs 벤치마크 격차 (시장 대비) - 🎯 주식 배치율: 실제 주식비중 vs 설계 80% (설계 대비) - 📅 진행률: n/60일 · 커버리지 % · 잔여 결측예산 (일정 대비) - 💸 누적 비용: 누적/연환산(기간 미충족 시 참고 표기) - ⚠️ 미체결 슬롯: 현재 자본으로 못 채우는 슬롯 경고(#422) 구조: - core/basket_evaluation.py: 순수 포맷터 build_daily_report_extras + 평가결과에 snapshot_days 노출(결측예산 계산). 테스트 용이하게 분리. - core/basket_rebalancer.py: 읽기전용 diagnose_deployment — 종목별 드리프트가 못 보는 집계 배치율 이탈 + 미체결 슬롯을 함께 드러낸다. - main.py: 일일 사이클 배선. 부가필드 실패해도 기본 카드는 발송(채널 보조). - core/notifier.py: 값 있을 때만 렌더링(하위 호환). 적대적 리뷰 반영: 스냅샷 결측일 NAV 단일소스화(카드 누적수익률과 일치), 결측예산 게이트 정합(max(min_days, progress_days)), min_trading_days=0 방어, 경계 테스트 보강(>3 슬롯 요약, min_trade arm, 가격/총자산 0). 전체 스위트 1548 통과, 실데이터 스모크 확인.
1 parent d6044d8 commit 478b683

8 files changed

Lines changed: 459 additions & 2 deletions

‎core/basket_evaluation.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def evaluate_basket_paper_operation(
9191
"operation_start": str(operation_start),
9292
"today": str(today),
9393
"progress_days": trading_days_total,
94+
"snapshot_days": snapshot_days,
9495
"min_trading_days": min_trading_days,
9596
"progress_pct": min(1.0, trading_days_total / min_trading_days) if min_trading_days > 0 else 1.0,
9697
"snapshot_coverage": round(coverage, 4),
@@ -145,6 +146,101 @@ def format_evaluation_report(result: dict[str, Any], basket_name: str = "") -> s
145146
return "\n".join(lines)
146147

147148

149+
def build_daily_report_extras(
150+
*,
151+
eval_result: dict[str, Any] | None = None,
152+
deployment: dict[str, Any] | None = None,
153+
nav_return_pct: float | None = None,
154+
) -> dict[str, str]:
155+
"""일일 리포트 v2의 부가 필드(문자열)를 만든다 — 순수 함수(테스트 용이).
156+
157+
한 달 운영 리뷰(docs/PAPER_MONTH1_REVIEW_AND_PLAN.md)의 결론: 기존 리포트는
158+
절대 수익만 있어 "시장 대비/설계 대비/일정 대비" 판단이 불가능했다. 이 함수는
159+
그 세 축을 한눈에 보이게 한다.
160+
161+
- 시장 대비: NAV vs KS11 격차 (benchmark_gap)
162+
- 설계 대비: 주식 배치율, 미체결 슬롯 (deployment, slot_warning)
163+
- 일정 대비: 진행률·커버리지·잔여 결측 예산 (progress)
164+
- 실행 품질: 누적 비용 (cost)
165+
166+
nav_return_pct를 명시하면 그 값으로 NAV 격차를 계산한다 — 호출부(리포트 카드)의
167+
'누적 수익률'과 같은 소스를 쓰게 해, 스냅샷 결측일에 카드의 '누적 수익률'(오늘 시가
168+
기준)과 평가의 nav(직전 스냅샷 기준)가 갈려 같은 카드에 📊 수치 두 개가 어긋나는 것을
169+
막는다(미지정 시 평가결과의 nav 사용).
170+
171+
데이터 부재 시 해당 키를 생략한다(리포트가 조용히 축소 — 표시할 게 없으면 안 낸다).
172+
notifier.send_daily_report가 이 키들을 선택 필드로 렌더링한다.
173+
"""
174+
extras: dict[str, str] = {}
175+
176+
if eval_result:
177+
m = eval_result.get("metrics") or {}
178+
179+
# 시장 대비 — NAV vs KS11 격차 (베타 전략이라 격차가 '판정'은 아니지만 가시화 대상)
180+
nav = nav_return_pct if nav_return_pct is not None else m.get("nav_return_pct")
181+
bench = m.get("benchmark_return_pct")
182+
if nav is not None:
183+
if bench is not None:
184+
extras["benchmark_gap"] = (
185+
f"NAV {nav:+.2f}% vs KS11 {bench:+.2f}% (격차 {nav - bench:+.2f}%p)"
186+
)
187+
else:
188+
extras["benchmark_gap"] = f"NAV {nav:+.2f}% (KS11 조회 불가)"
189+
190+
# 일정 대비 — 진행률·커버리지·잔여 결측 예산
191+
progress_days = int(eval_result.get("progress_days", 0) or 0)
192+
min_days_raw = eval_result.get("min_trading_days")
193+
min_days = int(min_days_raw) if min_days_raw not in (None, "") else 60
194+
snapshot_days = int(eval_result.get("snapshot_days", 0) or 0)
195+
coverage = float(eval_result.get("snapshot_coverage", 0.0) or 0.0)
196+
pct = float(eval_result.get("progress_pct", 0.0) or 0.0)
197+
# 최종 커버리지 95%를 지키며 앞으로 더 놓쳐도 되는 영업일 수.
198+
# 허용 결측은 실제 게이트와 같은 기준(운영일수의 5%)이라 기간을 넘겨 운영하면
199+
# 분모가 늘어난다 — max(min_days, progress_days)로 게이트와 일치시킨다.
200+
denom_days = max(min_days, progress_days)
201+
max_allowed_miss = int(denom_days * 0.05)
202+
already_missed = max(0, progress_days - snapshot_days)
203+
budget = max(0, max_allowed_miss - already_missed)
204+
# 표시 분모는 목표 기간(min_days) — 기간 진척을 보여준다. 0(무의미 설정)이면
205+
# 운영일수로 폴백해 '5/0일' 같은 문자열을 피한다.
206+
disp_denom = min_days if min_days > 0 else progress_days
207+
extras["progress"] = (
208+
f"{progress_days}/{disp_denom}일 ({pct:.0%}) · 커버리지 {coverage:.0%} · 결측예산 {budget}일"
209+
)
210+
211+
# 실행 품질 — 누적 비용 (연환산은 기간 미충족 시 과장되므로 라벨로 구분)
212+
cum = eval_result.get("cost_drag_cum")
213+
ann = eval_result.get("cost_drag_annualized")
214+
if cum is not None:
215+
cost = f"누적 {cum:.3%}"
216+
if ann is not None:
217+
period_complete = progress_days >= min_days
218+
cost += f" · 연환산 {ann:.2%}" + ("" if period_complete else " (참고)")
219+
extras["cost"] = cost
220+
221+
if deployment:
222+
actual = float(deployment.get("deployment_ratio", 0.0) or 0.0)
223+
design = float(deployment.get("design_fraction", 0.0) or 0.0)
224+
extras["deployment"] = (
225+
f"주식 {actual:.0%} / 설계 {design:.0%} ({(actual - design) * 100:+.1f}%p)"
226+
)
227+
slots = deployment.get("unfilled_slots") or []
228+
if slots:
229+
# Discord 필드값 1024자 한도 — 최대 3개만 명시하고 나머지는 요약.
230+
shown = slots[:3]
231+
parts = [
232+
f"{s.get('symbol')} 1주 {float(s.get('price', 0)):,.0f}원 > 슬롯 "
233+
f"{float(s.get('slot_amount', 0)):,.0f}원"
234+
for s in shown
235+
]
236+
more = f" 외 {len(slots) - len(shown)}개" if len(slots) > len(shown) else ""
237+
extras["slot_warning"] = (
238+
f"미체결 {len(slots)}개: " + "; ".join(parts) + more + " — 자본 결정 대기(#422)"
239+
)
240+
241+
return extras
242+
243+
148244
def collect_basket_paper_evaluation(
149245
config=None,
150246
min_days: int | None = None,

‎core/basket_rebalancer.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,58 @@ def calculate_drift(self, prices: dict[str, float] = None) -> dict[str, dict]:
307307

308308
return result
309309

310+
def diagnose_deployment(self, prices: dict[str, float] = None) -> dict:
311+
"""읽기전용 배치 진단 — 일일 리포트 v2용. 주문을 만들지 않는다.
312+
313+
한 달 운영 리뷰(docs/PAPER_MONTH1_REVIEW_AND_PLAN.md)에서 드러난 문제:
314+
종목별 드리프트 트리거는 '집계 배치율' 이탈(예: 실효 61% vs 설계 80%)을
315+
영영 못 본다. 이 진단은 총자산 대비 실제 주식 비중과, 현재 자본으로는
316+
영원히 못 채우는 슬롯(#422)을 함께 드러낸다.
317+
318+
반환: {total_value, stock_value, cash, deployment_ratio(실제 주식비중),
319+
design_fraction(설계 주식비중), unfilled_slots(list[dict])}
320+
unfilled_slots 각 항목: {symbol, price, slot_amount, target_weight}
321+
"""
322+
prices = prices or self._fetch_current_prices()
323+
summary = self.portfolio_mgr.get_portfolio_summary(current_prices=prices)
324+
total_value = float(summary.get("total_value", 0) or 0)
325+
cash = float(summary.get("cash", 0) or 0)
326+
stock_value = max(0.0, total_value - cash)
327+
deployment_ratio = (stock_value / total_value) if total_value > 0 else 0.0
328+
design_fraction = self._stock_fraction()
329+
330+
investable = total_value * design_fraction
331+
targets = self.get_target_weights()
332+
actuals = self.get_current_weights(prices)
333+
min_trade = self.rebalance_cfg.get("min_trade_amount", 100000)
334+
335+
unfilled: list[dict] = []
336+
for symbol, target_w in targets.items():
337+
if actuals.get(symbol, 0.0) > 0:
338+
continue # 이미 보유 — 슬롯 채워짐
339+
price = prices.get(symbol, 0)
340+
if price <= 0:
341+
continue # 가격 미확보는 별도 문제(스냅샷 스킵) — 여기선 판정 보류
342+
slot_amount = investable * target_w
343+
# plan_rebalance와 같은 판정: 슬롯 목표금액이 최소 거래금액 미만이거나
344+
# 1주 가격이 슬롯 목표금액을 초과하면 현재 자본으론 못 채운다.
345+
if slot_amount < min_trade or price > slot_amount:
346+
unfilled.append({
347+
"symbol": symbol,
348+
"price": float(price),
349+
"slot_amount": float(slot_amount),
350+
"target_weight": float(target_w),
351+
})
352+
353+
return {
354+
"total_value": total_value,
355+
"stock_value": stock_value,
356+
"cash": cash,
357+
"deployment_ratio": deployment_ratio,
358+
"design_fraction": design_fraction,
359+
"unfilled_slots": unfilled,
360+
}
361+
310362
# ------------------------------------------------------------------
311363
# 트리거 판단
312364
# ------------------------------------------------------------------

‎core/notifier.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,18 @@ def send_daily_report(self, report: dict) -> None:
314314
{"name": "📋 보유 종목", "value": f"{report.get('position_count', 0)}개", "inline": True},
315315
{"name": "🔄 당일 매매", "value": f"{report.get('total_trades', 0)}건", "inline": True},
316316
]
317+
# 리포트 v2 부가 필드(있을 때만) — 시장/설계/일정 대비 판단용.
318+
# 값은 core.basket_evaluation.build_daily_report_extras가 만든 문자열이다.
319+
for key, label, inline in (
320+
("benchmark_gap", "📊 vs KS11", False),
321+
("deployment", "🎯 주식 배치율", False),
322+
("progress", "📅 진행률", False),
323+
("cost", "💸 누적 비용", True),
324+
("slot_warning", "⚠️ 미체결 슬롯", False),
325+
):
326+
val = report.get(key)
327+
if val:
328+
fields.append({"name": label, "value": str(val), "inline": inline})
317329
diag = report.get("strategy_diagnosis")
318330
if diag:
319331
if isinstance(diag, (list, tuple)):

‎docs/PAPER_MONTH1_REVIEW_AND_PLAN.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,17 @@ P0-1(하트비트·재시도)을 먼저 머지하고 재시작하는 순서를
152152

153153
각 항목에 [축]과 수용 기준을 명시한다. 축 = 안정성 / 편의성 / 수익성 / 기능.
154154

155+
> **구현 현황** (PR로 갱신)
156+
> - ✅ **P0-3 일일 리포트 v2** — 벤치마크 격차·배치율·진행률(결측예산)·미체결 슬롯·누적비용 추가.
157+
> 순수 포맷터 `build_daily_report_extras` + 읽기전용 진단 `BasketRebalancer.diagnose_deployment`,
158+
> 실데이터 스모크 확인. 다중 에이전트 적대적 리뷰 반영(스냅샷 결측일 NAV 단일소스화,
159+
> 결측예산 게이트 정합, min_days=0 방어, 경계 테스트 보강).
160+
> - 남은 저순위: KS11 벤치마크 조회 캐시 없음 → 바스켓 수(N)만큼 매일 재조회(현재 N=1이라 무해).
161+
> 공유 함수(`fetch_benchmark_return`) 변경이라 별도 처리 — P1에서 검토.
162+
> - ⬜ P0-1 사이클 하트비트/결측경보/재시도 — 재시작 전에 머지 예정
163+
> - ⬜ P0-2 SMTP 재발급 — 오너 액션
164+
> - ⬜ P1~P3 — 대기
165+
155166
### P0 — 자본 결정과 같은 주에
156167

157168
| # | 항목 | 축 | 내용 · 수용 기준 |

‎main.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,7 @@ def run_rebalance(args):
791791
daily_ret = (last / prev - 1) * 100
792792
except Exception:
793793
pass
794-
notifier.send_daily_report({
794+
report_card = {
795795
"total_value": summary_data.get("total_value", 0),
796796
"cash": summary_data.get("cash", 0),
797797
"daily_return": daily_ret,
@@ -800,7 +800,26 @@ def run_rebalance(args):
800800
"position_count": summary_data.get("position_count", 0),
801801
"total_trades": (result.get("executed", 0) if executed else 0),
802802
"strategy_diagnosis": f"바스켓 {name} · paper 트랙레코드 일일 사이클",
803-
})
803+
}
804+
# 리포트 v2 부가 필드(시장/설계/일정 대비) — 실패해도 기본 카드는 발송.
805+
try:
806+
from core.basket_evaluation import (
807+
build_daily_report_extras,
808+
collect_basket_paper_evaluation,
809+
)
810+
deployment = rebalancer.diagnose_deployment(_prices or None)
811+
eval_result, _ = collect_basket_paper_evaluation(basket_name=name)
812+
report_card.update(
813+
build_daily_report_extras(
814+
eval_result=eval_result, deployment=deployment,
815+
# 카드의 '누적 수익률'과 같은 소스(오늘 시가 기준)로 격차 계산
816+
# — 스냅샷 결측일에 📊 수치가 어긋나지 않게.
817+
nav_return_pct=summary_data.get("total_return"),
818+
)
819+
)
820+
except Exception as e:
821+
logger.debug("바스켓 '{}' 리포트 v2 부가필드 생략: {}", name, e)
822+
notifier.send_daily_report(report_card)
804823
except Exception as e:
805824
logger.debug("바스켓 '{}' 일일 리포트 발송 실패(무시): {}", name, e)
806825

‎tests/test_basket_rebalancer.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,3 +729,85 @@ def test_portfolio_mgr_gets_resolved_account_key(self, monkeypatch):
729729
"""인자 생략 시 portfolio_mgr는 ''(전 계정 합산)가 아니라 바스켓 키를 봐야 한다."""
730730
rb = self._make(monkeypatch, {"holdings": {"005930": 1.0}})
731731
assert rb.portfolio_mgr.account_key == "basket_rebalance:t"
732+
733+
734+
class TestDiagnoseDeployment:
735+
"""읽기전용 배치 진단(리포트 v2용): 집계 배치율 + 미체결 슬롯(#422)."""
736+
737+
def _prep(self, rebalancer, *, total_value, cash, prices, actual_weights):
738+
rebalancer._fetch_current_prices = MagicMock(return_value=prices)
739+
rebalancer.portfolio_mgr.get_portfolio_summary = MagicMock(
740+
return_value={"total_value": total_value, "cash": cash}
741+
)
742+
rebalancer.get_current_weights = MagicMock(return_value=actual_weights)
743+
744+
def test_deployment_ratio_from_cash(self, rebalancer):
745+
self._prep(
746+
rebalancer,
747+
total_value=1_000_000, cash=400_000,
748+
prices={"005930": 60_000, "000660": 2_129_000, "035420": 100_000},
749+
actual_weights={"005930": 0.5, "000660": 0.0, "035420": 0.0},
750+
)
751+
d = rebalancer.diagnose_deployment()
752+
assert d["total_value"] == 1_000_000
753+
assert d["stock_value"] == 600_000
754+
assert d["deployment_ratio"] == pytest.approx(0.60)
755+
assert d["design_fraction"] == pytest.approx(0.80) # min_cash_ratio 0.20
756+
757+
def test_unfilled_slot_when_share_exceeds_slot(self, rebalancer):
758+
# investable = 1M*0.8 = 800k. 000660 슬롯 280k < 1주 2.13M → 미체결.
759+
# 035420 슬롯 200k > 1주 10만 → 채움 가능(미체결 아님).
760+
self._prep(
761+
rebalancer,
762+
total_value=1_000_000, cash=400_000,
763+
prices={"005930": 60_000, "000660": 2_129_000, "035420": 100_000},
764+
actual_weights={"005930": 0.5, "000660": 0.0, "035420": 0.0},
765+
)
766+
d = rebalancer.diagnose_deployment()
767+
syms = [s["symbol"] for s in d["unfilled_slots"]]
768+
assert syms == ["000660"]
769+
assert d["unfilled_slots"][0]["price"] == 2_129_000
770+
771+
def test_held_slot_not_flagged(self, rebalancer):
772+
# 전 종목 보유(actual>0) → 미체결 없음
773+
self._prep(
774+
rebalancer,
775+
total_value=1_000_000, cash=200_000,
776+
prices={"005930": 60_000, "000660": 100_000, "035420": 100_000},
777+
actual_weights={"005930": 0.5, "000660": 0.5, "035420": 0.5},
778+
)
779+
assert rebalancer.diagnose_deployment()["unfilled_slots"] == []
780+
781+
def test_unfilled_when_slot_below_min_trade(self, rebalancer):
782+
# 두 번째 판정 arm: 슬롯 목표금액 < 최소 거래금액(50k)이라 1주는 살 수 있어도 못 채움.
783+
# 총자산 100k → investable 80k. 035420 슬롯 = 80k*0.25 = 20k < 50k → 미체결(가격은 저렴해도).
784+
self._prep(
785+
rebalancer,
786+
total_value=100_000, cash=80_000,
787+
prices={"005930": 5_000, "000660": 5_000, "035420": 5_000},
788+
actual_weights={"005930": 0.0, "000660": 0.0, "035420": 0.0},
789+
)
790+
d = rebalancer.diagnose_deployment()
791+
# 세 슬롯 모두 목표금액(32k/28k/20k)이 min_trade 50k 미만 → 전부 미체결
792+
assert {s["symbol"] for s in d["unfilled_slots"]} == {"005930", "000660", "035420"}
793+
794+
def test_zero_price_symbol_skipped(self, rebalancer):
795+
# 가격 미확보(0)는 판정 보류(스냅샷 스킵이 별도로 처리) — 미체결로 잘못 표기하지 않음.
796+
self._prep(
797+
rebalancer,
798+
total_value=1_000_000, cash=1_000_000,
799+
prices={"005930": 0, "000660": 0, "035420": 0},
800+
actual_weights={"005930": 0.0, "000660": 0.0, "035420": 0.0},
801+
)
802+
assert rebalancer.diagnose_deployment()["unfilled_slots"] == []
803+
804+
def test_zero_total_value_ratio_is_zero(self, rebalancer):
805+
self._prep(
806+
rebalancer,
807+
total_value=0, cash=0,
808+
prices={"005930": 60_000, "000660": 100_000, "035420": 100_000},
809+
actual_weights={"005930": 0.0, "000660": 0.0, "035420": 0.0},
810+
)
811+
d = rebalancer.diagnose_deployment()
812+
assert d["deployment_ratio"] == 0.0
813+
assert d["total_value"] == 0

0 commit comments

Comments
 (0)