Alpha Vantage API에서 미국 주식 분석기 만들기 4)

Tasker_Jang·2025년 3월 30일
0

LLM 기반 주식 티커 추출 기능의 효과적인 테스트 전략

금융 애플리케이션을 개발하다 보면 사용자의 자연어 쿼리에서 주식 티커 심볼을 정확히 추출하는 기능이 필수적입니다. 특히 요즘 같은 AI 시대에는 LLM(Large Language Model)을 활용해 회사 이름을 티커로 변환하는 고급 기능도 구현하게 됩니다. 이런 복잡한 기능을 어떻게 효과적으로 테스트할 수 있을까요? 이 글에서는 금융 애플리케이션의 핵심 기능인 티커 추출에 대한 포괄적인 테스트 전략을 소개합니다.

1. 티커 추출 기능의 테스트 시나리오 설계

효과적인 테스트를 위해서는 다양한 시나리오를 고려해야 합니다. 티커 추출 기능의 경우 다음과 같은 시나리오를 테스트해야 합니다:

명시적 티커 추출 (Explicit Ticker Extraction)

사용자가 쿼리에 직접 티커 심볼을 포함시키는 경우입니다. 이는 가장 기본적인 시나리오로, 정규 표현식만으로도 구현 가능합니다.

def test_explicit_ticker_extraction(self):
    """Test extraction of explicitly mentioned tickers"""
    testcases = [
        ("Analyze financials for AAPL", "AAPL"),
        ("What's the financial status of ticker MSFT?", "MSFT"),
        ("Do a financial analysis of TSLA", "TSLA"),
        ("Ticker: AMZN financial analysis", "AMZN"),
        ("Google GOOG financial statements", "GOOG"),
        ("Apple (AAPL) financial status", "AAPL")
    ]

    for query, expected in testcases:
        result = self.tool._extract_ticker(query)
        self.assertEqual(result, expected, f"Query: {query}")

이 테스트는 다음과 같은 패턴을 검증합니다:

  • 단순 티커 언급: "AAPL"
  • 'ticker' 키워드 뒤의 티커: "ticker MSFT"
  • 콜론 뒤의 티커: "Ticker: AMZN"
  • 괄호 안의 티커: "Apple (AAPL)"

회사 이름에서 티커 추출 (Company-to-Ticker Mapping)

사용자가 티커 대신 회사 이름만 언급하는 경우입니다. 이 경우 미리 정의된 매핑 사전을 활용합니다.

def test_company_name_to_ticker_mapping(self):
    """Test extraction of tickers from company names using mapping dictionary"""
    testcases = [
        ("Analyze Apple's financials", "AAPL"),
        ("How is Microsoft doing?", "MSFT"),
        ("Google's financial status", "GOOGL"),
        ("Should I invest in Tesla?", "TSLA"),
        ("Amazon financial analysis", "AMZN"),
        ("Facebook (Meta) performance?", "META")
    ]

    for query, expected in testcases:
        result = self.tool._extract_ticker(query)
        self.assertEqual(result, expected, f"Query: {query}")

이 테스트는 아래와 같은 시나리오를 검증합니다:

  • 정확한 회사 이름: "Apple" → "AAPL"
  • 소유격 형태: "Google's" → "GOOGL"
  • 대체 이름: "Facebook (Meta)" → "META"

티커 추출 실패 시나리오 (No Ticker Extraction)

티커나 회사 이름이 언급되지 않은 일반적인 쿼리의 경우입니다.

def test_no_ticker_extraction(self):
    """Test cases where no ticker can be extracted"""
    testcases = [
        "Analyze financial statements",
        "How's the stock market?",
        "Recent corporate earnings trends",
        "Recommend good investments"
    ]

    for query in testcases:
        result = self.tool._extract_ticker(query)
        self.assertIsNone(result, f"Query: {query}")

이러한 케이스들은 티커 추출에 실패해야 하며, None을 반환하는 것이 적절합니다. 이를 통해 후속 로직이 적절한 오류 메시지를 제공할 수 있습니다.

LLM 기반 티커 추출 (LLM-based Ticker Inference)

정규 표현식이나 매핑 사전으로 해결할 수 없는 경우, LLM을 활용한 추론을 사용합니다.

@patch('langchain_openai.ChatOpenAI')
def test_llm_ticker_extraction(self, mock_llm):
    """Test ticker extraction using LLM"""
    # Add LLM attribute to tool if not present
    if not hasattr(self.tool, 'llm'):
        self.tool.llm = None
        
    # Setup LLM mock
    mock_llm_instance = MagicMock()
    self.tool.llm = mock_llm_instance
    
    # Test various LLM response scenarios
    test_scenarios = [
        {
            "query": "How is Nvidia's financial health?",
            "llm_response": "NVDA",
            "expected": "NVDA"
        },
        {
            "query": "Netflix stock analysis",
            "llm_response": "NFLX",
            "expected": "NFLX"
        },
        {
            "query": "Analyze an unknown company",
            "llm_response": "UNKNOWN",
            "expected": None
        }
    ]
    
    for scenario in test_scenarios:
        # Set LLM response for each scenario
        mock_llm_instance.predict.return_value = scenario["llm_response"]
        
        # Run test
        result = self.tool._extract_ticker(scenario["query"])
        
        # Verify result
        self.assertEqual(result, scenario["expected"], 
                        f"Query: {scenario['query']}, LLM response: {scenario['llm_response']}")

이 테스트에서는 LLM의 응답을 모킹(mocking)하여 다양한 시나리오를 테스트합니다:

  • 성공적인 추론: "Nvidia" → "NVDA"
  • 성공적인 추론: "Netflix" → "NFLX"
  • 추론 실패: "unknown company" → None

2. 분석 결과에서 티커 추출 테스트

사용자 쿼리에서 티커를 추출하는 것 외에도, 분석 결과 텍스트에서 티커를 다시 추출하는 로직도 테스트해야 합니다. 이는 노드 간 데이터 전달 시 중요합니다.

def test_node_ticker_extraction_from_result(self):
    """Test ticker extraction from analysis result text"""
    testcases = [
        ("Financial Statement Analysis for Apple Inc (Ticker: AAPL)", {"ticker": "AAPL"}),
        ("Analysis shows that ticker MSFT has strong fundamentals", {"ticker": "MSFT"}),
        ("The company Amazon (AMZN) shows improving metrics", {"ticker": "AMZN", "company": "The company Amazon"}),
        ("Financial analysis for TSLA indicates positive growth", {"ticker": "TSLA"}),
        ("Financial analysis completed but no ticker found", {"ticker": "unknown"})
    ]

    for text, expected in testcases:
        result = self.node._extract_ticker_from_result(text)
        self.assertEqual(result, expected, f"Text: {text}")

이 테스트에서는 다양한 형식의 결과 텍스트에서 티커와 회사 정보를 올바르게 추출하는지 확인합니다. 추출에 실패하면 "unknown"을 기본값으로 반환해야 합니다.

3. 노드 실행 및 통합 테스트

티커 추출 기능이 전체 애플리케이션 흐름에서 올바르게 작동하는지 확인하는 통합 테스트도 중요합니다.

명시적 티커가 있는 쿼리 처리

@patch('src.graph.nodes.us_financial.create_react_agent')
@patch('src.tools.us_stock.tool.USFinancialStatementTool._extract_ticker')
def test_run_with_explicit_ticker(self, mock_extract_ticker, mock_create_agent):
    """Test node execution with explicit ticker in query"""
    # Set up mocked ticker extraction
    mock_extract_ticker.return_value = "AAPL"
    
    # Set up mocked result
    result_content = "Financial Statement Analysis for Apple Inc (Ticker: AAPL)\n\nThe company shows strong financial health..."
    
    # Set up mocked agent
    mock_agent = MagicMock()
    mock_agent.invoke.return_value = {
        "messages": [
            AIMessage(content=result_content)
        ]
    }
    mock_create_agent.return_value = mock_agent
    
    # Test state object with English message containing a ticker
    state = {
        "llm": MagicMock(),
        "messages": [HumanMessage(content="Analyze AAPL financials")]
    }
    
    # Execute node
    self.node.agent = mock_agent
    result = self.node._run(state)
    
    # Verify
    self.assertEqual(result.goto, "supervisor")
    self.assertEqual(result.update["financial_analysis"]["ticker"], "AAPL")
    self.assertEqual(result.update["financial_analysis"]["market"], "US")
    self.assertEqual(result.update["financial_analysis"]["analysis_text"], result_content)

회사 이름만 있는 쿼리 처리

@patch('src.graph.nodes.us_financial.create_react_agent')
@patch('src.tools.us_stock.tool.USFinancialStatementTool._extract_ticker')
def test_run_with_company_name(self, mock_extract_ticker, mock_create_agent):
    """Test node execution with company name instead of ticker"""
    # Set up mocked ticker extraction (simulating LLM-based inference)
    mock_extract_ticker.return_value = "AAPL"
    
    # Set up mocked result
    result_content = "Financial Statement Analysis for Apple Inc (Ticker: AAPL)\n\nThe company shows strong financial health..."
    
    # Set up mocked agent
    mock_agent = MagicMock()
    mock_agent.invoke.return_value = {
        "messages": [
            AIMessage(content=result_content)
        ]
    }
    mock_create_agent.return_value = mock_agent
    
    # Test state object with English message containing only company name
    state = {
        "llm": MagicMock(),
        "messages": [HumanMessage(content="Analyze Apple's financial status")]
    }
    
    # Execute node
    self.node.agent = mock_agent
    result = self.node._run(state)
    
    # Verify
    self.assertEqual(result.goto, "supervisor")
    self.assertEqual(result.update["financial_analysis"]["ticker"], "AAPL")
    self.assertEqual(result.update["financial_analysis"]["market"], "US")
    self.assertEqual(result.update["financial_analysis"]["analysis_text"], result_content)
    
    # Verify that extract_ticker was called with the company name query
    mock_extract_ticker.assert_called_with("Analyze Apple's financial status")

모호한 쿼리나 티커가 없는 쿼리 처리

@patch('src.graph.nodes.us_financial.create_react_agent')
@patch('src.tools.us_stock.tool.USFinancialStatementTool._extract_ticker')
def test_no_company_identified(self, mock_extract_ticker, mock_create_agent):
    """Test when neither ticker nor company can be identified"""
    # Set up extraction to return None (no ticker identified)
    mock_extract_ticker.return_value = None
    
    # Set up mocked result
    result_content = "I need more information. Please provide a specific company name or ticker symbol for financial analysis."
    
    # Set up mocked agent
    mock_agent = MagicMock()
    mock_agent.invoke.return_value = {
        "messages": [
            AIMessage(content=result_content)
        ]
    }
    mock_create_agent.return_value = mock_agent
    
    # Test state object with vague query
    state = {
        "llm": MagicMock(),
        "messages": [HumanMessage(content="Analyze a technology company")]
    }
    
    # Execute node
    self.node.agent = mock_agent
    result = self.node._run(state)
    
    # Verify - should return "unknown" ticker as specified in the current implementation
    self.assertEqual(result.goto, "supervisor")
    self.assertEqual(result.update["financial_analysis"]["ticker"], "unknown")
    self.assertEqual(result.update["financial_analysis"]["market"], "US")

다국어 쿼리 처리 (국제화 지원)

@patch('src.graph.nodes.us_financial.create_react_agent')
def test_non_english_query(self, mock_create_agent):
    """Test handling of non-English queries"""
    # Set up mocked result with English response, even though query is non-English
    result_content = "Financial Statement Analysis for Apple Inc (Ticker: AAPL)\n\nThe company shows strong financial health..."
    
    # Set up mocked agent
    mock_agent = MagicMock()
    mock_agent.invoke.return_value = {
        "messages": [
            AIMessage(content=result_content)
        ]
    }
    mock_create_agent.return_value = mock_agent
    
    # Test state object with non-English message
    state = {
        "llm": MagicMock(),
        "messages": [HumanMessage(content="애플 회사의 재무 상태를 분석해 주세요")]
    }
    
    # We need to mock the tool's _extract_ticker to handle Korean text
    with patch.object(USFinancialStatementTool, '_extract_ticker', return_value="AAPL"):
        # Execute node
        self.node.agent = mock_agent
        result = self.node._run(state)
        
        # Verify
        self.assertEqual(result.goto, "supervisor")
        self.assertEqual(result.update["financial_analysis"]["ticker"], "AAPL")
        self.assertEqual(result.update["financial_analysis"]["market"], "US")

4. 다양한 쿼리 시나리오에 대한 종합 테스트

마지막으로, 다양한 쿼리 유형에 대한 종합적인 테스트를 통해 전체 시스템의 강건성을 검증합니다.

@patch('src.graph.nodes.us_financial.create_react_agent')
@patch('src.tools.us_stock.tool.USFinancialStatementTool._extract_ticker')
def test_node_with_various_queries(self, mock_extract_ticker, mock_create_agent):
    """Test node behavior with various query types"""
    # Setup test cases
    test_cases = [
        {
            "query": "Analyze AAPL financials",
            "extracted_ticker": "AAPL",
            "result_ticker": "AAPL",
            "success": True
        },
        {
            "query": "What's Apple's financial status?",
            "extracted_ticker": "AAPL",  # Extracted from mapping dictionary
            "result_ticker": "AAPL",
            "success": True
        },
        {
            "query": "Analyze an AI company",
            "extracted_ticker": "NVDA",  # Inferred by LLM
            "result_ticker": "NVDA",
            "success": True
        },
        {
            "query": "Analyze the stock market",
            "extracted_ticker": None,  # Extraction failed
            "result_ticker": "unknown",
            "success": False
        }
    ]
    
    for case in test_cases:
        # Mock ticker extraction
        mock_extract_ticker.return_value = case["extracted_ticker"]
        
        # Mock agent response
        mock_agent = MagicMock()
        if case["success"]:
            result_content = f"Financial Statement Analysis for Company (Ticker: {case['result_ticker']})\n\nThe company shows..."
        else:
            result_content = "I need more information. Please provide a specific ticker symbol for analysis."
            
        mock_agent.invoke.return_value = {
            "messages": [
                MagicMock(content=result_content)
            ]
        }
        mock_create_agent.return_value = mock_agent
        
        # State object for node execution
        state = {
            "llm": MagicMock(),
            "messages": [MagicMock(content=case["query"])]
        }
        
        # Execute node
        self.node.agent = mock_agent
        result = self.node._run(state)
        
        # Verify results
        if case["success"]:
            self.assertEqual(result.update["financial_analysis"]["ticker"], case["result_ticker"])
        else:
            self.assertEqual(result.update["financial_analysis"]["ticker"], "unknown")

5. 테스트의 주요 구성 요소 및 패턴

위 테스트 코드들에서 사용된 주요 테스트 패턴과 구성 요소를 정리해보겠습니다:

1. 모킹(Mocking)을 활용한 외부 의존성 제거

  • @patch 데코레이터를 사용해 LLM, API 호출 등 외부 의존성을 모킹
  • 실제 API를 호출하지 않고도 다양한 시나리오 테스트 가능
  • 테스트의 일관성과 속도 향상

2. 테이블 기반 테스트(Table-Driven Tests)

  • 여러 테스트 케이스를 리스트로 정의하고 반복적으로 검증
  • 테스트 케이스 추가가 용이하고 가독성이 높음
  • 다양한 엣지 케이스를 효율적으로 테스트 가능

3. 단계적 검증(Step-by-Step Verification)

  • 입력부터 출력까지 각 단계별로 검증
  • 티커 추출 → 재무 분석 → 결과 처리의 전체 흐름 테스트
  • 함수 호출 여부, 파라미터, 반환값 등 세부적인 검증

4. 실패 케이스 테스트(Failure Case Testing)

  • 티커가 없는 경우, LLM이 추론에 실패하는 경우 등 오류 시나리오 테스트
  • 예외 처리와 기본값 반환이 올바르게 이루어지는지 확인
  • 애플리케이션의 안정성 보장

6. 테스트로부터 얻는 인사이트

이러한 포괄적인 테스트는 단순히 버그를 찾는 것을 넘어 다양한 인사이트를 제공합니다:

  1. 코드 품질 향상: 테스트를 작성하면서 코드 설계가 개선되고 리팩토링이 용이해집니다.
  2. 엣지 케이스 발견: 다양한 테스트 케이스를 통해 예상치 못한 문제 상황을 발견할 수 있습니다.
  3. 문서화 효과: 테스트 코드는 기능의 예상 동작을 문서화하는 역할도 합니다.
  4. 신뢰성 향상: 충분한 테스트 커버리지는 코드에 대한 신뢰성을 높여줍니다.
  5. LLM 활용의 이해: 테스트를 통해 LLM 활용의 최적 방법론을 탐색할 수 있습니다.

결론: 효과적인 테스트 전략의 중요성

LLM을 활용한 티커 추출 기능과 같은 복잡한 시스템을 테스트하려면 잘 설계된 테스트 전략이 필수적입니다. 단위 테스트부터 통합 테스트까지, 다양한 시나리오를 포괄하는 테스트 코드를 작성함으로써 애플리케이션의 품질과 신뢰성을 크게 향상시킬 수 있습니다.

특히 LLM과 같은 확률적 모델을 활용하는 기능은 일관된 결과를 보장하기 어렵기 때문에, 모킹을 통한 체계적인 테스트가 더욱 중요합니다. 이 글에서 소개한 테스트 패턴들은 금융 애플리케이션뿐만 아니라 다양한 AI 기반 애플리케이션의 테스트에도 적용할 수 있을 것입니다.

profile
터널을 지나고 있을 뿐, 길은 여전히 열려 있다.

0개의 댓글

관련 채용 정보