Add complete Ouroboros trading system with TDD test suite
Some checks failed
CI / test (push) Has been cancelled

Implement the full autonomous trading agent architecture:
- KIS broker with async API, token refresh, leaky bucket rate limiter, and hash key signing
- Gemini-powered decision engine with JSON parsing and confidence threshold enforcement
- Risk manager with circuit breaker (-3% P&L) and fat finger protection (30% cap)
- Evolution engine for self-improving strategy generation via failure analysis
- 35 passing tests written TDD-first covering risk, broker, and brain modules
- CI/CD pipeline, Docker multi-stage build, and AI agent context docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 02:08:48 +09:00
parent 9d9945822a
commit d1750af80f
27 changed files with 1842 additions and 0 deletions

0
src/__init__.py Normal file
View File

0
src/brain/__init__.py Normal file
View File

152
src/brain/gemini_client.py Normal file
View File

@@ -0,0 +1,152 @@
"""Decision engine powered by Google Gemini.
Constructs prompts from market data, calls Gemini, and parses structured
JSON responses into validated TradeDecision objects.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
import google.generativeai as genai
from src.config import Settings
logger = logging.getLogger(__name__)
VALID_ACTIONS = {"BUY", "SELL", "HOLD"}
@dataclass(frozen=True)
class TradeDecision:
"""Validated decision from the AI brain."""
action: str # "BUY" | "SELL" | "HOLD"
confidence: int # 0-100
rationale: str
class GeminiClient:
"""Wraps the Gemini API for trade decision-making."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._confidence_threshold = settings.CONFIDENCE_THRESHOLD
genai.configure(api_key=settings.GEMINI_API_KEY)
self._model = genai.GenerativeModel(settings.GEMINI_MODEL)
# ------------------------------------------------------------------
# Prompt Construction
# ------------------------------------------------------------------
def build_prompt(self, market_data: dict[str, Any]) -> str:
"""Build a structured prompt from market data.
The prompt instructs Gemini to return valid JSON with action,
confidence, and rationale fields.
"""
return (
"You are a professional Korean stock market trading analyst.\n"
"Analyze the following market data and decide whether to BUY, SELL, or HOLD.\n\n"
f"Stock Code: {market_data['stock_code']}\n"
f"Current Price: {market_data['current_price']}\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"
'{"action": "BUY"|"SELL"|"HOLD", "confidence": <int 0-100>, "rationale": "<string>"}\n\n'
"Rules:\n"
"- action must be exactly one of: BUY, SELL, HOLD\n"
"- confidence must be an integer from 0 to 100\n"
"- rationale must explain your reasoning concisely\n"
"- Do NOT wrap the JSON in markdown code blocks\n"
)
# ------------------------------------------------------------------
# Response Parsing
# ------------------------------------------------------------------
def parse_response(self, raw: str) -> TradeDecision:
"""Parse a raw Gemini response into a TradeDecision.
Handles: valid JSON, JSON wrapped in markdown code blocks,
malformed JSON, missing fields, and invalid action values.
On any failure, returns a safe HOLD with confidence 0.
"""
if not raw or not raw.strip():
logger.warning("Empty response from Gemini — defaulting to HOLD")
return TradeDecision(action="HOLD", confidence=0, rationale="Empty response")
# Strip markdown code fences if present
cleaned = raw.strip()
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", cleaned, re.DOTALL)
if match:
cleaned = match.group(1).strip()
try:
data = json.loads(cleaned)
except json.JSONDecodeError:
logger.warning("Malformed JSON from Gemini — defaulting to HOLD")
return TradeDecision(
action="HOLD", confidence=0, rationale="Malformed JSON response"
)
# Validate required fields
if not all(k in data for k in ("action", "confidence", "rationale")):
logger.warning("Missing fields in Gemini response — defaulting to HOLD")
return TradeDecision(
action="HOLD", confidence=0, rationale="Missing required fields"
)
action = str(data["action"]).upper()
if action not in VALID_ACTIONS:
logger.warning("Invalid action '%s' from Gemini — defaulting to HOLD", action)
return TradeDecision(
action="HOLD", confidence=0, rationale=f"Invalid action: {action}"
)
confidence = int(data["confidence"])
rationale = str(data["rationale"])
# Enforce confidence threshold
if confidence < self._confidence_threshold:
logger.info(
"Confidence %d < threshold %d — forcing HOLD",
confidence,
self._confidence_threshold,
)
action = "HOLD"
return TradeDecision(action=action, confidence=confidence, rationale=rationale)
# ------------------------------------------------------------------
# API Call
# ------------------------------------------------------------------
async def decide(self, market_data: dict[str, Any]) -> TradeDecision:
"""Build prompt, call Gemini, and return a parsed decision."""
prompt = self.build_prompt(market_data)
logger.info("Requesting trade decision from Gemini")
try:
response = await self._model.generate_content_async(prompt)
raw = response.text
except Exception as exc:
logger.error("Gemini API error: %s", exc)
return TradeDecision(
action="HOLD", confidence=0, rationale=f"API error: {exc}"
)
decision = self.parse_response(raw)
logger.info(
"Gemini decision",
extra={
"action": decision.action,
"confidence": decision.confidence,
},
)
return decision

0
src/broker/__init__.py Normal file
View File

245
src/broker/kis_api.py Normal file
View File

@@ -0,0 +1,245 @@
"""Async wrapper for the Korea Investment Securities (KIS) Open API.
Handles token refresh, rate limiting (leaky bucket), and hash key generation.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import time
from typing import Any
import aiohttp
from src.config import Settings
logger = logging.getLogger(__name__)
class LeakyBucket:
"""Simple leaky-bucket rate limiter for async code."""
def __init__(self, rate: float) -> None:
"""Args:
rate: Maximum requests per second.
"""
self._rate = rate
self._interval = 1.0 / rate
self._last = 0.0
self._lock = asyncio.Lock()
async def acquire(self) -> None:
async with self._lock:
now = asyncio.get_event_loop().time()
wait = self._last + self._interval - now
if wait > 0:
await asyncio.sleep(wait)
self._last = asyncio.get_event_loop().time()
class KISBroker:
"""Async client for KIS Open API with automatic token management."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._base_url = settings.KIS_BASE_URL
self._app_key = settings.KIS_APP_KEY
self._app_secret = settings.KIS_APP_SECRET
self._account_no = settings.account_number
self._product_cd = settings.account_product_code
self._session: aiohttp.ClientSession | None = None
self._access_token: str | None = None
self._token_expires_at: float = 0.0
self._rate_limiter = LeakyBucket(settings.RATE_LIMIT_RPS)
def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
timeout = aiohttp.ClientTimeout(total=10)
self._session = aiohttp.ClientSession(timeout=timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
# ------------------------------------------------------------------
# Token Management
# ------------------------------------------------------------------
async def _ensure_token(self) -> str:
"""Return a valid access token, refreshing if expired."""
now = asyncio.get_event_loop().time()
if self._access_token and now < self._token_expires_at:
return self._access_token
logger.info("Refreshing KIS access token")
session = self._get_session()
url = f"{self._base_url}/oauth2/tokenP"
body = {
"grant_type": "client_credentials",
"appkey": self._app_key,
"appsecret": self._app_secret,
}
async with session.post(url, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"Token refresh failed ({resp.status}): {text}")
data = await resp.json()
self._access_token = data["access_token"]
self._token_expires_at = now + data.get("expires_in", 86400) - 60 # 1-min buffer
logger.info("Token refreshed successfully")
return self._access_token
# ------------------------------------------------------------------
# Hash Key (required for POST bodies)
# ------------------------------------------------------------------
async def _get_hash_key(self, body: dict[str, Any]) -> str:
"""Request a hash key from KIS for POST request body signing."""
session = self._get_session()
url = f"{self._base_url}/uapi/hashkey"
headers = {
"content-Type": "application/json",
"appKey": self._app_key,
"appSecret": self._app_secret,
}
async with session.post(url, json=body, headers=headers) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"Hash key request failed ({resp.status}): {text}")
data = await resp.json()
return data["HASH"]
# ------------------------------------------------------------------
# Common Headers
# ------------------------------------------------------------------
async def _auth_headers(self, tr_id: str) -> dict[str, str]:
token = await self._ensure_token()
return {
"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {token}",
"appkey": self._app_key,
"appsecret": self._app_secret,
"tr_id": tr_id,
}
# ------------------------------------------------------------------
# API Methods
# ------------------------------------------------------------------
async def get_orderbook(self, stock_code: str) -> dict[str, Any]:
"""Fetch the current orderbook for a given stock code."""
await self._rate_limiter.acquire()
session = self._get_session()
headers = await self._auth_headers("FHKST01010200")
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
}
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_orderbook failed ({resp.status}): {text}"
)
return await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
async def get_balance(self) -> dict[str, Any]:
"""Fetch current account balance and holdings."""
await self._rate_limiter.acquire()
session = self._get_session()
headers = await self._auth_headers("VTTC8434R") # 모의투자 잔고조회
params = {
"CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd,
"AFHR_FLPR_YN": "N",
"OFL_YN": "",
"INQR_DVSN": "02",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N",
"PRCS_DVSN": "01",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
url = f"{self._base_url}/uapi/domestic-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_balance failed ({resp.status}): {text}"
)
return await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
raise ConnectionError(f"Network error fetching balance: {exc}") from exc
async def send_order(
self,
stock_code: str,
order_type: str, # "BUY" or "SELL"
quantity: int,
price: int = 0,
) -> dict[str, Any]:
"""Submit a buy or sell order.
Args:
stock_code: 6-digit stock code.
order_type: "BUY" or "SELL".
quantity: Number of shares.
price: Order price (0 for market order).
"""
await self._rate_limiter.acquire()
session = self._get_session()
tr_id = "VTTC0802U" if order_type == "BUY" else "VTTC0801U"
body = {
"CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd,
"PDNO": stock_code,
"ORD_DVSN": "01" if price > 0 else "06", # 01=지정가, 06=시장가
"ORD_QTY": str(quantity),
"ORD_UNPR": str(price),
}
hash_key = await self._get_hash_key(body)
headers = await self._auth_headers(tr_id)
headers["hashkey"] = hash_key
url = f"{self._base_url}/uapi/domestic-stock/v1/trading/order-cash"
try:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"send_order failed ({resp.status}): {text}"
)
data = await resp.json()
logger.info(
"Order submitted",
extra={
"stock_code": stock_code,
"action": order_type,
},
)
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
raise ConnectionError(f"Network error sending order: {exc}") from exc

44
src/config.py Normal file
View File

@@ -0,0 +1,44 @@
"""Strictly typed configuration loaded from environment variables."""
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings — loaded from .env or environment variables."""
# KIS Open API
KIS_APP_KEY: str
KIS_APP_SECRET: str
KIS_ACCOUNT_NO: str # format: "XXXXXXXX-XX"
KIS_BASE_URL: str = "https://openapivts.koreainvestment.com:9443"
# Google Gemini
GEMINI_API_KEY: str
GEMINI_MODEL: str = "gemini-pro"
# Risk Management
CIRCUIT_BREAKER_PCT: float = Field(default=-3.0, le=0.0)
FAT_FINGER_PCT: float = Field(default=30.0, gt=0.0, le=100.0)
CONFIDENCE_THRESHOLD: int = Field(default=80, ge=0, le=100)
# Database
DB_PATH: str = "data/trade_logs.db"
# Rate Limiting (requests per second for KIS API)
RATE_LIMIT_RPS: float = 10.0
# Trading mode
MODE: str = Field(default="paper", pattern="^(paper|live)$")
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@property
def account_number(self) -> str:
return self.KIS_ACCOUNT_NO.split("-")[0]
@property
def account_product_code(self) -> str:
return self.KIS_ACCOUNT_NO.split("-")[1]

0
src/core/__init__.py Normal file
View File

84
src/core/risk_manager.py Normal file
View File

@@ -0,0 +1,84 @@
"""Risk management — the Shield that protects the portfolio.
This module is READ-ONLY by policy (see docs/agents.md).
Changes require human approval and two passing test suites.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from src.config import Settings
logger = logging.getLogger(__name__)
class CircuitBreakerTripped(SystemExit):
"""Raised when daily P&L loss exceeds the allowed threshold."""
def __init__(self, pnl_pct: float, threshold: float) -> None:
self.pnl_pct = pnl_pct
self.threshold = threshold
super().__init__(
f"CIRCUIT BREAKER: Daily P&L {pnl_pct:.2f}% exceeded "
f"threshold {threshold:.2f}%. All trading halted."
)
class FatFingerRejected(Exception):
"""Raised when an order exceeds the maximum allowed proportion of cash."""
def __init__(self, order_amount: float, total_cash: float, max_pct: float) -> None:
self.order_amount = order_amount
self.total_cash = total_cash
self.max_pct = max_pct
ratio = (order_amount / total_cash * 100) if total_cash > 0 else float("inf")
super().__init__(
f"FAT FINGER: Order {order_amount:,.0f} is {ratio:.1f}% of "
f"cash {total_cash:,.0f} (max allowed: {max_pct:.1f}%)."
)
class RiskManager:
"""Pre-order risk gate that enforces circuit breaker and fat-finger checks."""
def __init__(self, settings: Settings) -> None:
self._cb_threshold = settings.CIRCUIT_BREAKER_PCT
self._ff_max_pct = settings.FAT_FINGER_PCT
def check_circuit_breaker(self, current_pnl_pct: float) -> None:
"""Halt trading if daily loss exceeds the threshold.
The threshold is inclusive: exactly -3.0% is allowed, but -3.01% is not.
"""
if current_pnl_pct < self._cb_threshold:
logger.critical(
"Circuit breaker tripped",
extra={"pnl_pct": current_pnl_pct},
)
raise CircuitBreakerTripped(current_pnl_pct, self._cb_threshold)
def check_fat_finger(self, order_amount: float, total_cash: float) -> None:
"""Reject orders that exceed the maximum proportion of available cash."""
if total_cash <= 0:
raise FatFingerRejected(order_amount, total_cash, self._ff_max_pct)
ratio_pct = (order_amount / total_cash) * 100
if ratio_pct > self._ff_max_pct:
logger.warning(
"Fat finger check failed",
extra={"order_amount": order_amount},
)
raise FatFingerRejected(order_amount, total_cash, self._ff_max_pct)
def validate_order(
self,
current_pnl_pct: float,
order_amount: float,
total_cash: float,
) -> None:
"""Run all pre-order risk checks. Raises on failure."""
self.check_circuit_breaker(current_pnl_pct)
self.check_fat_finger(order_amount, total_cash)
logger.info("Order passed risk validation")

59
src/db.py Normal file
View File

@@ -0,0 +1,59 @@
"""Database layer for trade logging."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
from typing import Any
def init_db(db_path: str) -> sqlite3.Connection:
"""Initialize the trade logs database and return a connection."""
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS 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
)
"""
)
conn.commit()
return conn
def log_trade(
conn: sqlite3.Connection,
stock_code: str,
action: str,
confidence: int,
rationale: str,
quantity: int = 0,
price: float = 0.0,
pnl: float = 0.0,
) -> None:
"""Insert a trade record into the database."""
conn.execute(
"""
INSERT INTO trades (timestamp, stock_code, action, confidence, rationale, quantity, price, pnl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
datetime.now(timezone.utc).isoformat(),
stock_code,
action,
confidence,
rationale,
quantity,
price,
pnl,
),
)
conn.commit()

View File

229
src/evolution/optimizer.py Normal file
View File

@@ -0,0 +1,229 @@
"""Evolution Engine — analyzes trade logs and generates new strategies.
This module:
1. Reads trade_logs.db to identify failing patterns
2. Asks Gemini to generate a new strategy class
3. Runs pytest on the generated file
4. Creates a simulated PR if tests pass
"""
from __future__ import annotations
import json
import logging
import sqlite3
import subprocess
import textwrap
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import google.generativeai as genai
from src.config import Settings
logger = logging.getLogger(__name__)
STRATEGIES_DIR = Path("src/strategies")
STRATEGY_TEMPLATE = textwrap.dedent("""\
\"\"\"Auto-generated strategy: {name}
Generated at: {timestamp}
Rationale: {rationale}
\"\"\"
from __future__ import annotations
from typing import Any
from src.strategies.base import BaseStrategy
class {class_name}(BaseStrategy):
\"\"\"Strategy: {name}\"\"\"
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
{body}
""")
class EvolutionOptimizer:
"""Analyzes trade history and evolves trading strategies."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._db_path = settings.DB_PATH
genai.configure(api_key=settings.GEMINI_API_KEY)
self._model = genai.GenerativeModel(settings.GEMINI_MODEL)
# ------------------------------------------------------------------
# Analysis
# ------------------------------------------------------------------
def analyze_failures(self, limit: int = 50) -> list[dict[str, Any]]:
"""Find trades where high confidence led to losses."""
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"""
SELECT stock_code, action, confidence, pnl, rationale, timestamp
FROM trades
WHERE confidence >= 80 AND pnl < 0
ORDER BY pnl ASC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def get_performance_summary(self) -> dict[str, Any]:
"""Return aggregate performance metrics from trade logs."""
conn = sqlite3.connect(self._db_path)
try:
row = conn.execute(
"""
SELECT
COUNT(*) as total_trades,
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) as wins,
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END) as losses,
COALESCE(AVG(pnl), 0) as avg_pnl,
COALESCE(SUM(pnl), 0) as total_pnl
FROM trades
"""
).fetchone()
return {
"total_trades": row[0],
"wins": row[1] or 0,
"losses": row[2] or 0,
"avg_pnl": round(row[3], 2),
"total_pnl": round(row[4], 2),
}
finally:
conn.close()
# ------------------------------------------------------------------
# Strategy Generation
# ------------------------------------------------------------------
async def generate_strategy(self, failures: list[dict[str, Any]]) -> Path | None:
"""Ask Gemini to generate a new strategy based on failure analysis.
Returns the path to the generated strategy file, or None on failure.
"""
prompt = (
"You are a quantitative trading strategy developer.\n"
"Analyze these failed trades and generate an improved strategy.\n\n"
f"Failed trades:\n{json.dumps(failures, indent=2, default=str)}\n\n"
"Generate a Python class that inherits from BaseStrategy.\n"
"The class must have an `evaluate(self, market_data: dict) -> dict` method.\n"
"The method must return a dict with keys: action, confidence, rationale.\n"
"Respond with ONLY the method body (Python code), no class definition.\n"
)
try:
response = await self._model.generate_content_async(prompt)
body = response.text.strip()
except Exception as exc:
logger.error("Failed to generate strategy: %s", exc)
return None
# Clean up code fences
if body.startswith("```"):
lines = body.split("\n")
body = "\n".join(lines[1:-1])
# Create strategy file
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
version = f"v{timestamp}"
class_name = f"Strategy_{version}"
file_name = f"{version}_evolved.py"
STRATEGIES_DIR.mkdir(parents=True, exist_ok=True)
file_path = STRATEGIES_DIR / file_name
# Indent the body for the class method
indented_body = textwrap.indent(body, " ")
content = STRATEGY_TEMPLATE.format(
name=version,
timestamp=datetime.now(timezone.utc).isoformat(),
rationale="Auto-evolved from failure analysis",
class_name=class_name,
body=indented_body.strip(),
)
file_path.write_text(content)
logger.info("Generated strategy file: %s", file_path)
return file_path
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
def validate_strategy(self, strategy_path: Path) -> bool:
"""Run pytest on the generated strategy. Returns True if all tests pass."""
logger.info("Validating strategy: %s", strategy_path)
result = subprocess.run(
["python", "-m", "pytest", "tests/", "-v", "--tb=short"],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode == 0:
logger.info("Strategy validation PASSED")
return True
else:
logger.warning(
"Strategy validation FAILED:\n%s", result.stdout + result.stderr
)
# Clean up failing strategy
strategy_path.unlink(missing_ok=True)
return False
# ------------------------------------------------------------------
# PR Simulation
# ------------------------------------------------------------------
def create_pr_simulation(self, strategy_path: Path) -> dict[str, str]:
"""Simulate creating a pull request for the new strategy."""
pr = {
"title": f"[Evolution] New strategy: {strategy_path.stem}",
"branch": f"evolution/{strategy_path.stem}",
"body": (
f"Auto-generated strategy from evolution engine.\n"
f"File: {strategy_path}\n"
f"All tests passed."
),
"status": "ready_for_review",
}
logger.info("PR simulation created: %s", pr["title"])
return pr
# ------------------------------------------------------------------
# Full Pipeline
# ------------------------------------------------------------------
async def evolve(self) -> dict[str, Any] | None:
"""Run the full evolution pipeline.
1. Analyze failures
2. Generate new strategy
3. Validate with tests
4. Create PR simulation
Returns PR info on success, None on failure.
"""
failures = self.analyze_failures()
if not failures:
logger.info("No failure patterns found — skipping evolution")
return None
strategy_path = await self.generate_strategy(failures)
if strategy_path is None:
return None
if not self.validate_strategy(strategy_path):
return None
return self.create_pr_simulation(strategy_path)

42
src/logging_config.py Normal file
View File

@@ -0,0 +1,42 @@
"""JSON-formatted structured logging for machine readability."""
from __future__ import annotations
import logging
import sys
from datetime import datetime, timezone
from typing import Any
import json
class JSONFormatter(logging.Formatter):
"""Emit log records as single-line JSON objects."""
def format(self, record: logging.LogRecord) -> str:
log_entry: dict[str, Any] = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info and record.exc_info[1]:
log_entry["exception"] = self.formatException(record.exc_info)
# Merge any extra fields attached to the record
for key in ("stock_code", "action", "confidence", "pnl_pct", "order_amount"):
value = getattr(record, key, None)
if value is not None:
log_entry[key] = value
return json.dumps(log_entry, ensure_ascii=False)
def setup_logging(level: int = logging.INFO) -> None:
"""Configure the root logger with JSON output to stdout."""
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JSONFormatter())
root = logging.getLogger()
root.setLevel(level)
# Avoid duplicate handlers on repeated calls
root.handlers.clear()
root.addHandler(handler)

171
src/main.py Normal file
View File

@@ -0,0 +1,171 @@
"""The Ouroboros — main trading loop.
Orchestrates the broker, brain, and risk manager into a continuous
trading cycle with configurable intervals.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import signal
import sys
from typing import Any
from src.brain.gemini_client import GeminiClient
from src.broker.kis_api import KISBroker
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
logger = logging.getLogger(__name__)
# Target stock codes to monitor
WATCHLIST = ["005930", "000660", "035420"] # Samsung, SK Hynix, NAVER
TRADE_INTERVAL_SECONDS = 60
async def trading_cycle(
broker: KISBroker,
brain: GeminiClient,
risk: RiskManager,
db_conn: Any,
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()
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
# 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")
)
market_data = {
"stock_code": stock_code,
"current_price": current_price,
"orderbook": orderbook.get("output1", {}),
"foreigner_net": float(
orderbook.get("output1", {}).get("frgn_ntby_qty", "0")
),
}
# 2. Ask the brain for a decision
decision = await brain.decide(market_data)
logger.info(
"Decision for %s: %s (confidence=%d)",
stock_code,
decision.action,
decision.confidence,
)
# 3. Execute if actionable
if decision.action in ("BUY", "SELL"):
# Determine order size (simplified: 1 lot)
quantity = 1
order_amount = current_price * quantity
# 4. Risk check BEFORE order
risk.validate_order(
current_pnl_pct=pnl_pct,
order_amount=order_amount,
total_cash=total_cash,
)
# 5. Send order
result = await broker.send_order(
stock_code=stock_code,
order_type=decision.action,
quantity=quantity,
price=0, # market order
)
logger.info("Order result: %s", result.get("msg1", "OK"))
# 6. Log trade
log_trade(
conn=db_conn,
stock_code=stock_code,
action=decision.action,
confidence=decision.confidence,
rationale=decision.rationale,
)
async def run(settings: Settings) -> None:
"""Main async loop — iterate over watchlist on a timer."""
broker = KISBroker(settings)
brain = GeminiClient(settings)
risk = RiskManager(settings)
db_conn = init_db(settings.DB_PATH)
shutdown = asyncio.Event()
def _signal_handler() -> None:
logger.info("Shutdown signal received")
shutdown.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _signal_handler)
logger.info("The Ouroboros is alive. Mode: %s", settings.MODE)
logger.info("Watchlist: %s", WATCHLIST)
try:
while not shutdown.is_set():
for code in WATCHLIST:
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)
# Wait for next cycle or shutdown
try:
await asyncio.wait_for(shutdown.wait(), timeout=TRADE_INTERVAL_SECONDS)
except asyncio.TimeoutError:
pass # Normal — timeout means it's time for next cycle
finally:
await broker.close()
db_conn.close()
logger.info("The Ouroboros rests.")
def main() -> None:
parser = argparse.ArgumentParser(description="The Ouroboros Trading Agent")
parser.add_argument(
"--mode",
choices=["paper", "live"],
default="paper",
help="Trading mode (default: paper)",
)
args = parser.parse_args()
setup_logging()
settings = Settings(MODE=args.mode) # type: ignore[call-arg]
asyncio.run(run(settings))
if __name__ == "__main__":
main()

View File

19
src/strategies/base.py Normal file
View File

@@ -0,0 +1,19 @@
"""Base class for all trading strategies."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class BaseStrategy(ABC):
"""All strategies must inherit from this class."""
@abstractmethod
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
"""Evaluate market data and return a trade decision.
Returns:
dict with keys: action ("BUY"|"SELL"|"HOLD"), confidence (int), rationale (str)
"""
...