feat: implement timezone-based global market auto-selection
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>
This commit is contained in:
agentson
2026-02-04 09:29:25 +09:00
parent 2e63ac4a29
commit b26ff0c1b8
16 changed files with 877 additions and 79 deletions

1
src/markets/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Global market scheduling and timezone management."""

252
src/markets/schedule.py Normal file
View File

@@ -0,0 +1,252 @@
"""Market schedule management with timezone support."""
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
@dataclass(frozen=True)
class MarketInfo:
"""Information about a trading market."""
code: str # Market code for internal use (e.g., "KR", "US_NASDAQ")
exchange_code: str # KIS API exchange code (e.g., "NASD", "NYSE")
name: str # Human-readable name
timezone: ZoneInfo # Market timezone
open_time: time # Market open time in local timezone
close_time: time # Market close time in local timezone
is_domestic: bool # True for Korean market, False for overseas
lunch_break: tuple[time, time] | None = None # (start, end) or None
# 10 global markets with their schedules
MARKETS: dict[str, MarketInfo] = {
"KR": MarketInfo(
code="KR",
exchange_code="KRX",
name="Korea Exchange",
timezone=ZoneInfo("Asia/Seoul"),
open_time=time(9, 0),
close_time=time(15, 30),
is_domestic=True,
lunch_break=None, # KRX removed lunch break
),
"US_NASDAQ": MarketInfo(
code="US_NASDAQ",
exchange_code="NASD",
name="NASDAQ",
timezone=ZoneInfo("America/New_York"),
open_time=time(9, 30),
close_time=time(16, 0),
is_domestic=False,
lunch_break=None,
),
"US_NYSE": MarketInfo(
code="US_NYSE",
exchange_code="NYSE",
name="New York Stock Exchange",
timezone=ZoneInfo("America/New_York"),
open_time=time(9, 30),
close_time=time(16, 0),
is_domestic=False,
lunch_break=None,
),
"US_AMEX": MarketInfo(
code="US_AMEX",
exchange_code="AMEX",
name="NYSE American",
timezone=ZoneInfo("America/New_York"),
open_time=time(9, 30),
close_time=time(16, 0),
is_domestic=False,
lunch_break=None,
),
"JP": MarketInfo(
code="JP",
exchange_code="TSE",
name="Tokyo Stock Exchange",
timezone=ZoneInfo("Asia/Tokyo"),
open_time=time(9, 0),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(12, 30)),
),
"HK": MarketInfo(
code="HK",
exchange_code="SEHK",
name="Hong Kong Stock Exchange",
timezone=ZoneInfo("Asia/Hong_Kong"),
open_time=time(9, 30),
close_time=time(16, 0),
is_domestic=False,
lunch_break=(time(12, 0), time(13, 0)),
),
"CN_SHA": MarketInfo(
code="CN_SHA",
exchange_code="SHAA",
name="Shanghai Stock Exchange",
timezone=ZoneInfo("Asia/Shanghai"),
open_time=time(9, 30),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(13, 0)),
),
"CN_SZA": MarketInfo(
code="CN_SZA",
exchange_code="SZAA",
name="Shenzhen Stock Exchange",
timezone=ZoneInfo("Asia/Shanghai"),
open_time=time(9, 30),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(13, 0)),
),
"VN_HAN": MarketInfo(
code="VN_HAN",
exchange_code="HNX",
name="Hanoi Stock Exchange",
timezone=ZoneInfo("Asia/Ho_Chi_Minh"),
open_time=time(9, 0),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(13, 0)),
),
"VN_HCM": MarketInfo(
code="VN_HCM",
exchange_code="HSX",
name="Ho Chi Minh Stock Exchange",
timezone=ZoneInfo("Asia/Ho_Chi_Minh"),
open_time=time(9, 0),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(13, 0)),
),
}
def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
"""
Check if a market is currently open for trading.
Args:
market: Market information
now: Current time (defaults to datetime.now(UTC) for testing)
Returns:
True if market is open, False otherwise
Note:
Does not account for holidays (KIS API will reject orders on holidays)
"""
if now is None:
now = datetime.now(ZoneInfo("UTC"))
# Convert to market's local timezone
local_now = now.astimezone(market.timezone)
# Check if it's a weekend
if local_now.weekday() >= 5: # Saturday=5, Sunday=6
return False
current_time = local_now.time()
# Check if within trading hours
if current_time < market.open_time or current_time >= market.close_time:
return False
# Check lunch break
if market.lunch_break:
lunch_start, lunch_end = market.lunch_break
if lunch_start <= current_time < lunch_end:
return False
return True
def get_open_markets(
enabled_markets: list[str] | None = None, now: datetime | None = None
) -> list[MarketInfo]:
"""
Get list of currently open markets.
Args:
enabled_markets: List of market codes to check (defaults to all markets)
now: Current time (defaults to datetime.now(UTC) for testing)
Returns:
List of open markets, sorted by market code
"""
if enabled_markets is None:
enabled_markets = list(MARKETS.keys())
open_markets = [
MARKETS[code]
for code in enabled_markets
if code in MARKETS and is_market_open(MARKETS[code], now)
]
return sorted(open_markets, key=lambda m: m.code)
def get_next_market_open(
enabled_markets: list[str] | None = None, now: datetime | None = None
) -> tuple[MarketInfo, datetime]:
"""
Find the next market that will open and when.
Args:
enabled_markets: List of market codes to check (defaults to all markets)
now: Current time (defaults to datetime.now(UTC) for testing)
Returns:
Tuple of (market, open_datetime) for the next market to open
Raises:
ValueError: If no enabled markets are configured
"""
if now is None:
now = datetime.now(ZoneInfo("UTC"))
if enabled_markets is None:
enabled_markets = list(MARKETS.keys())
if not enabled_markets:
raise ValueError("No enabled markets configured")
next_open_time: datetime | None = None
next_market: MarketInfo | None = None
for code in enabled_markets:
if code not in MARKETS:
continue
market = MARKETS[code]
market_now = now.astimezone(market.timezone)
# Calculate next open time for this market
for days_ahead in range(7): # Check next 7 days
check_date = market_now.date() + timedelta(days=days_ahead)
check_datetime = datetime.combine(
check_date, market.open_time, tzinfo=market.timezone
)
# Skip weekends
if check_datetime.weekday() >= 5:
continue
# Skip if this open time already passed today
if check_datetime <= market_now:
continue
# Convert to UTC for comparison
check_datetime_utc = check_datetime.astimezone(ZoneInfo("UTC"))
if next_open_time is None or check_datetime_utc < next_open_time:
next_open_time = check_datetime_utc
next_market = market
break
if next_market is None or next_open_time is None:
raise ValueError("Could not find next market open time")
return next_market, next_open_time