Files
The-Ouroboros/tests/test_brain.py
agentson 62fd4ff5e1 feat: implement data-driven external data integration (issue #22)
Add objective external data sources to enhance trading decisions beyond
market prices and user input.

## New Modules

### src/data/news_api.py
- News sentiment analysis with Alpha Vantage and NewsAPI support
- Sentiment scoring (-1.0 to +1.0) per article and aggregated
- 5-minute caching to minimize API quota usage
- Graceful degradation when APIs unavailable

### src/data/economic_calendar.py
- Track major economic events (FOMC, GDP, CPI)
- Earnings calendar per stock
- Event proximity checking for high-volatility periods
- Hardcoded major events for 2026 (no API required)

### src/data/market_data.py
- Market sentiment indicators (Fear & Greed equivalent)
- Market breadth (advance/decline ratios)
- Sector performance tracking
- Fear/Greed score calculation

## Integration

Enhanced GeminiClient to seamlessly integrate external data:
- Optional news_api, economic_calendar, and market_data parameters
- Async build_prompt() includes external context when available
- Backward-compatible build_prompt_sync() for existing code
- Graceful fallback when external data unavailable

External data automatically added to AI prompts:
- News sentiment with top articles
- Upcoming high-impact economic events
- Market sentiment and breadth indicators

## Configuration

Added optional settings to config.py:
- NEWS_API_KEY: API key for news provider
- NEWS_API_PROVIDER: "alphavantage" or "newsapi"
- MARKET_DATA_API_KEY: API key for market data

## Testing

Comprehensive test suite with 38 tests:
- NewsAPI caching, sentiment parsing, API integration
- EconomicCalendar event filtering, earnings lookup
- MarketData sentiment and breadth calculations
- GeminiClient integration with external data sources
- All tests use mocks (no real API keys required)
- 81% coverage for src/data module (exceeds 80% requirement)

## Circular Import Fix

Fixed circular dependency between gemini_client.py and cache.py:
- Use TYPE_CHECKING for imports in cache.py
- String annotations for TradeDecision type hints

All 195 existing tests pass. No breaking changes to existing functionality.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 18:06:34 +09:00

155 lines
5.8 KiB
Python

"""TDD tests for brain/gemini_client.py — written BEFORE implementation."""
from __future__ import annotations
from src.brain.gemini_client import GeminiClient
# ---------------------------------------------------------------------------
# Response Parsing
# ---------------------------------------------------------------------------
class TestResponseParsing:
"""Gemini responses must be parsed into validated TradeDecision objects."""
def test_valid_buy_response(self, settings):
client = GeminiClient(settings)
raw = '{"action": "BUY", "confidence": 90, "rationale": "Strong momentum"}'
decision = client.parse_response(raw)
assert decision.action == "BUY"
assert decision.confidence == 90
assert decision.rationale == "Strong momentum"
def test_valid_sell_response(self, settings):
client = GeminiClient(settings)
raw = '{"action": "SELL", "confidence": 85, "rationale": "Overbought RSI"}'
decision = client.parse_response(raw)
assert decision.action == "SELL"
def test_valid_hold_response(self, settings):
client = GeminiClient(settings)
raw = '{"action": "HOLD", "confidence": 95, "rationale": "Sideways market"}'
decision = client.parse_response(raw)
assert decision.action == "HOLD"
# ---------------------------------------------------------------------------
# Confidence Threshold Enforcement
# ---------------------------------------------------------------------------
class TestConfidenceThreshold:
"""If confidence < 80, the action MUST be forced to HOLD."""
def test_low_confidence_buy_becomes_hold(self, settings):
client = GeminiClient(settings)
raw = '{"action": "BUY", "confidence": 65, "rationale": "Weak signal"}'
decision = client.parse_response(raw)
assert decision.action == "HOLD"
assert decision.confidence == 65
def test_low_confidence_sell_becomes_hold(self, settings):
client = GeminiClient(settings)
raw = '{"action": "SELL", "confidence": 79, "rationale": "Uncertain"}'
decision = client.parse_response(raw)
assert decision.action == "HOLD"
def test_exactly_threshold_is_allowed(self, settings):
client = GeminiClient(settings)
raw = '{"action": "BUY", "confidence": 80, "rationale": "Just enough"}'
decision = client.parse_response(raw)
assert decision.action == "BUY"
# ---------------------------------------------------------------------------
# Malformed JSON Handling
# ---------------------------------------------------------------------------
class TestMalformedJsonHandling:
"""Gemini may return garbage — the parser must not crash."""
def test_empty_string_returns_hold(self, settings):
client = GeminiClient(settings)
decision = client.parse_response("")
assert decision.action == "HOLD"
assert decision.confidence == 0
def test_plain_text_returns_hold(self, settings):
client = GeminiClient(settings)
decision = client.parse_response("I think you should buy Samsung stock")
assert decision.action == "HOLD"
assert decision.confidence == 0
def test_partial_json_returns_hold(self, settings):
client = GeminiClient(settings)
decision = client.parse_response('{"action": "BUY", "confidence":')
assert decision.action == "HOLD"
assert decision.confidence == 0
def test_json_with_missing_fields_returns_hold(self, settings):
client = GeminiClient(settings)
decision = client.parse_response('{"action": "BUY"}')
assert decision.action == "HOLD"
assert decision.confidence == 0
def test_json_with_invalid_action_returns_hold(self, settings):
client = GeminiClient(settings)
decision = client.parse_response(
'{"action": "YOLO", "confidence": 99, "rationale": "moon"}'
)
assert decision.action == "HOLD"
assert decision.confidence == 0
def test_json_wrapped_in_markdown_code_block(self, settings):
"""Gemini often wraps JSON in ```json ... ``` blocks."""
client = GeminiClient(settings)
raw = '```json\n{"action": "BUY", "confidence": 92, "rationale": "Good"}\n```'
decision = client.parse_response(raw)
assert decision.action == "BUY"
assert decision.confidence == 92
# ---------------------------------------------------------------------------
# Prompt Construction
# ---------------------------------------------------------------------------
class TestPromptConstruction:
"""The prompt sent to Gemini must include all required market data."""
def test_prompt_contains_stock_code(self, settings):
client = GeminiClient(settings)
market_data = {
"stock_code": "005930",
"current_price": 72000,
"orderbook": {"asks": [], "bids": []},
"foreigner_net": -50000,
}
prompt = client.build_prompt_sync(market_data)
assert "005930" in prompt
def test_prompt_contains_price(self, settings):
client = GeminiClient(settings)
market_data = {
"stock_code": "005930",
"current_price": 72000,
"orderbook": {"asks": [], "bids": []},
"foreigner_net": -50000,
}
prompt = client.build_prompt_sync(market_data)
assert "72000" in prompt
def test_prompt_enforces_json_output_format(self, settings):
client = GeminiClient(settings)
market_data = {
"stock_code": "005930",
"current_price": 72000,
"orderbook": {"asks": [], "bids": []},
"foreigner_net": 0,
}
prompt = client.build_prompt_sync(market_data)
assert "JSON" in prompt
assert "action" in prompt
assert "confidence" in prompt