Some checks failed
CI / test (pull_request) Has been cancelled
Implement comprehensive multi-market trading system with automatic market selection based on timezone and trading hours. ## New Features - Market schedule module with 10 global markets (KR, US, JP, HK, CN, VN) - Overseas broker for KIS API international stock trading - Automatic market detection based on current time and timezone - Next market open waiting logic when all markets closed - ConnectionError retry with exponential backoff (max 3 attempts) ## Architecture Changes - Market-aware trading cycle with domestic/overseas broker routing - Market context in AI prompts for better decision making - Database schema extended with market and exchange_code columns - Config setting ENABLED_MARKETS for market selection ## Testing - 19 new tests for market schedule (timezone, DST, lunch breaks) - All 54 tests passing - Lint fixes with ruff ## Files Added - src/markets/schedule.py - Market schedule and timezone logic - src/broker/overseas.py - KIS overseas stock API client - tests/test_market_schedule.py - Market schedule test suite ## Files Modified - src/main.py - Multi-market main loop with retry logic - src/config.py - ENABLED_MARKETS setting - src/db.py - market/exchange_code columns with migration - src/brain/gemini_client.py - Dynamic market context in prompts Resolves #5 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
155 lines
5.8 KiB
Python
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(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(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(market_data)
|
|
assert "JSON" in prompt
|
|
assert "action" in prompt
|
|
assert "confidence" in prompt
|