Skip to content

Commit 7749982

Browse files
authored
Merge pull request #433 from easygap/feat/weekly-report
feat: 주간 요약 리포트 — 판단 주기 다이제스트 + 귀속 주간 푸시
2 parents 59f208b + 9666cc6 commit 7749982

4 files changed

Lines changed: 293 additions & 1 deletion

File tree

core/weekly_report.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""주간 요약 리포트 — 판단 주기(주 1회)에 맞춘 다이제스트.
2+
3+
한 달 운영 리뷰(docs/PAPER_MONTH1_REVIEW_AND_PLAN.md P1-7)의 배경: 일일 숫자는
4+
노이즈가 커서 매일 판단하기엔 부적합하다. 주간 요약은 (1) 주간·누적 성과, (2) 성과
5+
귀속(실행 격차/구성 격차 — CLI에만 있던 P1-4 분석을 오너에게 주간 푸시), (3) 일정
6+
대비 진행률·커버리지, (4) 주간 운영 이벤트(결측·스킵·오류)를 한 장으로 모은다.
7+
8+
build_weekly_summary는 순수 함수다 — 입력(평가결과·주간 NAV 변화·이벤트 카운트)을
9+
받아 Discord embed 필드 목록과 텍스트 폴백을 만든다. 데이터 수집은 호출부(main.py
10+
run_weekly_report)가 담당한다.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import Any
16+
17+
18+
def build_weekly_summary(
19+
*,
20+
basket_name: str,
21+
eval_result: dict[str, Any],
22+
week_nav_change_pct: float | None = None,
23+
missing_days: int = 0,
24+
cycle_errors: int = 0,
25+
) -> dict[str, Any]:
26+
"""주간 다이제스트를 만든다(순수 함수).
27+
28+
missing_days: 이번 주(최근 영업일) 스냅샷이 빠진 '고유 일수' — SNAPSHOT_GAP 이벤트
29+
원시 카운트가 아니다(P0-1이 미복구 결측을 매 사이클 재경보하므로 이벤트 수는
30+
하루 결측을 여러 건으로 부풀린다 → 고유 일수로 집계해야 정확).
31+
cycle_errors: 이번 주 CYCLE_ERROR 발생 건수.
32+
33+
반환: {"title": str, "fields": [{"name","value","inline"}...], "text": str}
34+
- fields: notifier.send_embed용
35+
- text: 임베드 실패 시 폴백 텍스트
36+
"""
37+
m = (eval_result or {}).get("metrics") or {}
38+
verdict = (eval_result or {}).get("verdict", "?")
39+
icon = {"WAIT": "⏳", "PASS_CANDIDATE": "✅", "FAIL_REVIEW": "❌"}.get(verdict, "❓")
40+
41+
fields: list[dict[str, Any]] = []
42+
43+
# 1) 성과 — 주간 변화 + 누적
44+
nav = m.get("nav_return_pct")
45+
perf = []
46+
if week_nav_change_pct is not None:
47+
perf.append(f"주간 {week_nav_change_pct:+.2f}%")
48+
if nav is not None:
49+
perf.append(f"누적 {nav:+.2f}%")
50+
if perf:
51+
fields.append({"name": "💰 성과", "value": " · ".join(perf), "inline": False})
52+
53+
# 2) vs KS11 + 귀속 분해(실행/구성)
54+
bench = m.get("benchmark_return_pct")
55+
exe = m.get("execution_gap_pct")
56+
comp = m.get("composition_gap_pct")
57+
if bench is not None and nav is not None:
58+
line = f"NAV {nav:+.2f}% vs KS11 {bench:+.2f}% (격차 {nav - bench:+.2f}%p)"
59+
fields.append({"name": "📊 vs KS11", "value": line, "inline": False})
60+
if exe is not None or comp is not None:
61+
parts = []
62+
if exe is not None:
63+
parts.append(f"실행 {exe:+.2f}%p")
64+
if comp is not None:
65+
parts.append(f"구성 {comp:+.2f}%p")
66+
fields.append({
67+
"name": "🔎 귀속 분해",
68+
"value": " · ".join(parts) + " (실행=통제가능 / 구성=설계수용)",
69+
"inline": False,
70+
})
71+
72+
# 3) 일정 대비 — 진행률·커버리지
73+
progress_days = eval_result.get("progress_days") if eval_result else None
74+
min_days = eval_result.get("min_trading_days") if eval_result else None
75+
coverage = eval_result.get("snapshot_coverage") if eval_result else None
76+
if progress_days is not None and min_days:
77+
cov = f" · 커버리지 {coverage:.0%}" if coverage is not None else ""
78+
fields.append({
79+
"name": "📅 진행률",
80+
"value": f"{progress_days}/{min_days}일 ({progress_days / min_days:.0%}){cov}",
81+
"inline": False,
82+
})
83+
84+
# 4) 주간 운영 이벤트 — 결측은 '고유 일수'(이벤트 재경보로 부풀지 않게), 오류는 건수.
85+
md = int(missing_days or 0)
86+
ce = int(cycle_errors or 0)
87+
ev_line = f"결측 {md}일 · 사이클 오류 {ce}건"
88+
if md == 0 and ce == 0:
89+
ev_line += " (무사고)"
90+
fields.append({"name": "🛠 주간 이벤트", "value": ev_line, "inline": False})
91+
92+
title = f"{icon} 주간 요약 — {basket_name} ({verdict})"
93+
text_lines = [title] + [f"{f['name']}: {f['value']}" for f in fields]
94+
return {"title": title, "fields": fields, "text": "\n".join(text_lines)}

docs/PAPER_MONTH1_REVIEW_AND_PLAN.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,13 @@ P0-1(하트비트·재시도)을 먼저 머지하고 재시작하는 순서를
181181
> (일일 사이클 부담 없음, `--no-attribution`로 생략 가능). 실CLI 스모크 확인.
182182
> 적대적 리뷰 반영: NAV(마지막 스냅샷 시점)와 벤치마크·설계 조회 종료일을 일치시켜
183183
> 하루치 시장 변동이 실행 격차로 오귀속되지 않게 창을 정합.
184-
> - ⬜ P1-6/7 (스냅샷 시점 정합·주간 리포트), P2~P3 — 대기
184+
> -**P1-7 주간 요약 리포트**`core/weekly_report.py`에 순수 `build_weekly_summary`,
185+
> `main.py``--mode weekly_report`. 주간 성과 + 귀속 분해(실행/구성 격차 — CLI 전용이던
186+
> P1-4를 오너에게 주간 푸시) + 진행률/커버리지 + 주간 이벤트를 Discord 한 장으로. 금요일
187+
> 크론에 추가해 쓴다. 적대적 리뷰 반영: 결측은 SNAPSHOT_GAP 이벤트 원시 카운트 대신
188+
> '고유 일수'로 집계(재경보 부풀림 방지), 주간 변화 기준 스냅샷을 ~1주 밴드로 제한
189+
> (공백 시 다주간 수익 오표기 방지). 실데이터 스모크 확인.
190+
> - ⬜ P1-6 (스냅샷 시점 정합), P2~P3 — 대기
185191
> - P1-6은 트랙레코드 재시작(오너 자본 결정) 시점에 함께 다루는 게 안전(운영 중 스냅샷
186192
> 시점 변경은 시계열 일관성 훼손) — 재시작 전까지 보류.
187193

main.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,102 @@ def _design_fraction(cfg: dict) -> float:
17241724
return {"OK": 0, "ATTENTION": 1, "BLOCKED": 2}.get(health["verdict"], 1)
17251725

17261726

1727+
def run_weekly_report() -> int:
1728+
"""주간 요약 리포트 — 판단 주기(주 1회) 다이제스트를 Discord로 발송.
1729+
1730+
일일 숫자는 노이즈가 커서 매일 판단하기엔 부적합하다(P1-7). 주간 성과·귀속 분해
1731+
(실행/구성 격차)·진행률·주간 이벤트를 한 장으로 모아 오너가 주 1회 이것만 봐도
1732+
운영 판단이 되게 한다. 금요일 스케줄 작업에 추가해 쓴다. 반환: 0 정상.
1733+
"""
1734+
from datetime import datetime, timedelta
1735+
from zoneinfo import ZoneInfo
1736+
from core.notifier import Notifier
1737+
from core.basket_evaluation import collect_basket_paper_evaluation
1738+
from core.weekly_report import build_weekly_summary
1739+
from core.cycle_observability import detect_snapshot_gaps_for_account
1740+
from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id
1741+
from database.models import get_session, PortfolioSnapshot, OperationEvent, init_database
1742+
1743+
init_database()
1744+
config = Config.get()
1745+
notifier = Notifier(config)
1746+
now_kst = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None)
1747+
1748+
def _d(v):
1749+
return v.date() if hasattr(v, "date") and callable(getattr(v, "date")) else v
1750+
1751+
names = BasketRebalancer.get_enabled_baskets()
1752+
if not names:
1753+
logger.warning("enabled 바스켓 없음 — 주간 리포트 생략")
1754+
return 0
1755+
1756+
for name in names:
1757+
try:
1758+
eval_result, basket_name = collect_basket_paper_evaluation(
1759+
basket_name=name, include_attribution=True,
1760+
)
1761+
key = rebalance_live_strategy_id(name)
1762+
session = get_session()
1763+
try:
1764+
snaps = (
1765+
session.query(PortfolioSnapshot)
1766+
.filter(PortfolioSnapshot.account_key == key)
1767+
.order_by(PortfolioSnapshot.date.asc())
1768+
.all()
1769+
)
1770+
since = now_kst - timedelta(days=7)
1771+
cycle_errors = (
1772+
session.query(OperationEvent)
1773+
.filter(OperationEvent.strategy == key)
1774+
.filter(OperationEvent.event_type == "CYCLE_ERROR")
1775+
.filter(OperationEvent.created_at >= since)
1776+
.count()
1777+
)
1778+
finally:
1779+
session.close()
1780+
1781+
# 주간 NAV 변화: 마지막 스냅샷 vs '약 1주 전' 스냅샷.
1782+
# 기준 스냅샷이 7일 전 근방(±3일 밴드) 안에 있을 때만 '주간'으로 표기한다 —
1783+
# 스냅샷 공백으로 기준이 2~4주 전이면 다주간 수익을 주간으로 오표기하게 되므로
1784+
# 그 경우 주간 항을 생략한다(누적은 유지). (적대적 리뷰 low)
1785+
week_change = None
1786+
if len(snaps) >= 2:
1787+
last_val = float(snaps[-1].total_value)
1788+
last_date = _d(snaps[-1].date)
1789+
band = [
1790+
s for s in snaps
1791+
if last_date - timedelta(days=10) <= _d(s.date) <= last_date - timedelta(days=4)
1792+
]
1793+
if band:
1794+
ref_val = float(band[-1].total_value)
1795+
if ref_val > 0:
1796+
week_change = (last_val / ref_val - 1) * 100
1797+
1798+
# 결측은 '고유 일수'로 집계(SNAPSHOT_GAP 이벤트는 미복구 결측을 매 사이클
1799+
# 재경보해 부풀려짐 → 최근 7일 실제 빠진 영업일 수를 직접 센다).
1800+
try:
1801+
missing_days = len(detect_snapshot_gaps_for_account(
1802+
config, key, now_kst, lookback_calendar_days=7,
1803+
))
1804+
except Exception:
1805+
missing_days = 0
1806+
1807+
summary = build_weekly_summary(
1808+
basket_name=basket_name, eval_result=eval_result,
1809+
week_nav_change_pct=week_change,
1810+
missing_days=missing_days, cycle_errors=cycle_errors,
1811+
)
1812+
logger.info("\n{}", summary["text"])
1813+
try:
1814+
notifier.send_embed(summary["title"], "", fields=summary["fields"])
1815+
except Exception as e:
1816+
logger.debug("주간 리포트 발송 실패(무시): {}", e)
1817+
except Exception as exc:
1818+
logger.warning("바스켓 '{}' 주간 리포트 생성 실패: {}", name, exc)
1819+
1820+
return 0
1821+
1822+
17271823
def _build_cli_guide(args) -> str:
17281824
"""인자 없이 실행하거나 --mode guide 일 때 보여줄 사용 가이드.
17291825
@@ -1862,6 +1958,7 @@ def main():
18621958
"rebalance",
18631959
"health",
18641960
"deploy_check",
1961+
"weekly_report",
18651962
"guide",
18661963
],
18671964
help="실행 모드. backtest_momentum_top: 모멘텀 상위 동일비중 멀티종목. portfolio_backtest: 멀티종목 포트폴리오 백테스트. paper: 워치리스트 1회. schedule: 모의 스케줄 무한 루프(상시 서버). rebalance: 바스켓 리밸런싱. health: 운영 통합 헬스 점검(전 전략 runtime + blockers). deploy_check: 바스켓 배포 점검(계획·비용·활성화 절차).",
@@ -2054,6 +2151,8 @@ def main():
20542151
raise SystemExit(run_health_check())
20552152
elif args.mode == "deploy_check":
20562153
raise SystemExit(run_deploy_check(args))
2154+
elif args.mode == "weekly_report":
2155+
raise SystemExit(run_weekly_report())
20572156
else:
20582157
logger.error("알 수 없는 모드: {}", args.mode)
20592158
except KeyboardInterrupt:

tests/test_weekly_report.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""build_weekly_summary — 주간 요약 다이제스트 포맷터 단위 테스트(순수 함수)."""
2+
3+
from core.weekly_report import build_weekly_summary
4+
5+
6+
def _eval(**over):
7+
base = {
8+
"verdict": "WAIT",
9+
"progress_days": 22,
10+
"min_trading_days": 60,
11+
"snapshot_coverage": 0.95,
12+
"metrics": {
13+
"nav_return_pct": -5.47,
14+
"benchmark_return_pct": 0.71,
15+
"execution_gap_pct": -2.04,
16+
"composition_gap_pct": -5.51,
17+
"total_gap_pct": -6.18,
18+
},
19+
}
20+
base.update(over)
21+
return base
22+
23+
24+
def _fields(out):
25+
return {f["name"]: f["value"] for f in out["fields"]}
26+
27+
28+
class TestBuildWeeklySummary:
29+
def test_title_has_verdict_and_basket(self):
30+
out = build_weekly_summary(basket_name="kr_diversified_hold", eval_result=_eval())
31+
assert "kr_diversified_hold" in out["title"]
32+
assert "WAIT" in out["title"]
33+
34+
def test_performance_week_and_cumulative(self):
35+
out = build_weekly_summary(
36+
basket_name="b", eval_result=_eval(), week_nav_change_pct=-1.2,
37+
)
38+
perf = _fields(out)["💰 성과"]
39+
assert "주간 -1.20%" in perf
40+
assert "누적 -5.47%" in perf
41+
42+
def test_attribution_fields_present(self):
43+
f = _fields(build_weekly_summary(basket_name="b", eval_result=_eval()))
44+
assert "NAV -5.47% vs KS11 +0.71%" in f["📊 vs KS11"]
45+
assert "실행 -2.04%p" in f["🔎 귀속 분해"]
46+
assert "구성 -5.51%p" in f["🔎 귀속 분해"]
47+
48+
def test_progress_field(self):
49+
f = _fields(build_weekly_summary(basket_name="b", eval_result=_eval()))
50+
assert "22/60일" in f["📅 진행률"]
51+
assert "커버리지 95%" in f["📅 진행률"]
52+
53+
def test_event_counts_and_no_incident(self):
54+
f = _fields(build_weekly_summary(
55+
basket_name="b", eval_result=_eval(), missing_days=0, cycle_errors=0,
56+
))
57+
assert "무사고" in f["🛠 주간 이벤트"]
58+
59+
def test_event_counts_with_incidents(self):
60+
f = _fields(build_weekly_summary(
61+
basket_name="b", eval_result=_eval(), missing_days=1, cycle_errors=2,
62+
))
63+
ev = f["🛠 주간 이벤트"]
64+
assert "결측 1일" in ev and "사이클 오류 2건" in ev
65+
assert "무사고" not in ev
66+
67+
def test_missing_days_counts_distinct_not_events(self):
68+
# 하루 결측이 여러 SNAPSHOT_GAP 이벤트로 재경보돼도 '1일'로만 집계돼야 한다
69+
# (호출부가 고유 일수를 넘기는 계약을 포맷터 수준에서 못박음).
70+
f = _fields(build_weekly_summary(
71+
basket_name="b", eval_result=_eval(), missing_days=1, cycle_errors=0,
72+
))
73+
assert "결측 1일" in f["🛠 주간 이벤트"]
74+
75+
def test_missing_week_change_omits_week_term(self):
76+
f = _fields(build_weekly_summary(basket_name="b", eval_result=_eval()))
77+
assert "주간" not in f["💰 성과"]
78+
assert "누적 -5.47%" in f["💰 성과"]
79+
80+
def test_text_fallback_contains_fields(self):
81+
out = build_weekly_summary(basket_name="b", eval_result=_eval())
82+
for f in out["fields"]:
83+
assert f["value"] in out["text"]
84+
85+
def test_missing_attribution_still_builds(self):
86+
out = build_weekly_summary(
87+
basket_name="b",
88+
eval_result={"verdict": "WAIT", "progress_days": 5, "min_trading_days": 60,
89+
"snapshot_coverage": 1.0, "metrics": {"nav_return_pct": None}},
90+
)
91+
names = [f["name"] for f in out["fields"]]
92+
assert "🔎 귀속 분해" not in names # 귀속 데이터 없으면 생략
93+
assert "🛠 주간 이벤트" in names # 이벤트 필드는 항상

0 commit comments

Comments
 (0)