fix: #400 US session transition handling [REQ-OPS-002] [TASK-OPS-002] [TEST-OPS-002] #406

Merged
agentson merged 3 commits from fix/400 into feature/398-400-401 2026-03-04 03:09:36 +09:00
4 changed files with 81 additions and 14 deletions
Showing only changes of commit 2e3aed5664 - Show all commits

View File

@@ -3550,6 +3550,20 @@ def _run_context_scheduler(
) )
def _has_market_session_transition(
market_states: dict[str, str], market_code: str, session_id: str
) -> bool:
"""Return True when market session changed (or market has no prior state)."""
return market_states.get(market_code) != session_id
def _should_rescan_market(
*, last_scan: float, now_timestamp: float, rescan_interval: float, session_changed: bool
) -> bool:
"""Force rescan on session transition; otherwise follow interval cadence."""
return session_changed or (now_timestamp - last_scan >= rescan_interval)
async def _run_evolution_loop( async def _run_evolution_loop(
evolution_optimizer: EvolutionOptimizer, evolution_optimizer: EvolutionOptimizer,
telegram: TelegramClient, telegram: TelegramClient,
@@ -4063,7 +4077,7 @@ async def run(settings: Settings) -> None:
last_scan_time: dict[str, float] = {} last_scan_time: dict[str, float] = {}
# Track market open/close state for notifications # Track market open/close state for notifications
_market_states: dict[str, bool] = {} # market_code -> is_open _market_states: dict[str, str] = {} # market_code -> session_id
# Trading control events # Trading control events
shutdown = asyncio.Event() shutdown = asyncio.Event()
@@ -4181,8 +4195,8 @@ async def run(settings: Settings) -> None:
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
for market_code, is_open in list(_market_states.items()): for market_code, session_id in list(_market_states.items()):
if is_open: if session_id:
try: try:
from src.markets.schedule import MARKETS from src.markets.schedule import MARKETS
@@ -4199,7 +4213,7 @@ async def run(settings: Settings) -> None:
) )
except Exception as exc: except Exception as exc:
logger.warning("Market close notification failed: %s", exc) logger.warning("Market close notification failed: %s", exc)
_market_states[market_code] = False _market_states.pop(market_code, None)
# Clear playbook for closed market (new one generated next open) # Clear playbook for closed market (new one generated next open)
playbooks.pop(market_code, None) playbooks.pop(market_code, None)
@@ -4245,13 +4259,16 @@ async def run(settings: Settings) -> None:
settings=settings, settings=settings,
) )
# Notify market open if it just opened # Notify on market/session transition (e.g., US_PRE -> US_REG)
if not _market_states.get(market.code, False): session_changed = _has_market_session_transition(
_market_states, market.code, session_info.session_id
)
if session_changed:
try: try:
await telegram.notify_market_open(market.name) await telegram.notify_market_open(market.name)
except Exception as exc: except Exception as exc:
logger.warning("Market open notification failed: %s", exc) logger.warning("Market open notification failed: %s", exc)
_market_states[market.code] = True _market_states[market.code] = session_info.session_id
# Check and handle domestic pending (unfilled) limit orders. # Check and handle domestic pending (unfilled) limit orders.
if market.is_domestic: if market.is_domestic:
@@ -4283,7 +4300,12 @@ async def run(settings: Settings) -> None:
now_timestamp = asyncio.get_event_loop().time() now_timestamp = asyncio.get_event_loop().time()
last_scan = last_scan_time.get(market.code, 0.0) last_scan = last_scan_time.get(market.code, 0.0)
rescan_interval = settings.RESCAN_INTERVAL_SECONDS rescan_interval = settings.RESCAN_INTERVAL_SECONDS
if now_timestamp - last_scan >= rescan_interval: if _should_rescan_market(
last_scan=last_scan,
now_timestamp=now_timestamp,
rescan_interval=rescan_interval,
session_changed=session_changed,
):
try: try:
logger.info("Smart Scanner: Scanning %s market", market.name) logger.info("Smart Scanner: Scanning %s market", market.name)

View File

@@ -207,7 +207,7 @@ def get_open_markets(
from src.core.order_policy import classify_session_id from src.core.order_policy import classify_session_id
session_id = classify_session_id(market, now) session_id = classify_session_id(market, now)
return session_id not in {"KR_OFF", "US_OFF"} return session_id not in {"KR_OFF", "US_OFF", "US_DAY"}
return is_market_open(market, now) return is_market_open(market, now)
open_markets = [ open_markets = [
@@ -254,10 +254,10 @@ def get_next_market_open(
from src.core.order_policy import classify_session_id from src.core.order_policy import classify_session_id
ts = start_utc.astimezone(ZoneInfo("UTC")).replace(second=0, microsecond=0) ts = start_utc.astimezone(ZoneInfo("UTC")).replace(second=0, microsecond=0)
prev_active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"} prev_active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF", "US_DAY"}
for _ in range(7 * 24 * 60): for _ in range(7 * 24 * 60):
ts = ts + timedelta(minutes=1) ts = ts + timedelta(minutes=1)
active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"} active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF", "US_DAY"}
if active and not prev_active: if active and not prev_active:
return ts return ts
prev_active = active prev_active = active

View File

@@ -33,6 +33,7 @@ from src.main import (
_extract_avg_price_from_balance, _extract_avg_price_from_balance,
_extract_held_codes_from_balance, _extract_held_codes_from_balance,
_extract_held_qty_from_balance, _extract_held_qty_from_balance,
_has_market_session_transition,
_handle_market_close, _handle_market_close,
_inject_staged_exit_features, _inject_staged_exit_features,
_maybe_queue_order_intent, _maybe_queue_order_intent,
@@ -41,6 +42,7 @@ from src.main import (
_retry_connection, _retry_connection,
_run_context_scheduler, _run_context_scheduler,
_run_evolution_loop, _run_evolution_loop,
_should_rescan_market,
_should_block_overseas_buy_for_fx_buffer, _should_block_overseas_buy_for_fx_buffer,
_should_force_exit_for_overnight, _should_force_exit_for_overnight,
_split_trade_pnl_components, _split_trade_pnl_components,
@@ -140,6 +142,38 @@ class TestExtractAvgPriceFromBalance:
result = _extract_avg_price_from_balance(balance, "AAPL", is_domestic=False) result = _extract_avg_price_from_balance(balance, "AAPL", is_domestic=False)
assert result == 170.5 assert result == 170.5
class TestRealtimeSessionStateHelpers:
"""Tests for realtime loop session-transition/rescan helper logic."""
def test_has_market_session_transition_when_state_missing(self) -> None:
states: dict[str, str] = {}
assert _has_market_session_transition(states, "US_NASDAQ", "US_REG")
def test_has_market_session_transition_when_session_changes(self) -> None:
states = {"US_NASDAQ": "US_PRE"}
assert _has_market_session_transition(states, "US_NASDAQ", "US_REG")
def test_has_market_session_transition_false_when_same_session(self) -> None:
states = {"US_NASDAQ": "US_REG"}
assert not _has_market_session_transition(states, "US_NASDAQ", "US_REG")
def test_should_rescan_market_forces_on_session_transition(self) -> None:
assert _should_rescan_market(
last_scan=1000.0,
now_timestamp=1050.0,
rescan_interval=300.0,
session_changed=True,
)
def test_should_rescan_market_uses_interval_without_transition(self) -> None:
assert not _should_rescan_market(
last_scan=1000.0,
now_timestamp=1050.0,
rescan_interval=300.0,
session_changed=False,
)
def test_returns_zero_when_field_absent(self) -> None: def test_returns_zero_when_field_absent(self) -> None:
"""Returns 0.0 when pchs_avg_pric key is missing entirely.""" """Returns 0.0 when pchs_avg_pric key is missing entirely."""
balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}]} balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}]}

View File

@@ -165,6 +165,17 @@ class TestGetOpenMarkets:
) )
assert {m.code for m in extended} == {"US_NASDAQ", "US_NYSE", "US_AMEX"} assert {m.code for m in extended} == {"US_NASDAQ", "US_NYSE", "US_AMEX"}
def test_get_open_markets_excludes_us_day_when_extended_enabled(self) -> None:
"""US_DAY should be treated as non-tradable even in extended-session lookup."""
# Monday 2026-02-02 10:30 KST = 01:30 UTC (US_DAY by session classification)
test_time = datetime(2026, 2, 2, 1, 30, tzinfo=ZoneInfo("UTC"))
extended = get_open_markets(
enabled_markets=["US_NASDAQ", "US_NYSE", "US_AMEX"],
now=test_time,
include_extended_sessions=True,
)
assert extended == []
class TestGetNextMarketOpen: class TestGetNextMarketOpen:
"""Test get_next_market_open function.""" """Test get_next_market_open function."""
@@ -214,8 +225,8 @@ class TestGetNextMarketOpen:
def test_get_next_market_open_prefers_extended_session(self) -> None: def test_get_next_market_open_prefers_extended_session(self) -> None:
"""Extended lookup should return premarket open time before regular open.""" """Extended lookup should return premarket open time before regular open."""
# Monday 2026-02-02 07:00 EST = 12:00 UTC # 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). # US_DAY is treated as non-tradable in extended lookup, so after entering
# At 12:00 UTC market is active, so next OFF->ON transition is 01:00 UTC next day. # US_DAY the next tradable OFF->ON transition is US_PRE at 09:00 UTC next day.
test_time = datetime(2026, 2, 2, 12, 0, tzinfo=ZoneInfo("UTC")) test_time = datetime(2026, 2, 2, 12, 0, tzinfo=ZoneInfo("UTC"))
market, next_open = get_next_market_open( market, next_open = get_next_market_open(
enabled_markets=["US_NASDAQ"], enabled_markets=["US_NASDAQ"],
@@ -223,7 +234,7 @@ class TestGetNextMarketOpen:
include_extended_sessions=True, include_extended_sessions=True,
) )
assert market.code == "US_NASDAQ" assert market.code == "US_NASDAQ"
assert next_open == datetime(2026, 2, 3, 1, 0, tzinfo=ZoneInfo("UTC")) assert next_open == datetime(2026, 2, 3, 9, 0, tzinfo=ZoneInfo("UTC"))
class TestExpandMarketCodes: class TestExpandMarketCodes: