feat: implement timezone-based global market auto-selection
Some checks failed
CI / test (pull_request) Has been cancelled
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:
187
src/main.py
187
src/main.py
@@ -10,66 +10,93 @@ import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from src.brain.gemini_client import GeminiClient
|
||||
from src.broker.kis_api import KISBroker
|
||||
from src.broker.overseas import OverseasBroker
|
||||
from src.config import Settings
|
||||
from src.core.risk_manager import CircuitBreakerTripped, RiskManager
|
||||
from src.db import init_db, log_trade
|
||||
from src.logging_config import setup_logging
|
||||
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Target stock codes to monitor
|
||||
WATCHLIST = ["005930", "000660", "035420"] # Samsung, SK Hynix, NAVER
|
||||
# Target stock codes to monitor per market
|
||||
WATCHLISTS = {
|
||||
"KR": ["005930", "000660", "035420"], # Samsung, SK Hynix, NAVER
|
||||
"US_NASDAQ": ["AAPL", "MSFT", "GOOGL"], # Example US stocks
|
||||
"US_NYSE": ["JPM", "BAC"], # Example NYSE stocks
|
||||
"JP": ["7203", "6758"], # Toyota, Sony
|
||||
}
|
||||
|
||||
TRADE_INTERVAL_SECONDS = 60
|
||||
MAX_CONNECTION_RETRIES = 3
|
||||
|
||||
|
||||
async def trading_cycle(
|
||||
broker: KISBroker,
|
||||
overseas_broker: OverseasBroker,
|
||||
brain: GeminiClient,
|
||||
risk: RiskManager,
|
||||
db_conn: Any,
|
||||
market: MarketInfo,
|
||||
stock_code: str,
|
||||
) -> None:
|
||||
"""Execute one trading cycle for a single stock."""
|
||||
# 1. Fetch market data
|
||||
orderbook = await broker.get_orderbook(stock_code)
|
||||
balance_data = await broker.get_balance()
|
||||
if market.is_domestic:
|
||||
orderbook = await broker.get_orderbook(stock_code)
|
||||
balance_data = await broker.get_balance()
|
||||
|
||||
output2 = balance_data.get("output2", [{}])
|
||||
total_eval = float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
|
||||
total_cash = float(
|
||||
balance_data.get("output2", [{}])[0].get("dnca_tot_amt", "0")
|
||||
if output2
|
||||
else "0"
|
||||
)
|
||||
purchase_total = float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0
|
||||
output2 = balance_data.get("output2", [{}])
|
||||
total_eval = float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
|
||||
total_cash = float(
|
||||
balance_data.get("output2", [{}])[0].get("dnca_tot_amt", "0")
|
||||
if output2
|
||||
else "0"
|
||||
)
|
||||
purchase_total = float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0
|
||||
|
||||
current_price = float(orderbook.get("output1", {}).get("stck_prpr", "0"))
|
||||
foreigner_net = float(orderbook.get("output1", {}).get("frgn_ntby_qty", "0"))
|
||||
else:
|
||||
# Overseas market
|
||||
price_data = await overseas_broker.get_overseas_price(
|
||||
market.exchange_code, stock_code
|
||||
)
|
||||
balance_data = await overseas_broker.get_overseas_balance(market.exchange_code)
|
||||
|
||||
output2 = balance_data.get("output2", [{}])
|
||||
total_eval = float(output2[0].get("frcr_evlu_tota", "0")) if output2 else 0
|
||||
total_cash = float(output2[0].get("frcr_dncl_amt_2", "0")) if output2 else 0
|
||||
purchase_total = float(output2[0].get("frcr_buy_amt_smtl", "0")) if output2 else 0
|
||||
|
||||
current_price = float(price_data.get("output", {}).get("last", "0"))
|
||||
foreigner_net = 0.0 # Not available for overseas
|
||||
|
||||
# Calculate daily P&L %
|
||||
pnl_pct = ((total_eval - purchase_total) / purchase_total * 100) if purchase_total > 0 else 0.0
|
||||
|
||||
current_price = float(
|
||||
orderbook.get("output1", {}).get("stck_prpr", "0")
|
||||
pnl_pct = (
|
||||
((total_eval - purchase_total) / purchase_total * 100)
|
||||
if purchase_total > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
market_data = {
|
||||
"stock_code": stock_code,
|
||||
"market_name": market.name,
|
||||
"current_price": current_price,
|
||||
"orderbook": orderbook.get("output1", {}),
|
||||
"foreigner_net": float(
|
||||
orderbook.get("output1", {}).get("frgn_ntby_qty", "0")
|
||||
),
|
||||
"foreigner_net": foreigner_net,
|
||||
}
|
||||
|
||||
# 2. Ask the brain for a decision
|
||||
decision = await brain.decide(market_data)
|
||||
logger.info(
|
||||
"Decision for %s: %s (confidence=%d)",
|
||||
"Decision for %s (%s): %s (confidence=%d)",
|
||||
stock_code,
|
||||
market.name,
|
||||
decision.action,
|
||||
decision.confidence,
|
||||
)
|
||||
@@ -88,12 +115,21 @@ async def trading_cycle(
|
||||
)
|
||||
|
||||
# 5. Send order
|
||||
result = await broker.send_order(
|
||||
stock_code=stock_code,
|
||||
order_type=decision.action,
|
||||
quantity=quantity,
|
||||
price=0, # market order
|
||||
)
|
||||
if market.is_domestic:
|
||||
result = await broker.send_order(
|
||||
stock_code=stock_code,
|
||||
order_type=decision.action,
|
||||
quantity=quantity,
|
||||
price=0, # market order
|
||||
)
|
||||
else:
|
||||
result = await overseas_broker.send_overseas_order(
|
||||
exchange_code=market.exchange_code,
|
||||
stock_code=stock_code,
|
||||
order_type=decision.action,
|
||||
quantity=quantity,
|
||||
price=0.0, # market order
|
||||
)
|
||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||
|
||||
# 6. Log trade
|
||||
@@ -103,12 +139,15 @@ async def trading_cycle(
|
||||
action=decision.action,
|
||||
confidence=decision.confidence,
|
||||
rationale=decision.rationale,
|
||||
market=market.code,
|
||||
exchange_code=market.exchange_code,
|
||||
)
|
||||
|
||||
|
||||
async def run(settings: Settings) -> None:
|
||||
"""Main async loop — iterate over watchlist on a timer."""
|
||||
"""Main async loop — iterate over open markets on a timer."""
|
||||
broker = KISBroker(settings)
|
||||
overseas_broker = OverseasBroker(broker)
|
||||
brain = GeminiClient(settings)
|
||||
risk = RiskManager(settings)
|
||||
db_conn = init_db(settings.DB_PATH)
|
||||
@@ -124,27 +163,93 @@ async def run(settings: Settings) -> None:
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
|
||||
logger.info("The Ouroboros is alive. Mode: %s", settings.MODE)
|
||||
logger.info("Watchlist: %s", WATCHLIST)
|
||||
logger.info("Enabled markets: %s", settings.enabled_market_list)
|
||||
|
||||
try:
|
||||
while not shutdown.is_set():
|
||||
for code in WATCHLIST:
|
||||
# Get currently open markets
|
||||
open_markets = get_open_markets(settings.enabled_market_list)
|
||||
|
||||
if not open_markets:
|
||||
# No markets open — wait until next market opens
|
||||
try:
|
||||
next_market, next_open_time = get_next_market_open(
|
||||
settings.enabled_market_list
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
wait_seconds = (next_open_time - now).total_seconds()
|
||||
logger.info(
|
||||
"No markets open. Next market: %s, opens in %.1f hours",
|
||||
next_market.name,
|
||||
wait_seconds / 3600,
|
||||
)
|
||||
await asyncio.wait_for(shutdown.wait(), timeout=wait_seconds)
|
||||
except TimeoutError:
|
||||
continue # Market should be open now
|
||||
except ValueError as exc:
|
||||
logger.error("Failed to find next market open: %s", exc)
|
||||
await asyncio.sleep(TRADE_INTERVAL_SECONDS)
|
||||
continue
|
||||
|
||||
# Process each open market
|
||||
for market in open_markets:
|
||||
if shutdown.is_set():
|
||||
break
|
||||
try:
|
||||
await trading_cycle(broker, brain, risk, db_conn, code)
|
||||
except CircuitBreakerTripped:
|
||||
logger.critical("Circuit breaker tripped — shutting down")
|
||||
raise
|
||||
except ConnectionError as exc:
|
||||
logger.error("Connection error for %s: %s", code, exc)
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error for %s: %s", code, exc)
|
||||
|
||||
# Get watchlist for this market
|
||||
watchlist = WATCHLISTS.get(market.code, [])
|
||||
if not watchlist:
|
||||
logger.debug("No watchlist for market %s", market.code)
|
||||
continue
|
||||
|
||||
logger.info("Processing market: %s (%d stocks)", market.name, len(watchlist))
|
||||
|
||||
# Process each stock in the watchlist
|
||||
for stock_code in watchlist:
|
||||
if shutdown.is_set():
|
||||
break
|
||||
|
||||
# Retry logic for connection errors
|
||||
for attempt in range(1, MAX_CONNECTION_RETRIES + 1):
|
||||
try:
|
||||
await trading_cycle(
|
||||
broker,
|
||||
overseas_broker,
|
||||
brain,
|
||||
risk,
|
||||
db_conn,
|
||||
market,
|
||||
stock_code,
|
||||
)
|
||||
break # Success — exit retry loop
|
||||
except CircuitBreakerTripped:
|
||||
logger.critical("Circuit breaker tripped — shutting down")
|
||||
raise
|
||||
except ConnectionError as exc:
|
||||
if attempt < MAX_CONNECTION_RETRIES:
|
||||
logger.warning(
|
||||
"Connection error for %s (attempt %d/%d): %s",
|
||||
stock_code,
|
||||
attempt,
|
||||
MAX_CONNECTION_RETRIES,
|
||||
exc,
|
||||
)
|
||||
await asyncio.sleep(2**attempt) # Exponential backoff
|
||||
else:
|
||||
logger.error(
|
||||
"Connection error for %s (all retries exhausted): %s",
|
||||
stock_code,
|
||||
exc,
|
||||
)
|
||||
break # Give up on this stock
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error for %s: %s", stock_code, exc)
|
||||
break # Don't retry on unexpected errors
|
||||
|
||||
# Wait for next cycle or shutdown
|
||||
try:
|
||||
await asyncio.wait_for(shutdown.wait(), timeout=TRADE_INTERVAL_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
pass # Normal — timeout means it's time for next cycle
|
||||
finally:
|
||||
await broker.close()
|
||||
|
||||
Reference in New Issue
Block a user