Skip to content

Commit f70ad7b

Browse files
committed
Convert reasons column to JSONB and update related logic
- Migrate `reasons` column in `stock_analysis_results` to JSONB to enhance data handling and performance. - Refactor related services and routers to handle JSONB format properly, replacing ad-hoc JSON parsing with a new `_normalize_reasons` utility. - Update model definitions and data access to reflect column type changes, ensuring backward compatibility where necessary. - Add comprehensive test coverage for reasons parsing and handling across different workflows.
1 parent 25d187f commit f70ad7b

File tree

7 files changed

+163
-24
lines changed

7 files changed

+163
-24
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Change reasons column to JSONB
2+
3+
Revision ID: 1b7e2a9a0a9d
4+
Revises: 7cff05b5aa4d
5+
Create Date: 2025-12-04 12:55:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
from sqlalchemy.dialects import postgresql
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "1b7e2a9a0a9d"
16+
down_revision: Union[str, Sequence[str], None] = "7cff05b5aa4d"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Alter reasons to JSONB while preserving existing JSON text data."""
23+
op.alter_column(
24+
"stock_analysis_results",
25+
"reasons",
26+
existing_type=sa.Text(),
27+
type_=postgresql.JSONB(astext_type=sa.Text()),
28+
existing_nullable=True,
29+
postgresql_using=(
30+
"CASE WHEN reasons IS NULL OR trim(reasons) = '' "
31+
"THEN NULL ELSE reasons::jsonb END"
32+
),
33+
)
34+
35+
36+
def downgrade() -> None:
37+
"""Revert reasons to TEXT."""
38+
op.alter_column(
39+
"stock_analysis_results",
40+
"reasons",
41+
existing_type=postgresql.JSONB(astext_type=sa.Text()),
42+
type_=sa.Text(),
43+
existing_nullable=True,
44+
postgresql_using="reasons::text",
45+
)

app/analysis/analyzer.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -336,10 +336,7 @@ async def _save_json_analysis_to_db(
336336
instrument_type=instrument_type
337337
)
338338

339-
# 2. 근거를 JSON 문자열로 변환
340-
reasons_json = json.dumps(result.reasons, ensure_ascii=False)
341-
342-
# 3. 분석 결과 저장
339+
# 2. 분석 결과 저장
343340
record = StockAnalysisResult(
344341
stock_info_id=stock_info.id, # 주식 정보와 연결
345342
prompt=prompt,
@@ -354,7 +351,7 @@ async def _save_json_analysis_to_db(
354351
buy_hope_max=result.price_analysis.buy_hope_range.max,
355352
sell_target_min=result.price_analysis.sell_target_range.min,
356353
sell_target_max=result.price_analysis.sell_target_range.max,
357-
reasons=reasons_json,
354+
reasons=result.reasons,
358355
detailed_text=result.detailed_text,
359356
)
360357
db.add(record)

app/models/analysis.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, ForeignKey, Boolean
33
from sqlalchemy.sql import func
44
from sqlalchemy.orm import relationship
5+
from sqlalchemy.dialects.postgresql import JSONB
56
from app.models.base import Base
67

78

@@ -53,7 +54,7 @@ class StockAnalysisResult(Base):
5354
sell_target_max = Column(Float, nullable=True, comment="매도 목표 범위 최대값")
5455

5556
# 근거 및 상세 분석
56-
reasons = Column(Text, nullable=True, comment="분석 근거 (JSON 형태로 저장)")
57+
reasons = Column(JSONB, nullable=True, comment="분석 근거 (JSON 형태로 저장)")
5758
detailed_text = Column(Text, nullable=True, comment="상세 분석 텍스트")
5859

5960
# 원본 프롬프트

app/routers/analysis_json.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import time
23
from typing import List, Optional
34

@@ -38,6 +39,23 @@
3839
)
3940

4041

42+
def _normalize_reasons(raw_reasons) -> List[str]:
43+
"""Convert stored reasons to a string list, handling JSON/text/null."""
44+
if raw_reasons is None:
45+
return []
46+
if isinstance(raw_reasons, list):
47+
return [str(r) for r in raw_reasons]
48+
if isinstance(raw_reasons, str):
49+
try:
50+
parsed = json.loads(raw_reasons)
51+
if isinstance(parsed, list):
52+
return [str(r) for r in parsed]
53+
return [str(parsed)]
54+
except Exception:
55+
return [raw_reasons]
56+
return []
57+
58+
4159
@router.get("/", response_class=HTMLResponse)
4260
async def analysis_json_dashboard(request: Request):
4361
"""JSON 분석 결과 대시보드 페이지"""
@@ -145,7 +163,7 @@ async def get_analysis_results(
145163
"buy_hope_max": analysis_result.buy_hope_max,
146164
"sell_target_min": analysis_result.sell_target_min,
147165
"sell_target_max": analysis_result.sell_target_max,
148-
"reasons": analysis_result.reasons,
166+
"reasons": _normalize_reasons(analysis_result.reasons),
149167
"detailed_text": analysis_result.detailed_text,
150168
"created_at": analysis_result.created_at.isoformat() if analysis_result.created_at else None,
151169
})
@@ -218,14 +236,7 @@ async def get_analysis_detail(
218236

219237
analysis_result, stock_info = row
220238

221-
# 근거를 JSON에서 파싱
222-
import json
223-
reasons = []
224-
try:
225-
if analysis_result.reasons:
226-
reasons = json.loads(analysis_result.reasons)
227-
except:
228-
reasons = []
239+
reasons = _normalize_reasons(analysis_result.reasons)
229240

230241
return {
231242
"id": analysis_result.id,

app/routers/stock_latest.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import json
23
from asyncio import AbstractEventLoop
34
from typing import List, Optional
45
from fastapi import APIRouter, Depends, Request, Query, HTTPException, BackgroundTasks
@@ -17,6 +18,23 @@
1718
router = APIRouter(prefix="/stock-latest", tags=["Stock Latest Analysis"])
1819

1920

21+
def _normalize_reasons(raw_reasons) -> List[str]:
22+
"""Convert stored reasons to a string list, handling JSON/text/null."""
23+
if raw_reasons is None:
24+
return []
25+
if isinstance(raw_reasons, list):
26+
return [str(r) for r in raw_reasons]
27+
if isinstance(raw_reasons, str):
28+
try:
29+
parsed = json.loads(raw_reasons)
30+
if isinstance(parsed, list):
31+
return [str(r) for r in parsed]
32+
return [str(parsed)]
33+
except Exception:
34+
return [raw_reasons]
35+
return []
36+
37+
2038
@router.get("/", response_class=HTMLResponse)
2139
async def stock_latest_dashboard(request: Request):
2240
"""종목별 최신 분석 결과 대시보드 페이지"""
@@ -173,14 +191,7 @@ async def get_stock_analysis_history(
173191
# 응답 데이터 구성
174192
history_results = []
175193
for analysis in analysis_history:
176-
# 근거를 JSON에서 파싱
177-
import json
178-
reasons = []
179-
try:
180-
if analysis.reasons:
181-
reasons = json.loads(analysis.reasons)
182-
except:
183-
reasons = []
194+
reasons = _normalize_reasons(analysis.reasons)
184195

185196
history_results.append({
186197
"id": analysis.id,

app/tasks/kis.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import json
23
import logging
34
from typing import Any, Callable, Dict, List, Optional
45

@@ -110,7 +111,23 @@ async def _send_toss_recommendation_async(
110111

111112
decision = analysis.decision.lower() if analysis.decision else "hold"
112113
confidence = analysis.confidence if analysis.confidence else 0
113-
reasons = analysis.reasons if analysis.reasons else []
114+
raw_reasons = analysis.reasons
115+
if isinstance(raw_reasons, list):
116+
reasons = [str(r) for r in raw_reasons]
117+
elif isinstance(raw_reasons, str):
118+
try:
119+
parsed = json.loads(raw_reasons)
120+
reasons = [str(r) for r in parsed] if isinstance(parsed, list) else [str(parsed)]
121+
except Exception as parse_error:
122+
logger.debug(
123+
"Failed to parse analysis reasons for %s(%s): %s",
124+
name,
125+
code,
126+
parse_error,
127+
)
128+
reasons = [raw_reasons]
129+
else:
130+
reasons = []
114131

115132
# AI 결정과 무관하게 항상 가격 제안 알림 발송
116133
await notifier.notify_toss_price_recommendation(

tests/test_kis_manual_holdings_integration.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
44
수동 잔고(토스 등)를 자동화 태스크에 통합하는 기능 테스트
55
"""
6+
import json
67
import pytest
78
from decimal import Decimal
89
from unittest.mock import AsyncMock, MagicMock, patch
@@ -726,6 +727,62 @@ async def get_latest_analysis_by_symbol(self, symbol):
726727
assert notification_sent[0]["appropriate_buy_min"] == 23000.0
727728
assert notification_sent[0]["appropriate_sell_max"] == 28000.0
728729

730+
@pytest.mark.asyncio
731+
async def test_send_toss_price_recommendation_parses_json_reasons(self, monkeypatch):
732+
"""DB에 JSON 문자열로 저장된 근거를 리스트로 파싱해 전달"""
733+
from app.tasks.kis import _send_toss_recommendation_async
734+
from app.models.analysis import StockAnalysisResult
735+
736+
notification_sent = []
737+
738+
class MockNotifier:
739+
_enabled = True
740+
741+
async def notify_toss_price_recommendation(self, **kwargs):
742+
notification_sent.append(kwargs)
743+
return True
744+
745+
mock_analysis = MagicMock(spec=StockAnalysisResult)
746+
mock_analysis.decision = "buy"
747+
mock_analysis.confidence = 70
748+
mock_analysis.reasons = json.dumps(
749+
["첫째 근거", "둘째 근거", "셋째 근거"], ensure_ascii=False
750+
)
751+
mock_analysis.appropriate_buy_min = 23000.0
752+
mock_analysis.appropriate_buy_max = 24000.0
753+
mock_analysis.appropriate_sell_min = 26000.0
754+
mock_analysis.appropriate_sell_max = 28000.0
755+
mock_analysis.buy_hope_min = 22000.0
756+
mock_analysis.buy_hope_max = 23000.0
757+
mock_analysis.sell_target_min = 28000.0
758+
mock_analysis.sell_target_max = 30000.0
759+
760+
class MockAnalysisService:
761+
def __init__(self, db):
762+
pass
763+
764+
async def get_latest_analysis_by_symbol(self, symbol):
765+
return mock_analysis
766+
767+
mock_db_session = MagicMock()
768+
mock_db_session.__aenter__ = AsyncMock(return_value=MagicMock())
769+
mock_db_session.__aexit__ = AsyncMock(return_value=None)
770+
771+
with patch('app.tasks.kis.get_trade_notifier', return_value=MockNotifier()), \
772+
patch('app.core.db.AsyncSessionLocal', return_value=mock_db_session), \
773+
patch('app.services.stock_info_service.StockAnalysisService', MockAnalysisService):
774+
775+
await _send_toss_recommendation_async(
776+
code="015760",
777+
name="한국전력",
778+
current_price=25000.0,
779+
toss_quantity=10,
780+
toss_avg_price=23000.0,
781+
)
782+
783+
assert len(notification_sent) == 1
784+
assert notification_sent[0]["reasons"] == ["첫째 근거", "둘째 근거", "셋째 근거"]
785+
729786
def test_format_toss_price_recommendation_html_escapes_special_chars(self):
730787
"""HTML 포맷 메시지가 특수문자를 올바르게 이스케이프하는지 확인"""
731788
from app.monitoring.trade_notifier import TradeNotifier

0 commit comments

Comments
 (0)