@@ -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+
17271823def _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 :
0 commit comments