Skip to content

Commit e4d7ca6

Browse files
authored
Merge pull request #425 from easygap/feat/daily-discord-report
feat: 일일 사이클 디스코드 NAV 리포트 — 60일 구간 푸시 0건 해소
2 parents eaf7b13 + 0ebc4b9 commit e4d7ca6

3 files changed

Lines changed: 83 additions & 5 deletions

File tree

core/basket_rebalancer.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -391,16 +391,22 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder
391391
continue
392392
quantity = int(trade_value / price)
393393
if quantity <= 0:
394-
if drift > 0:
395-
# 1주 가격이 목표 거래금액을 초과 — 현재 자본 규모로는 이 슬롯을
396-
# 영원히 채울 수 없다(예: 자본 1,000만·목표 8%=80만 < SK하이닉스
397-
# 1주 213만). 침묵 스킵하면 운영자가 모른 채 배분이 설계와
398-
# 달라진다 — 자본 증액 또는 비중 조정이 필요한 운영자 결정 사항.
394+
if drift > 0 and actual_w <= 0:
395+
# 미보유 슬롯인데 1주 가격이 목표 거래금액을 초과 — 현재 자본
396+
# 규모로는 이 슬롯을 영원히 채울 수 없다(예: 자본 1,000만·목표
397+
# 8%=80만 < SK하이닉스 1주 213만). 침묵 스킵하면 운영자가 모른 채
398+
# 배분이 설계와 달라진다 — 자본 증액/비중 조정이 필요한 결정 사항.
399+
# (보유 중 종목의 소폭 드리프트 교정 불가는 자연 상태라 debug만)
399400
logger.warning(
400401
"종목 {} 채움 불가: 1주 가격 {:,.0f}원 > 목표 거래금액 {:,.0f}원 "
401402
"— 자본 증액 또는 baskets.yaml 비중 조정 필요 (현재 미보유 비중 {:.1%})",
402403
symbol, price, trade_value, drift,
403404
)
405+
elif drift > 0:
406+
logger.debug(
407+
"종목 {} 드리프트 교정 보류: 교정 거래액 {:,.0f}원 < 1주 가격 {:,.0f}원",
408+
symbol, trade_value, price,
409+
)
404410
continue
405411
if drift > 0:
406412
candidates.append((RebalanceOrder(

main.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,44 @@ def run_rebalance(args):
765765
# 보유 종목 가격이 전부 확보된 경우에만 저장(가짜 NAV 방지), 멱등 upsert.
766766
if not dry_run:
767767
rebalancer.save_daily_nav_snapshot()
768+
# 일일 디스코드 리포트: 상시 스케줄러의 장마감 리포트는 일일 CLI 운영
769+
# 에서는 돌지 않아 운영자가 받는 푸시가 0건이었다 — 사이클마다 바스켓
770+
# NAV 요약 카드를 보낸다. 실패해도 사이클에는 영향 없음(채널은 보조).
771+
try:
772+
# 시장가 기준으로 평가해야 한다 — current_prices 없이 호출하면 paper
773+
# 포지션이 avg_price로 평가돼 누적수익 −0.0%·MDD 0.0%가 60일 내내
774+
# 표시된다(자기검토 2라운드 HIGH). 스냅샷 단계가 채운 가격 캐시 재사용.
775+
_snap_cache = getattr(rebalancer, "_market_snapshot", None) or {}
776+
_prices = {s: v["price"] for s, v in _snap_cache.items()}
777+
summary_data = rebalancer.portfolio_mgr.get_portfolio_summary(
778+
current_prices=_prices or None,
779+
)
780+
# 일간 수익률: 직전 스냅샷 대비 (summary에는 누적치만 있다)
781+
daily_ret = 0.0
782+
try:
783+
from database.repositories import get_portfolio_snapshots
784+
snaps = get_portfolio_snapshots(
785+
days=7, account_key=live_strategy_name,
786+
)
787+
if snaps is not None and len(snaps) >= 2:
788+
vals = snaps.sort_values("date")["total_value"].astype(float)
789+
prev, last = float(vals.iloc[-2]), float(vals.iloc[-1])
790+
if prev > 0:
791+
daily_ret = (last / prev - 1) * 100
792+
except Exception:
793+
pass
794+
notifier.send_daily_report({
795+
"total_value": summary_data.get("total_value", 0),
796+
"cash": summary_data.get("cash", 0),
797+
"daily_return": daily_ret,
798+
"cumulative_return": summary_data.get("total_return", 0),
799+
"mdd": summary_data.get("mdd", 0),
800+
"position_count": summary_data.get("position_count", 0),
801+
"total_trades": (result.get("executed", 0) if executed else 0),
802+
"strategy_diagnosis": f"바스켓 {name} · paper 트랙레코드 일일 사이클",
803+
})
804+
except Exception as e:
805+
logger.debug("바스켓 '{}' 일일 리포트 발송 실패(무시): {}", name, e)
768806

769807
except Exception as e:
770808
logger.error("바스켓 '{}' 리밸런싱 실패: {}", name, e)

tests/test_rebalance_snapshot.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,37 @@ def test_dry_run_rebalance_skips_db_backup(patched_rebalance, monkeypatch):
7575
)
7676
main_mod.run_rebalance(_args(dry_run=True))
7777
assert "backup" not in called
78+
79+
80+
def test_paper_rebalance_sends_daily_discord_report(patched_rebalance, monkeypatch):
81+
"""일일 CLI 사이클이 디스코드 일일 리포트를 발송한다(상시 스케줄러 없이도
82+
운영자가 매일 푸시를 받도록). 실패해도 사이클에는 영향 없어야 한다."""
83+
import main as main_mod
84+
from unittest.mock import MagicMock
85+
86+
fake_notifier = MagicMock()
87+
monkeypatch.setattr("core.notifier.Notifier", MagicMock(return_value=fake_notifier))
88+
patched_rebalance._market_snapshot = {"005930": {"price": 61000.0}}
89+
patched_rebalance.portfolio_mgr.get_portfolio_summary.return_value = {
90+
"total_value": 9_800_000, "cash": 2_000_000, "total_return": -2.0,
91+
"mdd": 2.0, "position_count": 9,
92+
}
93+
main_mod.run_rebalance(_args(dry_run=False))
94+
assert fake_notifier.send_daily_report.called
95+
payload = fake_notifier.send_daily_report.call_args.args[0]
96+
assert payload["total_value"] == 9_800_000
97+
assert payload["position_count"] == 9
98+
# 시장가 평가 계약: current_prices 없이 부르면 paper 포지션이 avg_price로 평가돼
99+
# 누적수익 -0.0%/MDD 0%가 60일 내내 표시된다(자기검토 2라운드 HIGH) — 가격 전달 고정.
100+
summary_kwargs = patched_rebalance.portfolio_mgr.get_portfolio_summary.call_args.kwargs
101+
assert summary_kwargs.get("current_prices") == {"005930": 61000.0}
102+
103+
104+
def test_dry_run_rebalance_does_not_send_daily_report(patched_rebalance, monkeypatch):
105+
import main as main_mod
106+
from unittest.mock import MagicMock
107+
108+
fake_notifier = MagicMock()
109+
monkeypatch.setattr("core.notifier.Notifier", MagicMock(return_value=fake_notifier))
110+
main_mod.run_rebalance(_args(dry_run=True))
111+
assert not fake_notifier.send_daily_report.called

0 commit comments

Comments
 (0)