12 Commits

Author SHA1 Message Date
815fb89585 Merge pull request 'feat: integrate 398-400-401 for main merge REQ-OPS-001 TASK-OPS-003 TEST-ACC-009' (#410) from feature/398-400-401 into main
All checks were successful
Gitea CI / test (push) Successful in 35s
Reviewed-on: #410
Reviewed-by: jihoson <kiparang7th@gmail.com>
2026-03-04 09:56:01 +09:00
agentson
79e51b8ece ci: retrigger PR #410 after governance traceability update
All checks were successful
Gitea CI / test (pull_request) Successful in 35s
Gitea CI / test (push) Successful in 36s
2026-03-04 09:53:53 +09:00
c29e5125bc Merge pull request 'docs: KIS API 공식 문서 복원 (docs/ 위치)' (#408) from fix/restore-kis-api-docs into feature/398-400-401
Some checks failed
Gitea CI / test (push) Successful in 36s
Gitea CI / test (pull_request) Failing after 5s
Reviewed-on: #408
2026-03-04 09:13:14 +09:00
agentson
4afae017a2 ci: retrigger for PR #408 body update
All checks were successful
Gitea CI / test (pull_request) Successful in 35s
2026-03-04 09:12:24 +09:00
agentson
650d464da5 docs: restore KIS API reference document to docs/
Some checks failed
Gitea CI / test (pull_request) Failing after 6s
한국투자증권_오픈API_전체문서_20260221_030000.xlsx was accidentally
removed during branch merge. Moved from project root to docs/ where
commands.md and MEMORY.md expect it to be.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:08:13 +09:00
b1f5f3e888 Merge pull request 'feat: #401 parallel multi-market processing [REQ-OPS-003] [TASK-OPS-003] [TEST-OPS-003]' (#407) from fix/401 into feature/398-400-401
All checks were successful
Gitea CI / test (push) Successful in 36s
2026-03-04 03:13:16 +09:00
agentson
ec5d656fdf feat: process active markets in parallel with fail-fast semantics (#401)
All checks were successful
Gitea CI / test (pull_request) Successful in 36s
2026-03-04 03:12:23 +09:00
bacb0d2037 Merge pull request 'fix: #400 US session transition handling [REQ-OPS-002] [TASK-OPS-002] [TEST-OPS-002]' (#406) from fix/400 into feature/398-400-401
All checks were successful
Gitea CI / test (push) Successful in 35s
2026-03-04 03:09:36 +09:00
agentson
a67a9aa41f style: sort imports for main tests
All checks were successful
Gitea CI / test (pull_request) Successful in 35s
2026-03-04 03:08:47 +09:00
agentson
47a87fa2f6 chore: retrigger CI for PR #406
Some checks failed
Gitea CI / test (pull_request) Failing after 6s
2026-03-04 03:07:43 +09:00
agentson
2e3aed5664 fix: handle US session transitions and suppress US_DAY trading (#400)
Some checks failed
Gitea CI / test (pull_request) Failing after 5s
2026-03-04 03:07:14 +09:00
f245f55a32 Merge pull request 'fix: #398 KR rt_cd rejection handling [REQ-OPS-001] [TASK-OPS-001] [TEST-OPS-001]' (#405) from fix/398 into feature/398-400-401
All checks were successful
Gitea CI / test (push) Successful in 35s
2026-03-04 03:04:42 +09:00
5 changed files with 145 additions and 38 deletions

View File

@@ -12,6 +12,7 @@ import json
import logging import logging
import signal import signal
import threading import threading
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -3550,6 +3551,47 @@ 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_markets_in_parallel(
markets: list[Any], processor: Callable[[Any], Awaitable[None]]
) -> None:
"""Run market processors in parallel and fail fast on the first exception."""
if not markets:
return
tasks = [asyncio.create_task(processor(market)) for market in markets]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
first_exc: BaseException | None = None
for task in done:
exc = task.exception()
if exc is not None and first_exc is None:
first_exc = exc
if first_exc is not None:
for task in pending:
task.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
raise first_exc
if pending:
await asyncio.gather(*pending)
async def _run_evolution_loop( async def _run_evolution_loop(
evolution_optimizer: EvolutionOptimizer, evolution_optimizer: EvolutionOptimizer,
telegram: TelegramClient, telegram: TelegramClient,
@@ -4063,7 +4105,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 +4223,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 +4241,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)
@@ -4224,10 +4266,9 @@ async def run(settings: Settings) -> None:
await asyncio.sleep(TRADE_INTERVAL_SECONDS) await asyncio.sleep(TRADE_INTERVAL_SECONDS)
continue continue
# Process each open market async def _process_realtime_market(market: MarketInfo) -> None:
for market in open_markets:
if shutdown.is_set(): if shutdown.is_set():
break return
session_info = get_session_info(market) session_info = get_session_info(market)
_session_risk_overrides(market=market, settings=settings) _session_risk_overrides(market=market, settings=settings)
@@ -4245,13 +4286,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 +4327,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)
@@ -4308,12 +4357,9 @@ async def run(settings: Settings) -> None:
) )
if candidates: if candidates:
# Use scanner results directly as trading candidates
active_stocks[market.code] = smart_scanner.get_stock_codes( active_stocks[market.code] = smart_scanner.get_stock_codes(
candidates candidates
) )
# Store candidates per market for selection context logging
scan_candidates[market.code] = {c.stock_code: c for c in candidates} scan_candidates[market.code] = {c.stock_code: c for c in candidates}
logger.info( logger.info(
@@ -4323,12 +4369,8 @@ async def run(settings: Settings) -> None:
[f"{c.stock_code}(RSI={c.rsi:.0f})" for c in candidates], [f"{c.stock_code}(RSI={c.rsi:.0f})" for c in candidates],
) )
# Get market-local date for playbook keying
market_today = datetime.now(market.timezone).date() market_today = datetime.now(market.timezone).date()
# Load or generate playbook (1 Gemini call per market per day)
if market.code not in playbooks: if market.code not in playbooks:
# Try DB first (survives process restart)
stored_pb = playbook_store.load(market_today, market.code) stored_pb = playbook_store.load(market_today, market.code)
if stored_pb is not None: if stored_pb is not None:
playbooks[market.code] = stored_pb playbooks[market.code] = stored_pb
@@ -4388,12 +4430,6 @@ async def run(settings: Settings) -> None:
except Exception as exc: except Exception as exc:
logger.error("Smart Scanner failed for %s: %s", market.name, exc) logger.error("Smart Scanner failed for %s: %s", market.name, exc)
# Get active stocks from scanner (dynamic, no static fallback).
# Also include currently-held positions so stop-loss /
# take-profit can fire even when a holding drops off the
# scanner. Broker balance is the source of truth here —
# unlike the local DB it reflects actual fills and any
# manual trades done outside the bot.
scanner_codes = active_stocks.get(market.code, []) scanner_codes = active_stocks.get(market.code, [])
try: try:
if market.is_domestic: if market.is_domestic:
@@ -4424,16 +4460,14 @@ async def run(settings: Settings) -> None:
if not stock_codes: if not stock_codes:
logger.debug("No active stocks for market %s", market.code) logger.debug("No active stocks for market %s", market.code)
continue return
logger.info("Processing market: %s (%d stocks)", market.name, len(stock_codes)) logger.info("Processing market: %s (%d stocks)", market.name, len(stock_codes))
# Process each stock from scanner results
for stock_code in stock_codes: for stock_code in stock_codes:
if shutdown.is_set(): if shutdown.is_set():
break break
# Get playbook for this market
market_playbook = playbooks.get( market_playbook = playbooks.get(
market.code, market.code,
PreMarketPlanner._empty_playbook( PreMarketPlanner._empty_playbook(
@@ -4441,7 +4475,6 @@ async def run(settings: Settings) -> None:
), ),
) )
# Retry logic for connection errors
for attempt in range(1, MAX_CONNECTION_RETRIES + 1): for attempt in range(1, MAX_CONNECTION_RETRIES + 1):
try: try:
await trading_cycle( await trading_cycle(
@@ -4461,7 +4494,7 @@ async def run(settings: Settings) -> None:
settings, settings,
buy_cooldown, buy_cooldown,
) )
break # Success — exit retry loop break
except CircuitBreakerTripped as exc: except CircuitBreakerTripped as exc:
logger.critical("Circuit breaker tripped — shutting down") logger.critical("Circuit breaker tripped — shutting down")
try: try:
@@ -4483,17 +4516,19 @@ async def run(settings: Settings) -> None:
MAX_CONNECTION_RETRIES, MAX_CONNECTION_RETRIES,
exc, exc,
) )
await asyncio.sleep(2**attempt) # Exponential backoff await asyncio.sleep(2**attempt)
else: else:
logger.error( logger.error(
"Connection error for %s (all retries exhausted): %s", "Connection error for %s (all retries exhausted): %s",
stock_code, stock_code,
exc, exc,
) )
break # Give up on this stock break
except Exception as exc: except Exception as exc:
logger.exception("Unexpected error for %s: %s", stock_code, exc) logger.exception("Unexpected error for %s: %s", stock_code, exc)
break # Don't retry on unexpected errors break
await _run_markets_in_parallel(open_markets, _process_realtime_market)
# Log priority queue metrics periodically # Log priority queue metrics periodically
metrics = await priority_queue.get_metrics() metrics = await priority_queue.get_metrics()

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

@@ -1,5 +1,6 @@
"""Tests for main trading loop integration.""" """Tests for main trading loop integration."""
import asyncio
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from typing import Any from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, patch from unittest.mock import ANY, AsyncMock, MagicMock, patch
@@ -34,6 +35,7 @@ from src.main import (
_extract_held_codes_from_balance, _extract_held_codes_from_balance,
_extract_held_qty_from_balance, _extract_held_qty_from_balance,
_handle_market_close, _handle_market_close,
_has_market_session_transition,
_inject_staged_exit_features, _inject_staged_exit_features,
_maybe_queue_order_intent, _maybe_queue_order_intent,
_resolve_market_setting, _resolve_market_setting,
@@ -41,8 +43,10 @@ from src.main import (
_retry_connection, _retry_connection,
_run_context_scheduler, _run_context_scheduler,
_run_evolution_loop, _run_evolution_loop,
_run_markets_in_parallel,
_should_block_overseas_buy_for_fx_buffer, _should_block_overseas_buy_for_fx_buffer,
_should_force_exit_for_overnight, _should_force_exit_for_overnight,
_should_rescan_market,
_split_trade_pnl_components, _split_trade_pnl_components,
_start_dashboard_server, _start_dashboard_server,
_stoploss_cooldown_minutes, _stoploss_cooldown_minutes,
@@ -140,6 +144,63 @@ 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,
)
class TestMarketParallelRunner:
"""Tests for market-level parallel processing helper."""
@pytest.mark.asyncio
async def test_run_markets_in_parallel_runs_all_markets(self) -> None:
processed: list[str] = []
async def _processor(market: str) -> None:
await asyncio.sleep(0.01)
processed.append(market)
await _run_markets_in_parallel(["KR", "US_NASDAQ", "US_NYSE"], _processor)
assert set(processed) == {"KR", "US_NASDAQ", "US_NYSE"}
@pytest.mark.asyncio
async def test_run_markets_in_parallel_propagates_errors(self) -> None:
async def _processor(market: str) -> None:
if market == "US_NASDAQ":
raise RuntimeError("boom")
await asyncio.sleep(0.01)
with pytest.raises(RuntimeError, match="boom"):
await _run_markets_in_parallel(["KR", "US_NASDAQ"], _processor)
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: