Skip to content

Commit cce1d89

Browse files
committed
Add Toss price recommendation notification and test coverage
- Implement `_format_toss_price_recommendation` to generate AI-driven price recommendations for Toss manual holdings. - Add `notify_toss_price_recommendation` method in `trade_notifier.py` to send recommendations via Telegram. - Update `kis.py` to trigger recommendations for manual holdings instead of sell steps. - Introduce `_send_toss_recommendation_async` for streamlined recommendation notifications. - Add extensive test coverage for Toss recommendation notifications, including scenarios for buy, sell, and hold decisions with AI analysis.
1 parent 59b534a commit cce1d89

File tree

3 files changed

+404
-4
lines changed

3 files changed

+404
-4
lines changed

app/monitoring/trade_notifier.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,155 @@ async def notify_toss_sell_recommendation(
733733
logger.error(f"Failed to send Toss sell recommendation: {e}")
734734
return False
735735

736+
def _format_toss_price_recommendation(
737+
self,
738+
symbol: str,
739+
korean_name: str,
740+
current_price: float,
741+
toss_quantity: int,
742+
toss_avg_price: float,
743+
decision: str,
744+
confidence: float,
745+
reasons: List[str],
746+
appropriate_buy_min: float | None,
747+
appropriate_buy_max: float | None,
748+
appropriate_sell_min: float | None,
749+
appropriate_sell_max: float | None,
750+
buy_hope_min: float | None = None,
751+
buy_hope_max: float | None = None,
752+
sell_target_min: float | None = None,
753+
sell_target_max: float | None = None,
754+
currency: str = "원",
755+
) -> str:
756+
"""
757+
Format Toss price recommendation notification with AI analysis.
758+
"""
759+
is_usd = currency == "$"
760+
price_fmt = lambda p: f"${p:,.2f}" if is_usd else f"{p:,.0f}{currency}"
761+
762+
# 수익률 계산
763+
profit_percent = ((current_price / toss_avg_price) - 1) * 100 if toss_avg_price > 0 else 0
764+
profit_sign = "+" if profit_percent >= 0 else ""
765+
766+
# Decision emoji mapping
767+
decision_emoji = {"buy": "🟢", "hold": "🟡", "sell": "🔴"}
768+
decision_text = {"buy": "매수", "hold": "보유", "sell": "매도"}
769+
emoji = decision_emoji.get(decision.lower(), "⚪")
770+
decision_kr = decision_text.get(decision.lower(), decision)
771+
772+
parts = [
773+
f"📊 *\\[토스\\] {korean_name} ({symbol})*",
774+
"",
775+
f"*현재가:* {price_fmt(current_price)}",
776+
f"*보유:* {toss_quantity}주 (평단가 {price_fmt(toss_avg_price)}, {profit_sign}{profit_percent:.1f}%)",
777+
"",
778+
f"{emoji} *AI 판단:* {decision_kr} (신뢰도 {confidence:.0f}%)",
779+
]
780+
781+
# 근거 추가
782+
if reasons:
783+
parts.append("")
784+
parts.append("*근거:*")
785+
for i, reason in enumerate(reasons[:3], 1):
786+
# 긴 근거는 줄임
787+
short_reason = reason[:80] + "..." if len(reason) > 80 else reason
788+
parts.append(f" {i}. {short_reason}")
789+
790+
# 가격 제안 추가
791+
parts.append("")
792+
parts.append("*가격 제안:*")
793+
794+
if appropriate_buy_min or appropriate_buy_max:
795+
buy_range = []
796+
if appropriate_buy_min:
797+
buy_range.append(price_fmt(appropriate_buy_min))
798+
if appropriate_buy_max:
799+
buy_range.append(price_fmt(appropriate_buy_max))
800+
parts.append(f" • 적정 매수: {' ~ '.join(buy_range)}")
801+
802+
if appropriate_sell_min or appropriate_sell_max:
803+
sell_range = []
804+
if appropriate_sell_min:
805+
sell_range.append(price_fmt(appropriate_sell_min))
806+
if appropriate_sell_max:
807+
sell_range.append(price_fmt(appropriate_sell_max))
808+
parts.append(f" • 적정 매도: {' ~ '.join(sell_range)}")
809+
810+
if buy_hope_min or buy_hope_max:
811+
hope_range = []
812+
if buy_hope_min:
813+
hope_range.append(price_fmt(buy_hope_min))
814+
if buy_hope_max:
815+
hope_range.append(price_fmt(buy_hope_max))
816+
parts.append(f" • 매수 희망: {' ~ '.join(hope_range)}")
817+
818+
if sell_target_min or sell_target_max:
819+
target_range = []
820+
if sell_target_min:
821+
target_range.append(price_fmt(sell_target_min))
822+
if sell_target_max:
823+
target_range.append(price_fmt(sell_target_max))
824+
parts.append(f" • 매도 목표: {' ~ '.join(target_range)}")
825+
826+
return "\n".join(parts)
827+
828+
async def notify_toss_price_recommendation(
829+
self,
830+
symbol: str,
831+
korean_name: str,
832+
current_price: float,
833+
toss_quantity: int,
834+
toss_avg_price: float,
835+
decision: str,
836+
confidence: float,
837+
reasons: List[str],
838+
appropriate_buy_min: float | None,
839+
appropriate_buy_max: float | None,
840+
appropriate_sell_min: float | None,
841+
appropriate_sell_max: float | None,
842+
buy_hope_min: float | None = None,
843+
buy_hope_max: float | None = None,
844+
sell_target_min: float | None = None,
845+
sell_target_max: float | None = None,
846+
currency: str = "원",
847+
) -> bool:
848+
"""
849+
Send Toss price recommendation notification with AI analysis.
850+
851+
Always sends regardless of AI decision (buy/hold/sell).
852+
"""
853+
if not self._enabled:
854+
return False
855+
856+
if toss_quantity <= 0:
857+
logger.debug(f"Skipping Toss notification for {symbol}: no Toss holdings")
858+
return False
859+
860+
try:
861+
message = self._format_toss_price_recommendation(
862+
symbol=symbol,
863+
korean_name=korean_name,
864+
current_price=current_price,
865+
toss_quantity=toss_quantity,
866+
toss_avg_price=toss_avg_price,
867+
decision=decision,
868+
confidence=confidence,
869+
reasons=reasons,
870+
appropriate_buy_min=appropriate_buy_min,
871+
appropriate_buy_max=appropriate_buy_max,
872+
appropriate_sell_min=appropriate_sell_min,
873+
appropriate_sell_max=appropriate_sell_max,
874+
buy_hope_min=buy_hope_min,
875+
buy_hope_max=buy_hope_max,
876+
sell_target_min=sell_target_min,
877+
sell_target_max=sell_target_max,
878+
currency=currency,
879+
)
880+
return await self._send_to_telegram(message)
881+
except Exception as e:
882+
logger.error(f"Failed to send Toss price recommendation: {e}")
883+
return False
884+
736885
async def test_connection(self) -> bool:
737886
"""
738887
Test Telegram connection by sending a test message.

app/tasks/kis.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,62 @@ def _report_step_error(
7979
logger.error(f"Failed to report step error: {e}")
8080

8181

82+
async def _send_toss_recommendation_async(
83+
code: str,
84+
name: str,
85+
current_price: float,
86+
toss_quantity: int,
87+
toss_avg_price: float,
88+
kis_quantity: int | None = None,
89+
kis_avg_price: float | None = None,
90+
) -> None:
91+
"""수동 잔고(토스) 종목에 대해 AI 분석 결과와 가격 제안 알림 발송.
92+
93+
AI 결정(buy/hold/sell)과 무관하게 항상 가격 제안을 포함하여 알림을 발송합니다.
94+
"""
95+
from app.core.db import AsyncSessionLocal
96+
from app.services.stock_info_service import StockAnalysisService
97+
98+
notifier = get_trade_notifier()
99+
if not notifier._enabled:
100+
logger.debug(f"[토스추천] {name}({code}) - 알림 비활성화됨")
101+
return
102+
103+
async with AsyncSessionLocal() as db:
104+
service = StockAnalysisService(db)
105+
analysis = await service.get_latest_analysis_by_symbol(code)
106+
107+
if not analysis:
108+
logger.warning(f"[토스추천] {name}({code}) - 분석 결과 없음, 알림 스킵")
109+
return
110+
111+
decision = analysis.decision.lower() if analysis.decision else "hold"
112+
confidence = analysis.confidence if analysis.confidence else 0
113+
reasons = analysis.reasons if analysis.reasons else []
114+
115+
# AI 결정과 무관하게 항상 가격 제안 알림 발송
116+
await notifier.notify_toss_price_recommendation(
117+
symbol=code,
118+
korean_name=name,
119+
current_price=current_price,
120+
toss_quantity=toss_quantity,
121+
toss_avg_price=toss_avg_price,
122+
decision=decision,
123+
confidence=confidence,
124+
reasons=reasons,
125+
appropriate_buy_min=analysis.appropriate_buy_min,
126+
appropriate_buy_max=analysis.appropriate_buy_max,
127+
appropriate_sell_min=analysis.appropriate_sell_min,
128+
appropriate_sell_max=analysis.appropriate_sell_max,
129+
buy_hope_min=analysis.buy_hope_min,
130+
buy_hope_max=analysis.buy_hope_max,
131+
sell_target_min=analysis.sell_target_min,
132+
sell_target_max=analysis.sell_target_max,
133+
currency="원",
134+
)
135+
logger.info(f"[토스추천] {name}({code}) - 가격 제안 알림 발송 (AI 판단: {decision}, 신뢰도: {confidence}%)")
136+
137+
82138
# --- Domestic Stocks Tasks ---
83139

84140
async def _analyze_domestic_stock_async(code: str, progress_cb: ProgressCallback = None) -> Dict[str, object]:
@@ -576,10 +632,21 @@ async def _run() -> dict:
576632
except Exception as refresh_error:
577633
logger.warning("잔고 재조회 실패 - 기존 수량 사용 (%s)", refresh_error)
578634

579-
# 수동 잔고(토스 등)는 KIS에서 매도할 수 없으므로 매도 단계 스킵
635+
# 수동 잔고(토스 등)는 KIS에서 매도할 수 없으므로 텔레그램 추천 알림만 발송
580636
if is_manual:
581-
logger.info(f"[수동잔고] {name}({code}) - KIS 매도 불가, 매도 단계 스킵")
582-
stock_steps.append({'step': '매도', 'result': {'success': True, 'message': '수동잔고 - 매도 스킵', 'orders_placed': 0}})
637+
logger.info(f"[수동잔고] {name}({code}) - KIS 매도 불가, 토스 추천 알림 발송")
638+
try:
639+
await _send_toss_recommendation_async(
640+
code=code,
641+
name=name,
642+
current_price=refreshed_current_price,
643+
toss_quantity=refreshed_qty,
644+
toss_avg_price=avg_price,
645+
)
646+
stock_steps.append({'step': '매도', 'result': {'success': True, 'message': '수동잔고 - 토스 추천 알림 발송', 'orders_placed': 0}})
647+
except Exception as e:
648+
logger.warning(f"[수동잔고] {name}({code}) 토스 추천 알림 발송 실패: {e}")
649+
stock_steps.append({'step': '매도', 'result': {'success': True, 'message': '수동잔고 - 매도 스킵', 'orders_placed': 0}})
583650
results.append({'name': name, 'code': code, 'steps': stock_steps})
584651
continue
585652

0 commit comments

Comments
 (0)