Merge pull request 'fix: realtime include extended KR/US sessions (#301)' (#303) from feature/issue-301-extended-session-schedule into feature/v3-session-policy-stream

This commit was merged in pull request #303.
This commit is contained in:
2026-02-27 22:30:26 +09:00
3 changed files with 89 additions and 6 deletions

View File

@@ -3408,7 +3408,10 @@ async def run(settings: Settings) -> None:
_run_context_scheduler(context_scheduler, now=datetime.now(UTC)) _run_context_scheduler(context_scheduler, now=datetime.now(UTC))
# Get currently open markets # 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: if not open_markets:
# Notify market close for any markets that were open # 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 # No markets open — wait until next market opens
try: try:
next_market, next_open_time = get_next_market_open( 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) now = datetime.now(UTC)
wait_seconds = (next_open_time - now).total_seconds() wait_seconds = (next_open_time - now).total_seconds()
@@ -3459,6 +3463,14 @@ async def run(settings: Settings) -> None:
if shutdown.is_set(): if shutdown.is_set():
break 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( await process_blackout_recovery_orders(
broker=broker, broker=broker,
overseas_broker=overseas_broker, overseas_broker=overseas_broker,

View File

@@ -1,7 +1,7 @@
"""Market schedule management with timezone support.""" """Market schedule management with timezone support."""
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, time, timedelta from datetime import UTC, datetime, time, timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -181,7 +181,10 @@ def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
def get_open_markets( 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]: ) -> list[MarketInfo]:
""" """
Get list of currently open markets. Get list of currently open markets.
@@ -196,17 +199,31 @@ def get_open_markets(
if enabled_markets is None: if enabled_markets is None:
enabled_markets = list(MARKETS.keys()) 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 = [ open_markets = [
MARKETS[code] MARKETS[code]
for code in enabled_markets 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) return sorted(open_markets, key=lambda m: m.code)
def get_next_market_open( 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]: ) -> tuple[MarketInfo, datetime]:
""" """
Find the next market that will open and when. 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_open_time: datetime | None = None
next_market: MarketInfo | 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: for code in enabled_markets:
if code not in MARKETS: if code not in MARKETS:
continue continue
@@ -240,6 +272,13 @@ def get_next_market_open(
market = MARKETS[code] market = MARKETS[code]
market_now = now.astimezone(market.timezone) 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 # Calculate next open time for this market
for days_ahead in range(7): # Check next 7 days for days_ahead in range(7): # Check next 7 days
check_date = market_now.date() + timedelta(days=days_ahead) check_date = market_now.date() + timedelta(days=days_ahead)

View File

@@ -147,6 +147,24 @@ class TestGetOpenMarkets:
codes = [m.code for m in open_markets] codes = [m.code for m in open_markets]
assert codes == sorted(codes) 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: class TestGetNextMarketOpen:
"""Test get_next_market_open function.""" """Test get_next_market_open function."""
@@ -201,6 +219,20 @@ class TestGetNextMarketOpen:
) )
assert market.code == "KR" 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: class TestExpandMarketCodes:
"""Test shorthand market expansion.""" """Test shorthand market expansion."""