Compare commits

...

6 Commits

Author SHA1 Message Date
agentson
9c5bd254b5 docs: add agent workflow and parallel execution strategy
Some checks failed
CI / test (pull_request) Has been cancelled
- Document modern AI development workflow using specialized agents
- Add guidelines for when to use git worktree/subagents vs main conversation
- Define agent roles: ticket mgmt, design, code, test, docs, review
- Include implementation examples with Task tool
- Update test count (35 → 54) with new market_schedule tests

Closes #9

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 09:47:14 +09:00
5c9261ce5b Merge pull request 'feat: implement timezone-based global market auto-selection' (#7) from feature/issue-5-global-market-auto-selection into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #7
2026-02-04 09:33:39 +09:00
ef4305cfc3 Merge pull request 'docs: add Git workflow policy to CLAUDE.md' (#6) from feature/issue-4-add-git-workflow-policy into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #6
2026-02-04 09:33:16 +09:00
agentson
b26ff0c1b8 feat: implement timezone-based global market auto-selection
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>
2026-02-04 09:29:25 +09:00
agentson
1d46cb31fb docs: add Git workflow policy to CLAUDE.md
Some checks failed
CI / test (pull_request) Has been cancelled
Add comprehensive Git workflow policy section to ensure all code
changes follow proper process: issue creation, feature branch,
PR, and merge. Explicitly prohibit direct pushes to main branch.

Resolves #4

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 08:50:46 +09:00
2e63ac4a29 Merge pull request 'docs: add CLAUDE.md for AI assistant context' (#3) from feature/issue-2-add-claude-md into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #3
2026-02-04 03:29:34 +09:00
17 changed files with 953 additions and 80 deletions

View File

@@ -2,6 +2,80 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Git Workflow Policy
**CRITICAL: All code changes MUST follow this workflow. Direct pushes to `main` are ABSOLUTELY PROHIBITED.**
1. **Create Gitea Issue First** — All features, bug fixes, and policy changes require a Gitea issue before any code is written
2. **Create Feature Branch** — Branch from `main` using format `feature/issue-{N}-{short-description}`
3. **Implement Changes** — Write code, tests, and documentation on the feature branch
4. **Create Pull Request** — Submit PR to `main` branch referencing the issue number
5. **Review & Merge** — After approval, merge via PR (squash or merge commit)
**Never commit directly to `main`.** This policy applies to all changes, no exceptions.
## Agent Workflow
**Modern AI development leverages specialized agents for concurrent, efficient task execution.**
### Parallel Execution Strategy
Use **git worktree** or **subagents** (via the Task tool) to handle multiple requirements simultaneously:
- Each task runs in independent context
- Parallel branches for concurrent features
- Isolated test environments prevent interference
- Faster iteration with distributed workload
### Specialized Agent Roles
Deploy task-specific agents as needed instead of handling everything in the main conversation:
- **Conversational Agent** (main) — Interface with user, coordinate other agents
- **Ticket Management Agent** — Create/update Gitea issues, track task status
- **Design Agent** — Architectural planning, RFC documents, API design
- **Code Writing Agent** — Implementation following specs
- **Testing Agent** — Write tests, verify coverage, run test suites
- **Documentation Agent** — Update docs, docstrings, CLAUDE.md, README
- **Review Agent** — Code review, lint checks, security audits
- **Custom Agents** — Created dynamically for specialized tasks (performance analysis, migration scripts, etc.)
### When to Use Agents
**Prefer spawning specialized agents for:**
1. Complex multi-file changes requiring exploration
2. Tasks with clear, isolated scope (e.g., "write tests for module X")
3. Parallel work streams (feature A + bugfix B simultaneously)
4. Long-running analysis (codebase search, dependency audit)
5. Tasks requiring different contexts (multiple git worktrees)
**Use the main conversation for:**
1. User interaction and clarification
2. Quick single-file edits
3. Coordinating agent work
4. High-level decision making
### Implementation
```python
# Example: Spawn parallel test and documentation agents
task_tool(
subagent_type="general-purpose",
prompt="Write comprehensive tests for src/markets/schedule.py",
description="Write schedule tests"
)
task_tool(
subagent_type="general-purpose",
prompt="Update README.md with global market feature documentation",
description="Update README"
)
```
Use `run_in_background=True` for independent tasks that don't block subsequent work.
## Build & Test Commands ## Build & Test Commands
```bash ```bash
@@ -60,8 +134,9 @@ Pydantic Settings loaded from `.env` (see `.env.example`). Required vars: `KIS_A
## Test Structure ## Test Structure
35 tests across three files. `asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator. The `settings` fixture in `conftest.py` provides safe defaults with test credentials and in-memory DB. 54 tests across four files. `asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator. The `settings` fixture in `conftest.py` provides safe defaults with test credentials and in-memory DB.
- `test_risk.py` (11) — Circuit breaker boundaries, fat-finger edge cases - `test_risk.py` (11) — Circuit breaker boundaries, fat-finger edge cases
- `test_broker.py` (6) — Token lifecycle, rate limiting, hash keys, network errors - `test_broker.py` (6) — Token lifecycle, rate limiting, hash keys, network errors
- `test_brain.py` (18) — JSON parsing, confidence threshold, malformed responses, prompt construction - `test_brain.py` (18) — JSON parsing, confidence threshold, malformed responses, prompt construction
- `test_market_schedule.py` (19) — Market open/close logic, timezone handling, DST, lunch breaks

View File

@@ -49,15 +49,40 @@ class GeminiClient:
The prompt instructs Gemini to return valid JSON with action, The prompt instructs Gemini to return valid JSON with action,
confidence, and rationale fields. confidence, and rationale fields.
""" """
market_name = market_data.get("market_name", "Korean stock market")
# Build market data section dynamically based on available fields
market_info_lines = [
f"Market: {market_name}",
f"Stock Code: {market_data['stock_code']}",
f"Current Price: {market_data['current_price']}",
]
# Add orderbook if available (domestic markets)
if "orderbook" in market_data:
market_info_lines.append(
f"Orderbook: {json.dumps(market_data['orderbook'], ensure_ascii=False)}"
)
# Add foreigner net if non-zero
if market_data.get("foreigner_net", 0) != 0:
market_info_lines.append(
f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}"
)
market_info = "\n".join(market_info_lines)
json_format = (
'{"action": "BUY"|"SELL"|"HOLD", '
'"confidence": <int 0-100>, "rationale": "<string>"}'
)
return ( return (
"You are a professional Korean stock market trading analyst.\n" f"You are a professional {market_name} trading analyst.\n"
"Analyze the following market data and decide whether to BUY, SELL, or HOLD.\n\n" "Analyze the following market data and decide whether to "
f"Stock Code: {market_data['stock_code']}\n" "BUY, SELL, or HOLD.\n\n"
f"Current Price: {market_data['current_price']}\n" f"{market_info}\n\n"
f"Orderbook: {json.dumps(market_data['orderbook'], ensure_ascii=False)}\n"
f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}\n\n"
"You MUST respond with ONLY valid JSON in the following format:\n" "You MUST respond with ONLY valid JSON in the following format:\n"
'{"action": "BUY"|"SELL"|"HOLD", "confidence": <int 0-100>, "rationale": "<string>"}\n\n' f"{json_format}\n\n"
"Rules:\n" "Rules:\n"
"- action must be exactly one of: BUY, SELL, HOLD\n" "- action must be exactly one of: BUY, SELL, HOLD\n"
"- confidence must be an integer from 0 to 100\n" "- confidence must be an integer from 0 to 100\n"

View File

@@ -6,11 +6,8 @@ Handles token refresh, rate limiting (leaky bucket), and hash key generation.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import json
import logging import logging
import ssl import ssl
import time
from typing import Any from typing import Any
import aiohttp import aiohttp
@@ -168,7 +165,7 @@ class KISBroker:
f"get_orderbook failed ({resp.status}): {text}" f"get_orderbook failed ({resp.status}): {text}"
) )
return await resp.json() return await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
async def get_balance(self) -> dict[str, Any]: async def get_balance(self) -> dict[str, Any]:
@@ -200,7 +197,7 @@ class KISBroker:
f"get_balance failed ({resp.status}): {text}" f"get_balance failed ({resp.status}): {text}"
) )
return await resp.json() return await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching balance: {exc}") from exc raise ConnectionError(f"Network error fetching balance: {exc}") from exc
async def send_order( async def send_order(
@@ -253,5 +250,5 @@ class KISBroker:
}, },
) )
return data return data
except (aiohttp.ClientError, asyncio.TimeoutError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error sending order: {exc}") from exc raise ConnectionError(f"Network error sending order: {exc}") from exc

200
src/broker/overseas.py Normal file
View File

@@ -0,0 +1,200 @@
"""KIS Overseas Stock API client."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from src.broker.kis_api import KISBroker
logger = logging.getLogger(__name__)
class OverseasBroker:
"""KIS Overseas Stock API wrapper that reuses KISBroker infrastructure."""
def __init__(self, kis_broker: KISBroker) -> None:
"""
Initialize overseas broker.
Args:
kis_broker: Domestic KIS broker instance to reuse session/token/rate limiter
"""
self._broker = kis_broker
async def get_overseas_price(
self, exchange_code: str, stock_code: str
) -> dict[str, Any]:
"""
Fetch overseas stock price.
Args:
exchange_code: Exchange code (e.g., "NASD", "NYSE", "TSE")
stock_code: Stock ticker symbol
Returns:
API response with price data
Raises:
ConnectionError: On network or API errors
"""
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
headers = await self._broker._auth_headers("HHDFS00000300")
params = {
"AUTH": "",
"EXCD": exchange_code,
"SYMB": stock_code,
}
url = f"{self._broker._base_url}/uapi/overseas-price/v1/quotations/price"
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_overseas_price failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching overseas price: {exc}"
) from exc
async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]:
"""
Fetch overseas account balance.
Args:
exchange_code: Exchange code (e.g., "NASD", "NYSE")
Returns:
API response with balance data
Raises:
ConnectionError: On network or API errors
"""
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
# Virtual trading TR_ID for overseas balance inquiry
headers = await self._broker._auth_headers("VTTS3012R")
params = {
"CANO": self._broker._account_no,
"ACNT_PRDT_CD": self._broker._product_cd,
"OVRS_EXCG_CD": exchange_code,
"TR_CRCY_CD": self._get_currency_code(exchange_code),
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": "",
}
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-balance"
)
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_overseas_balance failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching overseas balance: {exc}"
) from exc
async def send_overseas_order(
self,
exchange_code: str,
stock_code: str,
order_type: str, # "BUY" or "SELL"
quantity: int,
price: float = 0.0,
) -> dict[str, Any]:
"""
Submit overseas stock order.
Args:
exchange_code: Exchange code (e.g., "NASD", "NYSE")
stock_code: Stock ticker symbol
order_type: "BUY" or "SELL"
quantity: Number of shares
price: Order price (0 for market order)
Returns:
API response with order result
Raises:
ConnectionError: On network or API errors
"""
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
# Virtual trading TR_IDs for overseas orders
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1006U"
body = {
"CANO": self._broker._account_no,
"ACNT_PRDT_CD": self._broker._product_cd,
"OVRS_EXCG_CD": exchange_code,
"PDNO": stock_code,
"ORD_DVSN": "00" if price > 0 else "01", # 00=지정가, 01=시장가
"ORD_QTY": str(quantity),
"OVRS_ORD_UNPR": str(price) if price > 0 else "0",
"ORD_SVR_DVSN_CD": "0", # 0=해외주문
}
hash_key = await self._broker._get_hash_key(body)
headers = await self._broker._auth_headers(tr_id)
headers["hashkey"] = hash_key
url = f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/order"
try:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"send_overseas_order failed ({resp.status}): {text}"
)
data = await resp.json()
logger.info(
"Overseas order submitted",
extra={
"exchange": exchange_code,
"stock_code": stock_code,
"action": order_type,
},
)
return data
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error sending overseas order: {exc}"
) from exc
def _get_currency_code(self, exchange_code: str) -> str:
"""
Map exchange code to currency code.
Args:
exchange_code: Exchange code
Returns:
Currency code (e.g., "USD", "JPY")
"""
currency_map = {
"NASD": "USD",
"NYSE": "USD",
"AMEX": "USD",
"TSE": "JPY",
"SEHK": "HKD",
"SHAA": "CNY",
"SZAA": "CNY",
"HNX": "VND",
"HSX": "VND",
}
return currency_map.get(exchange_code, "USD")

View File

@@ -33,6 +33,9 @@ class Settings(BaseSettings):
# Trading mode # Trading mode
MODE: str = Field(default="paper", pattern="^(paper|live)$") MODE: str = Field(default="paper", pattern="^(paper|live)$")
# Market selection (comma-separated market codes)
ENABLED_MARKETS: str = "KR"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@property @property
@@ -42,3 +45,8 @@ class Settings(BaseSettings):
@property @property
def account_product_code(self) -> str: def account_product_code(self) -> str:
return self.KIS_ACCOUNT_NO.split("-")[1] return self.KIS_ACCOUNT_NO.split("-")[1]
@property
def enabled_market_list(self) -> list[str]:
"""Parse ENABLED_MARKETS into list of market codes."""
return [m.strip() for m in self.ENABLED_MARKETS.split(",") if m.strip()]

View File

@@ -7,7 +7,6 @@ Changes require human approval and two passing test suites.
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from src.config import Settings from src.config import Settings

View File

@@ -3,9 +3,8 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any
def init_db(db_path: str) -> sqlite3.Connection: def init_db(db_path: str) -> sqlite3.Connection:
@@ -24,10 +23,22 @@ def init_db(db_path: str) -> sqlite3.Connection:
rationale TEXT, rationale TEXT,
quantity INTEGER, quantity INTEGER,
price REAL, price REAL,
pnl REAL DEFAULT 0.0 pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX'
) )
""" """
) )
# Migration: Add market and exchange_code columns if they don't exist
cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()}
if "market" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN market TEXT DEFAULT 'KR'")
if "exchange_code" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN exchange_code TEXT DEFAULT 'KRX'")
conn.commit() conn.commit()
return conn return conn
@@ -41,15 +52,20 @@ def log_trade(
quantity: int = 0, quantity: int = 0,
price: float = 0.0, price: float = 0.0,
pnl: float = 0.0, pnl: float = 0.0,
market: str = "KR",
exchange_code: str = "KRX",
) -> None: ) -> None:
"""Insert a trade record into the database.""" """Insert a trade record into the database."""
conn.execute( conn.execute(
""" """
INSERT INTO trades (timestamp, stock_code, action, confidence, rationale, quantity, price, pnl) INSERT INTO trades (
VALUES (?, ?, ?, ?, ?, ?, ?, ?) timestamp, stock_code, action, confidence, rationale,
quantity, price, pnl, market, exchange_code
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
datetime.now(timezone.utc).isoformat(), datetime.now(UTC).isoformat(),
stock_code, stock_code,
action, action,
confidence, confidence,
@@ -57,6 +73,8 @@ def log_trade(
quantity, quantity,
price, price,
pnl, pnl,
market,
exchange_code,
), ),
) )
conn.commit() conn.commit()

View File

@@ -14,7 +14,7 @@ import logging
import sqlite3 import sqlite3
import subprocess import subprocess
import textwrap import textwrap
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -136,7 +136,7 @@ class EvolutionOptimizer:
body = "\n".join(lines[1:-1]) body = "\n".join(lines[1:-1])
# Create strategy file # Create strategy file
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
version = f"v{timestamp}" version = f"v{timestamp}"
class_name = f"Strategy_{version}" class_name = f"Strategy_{version}"
file_name = f"{version}_evolved.py" file_name = f"{version}_evolved.py"
@@ -149,7 +149,7 @@ class EvolutionOptimizer:
content = STRATEGY_TEMPLATE.format( content = STRATEGY_TEMPLATE.format(
name=version, name=version,
timestamp=datetime.now(timezone.utc).isoformat(), timestamp=datetime.now(UTC).isoformat(),
rationale="Auto-evolved from failure analysis", rationale="Auto-evolved from failure analysis",
class_name=class_name, class_name=class_name,
body=indented_body.strip(), body=indented_body.strip(),

View File

@@ -2,20 +2,19 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import sys import sys
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import Any from typing import Any
import json
class JSONFormatter(logging.Formatter): class JSONFormatter(logging.Formatter):
"""Emit log records as single-line JSON objects.""" """Emit log records as single-line JSON objects."""
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
log_entry: dict[str, Any] = { log_entry: dict[str, Any] = {
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname, "level": record.levelname,
"logger": record.name, "logger": record.name,
"message": record.getMessage(), "message": record.getMessage(),

View File

@@ -10,33 +10,44 @@ import argparse
import asyncio import asyncio
import logging import logging
import signal import signal
import sys from datetime import UTC, datetime
from typing import Any from typing import Any
from src.brain.gemini_client import GeminiClient from src.brain.gemini_client import GeminiClient
from src.broker.kis_api import KISBroker from src.broker.kis_api import KISBroker
from src.broker.overseas import OverseasBroker
from src.config import Settings from src.config import Settings
from src.core.risk_manager import CircuitBreakerTripped, RiskManager from src.core.risk_manager import CircuitBreakerTripped, RiskManager
from src.db import init_db, log_trade from src.db import init_db, log_trade
from src.logging_config import setup_logging from src.logging_config import setup_logging
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Target stock codes to monitor # Target stock codes to monitor per market
WATCHLIST = ["005930", "000660", "035420"] # Samsung, SK Hynix, NAVER 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 TRADE_INTERVAL_SECONDS = 60
MAX_CONNECTION_RETRIES = 3
async def trading_cycle( async def trading_cycle(
broker: KISBroker, broker: KISBroker,
overseas_broker: OverseasBroker,
brain: GeminiClient, brain: GeminiClient,
risk: RiskManager, risk: RiskManager,
db_conn: Any, db_conn: Any,
market: MarketInfo,
stock_code: str, stock_code: str,
) -> None: ) -> None:
"""Execute one trading cycle for a single stock.""" """Execute one trading cycle for a single stock."""
# 1. Fetch market data # 1. Fetch market data
if market.is_domestic:
orderbook = await broker.get_orderbook(stock_code) orderbook = await broker.get_orderbook(stock_code)
balance_data = await broker.get_balance() balance_data = await broker.get_balance()
@@ -49,27 +60,43 @@ async def trading_cycle(
) )
purchase_total = float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0 purchase_total = float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0
# Calculate daily P&L % 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 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)
current_price = float( output2 = balance_data.get("output2", [{}])
orderbook.get("output1", {}).get("stck_prpr", "0") 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
) )
market_data = { market_data = {
"stock_code": stock_code, "stock_code": stock_code,
"market_name": market.name,
"current_price": current_price, "current_price": current_price,
"orderbook": orderbook.get("output1", {}), "foreigner_net": foreigner_net,
"foreigner_net": float(
orderbook.get("output1", {}).get("frgn_ntby_qty", "0")
),
} }
# 2. Ask the brain for a decision # 2. Ask the brain for a decision
decision = await brain.decide(market_data) decision = await brain.decide(market_data)
logger.info( logger.info(
"Decision for %s: %s (confidence=%d)", "Decision for %s (%s): %s (confidence=%d)",
stock_code, stock_code,
market.name,
decision.action, decision.action,
decision.confidence, decision.confidence,
) )
@@ -88,12 +115,21 @@ async def trading_cycle(
) )
# 5. Send order # 5. Send order
if market.is_domestic:
result = await broker.send_order( result = await broker.send_order(
stock_code=stock_code, stock_code=stock_code,
order_type=decision.action, order_type=decision.action,
quantity=quantity, quantity=quantity,
price=0, # market order 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")) logger.info("Order result: %s", result.get("msg1", "OK"))
# 6. Log trade # 6. Log trade
@@ -103,12 +139,15 @@ async def trading_cycle(
action=decision.action, action=decision.action,
confidence=decision.confidence, confidence=decision.confidence,
rationale=decision.rationale, rationale=decision.rationale,
market=market.code,
exchange_code=market.exchange_code,
) )
async def run(settings: Settings) -> None: 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) broker = KISBroker(settings)
overseas_broker = OverseasBroker(broker)
brain = GeminiClient(settings) brain = GeminiClient(settings)
risk = RiskManager(settings) risk = RiskManager(settings)
db_conn = init_db(settings.DB_PATH) db_conn = init_db(settings.DB_PATH)
@@ -124,27 +163,93 @@ async def run(settings: Settings) -> None:
loop.add_signal_handler(sig, _signal_handler) loop.add_signal_handler(sig, _signal_handler)
logger.info("The Ouroboros is alive. Mode: %s", settings.MODE) 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: try:
while not shutdown.is_set(): 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(): if shutdown.is_set():
break break
# 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: try:
await trading_cycle(broker, brain, risk, db_conn, code) await trading_cycle(
broker,
overseas_broker,
brain,
risk,
db_conn,
market,
stock_code,
)
break # Success — exit retry loop
except CircuitBreakerTripped: except CircuitBreakerTripped:
logger.critical("Circuit breaker tripped — shutting down") logger.critical("Circuit breaker tripped — shutting down")
raise raise
except ConnectionError as exc: except ConnectionError as exc:
logger.error("Connection error for %s: %s", code, 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: except Exception as exc:
logger.exception("Unexpected error for %s: %s", code, exc) logger.exception("Unexpected error for %s: %s", stock_code, exc)
break # Don't retry on unexpected errors
# Wait for next cycle or shutdown # Wait for next cycle or shutdown
try: try:
await asyncio.wait_for(shutdown.wait(), timeout=TRADE_INTERVAL_SECONDS) 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 pass # Normal — timeout means it's time for next cycle
finally: finally:
await broker.close() await broker.close()

1
src/markets/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Global market scheduling and timezone management."""

252
src/markets/schedule.py Normal file
View File

@@ -0,0 +1,252 @@
"""Market schedule management with timezone support."""
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
@dataclass(frozen=True)
class MarketInfo:
"""Information about a trading market."""
code: str # Market code for internal use (e.g., "KR", "US_NASDAQ")
exchange_code: str # KIS API exchange code (e.g., "NASD", "NYSE")
name: str # Human-readable name
timezone: ZoneInfo # Market timezone
open_time: time # Market open time in local timezone
close_time: time # Market close time in local timezone
is_domestic: bool # True for Korean market, False for overseas
lunch_break: tuple[time, time] | None = None # (start, end) or None
# 10 global markets with their schedules
MARKETS: dict[str, MarketInfo] = {
"KR": MarketInfo(
code="KR",
exchange_code="KRX",
name="Korea Exchange",
timezone=ZoneInfo("Asia/Seoul"),
open_time=time(9, 0),
close_time=time(15, 30),
is_domestic=True,
lunch_break=None, # KRX removed lunch break
),
"US_NASDAQ": MarketInfo(
code="US_NASDAQ",
exchange_code="NASD",
name="NASDAQ",
timezone=ZoneInfo("America/New_York"),
open_time=time(9, 30),
close_time=time(16, 0),
is_domestic=False,
lunch_break=None,
),
"US_NYSE": MarketInfo(
code="US_NYSE",
exchange_code="NYSE",
name="New York Stock Exchange",
timezone=ZoneInfo("America/New_York"),
open_time=time(9, 30),
close_time=time(16, 0),
is_domestic=False,
lunch_break=None,
),
"US_AMEX": MarketInfo(
code="US_AMEX",
exchange_code="AMEX",
name="NYSE American",
timezone=ZoneInfo("America/New_York"),
open_time=time(9, 30),
close_time=time(16, 0),
is_domestic=False,
lunch_break=None,
),
"JP": MarketInfo(
code="JP",
exchange_code="TSE",
name="Tokyo Stock Exchange",
timezone=ZoneInfo("Asia/Tokyo"),
open_time=time(9, 0),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(12, 30)),
),
"HK": MarketInfo(
code="HK",
exchange_code="SEHK",
name="Hong Kong Stock Exchange",
timezone=ZoneInfo("Asia/Hong_Kong"),
open_time=time(9, 30),
close_time=time(16, 0),
is_domestic=False,
lunch_break=(time(12, 0), time(13, 0)),
),
"CN_SHA": MarketInfo(
code="CN_SHA",
exchange_code="SHAA",
name="Shanghai Stock Exchange",
timezone=ZoneInfo("Asia/Shanghai"),
open_time=time(9, 30),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(13, 0)),
),
"CN_SZA": MarketInfo(
code="CN_SZA",
exchange_code="SZAA",
name="Shenzhen Stock Exchange",
timezone=ZoneInfo("Asia/Shanghai"),
open_time=time(9, 30),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(13, 0)),
),
"VN_HAN": MarketInfo(
code="VN_HAN",
exchange_code="HNX",
name="Hanoi Stock Exchange",
timezone=ZoneInfo("Asia/Ho_Chi_Minh"),
open_time=time(9, 0),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(13, 0)),
),
"VN_HCM": MarketInfo(
code="VN_HCM",
exchange_code="HSX",
name="Ho Chi Minh Stock Exchange",
timezone=ZoneInfo("Asia/Ho_Chi_Minh"),
open_time=time(9, 0),
close_time=time(15, 0),
is_domestic=False,
lunch_break=(time(11, 30), time(13, 0)),
),
}
def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
"""
Check if a market is currently open for trading.
Args:
market: Market information
now: Current time (defaults to datetime.now(UTC) for testing)
Returns:
True if market is open, False otherwise
Note:
Does not account for holidays (KIS API will reject orders on holidays)
"""
if now is None:
now = datetime.now(ZoneInfo("UTC"))
# Convert to market's local timezone
local_now = now.astimezone(market.timezone)
# Check if it's a weekend
if local_now.weekday() >= 5: # Saturday=5, Sunday=6
return False
current_time = local_now.time()
# Check if within trading hours
if current_time < market.open_time or current_time >= market.close_time:
return False
# Check lunch break
if market.lunch_break:
lunch_start, lunch_end = market.lunch_break
if lunch_start <= current_time < lunch_end:
return False
return True
def get_open_markets(
enabled_markets: list[str] | None = None, now: datetime | None = None
) -> list[MarketInfo]:
"""
Get list of currently open markets.
Args:
enabled_markets: List of market codes to check (defaults to all markets)
now: Current time (defaults to datetime.now(UTC) for testing)
Returns:
List of open markets, sorted by market code
"""
if enabled_markets is None:
enabled_markets = list(MARKETS.keys())
open_markets = [
MARKETS[code]
for code in enabled_markets
if code in MARKETS and is_market_open(MARKETS[code], now)
]
return sorted(open_markets, key=lambda m: m.code)
def get_next_market_open(
enabled_markets: list[str] | None = None, now: datetime | None = None
) -> tuple[MarketInfo, datetime]:
"""
Find the next market that will open and when.
Args:
enabled_markets: List of market codes to check (defaults to all markets)
now: Current time (defaults to datetime.now(UTC) for testing)
Returns:
Tuple of (market, open_datetime) for the next market to open
Raises:
ValueError: If no enabled markets are configured
"""
if now is None:
now = datetime.now(ZoneInfo("UTC"))
if enabled_markets is None:
enabled_markets = list(MARKETS.keys())
if not enabled_markets:
raise ValueError("No enabled markets configured")
next_open_time: datetime | None = None
next_market: MarketInfo | None = None
for code in enabled_markets:
if code not in MARKETS:
continue
market = MARKETS[code]
market_now = now.astimezone(market.timezone)
# 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)
check_datetime = datetime.combine(
check_date, market.open_time, tzinfo=market.timezone
)
# Skip weekends
if check_datetime.weekday() >= 5:
continue
# Skip if this open time already passed today
if check_datetime <= market_now:
continue
# Convert to UTC for comparison
check_datetime_utc = check_datetime.astimezone(ZoneInfo("UTC"))
if next_open_time is None or check_datetime_utc < next_open_time:
next_open_time = check_datetime_utc
next_market = market
break
if next_market is None or next_open_time is None:
raise ValueError("Could not find next market open time")
return next_market, next_open_time

View File

@@ -20,4 +20,5 @@ def settings() -> Settings:
FAT_FINGER_PCT=30.0, FAT_FINGER_PCT=30.0,
CONFIDENCE_THRESHOLD=80, CONFIDENCE_THRESHOLD=80,
DB_PATH=":memory:", DB_PATH=":memory:",
ENABLED_MARKETS="KR",
) )

View File

@@ -2,12 +2,7 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch from src.brain.gemini_client import GeminiClient
import pytest
from src.brain.gemini_client import GeminiClient, TradeDecision
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Response Parsing # Response Parsing

View File

@@ -3,14 +3,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import aiohttp
import pytest import pytest
from src.broker.kis_api import KISBroker from src.broker.kis_api import KISBroker
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Token Management # Token Management
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -68,7 +66,7 @@ class TestNetworkErrorHandling:
with patch( with patch(
"aiohttp.ClientSession.get", "aiohttp.ClientSession.get",
side_effect=asyncio.TimeoutError(), side_effect=TimeoutError(),
): ):
with pytest.raises(ConnectionError): with pytest.raises(ConnectionError):
await broker.get_orderbook("005930") await broker.get_orderbook("005930")

View File

@@ -0,0 +1,201 @@
"""Tests for market schedule management."""
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from src.markets.schedule import (
MARKETS,
get_next_market_open,
get_open_markets,
is_market_open,
)
class TestMarketInfo:
"""Test MarketInfo dataclass."""
def test_market_info_immutable(self) -> None:
"""MarketInfo should be frozen."""
market = MARKETS["KR"]
with pytest.raises(AttributeError):
market.code = "US" # type: ignore[misc]
def test_all_markets_defined(self) -> None:
"""All 10 markets should be defined."""
expected_markets = {
"KR",
"US_NASDAQ",
"US_NYSE",
"US_AMEX",
"JP",
"HK",
"CN_SHA",
"CN_SZA",
"VN_HAN",
"VN_HCM",
}
assert set(MARKETS.keys()) == expected_markets
class TestIsMarketOpen:
"""Test is_market_open function."""
def test_kr_market_open_weekday(self) -> None:
"""KR market should be open during trading hours on weekday."""
# Monday 2026-02-02 10:00 KST
test_time = datetime(2026, 2, 2, 10, 0, tzinfo=ZoneInfo("Asia/Seoul"))
assert is_market_open(MARKETS["KR"], test_time)
def test_kr_market_closed_before_open(self) -> None:
"""KR market should be closed before 9:00."""
# Monday 2026-02-02 08:30 KST
test_time = datetime(2026, 2, 2, 8, 30, tzinfo=ZoneInfo("Asia/Seoul"))
assert not is_market_open(MARKETS["KR"], test_time)
def test_kr_market_closed_after_close(self) -> None:
"""KR market should be closed after 15:30."""
# Monday 2026-02-02 15:30 KST (exact close time)
test_time = datetime(2026, 2, 2, 15, 30, tzinfo=ZoneInfo("Asia/Seoul"))
assert not is_market_open(MARKETS["KR"], test_time)
def test_kr_market_closed_weekend(self) -> None:
"""KR market should be closed on weekends."""
# Saturday 2026-02-07 10:00 KST
test_time = datetime(2026, 2, 7, 10, 0, tzinfo=ZoneInfo("Asia/Seoul"))
assert not is_market_open(MARKETS["KR"], test_time)
# Sunday 2026-02-08 10:00 KST
test_time = datetime(2026, 2, 8, 10, 0, tzinfo=ZoneInfo("Asia/Seoul"))
assert not is_market_open(MARKETS["KR"], test_time)
def test_us_nasdaq_open_with_dst(self) -> None:
"""US markets should respect DST."""
# Monday 2026-06-01 10:00 EDT (DST in effect)
test_time = datetime(2026, 6, 1, 10, 0, tzinfo=ZoneInfo("America/New_York"))
assert is_market_open(MARKETS["US_NASDAQ"], test_time)
# Monday 2026-12-07 10:00 EST (no DST)
test_time = datetime(2026, 12, 7, 10, 0, tzinfo=ZoneInfo("America/New_York"))
assert is_market_open(MARKETS["US_NASDAQ"], test_time)
def test_jp_market_lunch_break(self) -> None:
"""JP market should be closed during lunch break."""
# Monday 2026-02-02 12:00 JST (lunch break)
test_time = datetime(2026, 2, 2, 12, 0, tzinfo=ZoneInfo("Asia/Tokyo"))
assert not is_market_open(MARKETS["JP"], test_time)
# Before lunch
test_time = datetime(2026, 2, 2, 11, 0, tzinfo=ZoneInfo("Asia/Tokyo"))
assert is_market_open(MARKETS["JP"], test_time)
# After lunch
test_time = datetime(2026, 2, 2, 13, 0, tzinfo=ZoneInfo("Asia/Tokyo"))
assert is_market_open(MARKETS["JP"], test_time)
def test_hk_market_lunch_break(self) -> None:
"""HK market should be closed during lunch break."""
# Monday 2026-02-02 12:30 HKT (lunch break)
test_time = datetime(2026, 2, 2, 12, 30, tzinfo=ZoneInfo("Asia/Hong_Kong"))
assert not is_market_open(MARKETS["HK"], test_time)
def test_timezone_conversion(self) -> None:
"""Should correctly convert timezones."""
# 2026-02-02 10:00 KST = 2026-02-02 01:00 UTC
test_time = datetime(2026, 2, 2, 1, 0, tzinfo=ZoneInfo("UTC"))
assert is_market_open(MARKETS["KR"], test_time)
class TestGetOpenMarkets:
"""Test get_open_markets function."""
def test_get_open_markets_all_closed(self) -> None:
"""Should return empty list when all markets closed."""
# Sunday 2026-02-08 12:00 UTC (all markets closed)
test_time = datetime(2026, 2, 8, 12, 0, tzinfo=ZoneInfo("UTC"))
assert get_open_markets(now=test_time) == []
def test_get_open_markets_kr_only(self) -> None:
"""Should return only KR when filtering enabled markets."""
# Monday 2026-02-02 10:00 KST = 01:00 UTC
test_time = datetime(2026, 2, 2, 1, 0, tzinfo=ZoneInfo("UTC"))
open_markets = get_open_markets(enabled_markets=["KR"], now=test_time)
assert len(open_markets) == 1
assert open_markets[0].code == "KR"
def test_get_open_markets_multiple(self) -> None:
"""Should return multiple markets when open."""
# Monday 2026-02-02 14:30 EST = 19:30 UTC
# US markets: 9:30-16:00 EST → 14:30-21:00 UTC (open)
test_time = datetime(2026, 2, 2, 19, 30, tzinfo=ZoneInfo("UTC"))
open_markets = get_open_markets(
enabled_markets=["US_NASDAQ", "US_NYSE", "US_AMEX"], now=test_time
)
assert len(open_markets) == 3
codes = {m.code for m in open_markets}
assert codes == {"US_NASDAQ", "US_NYSE", "US_AMEX"}
def test_get_open_markets_sorted(self) -> None:
"""Should return markets sorted by code."""
# Monday 2026-02-02 14:30 EST
test_time = datetime(2026, 2, 2, 19, 30, tzinfo=ZoneInfo("UTC"))
open_markets = get_open_markets(
enabled_markets=["US_NYSE", "US_AMEX", "US_NASDAQ"], now=test_time
)
codes = [m.code for m in open_markets]
assert codes == sorted(codes)
class TestGetNextMarketOpen:
"""Test get_next_market_open function."""
def test_get_next_market_open_weekend(self) -> None:
"""Should find next Monday opening when called on weekend."""
# Saturday 2026-02-07 12:00 UTC
test_time = datetime(2026, 2, 7, 12, 0, tzinfo=ZoneInfo("UTC"))
market, open_time = get_next_market_open(
enabled_markets=["KR"], now=test_time
)
assert market.code == "KR"
# Monday 2026-02-09 09:00 KST
expected = datetime(2026, 2, 9, 9, 0, tzinfo=ZoneInfo("Asia/Seoul"))
assert open_time == expected.astimezone(ZoneInfo("UTC"))
def test_get_next_market_open_after_close(self) -> None:
"""Should find next day opening when called after market close."""
# Monday 2026-02-02 16:00 KST (after close)
test_time = datetime(2026, 2, 2, 16, 0, tzinfo=ZoneInfo("Asia/Seoul"))
market, open_time = get_next_market_open(
enabled_markets=["KR"], now=test_time
)
assert market.code == "KR"
# Tuesday 2026-02-03 09:00 KST
expected = datetime(2026, 2, 3, 9, 0, tzinfo=ZoneInfo("Asia/Seoul"))
assert open_time == expected.astimezone(ZoneInfo("UTC"))
def test_get_next_market_open_multiple_markets(self) -> None:
"""Should find earliest opening market among multiple."""
# Saturday 2026-02-07 12:00 UTC
test_time = datetime(2026, 2, 7, 12, 0, tzinfo=ZoneInfo("UTC"))
market, open_time = get_next_market_open(
enabled_markets=["KR", "US_NASDAQ"], now=test_time
)
# Monday 2026-02-09: KR opens at 09:00 KST = 00:00 UTC
# Monday 2026-02-09: US opens at 09:30 EST = 14:30 UTC
# KR opens first
assert market.code == "KR"
def test_get_next_market_open_no_markets(self) -> None:
"""Should raise ValueError when no markets enabled."""
test_time = datetime(2026, 2, 7, 12, 0, tzinfo=ZoneInfo("UTC"))
with pytest.raises(ValueError, match="No enabled markets"):
get_next_market_open(enabled_markets=[], now=test_time)
def test_get_next_market_open_invalid_market(self) -> None:
"""Should skip invalid market codes."""
test_time = datetime(2026, 2, 7, 12, 0, tzinfo=ZoneInfo("UTC"))
market, _ = get_next_market_open(
enabled_markets=["INVALID", "KR"], now=test_time
)
assert market.code == "KR"

View File

@@ -10,7 +10,6 @@ from src.core.risk_manager import (
RiskManager, RiskManager,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Circuit Breaker Tests # Circuit Breaker Tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------