- StockAgent.on_screener_schedule: snapshot/refresh → screener/run(mode=auto) → telegram_payload(MarkdownV2) 발송. skipped_holiday는 무발신, 실패 시 운영자 HTML 알림. - service_proxy: refresh_screener_snapshot, run_stock_screener 추가 (각각 180s timeout, STOCK_LAB_URL 기존 env 재사용). - telegram.messaging.send_raw: parse_mode 파라미터 추가 (기본 HTML 유지, MarkdownV2 페이로드 직접 전달용). - scheduler: cron day_of_week=mon-fri hour=16 minute=30 id=stock_screener (Asia/Seoul TZ). - on_command 'run_screener' 수동 트리거 추가. - tests: 성공/휴일/스냅샷실패/run실패/이상status 5케이스.
178 lines
5.9 KiB
Python
178 lines
5.9 KiB
Python
"""StockAgent.on_screener_schedule — 평일 16:30 KST 자동 잡 단위 테스트.
|
|
|
|
stock-lab HTTP 호출은 service_proxy mock, 텔레그램은 messaging.send_raw mock.
|
|
"""
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
|
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
|
os.close(_fd)
|
|
os.unlink(_TMP)
|
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, patch
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _init_db():
|
|
import gc
|
|
gc.collect()
|
|
if os.path.exists(_TMP):
|
|
os.remove(_TMP)
|
|
from app.db import init_db
|
|
init_db()
|
|
yield
|
|
gc.collect()
|
|
|
|
|
|
def _success_body(asof="2026-05-12"):
|
|
return {
|
|
"asof": asof,
|
|
"mode": "auto",
|
|
"status": "success",
|
|
"run_id": 42,
|
|
"survivors_count": 600,
|
|
"top_n": 20,
|
|
"results": [],
|
|
"telegram_payload": {
|
|
"chat_target": "default",
|
|
"parse_mode": "MarkdownV2",
|
|
"text": "*KRX 강세주 스크리너* test body",
|
|
},
|
|
"warnings": [],
|
|
}
|
|
|
|
|
|
def _holiday_body(asof="2026-05-05"):
|
|
return {
|
|
"asof": asof,
|
|
"mode": "auto",
|
|
"status": "skipped_holiday",
|
|
"run_id": None,
|
|
"survivors_count": None,
|
|
"top_n": 0,
|
|
"results": [],
|
|
"telegram_payload": None,
|
|
"warnings": [f"{asof} is a holiday — skipped"],
|
|
}
|
|
|
|
|
|
def test_screener_success_sends_markdownv2_telegram():
|
|
from app.agents.stock import StockAgent
|
|
from app import service_proxy
|
|
from app.telegram import messaging
|
|
|
|
fake_snap = AsyncMock(return_value={"status": "ok"})
|
|
fake_run = AsyncMock(return_value=_success_body())
|
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 7777})
|
|
|
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
|
patch.object(messaging, "send_raw", fake_send):
|
|
agent = StockAgent()
|
|
asyncio.run(agent.on_screener_schedule())
|
|
|
|
fake_snap.assert_awaited_once()
|
|
fake_run.assert_awaited_once_with(mode="auto")
|
|
fake_send.assert_awaited_once()
|
|
args, kwargs = fake_send.call_args
|
|
# 첫 인자(text) 또는 kwargs로 전달
|
|
text = args[0] if args else kwargs.get("text")
|
|
assert "KRX 강세주 스크리너" in text
|
|
assert kwargs.get("parse_mode") == "MarkdownV2"
|
|
assert agent.state == "idle"
|
|
|
|
|
|
def test_screener_holiday_skips_telegram():
|
|
from app.agents.stock import StockAgent
|
|
from app import service_proxy
|
|
from app.telegram import messaging
|
|
|
|
fake_snap = AsyncMock(return_value={"status": "skipped_weekend"})
|
|
fake_run = AsyncMock(return_value=_holiday_body())
|
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
|
|
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
|
patch.object(messaging, "send_raw", fake_send):
|
|
agent = StockAgent()
|
|
asyncio.run(agent.on_screener_schedule())
|
|
|
|
fake_run.assert_awaited_once()
|
|
# 휴일이면 텔레그램 미발신
|
|
fake_send.assert_not_awaited()
|
|
assert agent.state == "idle"
|
|
|
|
|
|
def test_screener_snapshot_failure_still_runs_screener():
|
|
"""스냅샷 실패는 경고만 남기고 screener 호출은 계속됨."""
|
|
from app.agents.stock import StockAgent
|
|
from app import service_proxy
|
|
from app.telegram import messaging
|
|
|
|
fake_snap = AsyncMock(side_effect=RuntimeError("snapshot upstream down"))
|
|
fake_run = AsyncMock(return_value=_success_body())
|
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 8888})
|
|
|
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
|
patch.object(messaging, "send_raw", fake_send):
|
|
agent = StockAgent()
|
|
asyncio.run(agent.on_screener_schedule())
|
|
|
|
fake_snap.assert_awaited_once()
|
|
fake_run.assert_awaited_once_with(mode="auto")
|
|
fake_send.assert_awaited_once()
|
|
|
|
|
|
def test_screener_run_failure_notifies_operator():
|
|
"""screener/run 실패 시 운영자 알림 텔레그램 발송."""
|
|
from app.agents.stock import StockAgent
|
|
from app import service_proxy
|
|
from app.telegram import messaging
|
|
|
|
fake_snap = AsyncMock(return_value={"status": "ok"})
|
|
fake_run = AsyncMock(side_effect=RuntimeError("stock-lab 500"))
|
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
|
|
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
|
patch.object(messaging, "send_raw", fake_send):
|
|
agent = StockAgent()
|
|
asyncio.run(agent.on_screener_schedule())
|
|
|
|
# 운영자 알림 1회는 호출
|
|
assert fake_send.await_count == 1
|
|
args, kwargs = fake_send.call_args
|
|
text = args[0] if args else kwargs.get("text")
|
|
assert "스크리너 실패" in text
|
|
assert agent.state == "idle"
|
|
|
|
|
|
def test_screener_unexpected_status_treated_as_failure():
|
|
from app.agents.stock import StockAgent
|
|
from app import service_proxy
|
|
from app.telegram import messaging
|
|
|
|
fake_snap = AsyncMock(return_value={"status": "ok"})
|
|
fake_run = AsyncMock(return_value={"status": "weird", "asof": "2026-05-12"})
|
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
|
|
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
|
patch.object(messaging, "send_raw", fake_send):
|
|
agent = StockAgent()
|
|
asyncio.run(agent.on_screener_schedule())
|
|
|
|
# 운영자 알림 1회 + screener payload 미발송
|
|
assert fake_send.await_count == 1
|
|
args, kwargs = fake_send.call_args
|
|
text = args[0] if args else kwargs.get("text")
|
|
assert "스크리너 실패" in text
|