Skip to content

Commit c7b2295

Browse files
committed
Add HTML support for Telegram notifications and update test coverage
- Enhance `_send_to_telegram` to handle HTML parse mode for compatibility with special characters. - Introduce `_escape_html` utility to sanitize HTML-specific characters in messages. - Add `_format_toss_price_recommendation_html` to generate Toss recommendations in HTML format. - Update `notify_toss_price_recommendation` to utilize HTML formatting for improved message clarity. - Expand test cases to validate escaping of special characters and correct HTML formatting in recommendations.
1 parent cce1d89 commit c7b2295

File tree

2 files changed

+111
-13
lines changed

2 files changed

+111
-13
lines changed

app/monitoring/trade_notifier.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -312,12 +312,15 @@ def _format_automation_summary(
312312

313313
return "\n".join(parts)
314314

315-
async def _send_to_telegram(self, message: str) -> bool:
315+
async def _send_to_telegram(
316+
self, message: str, parse_mode: str = "Markdown"
317+
) -> bool:
316318
"""
317319
Send message to all configured Telegram chats.
318320
319321
Args:
320322
message: Message to send
323+
parse_mode: Telegram parse mode ("Markdown" or "HTML")
321324
322325
Returns:
323326
True if at least one message was sent successfully
@@ -335,7 +338,7 @@ async def _send_to_telegram(self, message: str) -> bool:
335338
json={
336339
"chat_id": chat_id,
337340
"text": message,
338-
"parse_mode": "Markdown",
341+
"parse_mode": parse_mode,
339342
"disable_web_page_preview": True,
340343
},
341344
)
@@ -733,7 +736,15 @@ async def notify_toss_sell_recommendation(
733736
logger.error(f"Failed to send Toss sell recommendation: {e}")
734737
return False
735738

736-
def _format_toss_price_recommendation(
739+
def _escape_html(self, text: str) -> str:
740+
"""Escape HTML special characters for Telegram HTML parse mode."""
741+
return (
742+
text.replace("&", "&")
743+
.replace("<", "&lt;")
744+
.replace(">", "&gt;")
745+
)
746+
747+
def _format_toss_price_recommendation_html(
737748
self,
738749
symbol: str,
739750
korean_name: str,
@@ -754,7 +765,7 @@ def _format_toss_price_recommendation(
754765
currency: str = "원",
755766
) -> str:
756767
"""
757-
Format Toss price recommendation notification with AI analysis.
768+
Format Toss price recommendation notification with AI analysis (HTML format).
758769
"""
759770
is_usd = currency == "$"
760771
price_fmt = lambda p: f"${p:,.2f}" if is_usd else f"{p:,.0f}{currency}"
@@ -769,27 +780,31 @@ def _format_toss_price_recommendation(
769780
emoji = decision_emoji.get(decision.lower(), "⚪")
770781
decision_kr = decision_text.get(decision.lower(), decision)
771782

783+
# Escape korean_name for HTML
784+
safe_name = self._escape_html(korean_name)
785+
772786
parts = [
773-
f"📊 *\\[토스\\] {korean_name} ({symbol})*",
787+
f"📊 <b>[토스] {safe_name} ({symbol})</b>",
774788
"",
775-
f"*현재가:* {price_fmt(current_price)}",
776-
f"*보유:* {toss_quantity}주 (평단가 {price_fmt(toss_avg_price)}, {profit_sign}{profit_percent:.1f}%)",
789+
f"<b>현재가:</b> {price_fmt(current_price)}",
790+
f"<b>보유:</b> {toss_quantity}주 (평단가 {price_fmt(toss_avg_price)}, {profit_sign}{profit_percent:.1f}%)",
777791
"",
778-
f"{emoji} *AI 판단:* {decision_kr} (신뢰도 {confidence:.0f}%)",
792+
f"{emoji} <b>AI 판단:</b> {decision_kr} (신뢰도 {confidence:.0f}%)",
779793
]
780794

781795
# 근거 추가
782796
if reasons:
783797
parts.append("")
784-
parts.append("*근거:*")
798+
parts.append("<b>근거:</b>")
785799
for i, reason in enumerate(reasons[:3], 1):
786800
# 긴 근거는 줄임
787801
short_reason = reason[:80] + "..." if len(reason) > 80 else reason
788-
parts.append(f" {i}. {short_reason}")
802+
safe_reason = self._escape_html(short_reason)
803+
parts.append(f" {i}. {safe_reason}")
789804

790805
# 가격 제안 추가
791806
parts.append("")
792-
parts.append("*가격 제안:*")
807+
parts.append("<b>가격 제안:</b>")
793808

794809
if appropriate_buy_min or appropriate_buy_max:
795810
buy_range = []
@@ -849,6 +864,7 @@ async def notify_toss_price_recommendation(
849864
Send Toss price recommendation notification with AI analysis.
850865
851866
Always sends regardless of AI decision (buy/hold/sell).
867+
Uses HTML parse mode for better compatibility with special characters.
852868
"""
853869
if not self._enabled:
854870
return False
@@ -858,7 +874,7 @@ async def notify_toss_price_recommendation(
858874
return False
859875

860876
try:
861-
message = self._format_toss_price_recommendation(
877+
message = self._format_toss_price_recommendation_html(
862878
symbol=symbol,
863879
korean_name=korean_name,
864880
current_price=current_price,
@@ -877,7 +893,7 @@ async def notify_toss_price_recommendation(
877893
sell_target_max=sell_target_max,
878894
currency=currency,
879895
)
880-
return await self._send_to_telegram(message)
896+
return await self._send_to_telegram(message, parse_mode="HTML")
881897
except Exception as e:
882898
logger.error(f"Failed to send Toss price recommendation: {e}")
883899
return False

tests/test_kis_manual_holdings_integration.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,3 +725,85 @@ async def get_latest_analysis_by_symbol(self, symbol):
725725
assert notification_sent[0]["decision"] == "hold"
726726
assert notification_sent[0]["appropriate_buy_min"] == 23000.0
727727
assert notification_sent[0]["appropriate_sell_max"] == 28000.0
728+
729+
def test_format_toss_price_recommendation_html_escapes_special_chars(self):
730+
"""HTML 포맷 메시지가 특수문자를 올바르게 이스케이프하는지 확인"""
731+
from app.monitoring.trade_notifier import TradeNotifier
732+
733+
notifier = TradeNotifier()
734+
735+
# 특수문자가 포함된 데이터로 테스트
736+
message = notifier._format_toss_price_recommendation_html(
737+
symbol="005930",
738+
korean_name="삼성전자 <테스트>", # HTML 특수문자 포함
739+
current_price=72000.0,
740+
toss_quantity=10,
741+
toss_avg_price=70000.0,
742+
decision="buy",
743+
confidence=75.5,
744+
reasons=["RSI < 30 (과매도)", "이평선 & 정배열"], # 특수문자 포함
745+
appropriate_buy_min=68000.0,
746+
appropriate_buy_max=70000.0,
747+
appropriate_sell_min=75000.0,
748+
appropriate_sell_max=78000.0,
749+
buy_hope_min=65000.0,
750+
buy_hope_max=68000.0,
751+
sell_target_min=80000.0,
752+
sell_target_max=85000.0,
753+
currency="원",
754+
)
755+
756+
# HTML 특수문자가 올바르게 이스케이프 되어야 함
757+
assert "&lt;테스트&gt;" in message, "< > 문자가 이스케이프되어야 함"
758+
assert "&lt; 30" in message or "RSI &lt; 30" in message, "< 문자가 이스케이프되어야 함"
759+
assert "&amp;" in message, "& 문자가 이스케이프되어야 함"
760+
761+
# <b> 태그는 이스케이프되지 않아야 함 (HTML 포맷팅용)
762+
assert "<b>" in message, "볼드 태그는 유지되어야 함"
763+
assert "</b>" in message, "볼드 종료 태그는 유지되어야 함"
764+
765+
# 숫자와 퍼센트 등이 제대로 표시되어야 함
766+
assert "72,000원" in message, "현재가가 표시되어야 함"
767+
assert "+2.9%" in message, "수익률이 표시되어야 함"
768+
assert "76%" in message, "신뢰도가 표시되어야 함 (75.5 -> 76으로 반올림)"
769+
770+
def test_format_toss_price_recommendation_html_with_parentheses(self):
771+
"""괄호, 퍼센트 등이 포함된 메시지가 정상적으로 생성되는지 확인"""
772+
from app.monitoring.trade_notifier import TradeNotifier
773+
774+
notifier = TradeNotifier()
775+
776+
message = notifier._format_toss_price_recommendation_html(
777+
symbol="015760",
778+
korean_name="한국전력",
779+
current_price=25000.0,
780+
toss_quantity=10,
781+
toss_avg_price=23000.0,
782+
decision="sell",
783+
confidence=80,
784+
reasons=["수익률 8.7% 달성", "목표가(28,000원) 근접"],
785+
appropriate_buy_min=22000.0,
786+
appropriate_buy_max=23000.0,
787+
appropriate_sell_min=26000.0,
788+
appropriate_sell_max=28000.0,
789+
buy_hope_min=None,
790+
buy_hope_max=None,
791+
sell_target_min=28000.0,
792+
sell_target_max=30000.0,
793+
currency="원",
794+
)
795+
796+
# 메시지가 생성되어야 함
797+
assert len(message) > 0
798+
799+
# HTML 태그가 있어야 함
800+
assert "<b>" in message
801+
802+
# 이모지가 있어야 함
803+
assert "📊" in message
804+
assert "🔴" in message # sell decision
805+
806+
# 가격 제안이 있어야 함
807+
assert "적정 매수" in message
808+
assert "적정 매도" in message
809+
assert "매도 목표" in message

0 commit comments

Comments
 (0)