diff --git a/src/main.py b/src/main.py index fd80768..cf168e7 100644 --- a/src/main.py +++ b/src/main.py @@ -3408,7 +3408,10 @@ async def run(settings: Settings) -> None: _run_context_scheduler(context_scheduler, now=datetime.now(UTC)) # Get currently open markets - open_markets = get_open_markets(settings.enabled_market_list) + open_markets = get_open_markets( + settings.enabled_market_list, + include_extended_sessions=True, + ) if not open_markets: # Notify market close for any markets that were open @@ -3437,7 +3440,8 @@ async def run(settings: Settings) -> None: # No markets open — wait until next market opens try: next_market, next_open_time = get_next_market_open( - settings.enabled_market_list + settings.enabled_market_list, + include_extended_sessions=True, ) now = datetime.now(UTC) wait_seconds = (next_open_time - now).total_seconds() @@ -3459,6 +3463,14 @@ async def run(settings: Settings) -> None: if shutdown.is_set(): break + session_info = get_session_info(market) + logger.info( + "Market session active: %s (%s) session=%s", + market.code, + market.name, + session_info.session_id, + ) + await process_blackout_recovery_orders( broker=broker, overseas_broker=overseas_broker, diff --git a/src/markets/schedule.py b/src/markets/schedule.py index b7daf22..9d142d9 100644 --- a/src/markets/schedule.py +++ b/src/markets/schedule.py @@ -1,7 +1,7 @@ """Market schedule management with timezone support.""" from dataclasses import dataclass -from datetime import datetime, time, timedelta +from datetime import UTC, datetime, time, timedelta from zoneinfo import ZoneInfo @@ -181,7 +181,10 @@ def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool: def get_open_markets( - enabled_markets: list[str] | None = None, now: datetime | None = None + enabled_markets: list[str] | None = None, + now: datetime | None = None, + *, + include_extended_sessions: bool = False, ) -> list[MarketInfo]: """ Get list of currently open markets. @@ -196,17 +199,31 @@ def get_open_markets( if enabled_markets is None: enabled_markets = list(MARKETS.keys()) + def is_available(market: MarketInfo) -> bool: + if not include_extended_sessions: + return is_market_open(market, now) + if market.code == "KR" or market.code.startswith("US"): + # Import lazily to avoid module cycle at import-time. + from src.core.order_policy import classify_session_id + + session_id = classify_session_id(market, now) + return session_id not in {"KR_OFF", "US_OFF"} + return is_market_open(market, now) + open_markets = [ MARKETS[code] for code in enabled_markets - if code in MARKETS and is_market_open(MARKETS[code], now) + if code in MARKETS and is_available(MARKETS[code]) ] return sorted(open_markets, key=lambda m: m.code) def get_next_market_open( - enabled_markets: list[str] | None = None, now: datetime | None = None + enabled_markets: list[str] | None = None, + now: datetime | None = None, + *, + include_extended_sessions: bool = False, ) -> tuple[MarketInfo, datetime]: """ Find the next market that will open and when. @@ -233,6 +250,21 @@ def get_next_market_open( next_open_time: datetime | None = None next_market: MarketInfo | None = None + def first_extended_open_after(market: MarketInfo, start_utc: datetime) -> datetime | None: + # Search minute-by-minute for KR/US session transition into active window. + # Bounded to 7 days to match existing behavior. + from src.core.order_policy import classify_session_id + + ts = start_utc.astimezone(ZoneInfo("UTC")).replace(second=0, microsecond=0) + prev_active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"} + for _ in range(7 * 24 * 60): + ts = ts + timedelta(minutes=1) + active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"} + if active and not prev_active: + return ts + prev_active = active + return None + for code in enabled_markets: if code not in MARKETS: continue @@ -240,6 +272,13 @@ def get_next_market_open( market = MARKETS[code] market_now = now.astimezone(market.timezone) + if include_extended_sessions and (market.code == "KR" or market.code.startswith("US")): + ext_open = first_extended_open_after(market, now.astimezone(UTC)) + if ext_open and (next_open_time is None or ext_open < next_open_time): + next_open_time = ext_open + next_market = market + continue + # 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) diff --git a/tests/test_market_schedule.py b/tests/test_market_schedule.py index f3a9de7..49110bc 100644 --- a/tests/test_market_schedule.py +++ b/tests/test_market_schedule.py @@ -147,6 +147,24 @@ class TestGetOpenMarkets: codes = [m.code for m in open_markets] assert codes == sorted(codes) + def test_get_open_markets_us_pre_extended_session(self) -> None: + """US premarket should be considered open when extended sessions enabled.""" + # Monday 2026-02-02 08:30 EST = 13:30 UTC (premarket window) + test_time = datetime(2026, 2, 2, 13, 30, tzinfo=ZoneInfo("UTC")) + + regular = get_open_markets( + enabled_markets=["US_NASDAQ", "US_NYSE", "US_AMEX"], + now=test_time, + ) + assert regular == [] + + extended = get_open_markets( + enabled_markets=["US_NASDAQ", "US_NYSE", "US_AMEX"], + now=test_time, + include_extended_sessions=True, + ) + assert {m.code for m in extended} == {"US_NASDAQ", "US_NYSE", "US_AMEX"} + class TestGetNextMarketOpen: """Test get_next_market_open function.""" @@ -201,6 +219,20 @@ class TestGetNextMarketOpen: ) assert market.code == "KR" + def test_get_next_market_open_prefers_extended_session(self) -> None: + """Extended lookup should return premarket open time before regular open.""" + # Monday 2026-02-02 07:00 EST = 12:00 UTC + # By v3 KST session rules, US is OFF only in KST 07:00-10:00 (UTC 22:00-01:00). + # At 12:00 UTC market is active, so next OFF->ON transition is 01:00 UTC next day. + test_time = datetime(2026, 2, 2, 12, 0, tzinfo=ZoneInfo("UTC")) + market, next_open = get_next_market_open( + enabled_markets=["US_NASDAQ"], + now=test_time, + include_extended_sessions=True, + ) + assert market.code == "US_NASDAQ" + assert next_open == datetime(2026, 2, 3, 1, 0, tzinfo=ZoneInfo("UTC")) + class TestExpandMarketCodes: """Test shorthand market expansion."""