Compare commits

..

9 Commits

Author SHA1 Message Date
agentson
ac4fb00644 feat: Daily 모드 ConnectionError 재시도 로직 추가 (issue #209)
Some checks failed
CI / test (pull_request) Has been cancelled
- _retry_connection() 헬퍼 추가: MAX_CONNECTION_RETRIES(3회) 지수 백오프
  (2^attempt 초) 재시도, 읽기 전용 API 호출에만 적용 (주문 제외)
- run_daily_session(): get_current_price / get_overseas_price 호출에 적용
- run_daily_session(): get_balance / get_overseas_balance 호출에 적용
  - 잔고 조회 전체 실패 시 해당 마켓을 skip하고 다른 마켓은 계속 처리
- 테스트 5개 추가: TestRetryConnection 클래스

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:51:15 +09:00
641f3e8811 Merge pull request 'feat: trades 테이블 mode 컬럼 추가 (#212)' (#221) from feature/issue-212-trades-mode-column into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #221
2026-02-23 12:34:26 +09:00
agentson
ebd0a0297c chore: PR #221 충돌 해결 — WAL 테스트(#210)와 mode 컬럼 테스트(#212) 병합
Some checks failed
CI / test (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:34:06 +09:00
02a72e0f7e Merge pull request 'feat: DB WAL 모드 적용, .env.example 정리 (#210, #213, #216)' (#220) from feature/issue-210-213-216-db-wal-env-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #220
2026-02-23 12:32:36 +09:00
478a659ac2 Merge pull request 'feat: 실전 투자 전환 — TR_ID 분기, URL, 신뢰도 임계값, 텔레그램 알림 (#201~#205, #208, #214)' (#219) from feature/issue-201-202-203-broker-live-mode into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #219
2026-02-23 12:32:21 +09:00
agentson
48b87a79f6 docs: CLAUDE.md confidence 규칙 BULLISH=75 명시 (#205)
시장 전망별 BUY confidence 최소 임계값:
- BEARISH: 90 (더 엄격)
- NEUTRAL/기본: 80
- BULLISH: 75 (낙관적 시장에서 완화)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:29:39 +09:00
agentson
ad79082dcc docs: CLAUDE.md 비협상 규칙 명시 강화 — BULLISH 시 confidence 임계값 포함 (#205)
Some checks failed
CI / test (pull_request) Has been cancelled
BULLISH 시장에서도 confidence < 80 → HOLD 규칙이 동일하게 적용됨을 명시.
시장 전망별 임계값: BEARISH=90(더 엄격), BULLISH/NEUTRAL=80(최소값).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:28:30 +09:00
agentson
11dff9d3e5 feat: trades 테이블 mode 컬럼 추가 (paper/live 거래 분리) (#212)
Some checks failed
CI / test (pull_request) Has been cancelled
- trades 테이블에 mode TEXT DEFAULT 'paper' 컬럼 추가
- 기존 DB 마이그레이션: ALTER TABLE으로 mode 컬럼 자동 추가
- log_trade() 함수에 mode 파라미터 추가 (기본값 'paper')
- trading_cycle(), run_daily_session()에서 settings.MODE 전달
- 테스트 5개 추가 (mode 저장, 기본값, 스키마 검증, 마이그레이션)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 10:33:02 +09:00
agentson
3c5f1752e6 feat: DB WAL 모드 적용, .env.example 정리 (#210, #213, #216)
Some checks failed
CI / test (pull_request) Has been cancelled
- #210: init_db()에 WAL 저널 모드 적용 (파일 DB에만, :memory: 제외)
  - 대시보드(READ)와 거래루프(WRITE) 동시 접근 시 SQLite 락 오류 방지
  - busy_timeout=5000ms 설정
- #213: RATE_LIMIT_RPS 기본값 2.0으로 통일 (.env.example이 5.0으로 잘못 표기됨)
- #216: .env.example 중요 변수 추가 및 정리
  - KIS_BASE_URL 모의/실전 URL 주석 명시 (포트 29443 수정 포함)
  - MODE, TRADE_MODE, ENABLED_MARKETS, PAPER_OVERSEAS_CASH 추가
  - GEMINI_MODEL 업데이트 (gemini-pro → gemini-2.0-flash-exp)
  - DASHBOARD 설정 섹션 추가

테스트 2개 추가 (WAL 파일 DB 적용, 메모리 DB 미적용 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 10:30:47 +09:00
6 changed files with 369 additions and 20 deletions

View File

@@ -1,36 +1,82 @@
# ============================================================
# The Ouroboros — Environment Configuration
# ============================================================
# Copy this file to .env and fill in your values.
# Lines starting with # are comments.
# ============================================================
# Korea Investment Securities API
# ============================================================
KIS_APP_KEY=your_app_key_here
KIS_APP_SECRET=your_app_secret_here
KIS_ACCOUNT_NO=12345678-01
KIS_BASE_URL=https://openapivts.koreainvestment.com:9443
# Paper trading (VTS): https://openapivts.koreainvestment.com:29443
# Live trading: https://openapi.koreainvestment.com:9443
KIS_BASE_URL=https://openapivts.koreainvestment.com:29443
# ============================================================
# Trading Mode
# ============================================================
# paper = 모의투자 (safe for testing), live = 실전투자 (real money)
MODE=paper
# daily = batch per session, realtime = per-stock continuous scan
TRADE_MODE=daily
# Comma-separated market codes: KR, US, JP, HK, CN, VN
ENABLED_MARKETS=KR,US
# Simulated USD cash for paper (VTS) overseas trading.
# VTS overseas balance API often returns 0; this value is used as fallback.
# Set to 0 to disable fallback (not used in live mode).
PAPER_OVERSEAS_CASH=50000.0
# ============================================================
# Google Gemini
# ============================================================
GEMINI_API_KEY=your_gemini_api_key_here
GEMINI_MODEL=gemini-pro
# Recommended: gemini-2.0-flash-exp or gemini-1.5-pro
GEMINI_MODEL=gemini-2.0-flash-exp
# ============================================================
# Risk Management
# ============================================================
CIRCUIT_BREAKER_PCT=-3.0
FAT_FINGER_PCT=30.0
CONFIDENCE_THRESHOLD=80
# ============================================================
# Database
# ============================================================
DB_PATH=data/trade_logs.db
# Rate Limiting (requests per second for KIS API)
# Reduced to 5.0 to avoid "초당 거래건수 초과" errors (EGW00201)
RATE_LIMIT_RPS=5.0
# ============================================================
# Rate Limiting
# ============================================================
# KIS API real limit is ~2 RPS. Keep at 2.0 for maximum safety.
# Increasing this risks EGW00201 "초당 거래건수 초과" errors.
RATE_LIMIT_RPS=2.0
# Trading Mode (paper / live)
MODE=paper
# External Data APIs (optional — for enhanced decision-making)
# ============================================================
# External Data APIs (optional)
# ============================================================
# NEWS_API_KEY=your_news_api_key_here
# NEWS_API_PROVIDER=alphavantage
# MARKET_DATA_API_KEY=your_market_data_key_here
# ============================================================
# Telegram Notifications (optional)
# ============================================================
# Get bot token from @BotFather on Telegram
# Get chat ID from @userinfobot or your chat
# TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
# TELEGRAM_CHAT_ID=123456789
# TELEGRAM_ENABLED=true
# ============================================================
# Dashboard (optional)
# ============================================================
# DASHBOARD_ENABLED=false
# DASHBOARD_HOST=127.0.0.1
# DASHBOARD_PORT=8080

View File

@@ -170,7 +170,7 @@ Markets auto-detected based on timezone and enabled in `ENABLED_MARKETS` env var
- `src/core/risk_manager.py` is **READ-ONLY** — changes require human approval
- Circuit breaker at -3.0% P&L — may only be made **stricter**
- Fat-finger protection: max 30% of cash per order — always enforced
- Confidence < 80 → force HOLD — cannot be weakened
- Confidence 임계값 (market_outlook별, 낮출 수 없음): BEARISH ≥ 90, NEUTRAL/기본 ≥ 80, BULLISH ≥ 75
- All code changes → corresponding tests → coverage ≥ 80%
## Contributing

View File

@@ -14,6 +14,11 @@ def init_db(db_path: str) -> sqlite3.Connection:
if db_path != ":memory:":
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
# Enable WAL mode for concurrent read/write (dashboard + trading loop).
# WAL does not apply to in-memory databases.
if db_path != ":memory:":
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS trades (
@@ -28,12 +33,13 @@ def init_db(db_path: str) -> sqlite3.Connection:
pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX',
decision_id TEXT
decision_id TEXT,
mode TEXT DEFAULT 'paper'
)
"""
)
# Migration: Add market and exchange_code columns if they don't exist
# Migration: Add columns if they don't exist (backward-compatible schema upgrades)
cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()}
@@ -45,6 +51,8 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT")
if "decision_id" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT")
if "mode" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN mode TEXT DEFAULT 'paper'")
# Context tree tables for multi-layered memory management
conn.execute(
@@ -167,6 +175,7 @@ def log_trade(
exchange_code: str = "KRX",
selection_context: dict[str, any] | None = None,
decision_id: str | None = None,
mode: str = "paper",
) -> None:
"""Insert a trade record into the database.
@@ -182,6 +191,8 @@ def log_trade(
market: Market code
exchange_code: Exchange code
selection_context: Scanner selection data (RSI, volume_ratio, signal, score)
decision_id: Unique decision identifier for audit linking
mode: Trading mode ('paper' or 'live') for data separation
"""
# Serialize selection context to JSON
context_json = json.dumps(selection_context) if selection_context else None
@@ -190,9 +201,10 @@ def log_trade(
"""
INSERT INTO trades (
timestamp, stock_code, action, confidence, rationale,
quantity, price, pnl, market, exchange_code, selection_context, decision_id
quantity, price, pnl, market, exchange_code, selection_context, decision_id,
mode
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
datetime.now(UTC).isoformat(),
@@ -207,6 +219,7 @@ def log_trade(
exchange_code,
context_json,
decision_id,
mode,
),
)
conn.commit()

View File

@@ -88,6 +88,47 @@ DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions
async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kwargs: Any) -> Any:
"""Call an async function retrying on ConnectionError with exponential backoff.
Retries up to MAX_CONNECTION_RETRIES times (exclusive of the first attempt),
sleeping 2^attempt seconds between attempts. Use only for idempotent read
operations — never for order submission.
Args:
coro_factory: Async callable (method or function) to invoke.
*args: Positional arguments forwarded to coro_factory.
label: Human-readable label for log messages.
**kwargs: Keyword arguments forwarded to coro_factory.
Raises:
ConnectionError: If all retries are exhausted.
"""
for attempt in range(1, MAX_CONNECTION_RETRIES + 1):
try:
return await coro_factory(*args, **kwargs)
except ConnectionError as exc:
if attempt < MAX_CONNECTION_RETRIES:
wait_secs = 2 ** attempt
logger.warning(
"Connection error %s (attempt %d/%d), retrying in %ds: %s",
label,
attempt,
MAX_CONNECTION_RETRIES,
wait_secs,
exc,
)
await asyncio.sleep(wait_secs)
else:
logger.error(
"Connection error %s — all %d retries exhausted: %s",
label,
MAX_CONNECTION_RETRIES,
exc,
)
raise
def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
"""Extract symbol from overseas holding payload variants."""
for key in (
@@ -828,6 +869,7 @@ async def trading_cycle(
exchange_code=market.exchange_code,
selection_context=selection_context,
decision_id=decision_id,
mode=settings.MODE if settings else "paper",
)
# 7. Latency monitoring
@@ -963,11 +1005,18 @@ async def run_daily_session(
try:
if market.is_domestic:
current_price, price_change_pct, foreigner_net = (
await broker.get_current_price(stock_code)
await _retry_connection(
broker.get_current_price,
stock_code,
label=stock_code,
)
)
else:
price_data = await overseas_broker.get_overseas_price(
market.exchange_code, stock_code
price_data = await _retry_connection(
overseas_broker.get_overseas_price,
market.exchange_code,
stock_code,
label=f"{stock_code}@{market.exchange_code}",
)
current_price = safe_float(
price_data.get("output", {}).get("last", "0")
@@ -1018,9 +1067,27 @@ async def run_daily_session(
logger.warning("No valid stock data for market %s", market.code)
continue
# Get balance data once for the market
# Get balance data once for the market (read-only — safe to retry)
try:
if market.is_domestic:
balance_data = await _retry_connection(
broker.get_balance, label=f"balance:{market.code}"
)
else:
balance_data = await _retry_connection(
overseas_broker.get_overseas_balance,
market.exchange_code,
label=f"overseas_balance:{market.exchange_code}",
)
except ConnectionError as exc:
logger.error(
"Balance fetch failed for market %s after all retries — skipping market: %s",
market.code,
exc,
)
continue
if market.is_domestic:
balance_data = await broker.get_balance()
output2 = balance_data.get("output2", [{}])
total_eval = safe_float(
output2[0].get("tot_evlu_amt", "0")
@@ -1032,7 +1099,6 @@ async def run_daily_session(
output2[0].get("pchs_amt_smtl_amt", "0")
) if output2 else 0
else:
balance_data = await overseas_broker.get_overseas_balance(market.exchange_code)
output2 = balance_data.get("output2", [{}])
if isinstance(output2, list) and output2:
balance_info = output2[0]
@@ -1325,6 +1391,7 @@ async def run_daily_session(
market=market.code,
exchange_code=market.exchange_code,
decision_id=decision_id,
mode=settings.MODE,
)
logger.info("Daily trading session completed")

View File

@@ -1,5 +1,8 @@
"""Tests for database helper functions."""
import tempfile
import os
from src.db import get_open_position, init_db, log_trade
@@ -58,3 +61,135 @@ def test_get_open_position_returns_none_when_latest_is_sell() -> None:
def test_get_open_position_returns_none_when_no_trades() -> None:
conn = init_db(":memory:")
assert get_open_position(conn, "AAPL", "US_NASDAQ") is None
# ---------------------------------------------------------------------------
# WAL mode tests (issue #210)
# ---------------------------------------------------------------------------
def test_wal_mode_applied_to_file_db() -> None:
"""File-based DB must use WAL journal mode for dashboard concurrent reads."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
conn = init_db(db_path)
cursor = conn.execute("PRAGMA journal_mode")
mode = cursor.fetchone()[0]
assert mode == "wal", f"Expected WAL mode, got {mode}"
conn.close()
finally:
os.unlink(db_path)
# Clean up WAL auxiliary files if they exist
for ext in ("-wal", "-shm"):
path = db_path + ext
if os.path.exists(path):
os.unlink(path)
def test_wal_mode_not_applied_to_memory_db() -> None:
""":memory: DB must not apply WAL (SQLite does not support WAL for in-memory)."""
conn = init_db(":memory:")
cursor = conn.execute("PRAGMA journal_mode")
mode = cursor.fetchone()[0]
# In-memory DBs default to 'memory' journal mode
assert mode != "wal", "WAL should not be set on in-memory database"
conn.close()
# ---------------------------------------------------------------------------
# mode column tests (issue #212)
# ---------------------------------------------------------------------------
def test_log_trade_stores_mode_paper() -> None:
"""log_trade must persist mode='paper' in the trades table."""
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="BUY",
confidence=85,
rationale="test",
mode="paper",
)
row = conn.execute("SELECT mode FROM trades ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert row[0] == "paper"
def test_log_trade_stores_mode_live() -> None:
"""log_trade must persist mode='live' in the trades table."""
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="BUY",
confidence=85,
rationale="test",
mode="live",
)
row = conn.execute("SELECT mode FROM trades ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert row[0] == "live"
def test_log_trade_default_mode_is_paper() -> None:
"""log_trade without explicit mode must default to 'paper'."""
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="HOLD",
confidence=50,
rationale="test",
)
row = conn.execute("SELECT mode FROM trades ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert row[0] == "paper"
def test_mode_column_exists_in_schema() -> None:
"""trades table must have a mode column after init_db."""
conn = init_db(":memory:")
cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()}
assert "mode" in columns
def test_mode_migration_adds_column_to_existing_db() -> None:
"""init_db must add mode column to existing DBs that lack it (migration)."""
import sqlite3
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
# Create DB without mode column (simulate old schema)
old_conn = sqlite3.connect(db_path)
old_conn.execute(
"""CREATE TABLE trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
stock_code TEXT NOT NULL,
action TEXT NOT NULL,
confidence INTEGER NOT NULL,
rationale TEXT,
quantity INTEGER,
price REAL,
pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX',
decision_id TEXT
)"""
)
old_conn.commit()
old_conn.close()
# Run init_db — should add mode column via migration
conn = init_db(db_path)
cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()}
assert "mode" in columns
conn.close()
finally:
os.unlink(db_path)

View File

@@ -18,6 +18,7 @@ from src.main import (
_extract_held_codes_from_balance,
_extract_held_qty_from_balance,
_handle_market_close,
_retry_connection,
_run_context_scheduler,
_run_evolution_loop,
_start_dashboard_server,
@@ -3183,3 +3184,90 @@ class TestOverseasBrokerIntegration:
# DB도 브로커도 보유 없음 → BUY 주문이 실행되어야 함 (회귀 테스트)
overseas_broker.send_overseas_order.assert_called_once()
# ---------------------------------------------------------------------------
# _retry_connection — unit tests (issue #209)
# ---------------------------------------------------------------------------
class TestRetryConnection:
"""Unit tests for the _retry_connection helper (issue #209)."""
@pytest.mark.asyncio
async def test_success_on_first_attempt(self) -> None:
"""Returns the result immediately when the first call succeeds."""
async def ok() -> str:
return "data"
result = await _retry_connection(ok, label="test")
assert result == "data"
@pytest.mark.asyncio
async def test_succeeds_after_one_connection_error(self) -> None:
"""Retries once on ConnectionError and returns result on 2nd attempt."""
call_count = 0
async def flaky() -> str:
nonlocal call_count
call_count += 1
if call_count < 2:
raise ConnectionError("timeout")
return "ok"
with patch("src.main.asyncio.sleep") as mock_sleep:
mock_sleep.return_value = None
result = await _retry_connection(flaky, label="flaky")
assert result == "ok"
assert call_count == 2
mock_sleep.assert_called_once()
@pytest.mark.asyncio
async def test_raises_after_all_retries_exhausted(self) -> None:
"""Raises ConnectionError after MAX_CONNECTION_RETRIES attempts."""
from src.main import MAX_CONNECTION_RETRIES
call_count = 0
async def always_fail() -> None:
nonlocal call_count
call_count += 1
raise ConnectionError("unreachable")
with patch("src.main.asyncio.sleep") as mock_sleep:
mock_sleep.return_value = None
with pytest.raises(ConnectionError, match="unreachable"):
await _retry_connection(always_fail, label="always_fail")
assert call_count == MAX_CONNECTION_RETRIES
@pytest.mark.asyncio
async def test_passes_args_and_kwargs_to_factory(self) -> None:
"""Forwards positional and keyword arguments to the callable."""
received: dict = {}
async def capture(a: int, b: int, *, key: str) -> str:
received["a"] = a
received["b"] = b
received["key"] = key
return "captured"
result = await _retry_connection(capture, 1, 2, key="val", label="test")
assert result == "captured"
assert received == {"a": 1, "b": 2, "key": "val"}
@pytest.mark.asyncio
async def test_non_connection_error_not_retried(self) -> None:
"""Non-ConnectionError exceptions propagate immediately without retry."""
call_count = 0
async def bad_input() -> None:
nonlocal call_count
call_count += 1
raise ValueError("bad data")
with pytest.raises(ValueError, match="bad data"):
await _retry_connection(bad_input, label="bad")
assert call_count == 1 # No retry for non-ConnectionError