From efc90dfd24608695f598e90ffc35524f3d849715 Mon Sep 17 00:00:00 2001 From: Hojin Jang Date: Thu, 18 Jun 2026 21:49:55 +0900 Subject: [PATCH 1/5] docs: add yfinance paper mode plan --- .../2026-06-18_213222-yfinance-paper-mode.md | 1298 +++++++++++++++++ 1 file changed, 1298 insertions(+) create mode 100644 .hermes/plans/2026-06-18_213222-yfinance-paper-mode.md diff --git a/.hermes/plans/2026-06-18_213222-yfinance-paper-mode.md b/.hermes/plans/2026-06-18_213222-yfinance-paper-mode.md new file mode 100644 index 0000000..7025e7c --- /dev/null +++ b/.hermes/plans/2026-06-18_213222-yfinance-paper-mode.md @@ -0,0 +1,1298 @@ +# yfinance Paper Mode Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Add a KIS-free paper trading mode that uses yfinance for market data and the existing BeRich paper execution/dashboard flow. + +**Architecture:** Introduce an explicit broker/data-source boundary instead of hard-coding `KISBroker` in `TradingBot`. Add a `YFinanceBroker` that implements the same async methods BeRich already calls (`connect`, `get_current_price`, `get_historical_bars`, balances, positions, order submission/cancel). In `BROKER=yfinance` + `TRADING_MODE=paper`, use yfinance for price/history and existing-style simulated fills, without requiring KIS credentials. + +**Tech Stack:** Python 3.13, FastAPI dashboard, SQLAlchemy storage, pytest, uv, yfinance. + +--- + +## Current Context + +The repo is at `/home/hojinjang/dev/BeRich`. + +Observed current behavior: + +- `uv sync --extra dev` succeeds. +- `uv run pytest tests/ -v` currently passes: `194 passed`. +- Docker build succeeds. +- Existing `KIS_PAPER_TRADING=true` is not fully offline: `PaperBroker` delegates price/history/auth to `KISBroker`. +- Bot fails with example credentials because KIS auth is still required: + - `Authentication failed: {"error_code":"EGW00103","error_description":"유효하지 않은 AppKey입니다."}` + +Key files: + +- `src/bot/core.py` + - imports `KISBroker` directly. + - `_initialize_broker()` hard-codes KIS. +- `src/broker/paper.py` + - simulates order fills. + - delegates `connect`, `get_current_price`, `get_historical_bars` to a real KIS broker. +- `src/broker/kis/client.py` + - implicit broker API surface used by the bot/order manager. +- `src/execution/order_manager.py` + - calls `broker.get_current_price()`, `broker.get_positions()`, `broker.submit_order()`. +- `src/core/types.py` + - common `Market`, `Order`, `Position`, `Bar`, etc. +- `.env.example`, `pyproject.toml`, tests under `tests/`. + +## Assumptions + +- Initial goal is **not live trading**. +- Initial yfinance mode must support both US equities/ETFs and Korean equities/ETFs. +- KRX support should include `.KS`/`.KQ` handling. Numeric 6-digit symbols may default to `.KS`, but the implementation should allow explicit `.KQ` symbols/config where needed. +- Paper fills can be immediate at current yfinance price, same as current `PaperBroker` behavior. +- Existing dashboard and strategy code should remain mostly unchanged. +- Avoid broad refactor. Keep compatibility with current KIS behavior. + +## Proposed Design + +### New config knobs + +Add env vars: + +```env +BROKER=yfinance # kis | yfinance +TRADING_MODE=paper # paper only for yfinance initially +YFINANCE_PRICE_DELAY=0 # optional future knob, not required for first implementation +``` + +Keep current KIS vars working: + +```env +KIS_APP_KEY=... +KIS_APP_SECRET=... +KIS_ACCOUNT_NO=... +KIS_PAPER_TRADING=true +``` + +Compatibility rule: + +- If `BROKER` is missing, default to `kis` so existing behavior is unchanged. +- If `BROKER=yfinance`, force paper-style behavior and never instantiate `KISBroker`. + +### Broker API + +Create a lightweight typing Protocol to document the implicit interface: + +```python +# src/broker/base.py +from typing import Protocol +from decimal import Decimal + +from src.core.types import Market, Order, Position, Bar + + +class Broker(Protocol): + paper_trading: bool + + @property + def is_connected(self) -> bool: ... + + @property + def account_no(self) -> str: ... + + async def connect(self) -> None: ... + async def disconnect(self) -> None: ... + async def get_current_price(self, symbol: str, market: Market = Market.KRX) -> Decimal: ... + async def get_historical_bars(self, symbol: str, market: Market = Market.KRX, days: int = 100) -> list[Bar]: ... + async def get_positions(self, market: Market = Market.KRX) -> list[Position]: ... + async def get_account_balance(self, market: Market = Market.KRX) -> dict: ... + async def submit_order(self, order: Order) -> str: ... + async def cancel_order(self, order_id: str) -> bool: ... +``` + +No runtime inheritance needed. This is primarily for clarity/tests/type checking. + +### YFinanceBroker behavior + +Create `src/broker/yfinance.py`. + +Responsibilities: + +- Connect/disconnect as no-op state transitions. +- Map BeRich `Market` + symbol to yfinance ticker: + - `Market.NASDAQ`, `Market.NYSE`, `Market.AMEX`: symbol unchanged, e.g. `AAPL`, `SPY`. + - `Market.KRX`: initially support explicit suffix if user provides it; otherwise map numeric 6-digit symbol to `{symbol}.KS` as best-effort. +- `get_current_price()`: + - use `yf.Ticker(ticker).history(period="5d", interval="1d")` or `fast_info` fallback. + - return latest close/last price as `Decimal`. +- `get_historical_bars()`: + - use daily history for at least `days` rows, preferably `period=f"{max(days * 2, 120)}d"` to account for weekends/holidays. + - convert rows to `Bar` with `timeframe="1d"`. +- Paper account state: + - initial USD and KRW from existing config keys `trading.paper_cash_usd`, `trading.paper_cash_krw`. + - persist paper cash, positions, orders, and fills across restarts by default using the existing database/storage layer or a small broker-owned JSON state file under `data/` if DB integration is too invasive for v1. + - keep an explicit reset path for development, but do not reset on ordinary process restart. +- `submit_order()`: + - immediate fill at `get_current_price()` or order price fallback. + - update cash/positions. + - emit `ORDER_FILLED` event like current `PaperBroker`. +- `get_positions()`: + - return in-memory positions with current yfinance price. +- `get_account_balance()`: + - return dict with `total_eval`, `cash`, `stocks_eval`, `profit_loss`. +- `cancel_order()`: + - if pending/submitted order exists, mark cancelled; otherwise return false. + +### Broker factory + +Create `src/broker/factory.py`: + +```python +from decimal import Decimal +from src.core.events import EventBus +from src.utils.config import Config +from src.broker.kis.client import KISBroker +from src.broker.paper import PaperBroker +from src.broker.yfinance import YFinanceBroker + + +def create_broker(config: Config, event_bus: EventBus): + broker_name = config.get_broker_name() + trading_mode = config.get_trading_mode() + + if broker_name == "yfinance": + if trading_mode != "paper": + raise ValueError("BROKER=yfinance only supports TRADING_MODE=paper") + return YFinanceBroker( + event_bus=event_bus, + initial_cash_usd=Decimal(str(config.get("trading.paper_cash_usd", 10000))), + initial_cash_krw=Decimal(str(config.get("trading.paper_cash_krw", 0))), + ) + + if broker_name == "kis": + kis_config = config.get_kis_config() + real_broker = KISBroker(...) + if kis_config["paper_trading"]: + return PaperBroker(...) + return real_broker + + raise ValueError(f"Unsupported BROKER: {broker_name}") +``` + +Then simplify `TradingBot._initialize_broker()` to call factory. + +--- + +## Step-by-Step Plan + +### Task 1: Add yfinance dependency + +**Objective:** Make yfinance available in both uv/dev and Docker installs. + +**Files:** + +- Modify: `pyproject.toml` +- Modify: `uv.lock` via `uv lock` or `uv sync` + +**Change:** + +Add to `[project].dependencies`: + +```toml +"yfinance>=0.2.50", +``` + +**Validation:** + +Run: + +```bash +uv sync --extra dev +uv run python - <<'PY' +import yfinance as yf +print(yf.__version__) +PY +``` + +Expected: + +- `uv sync` succeeds. +- Python prints a yfinance version. + +--- + +### Task 2: Document the broker Protocol + +**Objective:** Make the currently implicit broker interface explicit without changing runtime behavior. + +**Files:** + +- Create: `src/broker/base.py` +- Modify: `tests/test_broker_base.py` or create if absent + +**Implementation:** + +Create: + +```python +from typing import Protocol +from decimal import Decimal + +from src.core.types import Market, Order, Position, Bar + + +class Broker(Protocol): + """Async broker interface used by TradingBot and OrderManager.""" + + paper_trading: bool + + @property + def is_connected(self) -> bool: + """Whether the broker is connected.""" + ... + + @property + def account_no(self) -> str: + """Account identifier for logs/dashboard.""" + ... + + async def connect(self) -> None: + ... + + async def disconnect(self) -> None: + ... + + async def get_current_price( + self, + symbol: str, + market: Market = Market.KRX, + ) -> Decimal: + ... + + async def get_historical_bars( + self, + symbol: str, + market: Market = Market.KRX, + days: int = 100, + ) -> list[Bar]: + ... + + async def get_positions( + self, + market: Market = Market.KRX, + ) -> list[Position]: + ... + + async def get_account_balance( + self, + market: Market = Market.KRX, + ) -> dict: + ... + + async def submit_order(self, order: Order) -> str: + ... + + async def cancel_order(self, order_id: str) -> bool: + ... +``` + +**Test:** + +Basic import test: + +```python +from src.broker.base import Broker + + +def test_broker_protocol_importable(): + assert Broker is not None +``` + +**Validation:** + +Run: + +```bash +uv run pytest tests/test_broker_base.py -v +``` + +Expected: pass. + +--- + +### Task 3: Add config helpers for broker selection + +**Objective:** Centralize broker/mode env parsing while preserving current defaults. + +**Files:** + +- Modify: `src/utils/config.py` +- Modify: `tests/test_core.py` or create `tests/test_config.py` +- Modify: `.env.example` +- Modify: `README.md` setup section later in Task 10; avoid doing README here if keeping tasks tiny. + +**Implementation in `src/utils/config.py`:** + +Add methods to `Config`: + +```python + def get_broker_name(self) -> str: + """Get selected broker implementation.""" + return os.getenv("BROKER", "kis").strip().lower() + + def get_trading_mode(self) -> str: + """Get trading mode: paper or live.""" + mode = os.getenv("TRADING_MODE") + if mode: + return mode.strip().lower() + return "paper" if os.getenv("KIS_PAPER_TRADING", "true").lower() == "true" else "live" +``` + +Update `.env.example` near the top: + +```env +# Broker selection: kis or yfinance +BROKER=kis + +# Trading mode: paper or live. yfinance supports paper only. +TRADING_MODE=paper +``` + +**Tests:** + +Use monkeypatch: + +```python +from src.utils.config import Config + + +def test_broker_defaults_to_kis(monkeypatch): + monkeypatch.delenv("BROKER", raising=False) + assert Config().get_broker_name() == "kis" + + +def test_broker_reads_env_lowercase(monkeypatch): + monkeypatch.setenv("BROKER", "YFINANCE") + assert Config().get_broker_name() == "yfinance" + + +def test_trading_mode_defaults_from_kis_paper_flag(monkeypatch): + monkeypatch.delenv("TRADING_MODE", raising=False) + monkeypatch.setenv("KIS_PAPER_TRADING", "false") + assert Config().get_trading_mode() == "live" +``` + +**Validation:** + +Run: + +```bash +uv run pytest tests/test_config.py -v +``` + +Expected: pass. + +--- + +### Task 4: Implement yfinance symbol mapping and history conversion helpers + +**Objective:** Add deterministic helper functions before wiring the full broker. + +**Files:** + +- Create: `src/broker/yfinance.py` +- Create: `tests/test_yfinance_broker.py` + +**Implementation skeleton:** + +```python +from datetime import datetime +from decimal import Decimal +from uuid import uuid4 +import logging + +import pandas as pd +import yfinance as yf + +from src.core.events import EventBus, Event, EventType +from src.core.types import Bar, Fill, Market, Order, OrderSide, OrderStatus, Position + +logger = logging.getLogger("TradingBot") + + +def to_yfinance_symbol(symbol: str, market: Market) -> str: + symbol = symbol.strip().upper() + if market in {Market.NASDAQ, Market.NYSE, Market.AMEX}: + return symbol + if market == Market.KRX: + if symbol.endswith((".KS", ".KQ")): + return symbol + if symbol.isdigit() and len(symbol) == 6: + return f"{symbol}.KS" + return symbol + + +def row_to_bar(symbol: str, market: Market, timestamp, row) -> Bar: + return Bar( + symbol=symbol, + market=market, + open=Decimal(str(row["Open"])), + high=Decimal(str(row["High"])), + low=Decimal(str(row["Low"])), + close=Decimal(str(row["Close"])), + volume=int(row.get("Volume", 0) or 0), + timestamp=timestamp.to_pydatetime() if hasattr(timestamp, "to_pydatetime") else timestamp, + timeframe="1d", + ) +``` + +**Tests:** + +```python +from decimal import Decimal +import pandas as pd + +from src.broker.yfinance import to_yfinance_symbol, row_to_bar +from src.core.types import Market + + +def test_to_yfinance_symbol_us_markets_unchanged(): + assert to_yfinance_symbol("aapl", Market.NASDAQ) == "AAPL" + assert to_yfinance_symbol("spy", Market.AMEX) == "SPY" + + +def test_to_yfinance_symbol_krx_numeric_defaults_to_ks(): + assert to_yfinance_symbol("005930", Market.KRX) == "005930.KS" + + +def test_to_yfinance_symbol_krx_suffix_preserved(): + assert to_yfinance_symbol("091990.KQ", Market.KRX) == "091990.KQ" + + +def test_row_to_bar_converts_ohlcv(): + row = pd.Series({"Open": 1.1, "High": 2.2, "Low": 1.0, "Close": 2.0, "Volume": 123}) + ts = pd.Timestamp("2026-01-02") + bar = row_to_bar("AAPL", Market.NASDAQ, ts, row) + assert bar.symbol == "AAPL" + assert bar.close == Decimal("2.0") + assert bar.volume == 123 + assert bar.timeframe == "1d" +``` + +**Validation:** + +Run: + +```bash +uv run pytest tests/test_yfinance_broker.py -v +``` + +Expected: pass. + +--- + +### Task 5: Implement `YFinanceBroker` read methods + +**Objective:** Support connection state, current price, historical bars, and paper account balance reads. + +**Files:** + +- Modify: `src/broker/yfinance.py` +- Modify: `tests/test_yfinance_broker.py` + +**Implementation notes:** + +Add class: + +```python +class YFinanceBroker: + """KIS-free paper broker using yfinance market data.""" + + def __init__( + self, + event_bus: EventBus, + initial_cash_krw: Decimal = Decimal("0"), + initial_cash_usd: Decimal = Decimal("10000"), + ): + self.event_bus = event_bus + self.paper_trading = True + self._connected = False + self._cash = {"krw": initial_cash_krw, "usd": initial_cash_usd} + self._positions: dict[str, dict] = {} + self._orders: dict[str, Order] = {} + self._fills: list[Fill] = [] + + @property + def is_connected(self) -> bool: + return self._connected + + @property + def account_no(self) -> str: + return "YFINANCE-PAPER" + + async def connect(self) -> None: + self._connected = True + logger.info("YFinance paper broker connected") + await self.event_bus.publish(Event( + event_type=EventType.BROKER_CONNECTED, + data={"broker": "yfinance", "paper_trading": True}, + timestamp=datetime.now(), + source="YFinanceBroker", + )) + + async def disconnect(self) -> None: + self._connected = False + logger.info("YFinance paper broker disconnected") + + def _history(self, symbol: str, market: Market, period: str, interval: str) -> pd.DataFrame: + ticker = to_yfinance_symbol(symbol, market) + return yf.Ticker(ticker).history(period=period, interval=interval, auto_adjust=False) + + async def get_current_price(self, symbol: str, market: Market = Market.KRX) -> Decimal: + df = self._history(symbol, market, period="5d", interval="1d") + if df.empty: + raise ValueError(f"No yfinance price data for {symbol} ({market.value})") + close = df["Close"].dropna().iloc[-1] + return Decimal(str(close)) + + async def get_historical_bars(self, symbol: str, market: Market = Market.KRX, days: int = 100) -> list[Bar]: + period_days = max(days * 2, 120) + df = self._history(symbol, market, period=f"{period_days}d", interval="1d") + if df.empty: + raise ValueError(f"No yfinance historical data for {symbol} ({market.value})") + bars = [row_to_bar(symbol, market, idx, row) for idx, row in df.tail(days).iterrows()] + return bars +``` + +Add balance and positions read methods: + +```python + async def get_positions(self, market: Market = Market.KRX) -> list[Position]: + positions = [] + for symbol, pos in self._positions.items(): + if pos["market"] != market or pos["quantity"] <= 0: + continue + current_price = await self.get_current_price(symbol, market) + positions.append(Position( + symbol=symbol, + market=market, + quantity=pos["quantity"], + avg_entry_price=pos["avg_price"], + current_price=current_price, + unrealized_pnl=(current_price - pos["avg_price"]) * pos["quantity"], + )) + return positions + + async def get_account_balance(self, market: Market = Market.KRX) -> dict: + cash_key = "krw" if market == Market.KRX else "usd" + positions = await self.get_positions(market) + stocks_eval = sum((p.current_price * p.quantity for p in positions), Decimal("0")) + profit_loss = sum((p.unrealized_pnl for p in positions), Decimal("0")) + cash = self._cash[cash_key] + return { + "total_eval": cash + stocks_eval, + "cash": cash, + "stocks_eval": stocks_eval, + "profit_loss": profit_loss, + } +``` + +**Tests:** + +Patch `_history` to avoid network: + +```python +from decimal import Decimal +import pandas as pd +import pytest + +from src.broker.yfinance import YFinanceBroker +from src.core.events import EventBus +from src.core.types import Market + + +@pytest.mark.asyncio +async def test_yfinance_broker_connects(): + broker = YFinanceBroker(EventBus()) + await broker.connect() + assert broker.is_connected is True + assert broker.account_no == "YFINANCE-PAPER" + + +@pytest.mark.asyncio +async def test_get_current_price_uses_latest_close(monkeypatch): + broker = YFinanceBroker(EventBus()) + df = pd.DataFrame({"Close": [10.0, 12.5]}, index=pd.date_range("2026-01-01", periods=2)) + monkeypatch.setattr(broker, "_history", lambda *args, **kwargs: df) + assert await broker.get_current_price("AAPL", Market.NASDAQ) == Decimal("12.5") + + +@pytest.mark.asyncio +async def test_get_historical_bars_returns_tail(monkeypatch): + broker = YFinanceBroker(EventBus()) + df = pd.DataFrame( + {"Open": [1, 2, 3], "High": [2, 3, 4], "Low": [0, 1, 2], "Close": [1.5, 2.5, 3.5], "Volume": [10, 20, 30]}, + index=pd.date_range("2026-01-01", periods=3), + ) + monkeypatch.setattr(broker, "_history", lambda *args, **kwargs: df) + bars = await broker.get_historical_bars("AAPL", Market.NASDAQ, days=2) + assert len(bars) == 2 + assert bars[-1].close == Decimal("3.5") +``` + +**Validation:** + +Run: + +```bash +uv run pytest tests/test_yfinance_broker.py -v +``` + +Expected: pass. + +--- + +### Task 6: Implement `YFinanceBroker` paper order execution + +**Objective:** Make yfinance broker usable by `OrderManager` for simulated fills. + +**Files:** + +- Modify: `src/broker/yfinance.py` +- Modify: `tests/test_yfinance_broker.py` + +**Implementation:** + +Port the minimal order execution behavior from `PaperBroker`, but keep it self-contained. + +Important behavior: + +- BUY reduces cash and creates/averages position. +- SELL requires existing quantity and increases cash. +- Filled orders emit `ORDER_FILLED` with the `Order` as data, same as current `PaperBroker`. +- Rejected orders return an order id with `OrderStatus.REJECTED`. + +**Core code shape:** + +```python + async def submit_order(self, order: Order) -> str: + order_id = f"YF-PAPER-{uuid4().hex[:8].upper()}" + order.order_id = order_id + order.status = OrderStatus.SUBMITTED + fill_price = order.price or await self.get_current_price(order.symbol, order.market) + cash_key = "krw" if order.market == Market.KRX else "usd" + pnl = None + + if order.side == OrderSide.BUY: + cost = fill_price * order.quantity + if cost > self._cash[cash_key]: + order.status = OrderStatus.REJECTED + self._orders[order_id] = order + return order_id + self._cash[cash_key] -= cost + pos = self._positions.get(order.symbol) + if pos: + old_qty = pos["quantity"] + new_qty = old_qty + order.quantity + pos["avg_price"] = ((pos["avg_price"] * old_qty) + (fill_price * order.quantity)) / new_qty + pos["quantity"] = new_qty + else: + self._positions[order.symbol] = { + "quantity": order.quantity, + "avg_price": fill_price, + "market": order.market, + } + + elif order.side == OrderSide.SELL: + pos = self._positions.get(order.symbol) + if not pos or pos["quantity"] < order.quantity: + order.status = OrderStatus.REJECTED + self._orders[order_id] = order + return order_id + proceeds = fill_price * order.quantity + self._cash[cash_key] += proceeds + pnl = (fill_price - pos["avg_price"]) * order.quantity + pos["quantity"] -= order.quantity + if pos["quantity"] <= 0: + del self._positions[order.symbol] + + order.status = OrderStatus.FILLED + order.filled_quantity = order.quantity + order.filled_avg_price = fill_price + self._orders[order_id] = order + self._fills.append(Fill( + order_id=order_id, + symbol=order.symbol, + market=order.market, + side=order.side, + quantity=order.quantity, + price=fill_price, + commission=Decimal("0"), + timestamp=datetime.now(), + pnl=pnl, + )) + await self.event_bus.publish(Event( + event_type=EventType.ORDER_FILLED, + data=order, + timestamp=datetime.now(), + source="YFinanceBroker", + )) + return order_id + + async def cancel_order(self, order_id: str) -> bool: + order = self._orders.get(order_id) + if not order or order.status == OrderStatus.FILLED: + return False + order.status = OrderStatus.CANCELLED + return True +``` + +**Tests:** + +```python +from src.core.types import Order, OrderSide, OrderType, OrderStatus + + +@pytest.mark.asyncio +async def test_submit_buy_order_updates_cash_and_position(monkeypatch): + broker = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000")) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("10"))) + order = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5) + order_id = await broker.submit_order(order) + assert order_id.startswith("YF-PAPER-") + assert order.status == OrderStatus.FILLED + assert broker._cash["usd"] == Decimal("950") + positions = await broker.get_positions(Market.NASDAQ) + assert positions[0].quantity == 5 + + +@pytest.mark.asyncio +async def test_submit_buy_rejects_insufficient_cash(monkeypatch): + broker = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("10")) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("20"))) + order = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=1) + await broker.submit_order(order) + assert order.status == OrderStatus.REJECTED + + +@pytest.mark.asyncio +async def test_submit_sell_order_updates_cash_and_position(monkeypatch): + broker = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000")) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("10"))) + buy = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5) + await broker.submit_order(buy) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("12"))) + sell = Order("AAPL", Market.NASDAQ, OrderSide.SELL, OrderType.MARKET, quantity=2) + await broker.submit_order(sell) + assert sell.status == OrderStatus.FILLED + assert broker._cash["usd"] == Decimal("974") + positions = await broker.get_positions(Market.NASDAQ) + assert positions[0].quantity == 3 +``` + +**Validation:** + +Run: + +```bash +uv run pytest tests/test_yfinance_broker.py -v +``` + +Expected: pass. + +--- + +### Task 7: Persist yfinance paper state across restarts + +**Objective:** Make paper cash, positions, orders, and fills survive ordinary process restarts. + +**Files:** + +- Modify: `src/broker/yfinance.py` +- Modify: `tests/test_yfinance_broker.py` +- Possibly create: `data/yfinance_paper_state.json` at runtime only; do not commit runtime state. + +**Implementation approach:** + +Use a small JSON state file as the v1 persistence layer because it is broker-local and avoids a larger DB schema migration. Default path: + +```text +data/yfinance_paper_state.json +``` + +Add optional constructor arg: + +```python +state_path: str | Path = "data/yfinance_paper_state.json" +``` + +Behavior: + +- On `connect()`, load the state file if it exists. +- If it does not exist, initialize from configured paper cash. +- After every accepted/rejected order state change, save state atomically. +- Persist Decimals as strings. +- Persist markets/order statuses by enum value. +- Do not persist transient connection state. +- Add a helper method `reset_state()` for tests/dev, but do not call it during normal startup. + +**Tests:** + +- Create broker with temp `state_path`. +- Buy a position. +- Instantiate a new broker with the same `state_path`. +- Connect/load and assert cash and position are restored. + +Example assertion shape: + +```python +@pytest.mark.asyncio +async def test_yfinance_paper_state_persists_across_restart(tmp_path, monkeypatch): + state_path = tmp_path / "paper_state.json" + broker1 = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000"), state_path=state_path) + monkeypatch.setattr(broker1, "get_current_price", AsyncMock(return_value=Decimal("10"))) + await broker1.connect() + await broker1.submit_order(Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5)) + + broker2 = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000"), state_path=state_path) + monkeypatch.setattr(broker2, "get_current_price", AsyncMock(return_value=Decimal("10"))) + await broker2.connect() + positions = await broker2.get_positions(Market.NASDAQ) + + assert broker2._cash["usd"] == Decimal("950") + assert positions[0].symbol == "AAPL" + assert positions[0].quantity == 5 +``` + +**Validation:** + +Run: + +```bash +uv run pytest tests/test_yfinance_broker.py -v +``` + +Expected: pass. + +--- + +### Task 8: Add broker factory and preserve KIS behavior + +**Objective:** Select KIS or yfinance from config without changing the rest of the bot. + +**Files:** + +- Create: `src/broker/factory.py` +- Modify: `tests/test_broker_factory.py` + +**Implementation:** + +Implement `create_broker(config, event_bus)`. + +Key requirements: + +- `BROKER=yfinance` returns `YFinanceBroker` and does not construct `KISBroker`. +- `BROKER=kis` preserves existing KIS + `PaperBroker` wrapping behavior. +- Unknown broker raises `ValueError`. +- `BROKER=yfinance` with `TRADING_MODE=live` raises `ValueError`. + +**Tests:** + +Use monkeypatch env and avoid calling network. + +```python +from src.broker.factory import create_broker +from src.broker.yfinance import YFinanceBroker +from src.core.events import EventBus +from src.utils.config import Config + + +def test_factory_returns_yfinance_broker(monkeypatch): + monkeypatch.setenv("BROKER", "yfinance") + monkeypatch.setenv("TRADING_MODE", "paper") + broker = create_broker(Config(), EventBus()) + assert isinstance(broker, YFinanceBroker) + + +def test_factory_rejects_yfinance_live(monkeypatch): + monkeypatch.setenv("BROKER", "yfinance") + monkeypatch.setenv("TRADING_MODE", "live") + with pytest.raises(ValueError, match="paper"): + create_broker(Config(), EventBus()) + + +def test_factory_rejects_unknown_broker(monkeypatch): + monkeypatch.setenv("BROKER", "unknown") + with pytest.raises(ValueError, match="Unsupported BROKER"): + create_broker(Config(), EventBus()) +``` + +**Validation:** + +Run: + +```bash +uv run pytest tests/test_broker_factory.py -v +``` + +Expected: pass. + +--- + +### Task 9: Wire `TradingBot` to broker factory + +**Objective:** Remove direct KIS construction from bot initialization path. + +**Files:** + +- Modify: `src/bot/core.py` +- Modify: tests that assert bot initialization behavior if present, likely `tests/test_bot.py` + +**Implementation:** + +Change imports: + +```python +# remove or stop using this for type annotation only +from src.broker.kis.client import KISBroker + +# add +from src.broker.base import Broker +from src.broker.factory import create_broker +``` + +Change property annotation: + +```python +self.broker: Broker | None = None +``` + +Replace `_initialize_broker()` body construction section with: + +```python + async def _initialize_broker(self) -> None: + """Initialize broker connection""" + self.broker = create_broker(self.config, self.event_bus) + await self.broker.connect() + + try: + krw_balance = await self.broker.get_account_balance(Market.KRX) + ... existing balance/dashboard code unchanged ... +``` + +Keep existing dashboard balance update code as-is. + +**Tests:** + +Patch `create_broker` in `src.bot.core` and assert it is called by `_initialize_broker()`. + +```python +@pytest.mark.asyncio +async def test_initialize_broker_uses_factory(monkeypatch): + bot = TradingBot() + broker = AsyncMock() + broker.get_account_balance = AsyncMock(return_value={"total_eval": Decimal("1000"), "cash": Decimal("1000"), "profit_loss": Decimal("0")}) + monkeypatch.setattr("src.bot.core.create_broker", lambda config, event_bus: broker) + await bot._initialize_broker() + broker.connect.assert_awaited_once() + assert bot.broker is broker +``` + +Adjust for existing test fixtures/imports. + +**Validation:** + +Run: + +```bash +uv run pytest tests/test_bot.py -v +uv run pytest tests/test_broker_factory.py tests/test_yfinance_broker.py -v +``` + +Expected: pass. + +--- + +### Task 10: Integration smoke test for yfinance mode without KIS env + +**Objective:** Prove BeRich can initialize with yfinance paper mode without KIS credentials. + +**Files:** + +- Create or modify: `tests/test_yfinance_integration.py` + +**Approach:** + +Avoid real network by monkeypatching `YFinanceBroker._history` or `get_current_price/get_historical_bars`. + +Test that: + +- `BROKER=yfinance` +- `TRADING_MODE=paper` +- KIS env vars removed +- `TradingBot._initialize_broker()` succeeds +- selected broker account is `YFINANCE-PAPER` + +**Test shape:** + +```python +@pytest.mark.asyncio +async def test_yfinance_mode_does_not_require_kis_credentials(monkeypatch): + monkeypatch.setenv("BROKER", "yfinance") + monkeypatch.setenv("TRADING_MODE", "paper") + monkeypatch.delenv("KIS_APP_KEY", raising=False) + monkeypatch.delenv("KIS_APP_SECRET", raising=False) + monkeypatch.delenv("KIS_ACCOUNT_NO", raising=False) + + async def fake_get_account_balance(self, market=Market.KRX): + return {"total_eval": Decimal("10000"), "cash": Decimal("10000"), "stocks_eval": Decimal("0"), "profit_loss": Decimal("0")} + + monkeypatch.setattr(YFinanceBroker, "get_account_balance", fake_get_account_balance) + + bot = TradingBot() + await bot._initialize_broker() + assert bot.broker.account_no == "YFINANCE-PAPER" + await bot.broker.disconnect() +``` + +**Validation:** + +Run: + +```bash +uv run pytest tests/test_yfinance_integration.py -v +``` + +Expected: pass. + +--- + +### Task 11: Update documentation and examples + +**Objective:** Make yfinance paper mode discoverable and easy to run. + +**Files:** + +- Modify: `.env.example` +- Modify: `README.md` + +**README additions:** + +Add a section before KIS setup or inside install/run: + +```markdown +### KIS 없이 yfinance paper mode로 실행 + +KIS API 키 없이 UI와 RSI 전략을 paper trading으로 테스트하려면 `.env`에서: + +```env +BROKER=yfinance +TRADING_MODE=paper +KIS_PAPER_TRADING=true +DASHBOARD_USERNAME=admin +DASHBOARD_PASSWORD=change-me +``` + +이 모드는 yfinance에서 시세/일봉 데이터를 가져오고, 주문은 메모리 상에서 즉시 체결 처리합니다. 실제 주문은 발생하지 않습니다. + +실행: + +```bash +uv run python scripts/run_bot.py --web --web-port 9095 +``` + +또는 Docker: + +```bash +docker compose up -d --build +``` + +주의: + +- yfinance 데이터는 지연/누락될 수 있어 실거래 판단용으로 쓰면 안 됩니다. +- KRX 종목은 yfinance suffix가 필요할 수 있습니다. 예: `005930.KS`, `091990.KQ`. +- yfinance broker는 현재 live trading을 지원하지 않습니다. +``` + +**Validation:** + +Run: + +```bash +uv run pytest tests/ -v +``` + +Expected: all tests pass. + +--- + +### Task 12: Local runtime verification with real yfinance network + +**Objective:** Actually run the app in yfinance paper mode and verify dashboard responds. + +**Files:** + +- Modify local `.env` only after confirmation because it is environment/config write. + +**Manual `.env` change:** + +Set: + +```env +BROKER=yfinance +TRADING_MODE=paper +DASHBOARD_USERNAME=admin +DASHBOARD_PASSWORD= +``` + +Leave KIS values unused. + +**Commands:** + +Stop old mock dashboard if still running: + +```bash +# Identify Hermes background proc if needed, or stop process using port 9095 after confirming scope. +ss -ltnp | grep ':9095' +``` + +Run direct dev server: + +```bash +uv run python scripts/run_bot.py --web --web-port 9095 +``` + +Expected logs: + +```text +YFinance paper broker connected +Trading Bot initialized successfully +Web dashboard started at http://localhost:9095 +``` + +Verify HTTP: + +```bash +curl -sS -L -o /tmp/berich_yfinance_home.html -w '%{http_code} %{content_type}\n' http://127.0.0.1:9095/ +grep -o '[^<]*' /tmp/berich_yfinance_home.html | head -1 +``` + +Expected: + +```text +200 text/html; charset=utf-8 +<title>BeRich Dashboard +``` + +**Note:** If app redirects to login because auth is enabled, `200` login page is still acceptable; verify the process logs have yfinance broker connected. + +--- + +### Task 13: Docker verification + +**Objective:** Ensure container image still builds and runs with yfinance mode. + +**Commands:** + +```bash +docker compose build +docker compose up -d +sleep 10 +docker compose ps +docker logs --tail 100 quant-bot +``` + +Expected: + +- build succeeds. +- MySQL is healthy. +- `quant-bot` stays running. +- logs show `YFinance paper broker connected`, not KIS auth failure. + +If `logs/` or `data/` permissions recur: + +```bash +mkdir -p logs data +sudo chown -R 999:999 logs data +``` + +Then: + +```bash +docker compose restart trading-bot +``` + +--- + +## Files Likely to Change + +- `pyproject.toml` +- `uv.lock` +- `.env.example` +- `README.md` +- `src/utils/config.py` +- `src/broker/base.py` new +- `src/broker/yfinance.py` new +- `src/broker/factory.py` new +- `src/bot/core.py` +- tests: + - `tests/test_config.py` + - `tests/test_broker_base.py` + - `tests/test_yfinance_broker.py` + - `tests/test_broker_factory.py` + - `tests/test_yfinance_integration.py` + - possibly `tests/test_bot.py` + +## Test / Validation Matrix + +Run incrementally: + +```bash +uv run pytest tests/test_config.py -v +uv run pytest tests/test_broker_base.py -v +uv run pytest tests/test_yfinance_broker.py -v +uv run pytest tests/test_broker_factory.py -v +uv run pytest tests/test_yfinance_integration.py -v +uv run pytest tests/test_bot.py -v +``` + +Final full suite: + +```bash +uv run pytest tests/ -v +``` + +Runtime smoke: + +```bash +BROKER=yfinance TRADING_MODE=paper uv run python scripts/run_bot.py --web --web-port 9095 +curl -sS -L -o /tmp/berich_home.html -w '%{http_code} %{content_type}\n' http://127.0.0.1:9095/ +``` + +Docker: + +```bash +docker compose build +docker compose up -d +docker compose ps +docker logs --tail 100 quant-bot +``` + +## Risks / Tradeoffs + +1. **yfinance is not a broker.** It is data only. This mode must remain paper-only. +2. **Data quality/latency.** yfinance data can be delayed, adjusted, missing, or rate-limited. Good for prototyping, not production trading. +3. **KRX symbol mapping.** `.KS` vs `.KQ` cannot be inferred perfectly from a 6-digit code. Default `.KS` is best-effort only, so explicit `.KQ` symbols should be supported for KOSDAQ. +4. **Persistent paper state.** Persisting paper cash/positions/orders/fills in JSON is simpler than a DB migration, but it requires atomic writes and clear reset tooling to avoid corrupted or stale simulation state. +5. **Existing `PaperBroker` duplication.** `YFinanceBroker` will duplicate some paper execution logic. This is acceptable for minimal change, but later refactor could extract `PaperExecutionMixin` or make `PaperBroker` accept any data broker without requiring auth. +6. **Scheduler is currently `TradingScheduler(interval_seconds=60, us_only=True)`.** Supporting Korea as well as US may require revisiting market-hours logic so KRX strategies are not skipped unintentionally. + +## Confirmed Decisions + +1. Support both US and Korean symbols in yfinance paper mode. +2. Persist paper positions/state across ordinary restarts by default. +3. Update local `.env` to use `BROKER=yfinance` during implementation while leaving KIS values unused. + +## Recommended v1 Scope + +- Implement `BROKER=yfinance`, `TRADING_MODE=paper`. +- Support US symbols robustly. +- Support Korean symbols with explicit `.KS`/`.KQ` and numeric-code best-effort mapping. +- Persist paper cash/positions/orders/fills across restarts by default. +- Do not touch live trading. +- Verify direct dev run and Docker run. From b1536ae748b02afc64f8cbbac88f2b1d72f3acae Mon Sep 17 00:00:00 2001 From: Hojin Jang <jhojin7@users.noreply.github.com> Date: Fri, 19 Jun 2026 04:24:06 +0900 Subject: [PATCH 2/5] feat: add yfinance paper broker --- .env.example | 10 +- README.md | 45 ++- pyproject.toml | 1 + src/bot/core.py | 50 +--- src/broker/__init__.py | 4 +- src/broker/base.py | 65 +++++ src/broker/factory.py | 45 +++ src/broker/yfinance.py | 443 +++++++++++++++++++++++++++++ src/execution/order_manager.py | 28 +- src/strategy/engine.py | 4 +- src/utils/config.py | 15 +- tests/test_bot.py | 6 +- tests/test_broker_base.py | 5 + tests/test_broker_factory.py | 26 ++ tests/test_config.py | 31 ++ tests/test_yfinance_broker.py | 128 +++++++++ tests/test_yfinance_integration.py | 34 +++ uv.lock | 245 ++++++++++++++++ 18 files changed, 1119 insertions(+), 66 deletions(-) create mode 100644 src/broker/base.py create mode 100644 src/broker/factory.py create mode 100644 src/broker/yfinance.py create mode 100644 tests/test_broker_base.py create mode 100644 tests/test_broker_factory.py create mode 100644 tests/test_config.py create mode 100644 tests/test_yfinance_broker.py create mode 100644 tests/test_yfinance_integration.py diff --git a/.env.example b/.env.example index 03843aa..17d56b2 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ -# KIS API Credentials +# Broker selection: kis or yfinance +BROKER=yfinance + +# Trading mode: paper or live. yfinance supports paper only. +TRADING_MODE=paper + +# KIS API Credentials (required only when BROKER=kis) KIS_APP_KEY=your_app_key_here KIS_APP_SECRET=your_app_secret_here KIS_ACCOUNT_NO=12345678-01 -# Mode: real or paper +# Backward-compatible KIS mode flag KIS_PAPER_TRADING=true # MySQL Database diff --git a/README.md b/README.md index 592863d..c442d46 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,18 @@ `.env` 파일 생성: ```env -# KIS API +# Broker selection: kis or yfinance +BROKER=yfinance + +# Trading mode: paper or live. yfinance supports paper only. +TRADING_MODE=paper + +# KIS API (BROKER=kis일 때 필요) KIS_APP_KEY=your_app_key KIS_APP_SECRET=your_app_secret KIS_ACCOUNT_NO=your_account_number -# 모드: true=페이퍼트레이딩, false=실거래 +# Backward-compatible KIS mode flag KIS_PAPER_TRADING=true # MySQL @@ -132,14 +138,43 @@ BeRich/ - RSI 기간, 손절 %, 쿨다운 일수 - 매수/매도 레벨 및 비율 +### yfinance paper mode (KIS 없이 실행) + +KIS API 키 없이 미국/한국 종목을 paper trading으로 테스트하려면 `.env`에서: + +```env +BROKER=yfinance +TRADING_MODE=paper +DASHBOARD_USERNAME=admin +DASHBOARD_PASSWORD=change_me +``` + +이 모드는 yfinance에서 시세/일봉 데이터를 가져오고, 주문은 로컬 paper 계좌에서 즉시 체결 처리합니다. 실제 주문은 발생하지 않습니다. Paper cash/positions/orders/fills 상태는 기본적으로 `data/yfinance_paper_state.json`에 저장되어 재시작 후에도 유지됩니다. + +실행: + +```bash +uv run python scripts/run_bot.py --web --web-port 9095 +``` + +주의: + +- yfinance 데이터는 지연/누락될 수 있어 실거래 판단용으로 쓰면 안 됩니다. +- 한국 종목은 yfinance suffix가 필요할 수 있습니다. 예: `005930.KS`, `091990.KQ`. +- suffix 없는 6자리 KRX 코드는 기본적으로 `.KS`로 조회합니다. +- yfinance broker는 live trading을 지원하지 않습니다. + ### 페이퍼 트레이딩 -`.env`에서 `KIS_PAPER_TRADING=true`로 설정하면: -- 실제 시세 조회 (KIS API) -- 주문은 가상 체결 (실제 돈 사용 안 함) +`.env`에서 `BROKER=yfinance`, `TRADING_MODE=paper`로 설정하면: +- yfinance 시세/일봉 데이터 사용 +- 주문은 로컬 paper 계좌에서 가상 체결 (실제 돈 사용 안 함) +- paper 상태는 `data/yfinance_paper_state.json`에 유지 - 워밍업 없이 즉시 시작 - 대시보드에 `PAPER` 배지 표시 +KIS 기반 paper trading을 쓰려면 `BROKER=kis`, `KIS_PAPER_TRADING=true`와 유효한 KIS API 키가 필요합니다. + ## 테스트 ```bash diff --git a/pyproject.toml b/pyproject.toml index 6a6fcf3..10d4c67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pandas>=2.0", "numpy>=1.24", "pandas-ta>=0.4.71b0", + "yfinance>=0.2.50", # Database "sqlalchemy[asyncio]>=2.0", diff --git a/src/bot/core.py b/src/bot/core.py index f85afb8..8a03487 100644 --- a/src/bot/core.py +++ b/src/bot/core.py @@ -9,7 +9,8 @@ from src.core.events import EventBus from src.core.types import Market -from src.broker.kis.client import KISBroker +from src.broker.base import Broker +from src.broker.factory import create_broker from src.data.storage import Storage from src.strategy.engine import StrategyEngine from src.execution.order_manager import OrderManager @@ -36,7 +37,7 @@ def __init__(self, config_dir: str = "config", warmup_hours: int = 0): self.event_bus = EventBus() self.storage: Storage | None = None - self.broker: KISBroker | None = None + self.broker: Broker | None = None self.strategy_engine: StrategyEngine | None = None self.order_manager: OrderManager | None = None self.risk_manager: RiskManager | None = None @@ -103,12 +104,15 @@ async def initialize(self) -> None: await self._initialize_broker() # FIX-005: Use actual account balance instead of hardcoded 100M + if self.broker is None: + raise RuntimeError("Broker failed to initialize") risk_config = self.config.get_risk_config() limits = RiskLimits.from_config(risk_config) try: - balances = await self.broker.get_account_balance() + krw_balance = await self.broker.get_account_balance(Market.KRX) + usd_balance = await self.broker.get_account_balance(Market.NASDAQ) account_value = Decimal(str( - balances.get("total_usd", 0) + balances.get("total_krw", 0) + krw_balance.get("total_eval", 0) + usd_balance.get("total_eval", 0) )) if account_value <= 0: account_value = Decimal("100000000") @@ -161,45 +165,14 @@ async def initialize(self) -> None: ) # Initialize scheduler - self.scheduler = TradingScheduler(interval_seconds=60, us_only=True) + self.scheduler = TradingScheduler(interval_seconds=60, us_only=False) self.scheduler.add_callback(self.on_tick) logger.info("Trading Bot initialized successfully") async def _initialize_broker(self) -> None: """Initialize broker connection""" - kis_config = self.config.get_kis_config() - real_broker = KISBroker( - event_bus=self.event_bus, - app_key=kis_config["app_key"], - app_secret=kis_config["app_secret"], - account_no=kis_config["account_no"], - paper_trading=kis_config["paper_trading"], - ) - - # Use PaperBroker if KIS_PAPER_TRADING=true - if kis_config["paper_trading"]: - from src.broker.paper import PaperBroker - initial_cash_usd = Decimal( - str(self.config.get("trading.paper_cash_usd", 10000)) - ) - initial_cash_krw = Decimal( - str(self.config.get("trading.paper_cash_krw", 0)) - ) - self.broker = PaperBroker( - event_bus=self.event_bus, - real_broker=real_broker, - initial_cash_usd=initial_cash_usd, - initial_cash_krw=initial_cash_krw, - ) - logger.info( - f"Paper trading mode | " - f"USD: ${initial_cash_usd:,.0f}, " - f"KRW: {initial_cash_krw:,.0f}" - ) - else: - self.broker = real_broker - + self.broker = create_broker(self.config, self.event_bus) await self.broker.connect() # Get account balances @@ -219,9 +192,6 @@ async def _initialize_broker(self) -> None: except Exception as e: logger.warning(f"Failed to get USD balance: {e}") - total_eval = krw_balance.get("total_eval", Decimal("100000000")) - self.risk_manager.update_account_value(total_eval) - logger.info( f"Account balance - KRW: {self.dashboard.balance_krw:,.0f}, " f"USD: {self.dashboard.balance_usd:,.2f}" diff --git a/src/broker/__init__.py b/src/broker/__init__.py index a44b11e..29a38e6 100644 --- a/src/broker/__init__.py +++ b/src/broker/__init__.py @@ -1,3 +1,5 @@ +from .base import Broker from .kis.client import KISBroker +from .yfinance import YFinanceBroker -__all__ = ["KISBroker"] +__all__ = ["Broker", "KISBroker", "YFinanceBroker"] diff --git a/src/broker/base.py b/src/broker/base.py new file mode 100644 index 0000000..525d10d --- /dev/null +++ b/src/broker/base.py @@ -0,0 +1,65 @@ +"""Broker protocol shared by trading components.""" + +from decimal import Decimal +from typing import Protocol + +from src.core.types import Bar, Market, Order, Position + + +class Broker(Protocol): + """Async broker interface used by TradingBot, StrategyEngine, and OrderManager.""" + + paper_trading: bool + + @property + def is_connected(self) -> bool: + """Whether the broker is connected.""" + ... + + @property + def account_no(self) -> str: + """Account identifier for logs/dashboard.""" + ... + + async def connect(self) -> None: + ... + + async def disconnect(self) -> None: + ... + + async def get_current_price( + self, + symbol: str, + market: Market = Market.KRX, + ) -> Decimal: + ... + + async def get_historical_bars( + self, + symbol: str, + market: Market = Market.KRX, + days: int = 100, + ) -> list[Bar]: + ... + + async def get_positions( + self, + market: Market, + ) -> list[Position]: + ... + + async def get_account_balance( + self, + market: Market, + ) -> dict: + ... + + async def submit_order(self, order: Order) -> str: + ... + + async def cancel_order( + self, + order_id: str, + market: Market, + ) -> bool: + ... diff --git a/src/broker/factory.py b/src/broker/factory.py new file mode 100644 index 0000000..9ad1ae7 --- /dev/null +++ b/src/broker/factory.py @@ -0,0 +1,45 @@ +"""Broker factory for selecting runtime broker implementations.""" + +from decimal import Decimal + +from src.broker.base import Broker +from src.broker.kis.client import KISBroker +from src.broker.paper import PaperBroker +from src.broker.yfinance import YFinanceBroker +from src.core.events import EventBus +from src.utils.config import Config + + +def create_broker(config: Config, event_bus: EventBus) -> Broker: + """Create the configured broker implementation.""" + broker_name = config.get_broker_name() + trading_mode = config.get_trading_mode() + + if broker_name == "yfinance": + if trading_mode != "paper": + raise ValueError("BROKER=yfinance only supports TRADING_MODE=paper") + return YFinanceBroker( + event_bus=event_bus, + initial_cash_usd=Decimal(str(config.get("trading.paper_cash_usd", 10000))), + initial_cash_krw=Decimal(str(config.get("trading.paper_cash_krw", 0))), + ) + + if broker_name == "kis": + kis_config = config.get_kis_config() + real_broker = KISBroker( + event_bus=event_bus, + app_key=kis_config["app_key"], + app_secret=kis_config["app_secret"], + account_no=kis_config["account_no"], + paper_trading=kis_config["paper_trading"], + ) + if kis_config["paper_trading"]: + return PaperBroker( + event_bus=event_bus, + real_broker=real_broker, + initial_cash_usd=Decimal(str(config.get("trading.paper_cash_usd", 10000))), + initial_cash_krw=Decimal(str(config.get("trading.paper_cash_krw", 0))), + ) + return real_broker + + raise ValueError(f"Unsupported BROKER: {broker_name}") diff --git a/src/broker/yfinance.py b/src/broker/yfinance.py new file mode 100644 index 0000000..714faf5 --- /dev/null +++ b/src/broker/yfinance.py @@ -0,0 +1,443 @@ +"""KIS-free paper broker backed by yfinance market data.""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from uuid import uuid4 + +import pandas as pd +import yfinance as yf + +from src.core.events import Event, EventBus, EventType +from src.core.types import Bar, Fill, Market, Order, OrderSide, OrderStatus, Position + +logger = logging.getLogger("TradingBot") + +USD_MARKETS = {Market.NASDAQ, Market.NYSE, Market.AMEX} + + +def to_yfinance_symbol(symbol: str, market: Market) -> str: + """Map a BeRich symbol/market pair to a yfinance ticker.""" + normalized = symbol.strip().upper() + if market in USD_MARKETS: + return normalized + if market == Market.KRX: + if normalized.endswith((".KS", ".KQ")): + return normalized + if normalized.isdigit() and len(normalized) == 6: + return f"{normalized}.KS" + return normalized + + +def row_to_bar(symbol: str, market: Market, timestamp, row) -> Bar: + """Convert a yfinance OHLCV row into BeRich's Bar type.""" + return Bar( + symbol=symbol, + market=market, + open=Decimal(str(row["Open"])), + high=Decimal(str(row["High"])), + low=Decimal(str(row["Low"])), + close=Decimal(str(row["Close"])), + volume=int(row.get("Volume", 0) or 0), + timestamp=( + timestamp.to_pydatetime() + if hasattr(timestamp, "to_pydatetime") + else timestamp + ), + timeframe="1d", + ) + + +class YFinanceBroker: + """Paper broker that uses yfinance for prices and persists virtual state.""" + + def __init__( + self, + event_bus: EventBus, + initial_cash_krw: Decimal = Decimal("0"), + initial_cash_usd: Decimal = Decimal("10000"), + state_path: str | Path = "data/yfinance_paper_state.json", + ): + self.event_bus = event_bus + self.paper_trading = True + self._connected = False + self._initial_cash = { + "krw": Decimal(str(initial_cash_krw)), + "usd": Decimal(str(initial_cash_usd)), + } + self._cash: dict[str, Decimal] = dict(self._initial_cash) + self._positions: dict[str, dict] = {} + self._orders: dict[str, Order] = {} + self._fills: list[Fill] = [] + self.state_path = Path(state_path) + + @property + def is_connected(self) -> bool: + return self._connected + + @property + def account_no(self) -> str: + return "YFINANCE-PAPER" + + async def connect(self) -> None: + """Connect the paper broker and load persisted state if present.""" + self._load_state() + self._connected = True + logger.info( + "YFinance paper broker connected | Cash KRW: %s, USD: $%s", + f"{self._cash['krw']:,.0f}", + f"{self._cash['usd']:,.2f}", + ) + await self.event_bus.publish( + Event( + event_type=EventType.BROKER_CONNECTED, + data={"broker": "yfinance", "paper_trading": True}, + timestamp=datetime.now(), + source="YFinanceBroker", + ) + ) + + async def disconnect(self) -> None: + self._connected = False + logger.info("YFinance paper broker disconnected") + + def reset_state(self) -> None: + """Reset paper account state and remove persisted state file.""" + self._cash = dict(self._initial_cash) + self._positions = {} + self._orders = {} + self._fills = [] + if self.state_path.exists(): + self.state_path.unlink() + + def _history( + self, + symbol: str, + market: Market, + period: str, + interval: str, + ) -> pd.DataFrame: + ticker = to_yfinance_symbol(symbol, market) + return yf.Ticker(ticker).history( + period=period, + interval=interval, + auto_adjust=False, + ) + + async def get_current_price( + self, + symbol: str, + market: Market = Market.KRX, + ) -> Decimal: + """Get the latest available close price from yfinance.""" + df = self._history(symbol, market, period="5d", interval="1d") + if df.empty or "Close" not in df: + raise ValueError(f"No yfinance price data for {symbol} ({market.value})") + close_values = df["Close"].dropna() + if close_values.empty: + raise ValueError(f"No yfinance close price for {symbol} ({market.value})") + return Decimal(str(close_values.iloc[-1])) + + async def get_historical_bars( + self, + symbol: str, + market: Market = Market.KRX, + days: int = 100, + ) -> list[Bar]: + """Get daily historical bars from yfinance.""" + period_days = max(days * 2, 120) + df = self._history(symbol, market, period=f"{period_days}d", interval="1d") + if df.empty: + raise ValueError(f"No yfinance historical data for {symbol} ({market.value})") + df = df.dropna(subset=["Open", "High", "Low", "Close"]) + return [ + row_to_bar(symbol, market, idx, row) + for idx, row in df.tail(days).iterrows() + ] + + async def submit_order(self, order: Order) -> str: + """Simulate immediate order execution at the current yfinance price.""" + order_id = f"YF-PAPER-{uuid4().hex[:8].upper()}" + order.order_id = order_id + order.status = OrderStatus.SUBMITTED + pnl = None + + try: + fill_price = await self.get_current_price(order.symbol, order.market) + except Exception: + fill_price = order.price or Decimal("0") + + cash_key = self._cash_key(order.market) + + if order.side == OrderSide.BUY: + cost = fill_price * order.quantity + if cost > self._cash[cash_key]: + logger.warning( + "[YF PAPER] Insufficient cash for %s: need %s, have %s", + order.symbol, + f"{cost:,.2f}", + f"{self._cash[cash_key]:,.2f}", + ) + order.status = OrderStatus.REJECTED + self._orders[order_id] = order + self._save_state() + return order_id + + self._cash[cash_key] -= cost + if pos := self._positions.get(order.symbol): + old_qty = pos["quantity"] + new_qty = old_qty + order.quantity + pos["avg_price"] = ( + (pos["avg_price"] * old_qty) + (fill_price * order.quantity) + ) / new_qty + pos["quantity"] = new_qty + else: + self._positions[order.symbol] = { + "quantity": order.quantity, + "avg_price": fill_price, + "market": order.market, + } + + elif order.side == OrderSide.SELL: + pos = self._positions.get(order.symbol) + if not pos or pos["quantity"] < order.quantity: + logger.warning("[YF PAPER] Insufficient position for %s", order.symbol) + order.status = OrderStatus.REJECTED + self._orders[order_id] = order + self._save_state() + return order_id + + proceeds = fill_price * order.quantity + self._cash[cash_key] += proceeds + pnl = (fill_price - pos["avg_price"]) * order.quantity + pos["quantity"] -= order.quantity + if pos["quantity"] <= 0: + del self._positions[order.symbol] + + order.status = OrderStatus.FILLED + order.filled_quantity = order.quantity + order.filled_avg_price = fill_price + self._orders[order_id] = order + + current_rsi = None + try: + from src.web.app import get_dashboard_state + + current_rsi = get_dashboard_state().rsi_values.get(order.symbol) + except Exception: + pass + + self._fills.append( + Fill( + order_id=order_id, + symbol=order.symbol, + market=order.market, + side=order.side, + quantity=order.quantity, + price=fill_price, + commission=Decimal("0"), + timestamp=datetime.now(), + pnl=pnl, + rsi=current_rsi, + ) + ) + self._save_state() + await self.event_bus.publish( + Event( + event_type=EventType.ORDER_FILLED, + data=order, + timestamp=datetime.now(), + source="YFinanceBroker", + ) + ) + return order_id + + async def get_positions( + self, + market: Market = Market.KRX, + ) -> list[Position]: + positions = [] + for symbol, pos in self._positions.items(): + if pos["market"] != market or pos["quantity"] <= 0: + continue + try: + current_price = await self.get_current_price(symbol, market) + except Exception: + current_price = pos["avg_price"] + positions.append( + Position( + symbol=symbol, + market=market, + quantity=pos["quantity"], + avg_entry_price=pos["avg_price"], + current_price=current_price, + unrealized_pnl=(current_price - pos["avg_price"]) * pos["quantity"], + ) + ) + return positions + + async def get_account_balance( + self, + market: Market = Market.KRX, + ) -> dict: + cash_key = self._cash_key(market) + relevant_markets = USD_MARKETS if cash_key == "usd" else {Market.KRX} + cash = self._cash[cash_key] + position_value = Decimal("0") + cost_basis = Decimal("0") + + for symbol, pos in self._positions.items(): + if pos["market"] not in relevant_markets: + continue + try: + price = await self.get_current_price(symbol, pos["market"]) + except Exception: + price = pos["avg_price"] + position_value += price * pos["quantity"] + cost_basis += pos["avg_price"] * pos["quantity"] + + profit_loss = position_value - cost_basis + total_eval = cash + position_value + return { + "total_eval": total_eval, + "cash": cash, + "stocks_eval": position_value, + "profit_loss": profit_loss, + "total_usd": total_eval if cash_key == "usd" else Decimal("0"), + "total_krw": total_eval if cash_key == "krw" else Decimal("0"), + } + + async def cancel_order( + self, + order_id: str, + market: Market = Market.KRX, + ) -> bool: + order = self._orders.get(order_id) + if not order or order.status == OrderStatus.FILLED: + return False + order.status = OrderStatus.CANCELLED + self._save_state() + return True + + def _cash_key(self, market: Market) -> str: + return "krw" if market == Market.KRX else "usd" + + def _save_state(self) -> None: + self.state_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "cash": {key: str(value) for key, value in self._cash.items()}, + "positions": { + symbol: { + "quantity": pos["quantity"], + "avg_price": str(pos["avg_price"]), + "market": pos["market"].value, + } + for symbol, pos in self._positions.items() + }, + "orders": { + order_id: self._order_to_dict(order) + for order_id, order in self._orders.items() + }, + "fills": [self._fill_to_dict(fill) for fill in self._fills], + } + tmp_path = self.state_path.with_suffix(f"{self.state_path.suffix}.tmp") + tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2)) + os.replace(tmp_path, self.state_path) + + def _load_state(self) -> None: + if not self.state_path.exists(): + return + data = json.loads(self.state_path.read_text()) + self._cash = { + "krw": Decimal(str(data.get("cash", {}).get("krw", self._initial_cash["krw"]))), + "usd": Decimal(str(data.get("cash", {}).get("usd", self._initial_cash["usd"]))), + } + self._positions = { + symbol: { + "quantity": int(pos["quantity"]), + "avg_price": Decimal(str(pos["avg_price"])), + "market": Market.from_string(pos["market"]), + } + for symbol, pos in data.get("positions", {}).items() + } + self._orders = { + order_id: self._order_from_dict(order_data) + for order_id, order_data in data.get("orders", {}).items() + } + self._fills = [ + self._fill_from_dict(fill_data) + for fill_data in data.get("fills", []) + ] + + def _order_to_dict(self, order: Order) -> dict: + return { + "symbol": order.symbol, + "market": order.market.value, + "side": order.side.value, + "order_type": order.order_type.value, + "quantity": order.quantity, + "price": str(order.price) if order.price is not None else None, + "order_id": order.order_id, + "status": order.status.value, + "created_at": order.created_at.isoformat(), + "filled_quantity": order.filled_quantity, + "filled_avg_price": ( + str(order.filled_avg_price) + if order.filled_avg_price is not None + else None + ), + } + + def _order_from_dict(self, data: dict) -> Order: + from src.core.types import OrderType + + return Order( + symbol=data["symbol"], + market=Market.from_string(data["market"]), + side=OrderSide(data["side"]), + order_type=OrderType(data["order_type"]), + quantity=int(data["quantity"]), + price=(Decimal(str(data["price"])) if data.get("price") is not None else None), + order_id=data.get("order_id"), + status=OrderStatus(data.get("status", OrderStatus.PENDING.value)), + created_at=datetime.fromisoformat(data["created_at"]), + filled_quantity=int(data.get("filled_quantity", 0)), + filled_avg_price=( + Decimal(str(data["filled_avg_price"])) + if data.get("filled_avg_price") is not None + else None + ), + ) + + def _fill_to_dict(self, fill: Fill) -> dict: + return { + "order_id": fill.order_id, + "symbol": fill.symbol, + "market": fill.market.value, + "side": fill.side.value, + "quantity": fill.quantity, + "price": str(fill.price), + "commission": str(fill.commission), + "timestamp": fill.timestamp.isoformat(), + "pnl": str(fill.pnl) if fill.pnl is not None else None, + "rsi": fill.rsi, + } + + def _fill_from_dict(self, data: dict) -> Fill: + return Fill( + order_id=data["order_id"], + symbol=data["symbol"], + market=Market.from_string(data["market"]), + side=OrderSide(data["side"]), + quantity=int(data["quantity"]), + price=Decimal(str(data["price"])), + commission=Decimal(str(data.get("commission", "0"))), + timestamp=datetime.fromisoformat(data["timestamp"]), + pnl=(Decimal(str(data["pnl"])) if data.get("pnl") is not None else None), + rsi=data.get("rsi"), + ) diff --git a/src/execution/order_manager.py b/src/execution/order_manager.py index f65de6a..1889357 100644 --- a/src/execution/order_manager.py +++ b/src/execution/order_manager.py @@ -9,9 +9,10 @@ SignalType, OrderSide, OrderType, + Market, ) from src.core.events import EventBus, Event, EventType -from src.broker.kis.client import KISBroker +from src.broker.base import Broker from src.risk.manager import RiskManager from src.data.storage import Storage from src.utils.notifier import DiscordNotifier @@ -26,7 +27,7 @@ class OrderManager: def __init__( self, event_bus: EventBus, - broker: KISBroker, + broker: Broker, risk_manager: RiskManager, storage: Storage, is_trading_enabled: Callable = None, @@ -135,7 +136,7 @@ async def _signal_to_order(self, signal: Signal) -> Order | None: # Calculate buy quantity based on portfolio weight if signal.signal_type == SignalType.ENTRY_LONG: quantity = await self._calculate_buy_quantity( - signal.symbol, price, signal.strength, + signal.symbol, signal.market, price, signal.strength, ) if quantity <= 0: return None @@ -172,7 +173,7 @@ async def _signal_to_order(self, signal: Signal) -> Order | None: ) async def _calculate_buy_quantity( - self, symbol: str, price, stage_portion: float, + self, symbol: str, market: Market, price, stage_portion: float, ) -> int: """Calculate buy quantity based on portion of REMAINING weight room. @@ -183,9 +184,11 @@ async def _calculate_buy_quantity( remaining = $1,000, stage 0.5 → buy $500. """ dashboard = get_dashboard_state() - total_value = float(dashboard.balance_usd) + is_krx = market == Market.KRX + total_value = float(dashboard.balance_krw if is_krx else dashboard.balance_usd) + currency_symbol = "₩" if is_krx else "$" if total_value <= 0: - logger.debug(f"[{symbol}] No portfolio value") + logger.debug(f"[{symbol}] No portfolio value for {market.value}") return 0 # Get max_weight from strategy_configs DB @@ -233,8 +236,8 @@ async def _calculate_buy_quantity( remaining_room = max_symbol_value - current_value buy_amount = remaining_room * stage_portion - # Check available cash - cash = float(dashboard.cash_usd + dashboard.cash_krw) + # Check available cash in the signal market currency + cash = float(dashboard.cash_krw if is_krx else dashboard.cash_usd) # Don't exceed available cash buy_amount = min(buy_amount, cash) @@ -246,10 +249,11 @@ async def _calculate_buy_quantity( logger.info( f"[{symbol}] Buy calc: " - f"remaining ${remaining_room:,.0f} × " - f"{stage_portion * 100:.0f}% = ${buy_amount:,.0f} " - f"(max ${max_symbol_value:,.0f}, held ${current_value:,.0f}) " - f"→ {quantity} shares @ ${float(price):,.2f}" + f"remaining {currency_symbol}{remaining_room:,.0f} × " + f"{stage_portion * 100:.0f}% = {currency_symbol}{buy_amount:,.0f} " + f"(max {currency_symbol}{max_symbol_value:,.0f}, " + f"held {currency_symbol}{current_value:,.0f}) " + f"→ {quantity} shares @ {currency_symbol}{float(price):,.2f}" ) return quantity diff --git a/src/strategy/engine.py b/src/strategy/engine.py index 54919cf..e039603 100644 --- a/src/strategy/engine.py +++ b/src/strategy/engine.py @@ -4,7 +4,7 @@ from src.core.types import Bar, Quote, Signal, Market, Position from src.core.events import EventBus, Event, EventType -from src.broker.kis.client import KISBroker +from src.broker.base import Broker from src.utils.notifier import DiscordNotifier from .base import BaseStrategy @@ -17,7 +17,7 @@ class StrategyEngine: def __init__( self, event_bus: EventBus, - broker: KISBroker, + broker: Broker, notifier: DiscordNotifier | None = None, ): self.event_bus = event_bus diff --git a/src/utils/config.py b/src/utils/config.py index c7afd82..33cc7a8 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -51,7 +51,7 @@ def get(self, key: str, default: Any = None) -> Any: value = value.get(k) else: return default - if value is None: + if value is None or value == "": return default return value @@ -64,13 +64,24 @@ def settings(self) -> dict: def strategies(self) -> list: return self._strategies + def get_broker_name(self) -> str: + """Get selected broker implementation.""" + return os.getenv("BROKER", "kis").strip().lower() + + def get_trading_mode(self) -> str: + """Get trading mode: paper or live.""" + mode = os.getenv("TRADING_MODE") + if mode: + return mode.strip().lower() + return "paper" if os.getenv("KIS_PAPER_TRADING", "true").lower() == "true" else "live" + def get_kis_config(self) -> dict: """Get KIS broker configuration""" return { "app_key": os.getenv("KIS_APP_KEY", ""), "app_secret": os.getenv("KIS_APP_SECRET", ""), "account_no": os.getenv("KIS_ACCOUNT_NO", ""), - "paper_trading": os.getenv("KIS_PAPER_TRADING", "true").lower() == "true", + "paper_trading": self.get_trading_mode() == "paper", } def get_risk_config(self) -> dict: diff --git a/tests/test_bot.py b/tests/test_bot.py index c64e8bb..f863095 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -30,6 +30,8 @@ def mock_config(self, tmp_path): "account_no": "test_account", "paper_trading": True, }) + config.get_broker_name = MagicMock(return_value="kis") + config.get_trading_mode = MagicMock(return_value="paper") config.get_discord_config = MagicMock(return_value={ "enabled": False, "webhook_url": None, @@ -69,7 +71,7 @@ async def test_initialize(self, bot, mock_config): bot.config = mock_config with patch("src.bot.core.Storage") as MockStorage, \ - patch("src.bot.core.KISBroker") as MockBroker, \ + patch("src.bot.core.create_broker") as mock_create_broker, \ patch("src.bot.core.StrategyEngine") as MockEngine, \ patch("src.bot.core.OrderManager") as MockOrderManager, \ patch("src.bot.core.TradingScheduler") as MockScheduler, \ @@ -87,7 +89,7 @@ async def test_initialize(self, bot, mock_config): "cash": Decimal("500000"), "profit_loss": Decimal("10000"), }) - MockBroker.return_value = mock_broker + mock_create_broker.return_value = mock_broker mock_engine = MagicMock() MockEngine.return_value = mock_engine diff --git a/tests/test_broker_base.py b/tests/test_broker_base.py new file mode 100644 index 0000000..db4592d --- /dev/null +++ b/tests/test_broker_base.py @@ -0,0 +1,5 @@ +from src.broker.base import Broker + + +def test_broker_protocol_importable(): + assert Broker is not None diff --git a/tests/test_broker_factory.py b/tests/test_broker_factory.py new file mode 100644 index 0000000..f9c1645 --- /dev/null +++ b/tests/test_broker_factory.py @@ -0,0 +1,26 @@ +import pytest + +from src.broker.factory import create_broker +from src.broker.yfinance import YFinanceBroker +from src.core.events import EventBus +from src.utils.config import Config + + +def test_factory_returns_yfinance_broker(monkeypatch): + monkeypatch.setenv("BROKER", "yfinance") + monkeypatch.setenv("TRADING_MODE", "paper") + broker = create_broker(Config(), EventBus()) + assert isinstance(broker, YFinanceBroker) + + +def test_factory_rejects_yfinance_live(monkeypatch): + monkeypatch.setenv("BROKER", "yfinance") + monkeypatch.setenv("TRADING_MODE", "live") + with pytest.raises(ValueError, match="paper"): + create_broker(Config(), EventBus()) + + +def test_factory_rejects_unknown_broker(monkeypatch): + monkeypatch.setenv("BROKER", "unknown") + with pytest.raises(ValueError, match="Unsupported BROKER"): + create_broker(Config(), EventBus()) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..26f30c3 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,31 @@ +from src.utils.config import Config + + +def test_broker_defaults_to_kis(monkeypatch): + monkeypatch.setattr("src.utils.config.load_dotenv", lambda *args, **kwargs: None) + monkeypatch.delenv("BROKER", raising=False) + assert Config().get_broker_name() == "kis" + + +def test_broker_reads_env_lowercase(monkeypatch): + monkeypatch.setenv("BROKER", "YFINANCE") + assert Config().get_broker_name() == "yfinance" + + +def test_trading_mode_defaults_from_kis_paper_flag(monkeypatch): + monkeypatch.setattr("src.utils.config.load_dotenv", lambda *args, **kwargs: None) + monkeypatch.delenv("TRADING_MODE", raising=False) + monkeypatch.setenv("KIS_PAPER_TRADING", "false") + assert Config().get_trading_mode() == "live" + + +def test_trading_mode_env_overrides_kis_paper_flag(monkeypatch): + monkeypatch.setenv("TRADING_MODE", "PAPER") + monkeypatch.setenv("KIS_PAPER_TRADING", "false") + assert Config().get_trading_mode() == "paper" + + +def test_get_returns_default_for_empty_string(): + config = Config() + config._settings = {"database": {"url": ""}} + assert config.get("database.url", "sqlite+aiosqlite:///data/trading.db") == "sqlite+aiosqlite:///data/trading.db" diff --git a/tests/test_yfinance_broker.py b/tests/test_yfinance_broker.py new file mode 100644 index 0000000..34b61a5 --- /dev/null +++ b/tests/test_yfinance_broker.py @@ -0,0 +1,128 @@ +from decimal import Decimal +from unittest.mock import AsyncMock + +import pandas as pd +import pytest + +from src.broker.yfinance import YFinanceBroker, row_to_bar, to_yfinance_symbol +from src.core.events import EventBus +from src.core.types import Market, Order, OrderSide, OrderStatus, OrderType + + +def test_to_yfinance_symbol_us_markets_unchanged(): + assert to_yfinance_symbol("aapl", Market.NASDAQ) == "AAPL" + assert to_yfinance_symbol("spy", Market.AMEX) == "SPY" + + +def test_to_yfinance_symbol_krx_numeric_defaults_to_ks(): + assert to_yfinance_symbol("005930", Market.KRX) == "005930.KS" + + +def test_to_yfinance_symbol_krx_suffix_preserved(): + assert to_yfinance_symbol("091990.KQ", Market.KRX) == "091990.KQ" + + +def test_row_to_bar_converts_ohlcv(): + row = pd.Series({"Open": 1.1, "High": 2.2, "Low": 1.0, "Close": 2.0, "Volume": 123}) + ts = pd.Timestamp("2026-01-02") + bar = row_to_bar("AAPL", Market.NASDAQ, ts, row) + assert bar.symbol == "AAPL" + assert bar.close == Decimal("2.0") + assert bar.volume == 123 + assert bar.timeframe == "1d" + + +@pytest.mark.asyncio +async def test_yfinance_broker_connects(tmp_path): + broker = YFinanceBroker(EventBus(), state_path=tmp_path / "state.json") + await broker.connect() + assert broker.is_connected is True + assert broker.account_no == "YFINANCE-PAPER" + + +@pytest.mark.asyncio +async def test_get_current_price_uses_latest_close(monkeypatch, tmp_path): + broker = YFinanceBroker(EventBus(), state_path=tmp_path / "state.json") + df = pd.DataFrame({"Close": [10.0, 12.5]}, index=pd.date_range("2026-01-01", periods=2)) + monkeypatch.setattr(broker, "_history", lambda *args, **kwargs: df) + assert await broker.get_current_price("AAPL", Market.NASDAQ) == Decimal("12.5") + + +@pytest.mark.asyncio +async def test_get_historical_bars_returns_tail(monkeypatch, tmp_path): + broker = YFinanceBroker(EventBus(), state_path=tmp_path / "state.json") + df = pd.DataFrame( + { + "Open": [1, 2, 3], + "High": [2, 3, 4], + "Low": [0, 1, 2], + "Close": [1.5, 2.5, 3.5], + "Volume": [10, 20, 30], + }, + index=pd.date_range("2026-01-01", periods=3), + ) + monkeypatch.setattr(broker, "_history", lambda *args, **kwargs: df) + bars = await broker.get_historical_bars("AAPL", Market.NASDAQ, days=2) + assert len(bars) == 2 + assert bars[-1].close == Decimal("3.5") + + +@pytest.mark.asyncio +async def test_submit_buy_order_updates_cash_and_position(monkeypatch, tmp_path): + broker = YFinanceBroker( + EventBus(), initial_cash_usd=Decimal("1000"), state_path=tmp_path / "state.json" + ) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("10"))) + order = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5) + order_id = await broker.submit_order(order) + assert order_id.startswith("YF-PAPER-") + assert order.status == OrderStatus.FILLED + assert broker._cash["usd"] == Decimal("950") + positions = await broker.get_positions(Market.NASDAQ) + assert positions[0].quantity == 5 + + +@pytest.mark.asyncio +async def test_submit_buy_rejects_insufficient_cash(monkeypatch, tmp_path): + broker = YFinanceBroker( + EventBus(), initial_cash_usd=Decimal("10"), state_path=tmp_path / "state.json" + ) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("20"))) + order = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=1) + await broker.submit_order(order) + assert order.status == OrderStatus.REJECTED + + +@pytest.mark.asyncio +async def test_submit_sell_order_updates_cash_and_position(monkeypatch, tmp_path): + broker = YFinanceBroker( + EventBus(), initial_cash_usd=Decimal("1000"), state_path=tmp_path / "state.json" + ) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("10"))) + buy = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5) + await broker.submit_order(buy) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("12"))) + sell = Order("AAPL", Market.NASDAQ, OrderSide.SELL, OrderType.MARKET, quantity=2) + await broker.submit_order(sell) + assert sell.status == OrderStatus.FILLED + assert broker._cash["usd"] == Decimal("974") + positions = await broker.get_positions(Market.NASDAQ) + assert positions[0].quantity == 3 + + +@pytest.mark.asyncio +async def test_yfinance_paper_state_persists_across_restart(tmp_path, monkeypatch): + state_path = tmp_path / "paper_state.json" + broker1 = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000"), state_path=state_path) + monkeypatch.setattr(broker1, "get_current_price", AsyncMock(return_value=Decimal("10"))) + await broker1.connect() + await broker1.submit_order(Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5)) + + broker2 = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000"), state_path=state_path) + monkeypatch.setattr(broker2, "get_current_price", AsyncMock(return_value=Decimal("10"))) + await broker2.connect() + positions = await broker2.get_positions(Market.NASDAQ) + + assert broker2._cash["usd"] == Decimal("950") + assert positions[0].symbol == "AAPL" + assert positions[0].quantity == 5 diff --git a/tests/test_yfinance_integration.py b/tests/test_yfinance_integration.py new file mode 100644 index 0000000..50d7dc5 --- /dev/null +++ b/tests/test_yfinance_integration.py @@ -0,0 +1,34 @@ +from decimal import Decimal + +import pytest + +from src.bot.core import TradingBot +from src.broker.yfinance import YFinanceBroker +from src.core.types import Market + + +@pytest.mark.asyncio +async def test_yfinance_mode_does_not_require_kis_credentials(monkeypatch, tmp_path): + monkeypatch.setenv("BROKER", "yfinance") + monkeypatch.setenv("TRADING_MODE", "paper") + monkeypatch.delenv("KIS_APP_KEY", raising=False) + monkeypatch.delenv("KIS_APP_SECRET", raising=False) + monkeypatch.delenv("KIS_ACCOUNT_NO", raising=False) + + async def fake_get_account_balance(self, market=Market.KRX): + return { + "total_eval": Decimal("10000"), + "cash": Decimal("10000"), + "stocks_eval": Decimal("0"), + "profit_loss": Decimal("0"), + } + + monkeypatch.setattr(YFinanceBroker, "get_account_balance", fake_get_account_balance) + + bot = TradingBot(config_dir=str(tmp_path)) + bot.config.load() + bot._data_dir = tmp_path + await bot._initialize_broker() + assert bot.broker is not None + assert bot.broker.account_no == "YFINANCE-PAPER" + await bot.broker.disconnect() diff --git a/uv.lock b/uv.lock index 0a4f72b..fb7d4ca 100644 --- a/uv.lock +++ b/uv.lock @@ -151,6 +151,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + [[package]] name = "berich" version = "0.1.0" @@ -176,6 +189,7 @@ dependencies = [ { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn" }, { name = "websockets" }, + { name = "yfinance" }, ] [package.optional-dependencies] @@ -214,9 +228,19 @@ requires-dist = [ { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "uvicorn", specifier = ">=0.27" }, { name = "websockets", specifier = ">=12.0" }, + { name = "yfinance", specifier = ">=0.2.50" }, ] provides-extras = ["dev"] +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -262,6 +286,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.3.2" @@ -405,6 +486,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" }, + { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -656,6 +770,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload-time = "2025-01-20T11:14:38.578Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -708,6 +834,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -789,6 +924,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "multitasking" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/ac2cc9307fb15cc28ed6d4a9266b216c83ee7fe64299f0264047982bce88/multitasking-0.0.13.tar.gz", hash = "sha256:d896b5df877c9ca5eeddbf0e5994124694d6cb535aba698fb23344c7025155a1", size = 20585, upload-time = "2026-04-23T12:14:15.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/1c/24dbf69b247f287401c904a396233a43c89fd4fb9b7cd2e50e430e9cd57c/multitasking-0.0.13-py3-none-any.whl", hash = "sha256:ec9243af140c67bfe52dc98d7173c294512735a88e8425c458b250db99dc2b48", size = 16380, upload-time = "2026-04-23T12:14:13.776Z" }, +] + [[package]] name = "mypy" version = "1.20.0" @@ -952,6 +1096,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "peewee" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/57/19440fc02ffd66c0c1a92da1f3a28e17bd4fa1099f8152fd08f1bbe43991/peewee-4.1.0.tar.gz", hash = "sha256:58090f75c58a5e9e9e43b6265c5a17a46ae1797c51b7554d24577dcff11e8515", size = 752243, upload-time = "2026-06-16T14:40:05.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/dd/fbc5bbaf69eea289182e03d3120b301b19559ccc887053105001a2ace155/peewee-4.1.0-py3-none-any.whl", hash = "sha256:222aec3df487045dd5deaff08fd8984f956af538df7e13cc2e63815968e25ac7", size = 155074, upload-time = "2026-06-16T14:40:04.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1030,6 +1192,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1242,6 +1419,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.15.10" @@ -1297,6 +1502,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" @@ -1395,6 +1609,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "uvicorn" version = "0.44.0" @@ -1582,3 +1805,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] + +[[package]] +name = "yfinance" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/d2/941eea19644200c3f82e1be35a1faa94f1149760a0b6f43e3633bfa052d5/yfinance-1.4.1.tar.gz", hash = "sha256:9acecec3036b4aa96d1e3120ff85ca4f6f81d239d968f56b6eb7877f89fea7a3", size = 153823, upload-time = "2026-05-28T20:03:05.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/ec/8f432c0370e667fb0d8a54ffb75d7737c9224d68eca0db91ee1bd84f74ee/yfinance-1.4.1-py2.py3-none-any.whl", hash = "sha256:1e1c506ca81dc15635380e7129813a5b32da80201af9bb404cac5d528ecfddc3", size = 137770, upload-time = "2026-05-28T20:03:04.584Z" }, +] From 3dfcf94a79a356b4b2417c48ff3960d8001b23d1 Mon Sep 17 00:00:00 2001 From: Hojin Jang <jhojin7@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:57:05 +0900 Subject: [PATCH 3/5] fix: harden yfinance paper mode storage defaults --- config/settings.yaml | 4 ++-- src/data/storage.py | 17 ++++++++++++++++- tests/test_data.py | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/config/settings.yaml b/config/settings.yaml index 912fb7a..7a05064 100644 --- a/config/settings.yaml +++ b/config/settings.yaml @@ -13,8 +13,8 @@ discord: trading: warmup_hours: 0 # Hours to collect data before trading starts check_interval: 120 # Price check interval in seconds - paper_cash_usd: 10000 # Initial paper trading cash (USD) - paper_cash_krw: 0 # Initial paper trading cash (KRW) + paper_cash_usd: 10000 # Initial paper trading cash (USD) + paper_cash_krw: 10000000 # Initial paper trading cash (KRW) # Database database: diff --git a/src/data/storage.py b/src/data/storage.py index 7f13aa2..307d598 100644 --- a/src/data/storage.py +++ b/src/data/storage.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta from decimal import Decimal +from typing import Any from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker from sqlalchemy import select @@ -26,7 +27,21 @@ def __init__(self, database_url: str): elif database_url.startswith("mysql://"): database_url = database_url.replace("mysql://", "mysql+aiomysql://") - self.engine = create_async_engine(database_url, echo=False) + engine_kwargs: dict[str, Any] = {"echo": False} + if database_url.startswith("mysql+aiomysql://"): + # MySQL closes idle connections (wait_timeout) and Dockerized DB + # restarts can leave stale sockets in SQLAlchemy's pool. Validate + # pooled connections before use and recycle them periodically so + # scheduled account/equity snapshot writes do not fail with + # "Lost connection to MySQL server during query". + engine_kwargs.update( + pool_size=5, + max_overflow=10, + pool_recycle=3600, + pool_pre_ping=True, + ) + + self.engine = create_async_engine(database_url, **engine_kwargs) self.async_session = sessionmaker( self.engine, class_=AsyncSession, expire_on_commit=False ) diff --git a/tests/test_data.py b/tests/test_data.py index 8fbe12a..a88b731 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -51,6 +51,24 @@ async def test_sqlite_url_conversion(self): assert "aiosqlite" in str(store.engine.url) await store.close() + async def test_mysql_url_conversion(self): + """mysql:// URLs should be converted to mysql+aiomysql://""" + store = Storage("mysql://user:pass@localhost:3306/berich") + assert "aiomysql" in str(store.engine.url) + await store.close() + + async def test_mysql_engine_uses_stale_connection_guards(self): + """MySQL engine should recycle and pre-ping pooled connections.""" + store = Storage("mysql+aiomysql://user:pass@localhost:3306/berich") + pool = store.engine.sync_engine.pool + + assert pool.size() == 5 + assert pool._max_overflow == 10 + assert pool._recycle == 3600 + assert pool._pre_ping is True + + await store.close() + class TestSaveOrder: From 12342fdfab77d18ac60e19691181cb3b369bf27b Mon Sep 17 00:00:00 2001 From: Hojin Jang <jhojin7@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:57:46 +0900 Subject: [PATCH 4/5] chore: drop local implementation plan from PR --- .../2026-06-18_213222-yfinance-paper-mode.md | 1298 ----------------- 1 file changed, 1298 deletions(-) delete mode 100644 .hermes/plans/2026-06-18_213222-yfinance-paper-mode.md diff --git a/.hermes/plans/2026-06-18_213222-yfinance-paper-mode.md b/.hermes/plans/2026-06-18_213222-yfinance-paper-mode.md deleted file mode 100644 index 7025e7c..0000000 --- a/.hermes/plans/2026-06-18_213222-yfinance-paper-mode.md +++ /dev/null @@ -1,1298 +0,0 @@ -# yfinance Paper Mode Implementation Plan - -> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. - -**Goal:** Add a KIS-free paper trading mode that uses yfinance for market data and the existing BeRich paper execution/dashboard flow. - -**Architecture:** Introduce an explicit broker/data-source boundary instead of hard-coding `KISBroker` in `TradingBot`. Add a `YFinanceBroker` that implements the same async methods BeRich already calls (`connect`, `get_current_price`, `get_historical_bars`, balances, positions, order submission/cancel). In `BROKER=yfinance` + `TRADING_MODE=paper`, use yfinance for price/history and existing-style simulated fills, without requiring KIS credentials. - -**Tech Stack:** Python 3.13, FastAPI dashboard, SQLAlchemy storage, pytest, uv, yfinance. - ---- - -## Current Context - -The repo is at `/home/hojinjang/dev/BeRich`. - -Observed current behavior: - -- `uv sync --extra dev` succeeds. -- `uv run pytest tests/ -v` currently passes: `194 passed`. -- Docker build succeeds. -- Existing `KIS_PAPER_TRADING=true` is not fully offline: `PaperBroker` delegates price/history/auth to `KISBroker`. -- Bot fails with example credentials because KIS auth is still required: - - `Authentication failed: {"error_code":"EGW00103","error_description":"유효하지 않은 AppKey입니다."}` - -Key files: - -- `src/bot/core.py` - - imports `KISBroker` directly. - - `_initialize_broker()` hard-codes KIS. -- `src/broker/paper.py` - - simulates order fills. - - delegates `connect`, `get_current_price`, `get_historical_bars` to a real KIS broker. -- `src/broker/kis/client.py` - - implicit broker API surface used by the bot/order manager. -- `src/execution/order_manager.py` - - calls `broker.get_current_price()`, `broker.get_positions()`, `broker.submit_order()`. -- `src/core/types.py` - - common `Market`, `Order`, `Position`, `Bar`, etc. -- `.env.example`, `pyproject.toml`, tests under `tests/`. - -## Assumptions - -- Initial goal is **not live trading**. -- Initial yfinance mode must support both US equities/ETFs and Korean equities/ETFs. -- KRX support should include `.KS`/`.KQ` handling. Numeric 6-digit symbols may default to `.KS`, but the implementation should allow explicit `.KQ` symbols/config where needed. -- Paper fills can be immediate at current yfinance price, same as current `PaperBroker` behavior. -- Existing dashboard and strategy code should remain mostly unchanged. -- Avoid broad refactor. Keep compatibility with current KIS behavior. - -## Proposed Design - -### New config knobs - -Add env vars: - -```env -BROKER=yfinance # kis | yfinance -TRADING_MODE=paper # paper only for yfinance initially -YFINANCE_PRICE_DELAY=0 # optional future knob, not required for first implementation -``` - -Keep current KIS vars working: - -```env -KIS_APP_KEY=... -KIS_APP_SECRET=... -KIS_ACCOUNT_NO=... -KIS_PAPER_TRADING=true -``` - -Compatibility rule: - -- If `BROKER` is missing, default to `kis` so existing behavior is unchanged. -- If `BROKER=yfinance`, force paper-style behavior and never instantiate `KISBroker`. - -### Broker API - -Create a lightweight typing Protocol to document the implicit interface: - -```python -# src/broker/base.py -from typing import Protocol -from decimal import Decimal - -from src.core.types import Market, Order, Position, Bar - - -class Broker(Protocol): - paper_trading: bool - - @property - def is_connected(self) -> bool: ... - - @property - def account_no(self) -> str: ... - - async def connect(self) -> None: ... - async def disconnect(self) -> None: ... - async def get_current_price(self, symbol: str, market: Market = Market.KRX) -> Decimal: ... - async def get_historical_bars(self, symbol: str, market: Market = Market.KRX, days: int = 100) -> list[Bar]: ... - async def get_positions(self, market: Market = Market.KRX) -> list[Position]: ... - async def get_account_balance(self, market: Market = Market.KRX) -> dict: ... - async def submit_order(self, order: Order) -> str: ... - async def cancel_order(self, order_id: str) -> bool: ... -``` - -No runtime inheritance needed. This is primarily for clarity/tests/type checking. - -### YFinanceBroker behavior - -Create `src/broker/yfinance.py`. - -Responsibilities: - -- Connect/disconnect as no-op state transitions. -- Map BeRich `Market` + symbol to yfinance ticker: - - `Market.NASDAQ`, `Market.NYSE`, `Market.AMEX`: symbol unchanged, e.g. `AAPL`, `SPY`. - - `Market.KRX`: initially support explicit suffix if user provides it; otherwise map numeric 6-digit symbol to `{symbol}.KS` as best-effort. -- `get_current_price()`: - - use `yf.Ticker(ticker).history(period="5d", interval="1d")` or `fast_info` fallback. - - return latest close/last price as `Decimal`. -- `get_historical_bars()`: - - use daily history for at least `days` rows, preferably `period=f"{max(days * 2, 120)}d"` to account for weekends/holidays. - - convert rows to `Bar` with `timeframe="1d"`. -- Paper account state: - - initial USD and KRW from existing config keys `trading.paper_cash_usd`, `trading.paper_cash_krw`. - - persist paper cash, positions, orders, and fills across restarts by default using the existing database/storage layer or a small broker-owned JSON state file under `data/` if DB integration is too invasive for v1. - - keep an explicit reset path for development, but do not reset on ordinary process restart. -- `submit_order()`: - - immediate fill at `get_current_price()` or order price fallback. - - update cash/positions. - - emit `ORDER_FILLED` event like current `PaperBroker`. -- `get_positions()`: - - return in-memory positions with current yfinance price. -- `get_account_balance()`: - - return dict with `total_eval`, `cash`, `stocks_eval`, `profit_loss`. -- `cancel_order()`: - - if pending/submitted order exists, mark cancelled; otherwise return false. - -### Broker factory - -Create `src/broker/factory.py`: - -```python -from decimal import Decimal -from src.core.events import EventBus -from src.utils.config import Config -from src.broker.kis.client import KISBroker -from src.broker.paper import PaperBroker -from src.broker.yfinance import YFinanceBroker - - -def create_broker(config: Config, event_bus: EventBus): - broker_name = config.get_broker_name() - trading_mode = config.get_trading_mode() - - if broker_name == "yfinance": - if trading_mode != "paper": - raise ValueError("BROKER=yfinance only supports TRADING_MODE=paper") - return YFinanceBroker( - event_bus=event_bus, - initial_cash_usd=Decimal(str(config.get("trading.paper_cash_usd", 10000))), - initial_cash_krw=Decimal(str(config.get("trading.paper_cash_krw", 0))), - ) - - if broker_name == "kis": - kis_config = config.get_kis_config() - real_broker = KISBroker(...) - if kis_config["paper_trading"]: - return PaperBroker(...) - return real_broker - - raise ValueError(f"Unsupported BROKER: {broker_name}") -``` - -Then simplify `TradingBot._initialize_broker()` to call factory. - ---- - -## Step-by-Step Plan - -### Task 1: Add yfinance dependency - -**Objective:** Make yfinance available in both uv/dev and Docker installs. - -**Files:** - -- Modify: `pyproject.toml` -- Modify: `uv.lock` via `uv lock` or `uv sync` - -**Change:** - -Add to `[project].dependencies`: - -```toml -"yfinance>=0.2.50", -``` - -**Validation:** - -Run: - -```bash -uv sync --extra dev -uv run python - <<'PY' -import yfinance as yf -print(yf.__version__) -PY -``` - -Expected: - -- `uv sync` succeeds. -- Python prints a yfinance version. - ---- - -### Task 2: Document the broker Protocol - -**Objective:** Make the currently implicit broker interface explicit without changing runtime behavior. - -**Files:** - -- Create: `src/broker/base.py` -- Modify: `tests/test_broker_base.py` or create if absent - -**Implementation:** - -Create: - -```python -from typing import Protocol -from decimal import Decimal - -from src.core.types import Market, Order, Position, Bar - - -class Broker(Protocol): - """Async broker interface used by TradingBot and OrderManager.""" - - paper_trading: bool - - @property - def is_connected(self) -> bool: - """Whether the broker is connected.""" - ... - - @property - def account_no(self) -> str: - """Account identifier for logs/dashboard.""" - ... - - async def connect(self) -> None: - ... - - async def disconnect(self) -> None: - ... - - async def get_current_price( - self, - symbol: str, - market: Market = Market.KRX, - ) -> Decimal: - ... - - async def get_historical_bars( - self, - symbol: str, - market: Market = Market.KRX, - days: int = 100, - ) -> list[Bar]: - ... - - async def get_positions( - self, - market: Market = Market.KRX, - ) -> list[Position]: - ... - - async def get_account_balance( - self, - market: Market = Market.KRX, - ) -> dict: - ... - - async def submit_order(self, order: Order) -> str: - ... - - async def cancel_order(self, order_id: str) -> bool: - ... -``` - -**Test:** - -Basic import test: - -```python -from src.broker.base import Broker - - -def test_broker_protocol_importable(): - assert Broker is not None -``` - -**Validation:** - -Run: - -```bash -uv run pytest tests/test_broker_base.py -v -``` - -Expected: pass. - ---- - -### Task 3: Add config helpers for broker selection - -**Objective:** Centralize broker/mode env parsing while preserving current defaults. - -**Files:** - -- Modify: `src/utils/config.py` -- Modify: `tests/test_core.py` or create `tests/test_config.py` -- Modify: `.env.example` -- Modify: `README.md` setup section later in Task 10; avoid doing README here if keeping tasks tiny. - -**Implementation in `src/utils/config.py`:** - -Add methods to `Config`: - -```python - def get_broker_name(self) -> str: - """Get selected broker implementation.""" - return os.getenv("BROKER", "kis").strip().lower() - - def get_trading_mode(self) -> str: - """Get trading mode: paper or live.""" - mode = os.getenv("TRADING_MODE") - if mode: - return mode.strip().lower() - return "paper" if os.getenv("KIS_PAPER_TRADING", "true").lower() == "true" else "live" -``` - -Update `.env.example` near the top: - -```env -# Broker selection: kis or yfinance -BROKER=kis - -# Trading mode: paper or live. yfinance supports paper only. -TRADING_MODE=paper -``` - -**Tests:** - -Use monkeypatch: - -```python -from src.utils.config import Config - - -def test_broker_defaults_to_kis(monkeypatch): - monkeypatch.delenv("BROKER", raising=False) - assert Config().get_broker_name() == "kis" - - -def test_broker_reads_env_lowercase(monkeypatch): - monkeypatch.setenv("BROKER", "YFINANCE") - assert Config().get_broker_name() == "yfinance" - - -def test_trading_mode_defaults_from_kis_paper_flag(monkeypatch): - monkeypatch.delenv("TRADING_MODE", raising=False) - monkeypatch.setenv("KIS_PAPER_TRADING", "false") - assert Config().get_trading_mode() == "live" -``` - -**Validation:** - -Run: - -```bash -uv run pytest tests/test_config.py -v -``` - -Expected: pass. - ---- - -### Task 4: Implement yfinance symbol mapping and history conversion helpers - -**Objective:** Add deterministic helper functions before wiring the full broker. - -**Files:** - -- Create: `src/broker/yfinance.py` -- Create: `tests/test_yfinance_broker.py` - -**Implementation skeleton:** - -```python -from datetime import datetime -from decimal import Decimal -from uuid import uuid4 -import logging - -import pandas as pd -import yfinance as yf - -from src.core.events import EventBus, Event, EventType -from src.core.types import Bar, Fill, Market, Order, OrderSide, OrderStatus, Position - -logger = logging.getLogger("TradingBot") - - -def to_yfinance_symbol(symbol: str, market: Market) -> str: - symbol = symbol.strip().upper() - if market in {Market.NASDAQ, Market.NYSE, Market.AMEX}: - return symbol - if market == Market.KRX: - if symbol.endswith((".KS", ".KQ")): - return symbol - if symbol.isdigit() and len(symbol) == 6: - return f"{symbol}.KS" - return symbol - - -def row_to_bar(symbol: str, market: Market, timestamp, row) -> Bar: - return Bar( - symbol=symbol, - market=market, - open=Decimal(str(row["Open"])), - high=Decimal(str(row["High"])), - low=Decimal(str(row["Low"])), - close=Decimal(str(row["Close"])), - volume=int(row.get("Volume", 0) or 0), - timestamp=timestamp.to_pydatetime() if hasattr(timestamp, "to_pydatetime") else timestamp, - timeframe="1d", - ) -``` - -**Tests:** - -```python -from decimal import Decimal -import pandas as pd - -from src.broker.yfinance import to_yfinance_symbol, row_to_bar -from src.core.types import Market - - -def test_to_yfinance_symbol_us_markets_unchanged(): - assert to_yfinance_symbol("aapl", Market.NASDAQ) == "AAPL" - assert to_yfinance_symbol("spy", Market.AMEX) == "SPY" - - -def test_to_yfinance_symbol_krx_numeric_defaults_to_ks(): - assert to_yfinance_symbol("005930", Market.KRX) == "005930.KS" - - -def test_to_yfinance_symbol_krx_suffix_preserved(): - assert to_yfinance_symbol("091990.KQ", Market.KRX) == "091990.KQ" - - -def test_row_to_bar_converts_ohlcv(): - row = pd.Series({"Open": 1.1, "High": 2.2, "Low": 1.0, "Close": 2.0, "Volume": 123}) - ts = pd.Timestamp("2026-01-02") - bar = row_to_bar("AAPL", Market.NASDAQ, ts, row) - assert bar.symbol == "AAPL" - assert bar.close == Decimal("2.0") - assert bar.volume == 123 - assert bar.timeframe == "1d" -``` - -**Validation:** - -Run: - -```bash -uv run pytest tests/test_yfinance_broker.py -v -``` - -Expected: pass. - ---- - -### Task 5: Implement `YFinanceBroker` read methods - -**Objective:** Support connection state, current price, historical bars, and paper account balance reads. - -**Files:** - -- Modify: `src/broker/yfinance.py` -- Modify: `tests/test_yfinance_broker.py` - -**Implementation notes:** - -Add class: - -```python -class YFinanceBroker: - """KIS-free paper broker using yfinance market data.""" - - def __init__( - self, - event_bus: EventBus, - initial_cash_krw: Decimal = Decimal("0"), - initial_cash_usd: Decimal = Decimal("10000"), - ): - self.event_bus = event_bus - self.paper_trading = True - self._connected = False - self._cash = {"krw": initial_cash_krw, "usd": initial_cash_usd} - self._positions: dict[str, dict] = {} - self._orders: dict[str, Order] = {} - self._fills: list[Fill] = [] - - @property - def is_connected(self) -> bool: - return self._connected - - @property - def account_no(self) -> str: - return "YFINANCE-PAPER" - - async def connect(self) -> None: - self._connected = True - logger.info("YFinance paper broker connected") - await self.event_bus.publish(Event( - event_type=EventType.BROKER_CONNECTED, - data={"broker": "yfinance", "paper_trading": True}, - timestamp=datetime.now(), - source="YFinanceBroker", - )) - - async def disconnect(self) -> None: - self._connected = False - logger.info("YFinance paper broker disconnected") - - def _history(self, symbol: str, market: Market, period: str, interval: str) -> pd.DataFrame: - ticker = to_yfinance_symbol(symbol, market) - return yf.Ticker(ticker).history(period=period, interval=interval, auto_adjust=False) - - async def get_current_price(self, symbol: str, market: Market = Market.KRX) -> Decimal: - df = self._history(symbol, market, period="5d", interval="1d") - if df.empty: - raise ValueError(f"No yfinance price data for {symbol} ({market.value})") - close = df["Close"].dropna().iloc[-1] - return Decimal(str(close)) - - async def get_historical_bars(self, symbol: str, market: Market = Market.KRX, days: int = 100) -> list[Bar]: - period_days = max(days * 2, 120) - df = self._history(symbol, market, period=f"{period_days}d", interval="1d") - if df.empty: - raise ValueError(f"No yfinance historical data for {symbol} ({market.value})") - bars = [row_to_bar(symbol, market, idx, row) for idx, row in df.tail(days).iterrows()] - return bars -``` - -Add balance and positions read methods: - -```python - async def get_positions(self, market: Market = Market.KRX) -> list[Position]: - positions = [] - for symbol, pos in self._positions.items(): - if pos["market"] != market or pos["quantity"] <= 0: - continue - current_price = await self.get_current_price(symbol, market) - positions.append(Position( - symbol=symbol, - market=market, - quantity=pos["quantity"], - avg_entry_price=pos["avg_price"], - current_price=current_price, - unrealized_pnl=(current_price - pos["avg_price"]) * pos["quantity"], - )) - return positions - - async def get_account_balance(self, market: Market = Market.KRX) -> dict: - cash_key = "krw" if market == Market.KRX else "usd" - positions = await self.get_positions(market) - stocks_eval = sum((p.current_price * p.quantity for p in positions), Decimal("0")) - profit_loss = sum((p.unrealized_pnl for p in positions), Decimal("0")) - cash = self._cash[cash_key] - return { - "total_eval": cash + stocks_eval, - "cash": cash, - "stocks_eval": stocks_eval, - "profit_loss": profit_loss, - } -``` - -**Tests:** - -Patch `_history` to avoid network: - -```python -from decimal import Decimal -import pandas as pd -import pytest - -from src.broker.yfinance import YFinanceBroker -from src.core.events import EventBus -from src.core.types import Market - - -@pytest.mark.asyncio -async def test_yfinance_broker_connects(): - broker = YFinanceBroker(EventBus()) - await broker.connect() - assert broker.is_connected is True - assert broker.account_no == "YFINANCE-PAPER" - - -@pytest.mark.asyncio -async def test_get_current_price_uses_latest_close(monkeypatch): - broker = YFinanceBroker(EventBus()) - df = pd.DataFrame({"Close": [10.0, 12.5]}, index=pd.date_range("2026-01-01", periods=2)) - monkeypatch.setattr(broker, "_history", lambda *args, **kwargs: df) - assert await broker.get_current_price("AAPL", Market.NASDAQ) == Decimal("12.5") - - -@pytest.mark.asyncio -async def test_get_historical_bars_returns_tail(monkeypatch): - broker = YFinanceBroker(EventBus()) - df = pd.DataFrame( - {"Open": [1, 2, 3], "High": [2, 3, 4], "Low": [0, 1, 2], "Close": [1.5, 2.5, 3.5], "Volume": [10, 20, 30]}, - index=pd.date_range("2026-01-01", periods=3), - ) - monkeypatch.setattr(broker, "_history", lambda *args, **kwargs: df) - bars = await broker.get_historical_bars("AAPL", Market.NASDAQ, days=2) - assert len(bars) == 2 - assert bars[-1].close == Decimal("3.5") -``` - -**Validation:** - -Run: - -```bash -uv run pytest tests/test_yfinance_broker.py -v -``` - -Expected: pass. - ---- - -### Task 6: Implement `YFinanceBroker` paper order execution - -**Objective:** Make yfinance broker usable by `OrderManager` for simulated fills. - -**Files:** - -- Modify: `src/broker/yfinance.py` -- Modify: `tests/test_yfinance_broker.py` - -**Implementation:** - -Port the minimal order execution behavior from `PaperBroker`, but keep it self-contained. - -Important behavior: - -- BUY reduces cash and creates/averages position. -- SELL requires existing quantity and increases cash. -- Filled orders emit `ORDER_FILLED` with the `Order` as data, same as current `PaperBroker`. -- Rejected orders return an order id with `OrderStatus.REJECTED`. - -**Core code shape:** - -```python - async def submit_order(self, order: Order) -> str: - order_id = f"YF-PAPER-{uuid4().hex[:8].upper()}" - order.order_id = order_id - order.status = OrderStatus.SUBMITTED - fill_price = order.price or await self.get_current_price(order.symbol, order.market) - cash_key = "krw" if order.market == Market.KRX else "usd" - pnl = None - - if order.side == OrderSide.BUY: - cost = fill_price * order.quantity - if cost > self._cash[cash_key]: - order.status = OrderStatus.REJECTED - self._orders[order_id] = order - return order_id - self._cash[cash_key] -= cost - pos = self._positions.get(order.symbol) - if pos: - old_qty = pos["quantity"] - new_qty = old_qty + order.quantity - pos["avg_price"] = ((pos["avg_price"] * old_qty) + (fill_price * order.quantity)) / new_qty - pos["quantity"] = new_qty - else: - self._positions[order.symbol] = { - "quantity": order.quantity, - "avg_price": fill_price, - "market": order.market, - } - - elif order.side == OrderSide.SELL: - pos = self._positions.get(order.symbol) - if not pos or pos["quantity"] < order.quantity: - order.status = OrderStatus.REJECTED - self._orders[order_id] = order - return order_id - proceeds = fill_price * order.quantity - self._cash[cash_key] += proceeds - pnl = (fill_price - pos["avg_price"]) * order.quantity - pos["quantity"] -= order.quantity - if pos["quantity"] <= 0: - del self._positions[order.symbol] - - order.status = OrderStatus.FILLED - order.filled_quantity = order.quantity - order.filled_avg_price = fill_price - self._orders[order_id] = order - self._fills.append(Fill( - order_id=order_id, - symbol=order.symbol, - market=order.market, - side=order.side, - quantity=order.quantity, - price=fill_price, - commission=Decimal("0"), - timestamp=datetime.now(), - pnl=pnl, - )) - await self.event_bus.publish(Event( - event_type=EventType.ORDER_FILLED, - data=order, - timestamp=datetime.now(), - source="YFinanceBroker", - )) - return order_id - - async def cancel_order(self, order_id: str) -> bool: - order = self._orders.get(order_id) - if not order or order.status == OrderStatus.FILLED: - return False - order.status = OrderStatus.CANCELLED - return True -``` - -**Tests:** - -```python -from src.core.types import Order, OrderSide, OrderType, OrderStatus - - -@pytest.mark.asyncio -async def test_submit_buy_order_updates_cash_and_position(monkeypatch): - broker = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000")) - monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("10"))) - order = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5) - order_id = await broker.submit_order(order) - assert order_id.startswith("YF-PAPER-") - assert order.status == OrderStatus.FILLED - assert broker._cash["usd"] == Decimal("950") - positions = await broker.get_positions(Market.NASDAQ) - assert positions[0].quantity == 5 - - -@pytest.mark.asyncio -async def test_submit_buy_rejects_insufficient_cash(monkeypatch): - broker = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("10")) - monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("20"))) - order = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=1) - await broker.submit_order(order) - assert order.status == OrderStatus.REJECTED - - -@pytest.mark.asyncio -async def test_submit_sell_order_updates_cash_and_position(monkeypatch): - broker = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000")) - monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("10"))) - buy = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5) - await broker.submit_order(buy) - monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("12"))) - sell = Order("AAPL", Market.NASDAQ, OrderSide.SELL, OrderType.MARKET, quantity=2) - await broker.submit_order(sell) - assert sell.status == OrderStatus.FILLED - assert broker._cash["usd"] == Decimal("974") - positions = await broker.get_positions(Market.NASDAQ) - assert positions[0].quantity == 3 -``` - -**Validation:** - -Run: - -```bash -uv run pytest tests/test_yfinance_broker.py -v -``` - -Expected: pass. - ---- - -### Task 7: Persist yfinance paper state across restarts - -**Objective:** Make paper cash, positions, orders, and fills survive ordinary process restarts. - -**Files:** - -- Modify: `src/broker/yfinance.py` -- Modify: `tests/test_yfinance_broker.py` -- Possibly create: `data/yfinance_paper_state.json` at runtime only; do not commit runtime state. - -**Implementation approach:** - -Use a small JSON state file as the v1 persistence layer because it is broker-local and avoids a larger DB schema migration. Default path: - -```text -data/yfinance_paper_state.json -``` - -Add optional constructor arg: - -```python -state_path: str | Path = "data/yfinance_paper_state.json" -``` - -Behavior: - -- On `connect()`, load the state file if it exists. -- If it does not exist, initialize from configured paper cash. -- After every accepted/rejected order state change, save state atomically. -- Persist Decimals as strings. -- Persist markets/order statuses by enum value. -- Do not persist transient connection state. -- Add a helper method `reset_state()` for tests/dev, but do not call it during normal startup. - -**Tests:** - -- Create broker with temp `state_path`. -- Buy a position. -- Instantiate a new broker with the same `state_path`. -- Connect/load and assert cash and position are restored. - -Example assertion shape: - -```python -@pytest.mark.asyncio -async def test_yfinance_paper_state_persists_across_restart(tmp_path, monkeypatch): - state_path = tmp_path / "paper_state.json" - broker1 = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000"), state_path=state_path) - monkeypatch.setattr(broker1, "get_current_price", AsyncMock(return_value=Decimal("10"))) - await broker1.connect() - await broker1.submit_order(Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5)) - - broker2 = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000"), state_path=state_path) - monkeypatch.setattr(broker2, "get_current_price", AsyncMock(return_value=Decimal("10"))) - await broker2.connect() - positions = await broker2.get_positions(Market.NASDAQ) - - assert broker2._cash["usd"] == Decimal("950") - assert positions[0].symbol == "AAPL" - assert positions[0].quantity == 5 -``` - -**Validation:** - -Run: - -```bash -uv run pytest tests/test_yfinance_broker.py -v -``` - -Expected: pass. - ---- - -### Task 8: Add broker factory and preserve KIS behavior - -**Objective:** Select KIS or yfinance from config without changing the rest of the bot. - -**Files:** - -- Create: `src/broker/factory.py` -- Modify: `tests/test_broker_factory.py` - -**Implementation:** - -Implement `create_broker(config, event_bus)`. - -Key requirements: - -- `BROKER=yfinance` returns `YFinanceBroker` and does not construct `KISBroker`. -- `BROKER=kis` preserves existing KIS + `PaperBroker` wrapping behavior. -- Unknown broker raises `ValueError`. -- `BROKER=yfinance` with `TRADING_MODE=live` raises `ValueError`. - -**Tests:** - -Use monkeypatch env and avoid calling network. - -```python -from src.broker.factory import create_broker -from src.broker.yfinance import YFinanceBroker -from src.core.events import EventBus -from src.utils.config import Config - - -def test_factory_returns_yfinance_broker(monkeypatch): - monkeypatch.setenv("BROKER", "yfinance") - monkeypatch.setenv("TRADING_MODE", "paper") - broker = create_broker(Config(), EventBus()) - assert isinstance(broker, YFinanceBroker) - - -def test_factory_rejects_yfinance_live(monkeypatch): - monkeypatch.setenv("BROKER", "yfinance") - monkeypatch.setenv("TRADING_MODE", "live") - with pytest.raises(ValueError, match="paper"): - create_broker(Config(), EventBus()) - - -def test_factory_rejects_unknown_broker(monkeypatch): - monkeypatch.setenv("BROKER", "unknown") - with pytest.raises(ValueError, match="Unsupported BROKER"): - create_broker(Config(), EventBus()) -``` - -**Validation:** - -Run: - -```bash -uv run pytest tests/test_broker_factory.py -v -``` - -Expected: pass. - ---- - -### Task 9: Wire `TradingBot` to broker factory - -**Objective:** Remove direct KIS construction from bot initialization path. - -**Files:** - -- Modify: `src/bot/core.py` -- Modify: tests that assert bot initialization behavior if present, likely `tests/test_bot.py` - -**Implementation:** - -Change imports: - -```python -# remove or stop using this for type annotation only -from src.broker.kis.client import KISBroker - -# add -from src.broker.base import Broker -from src.broker.factory import create_broker -``` - -Change property annotation: - -```python -self.broker: Broker | None = None -``` - -Replace `_initialize_broker()` body construction section with: - -```python - async def _initialize_broker(self) -> None: - """Initialize broker connection""" - self.broker = create_broker(self.config, self.event_bus) - await self.broker.connect() - - try: - krw_balance = await self.broker.get_account_balance(Market.KRX) - ... existing balance/dashboard code unchanged ... -``` - -Keep existing dashboard balance update code as-is. - -**Tests:** - -Patch `create_broker` in `src.bot.core` and assert it is called by `_initialize_broker()`. - -```python -@pytest.mark.asyncio -async def test_initialize_broker_uses_factory(monkeypatch): - bot = TradingBot() - broker = AsyncMock() - broker.get_account_balance = AsyncMock(return_value={"total_eval": Decimal("1000"), "cash": Decimal("1000"), "profit_loss": Decimal("0")}) - monkeypatch.setattr("src.bot.core.create_broker", lambda config, event_bus: broker) - await bot._initialize_broker() - broker.connect.assert_awaited_once() - assert bot.broker is broker -``` - -Adjust for existing test fixtures/imports. - -**Validation:** - -Run: - -```bash -uv run pytest tests/test_bot.py -v -uv run pytest tests/test_broker_factory.py tests/test_yfinance_broker.py -v -``` - -Expected: pass. - ---- - -### Task 10: Integration smoke test for yfinance mode without KIS env - -**Objective:** Prove BeRich can initialize with yfinance paper mode without KIS credentials. - -**Files:** - -- Create or modify: `tests/test_yfinance_integration.py` - -**Approach:** - -Avoid real network by monkeypatching `YFinanceBroker._history` or `get_current_price/get_historical_bars`. - -Test that: - -- `BROKER=yfinance` -- `TRADING_MODE=paper` -- KIS env vars removed -- `TradingBot._initialize_broker()` succeeds -- selected broker account is `YFINANCE-PAPER` - -**Test shape:** - -```python -@pytest.mark.asyncio -async def test_yfinance_mode_does_not_require_kis_credentials(monkeypatch): - monkeypatch.setenv("BROKER", "yfinance") - monkeypatch.setenv("TRADING_MODE", "paper") - monkeypatch.delenv("KIS_APP_KEY", raising=False) - monkeypatch.delenv("KIS_APP_SECRET", raising=False) - monkeypatch.delenv("KIS_ACCOUNT_NO", raising=False) - - async def fake_get_account_balance(self, market=Market.KRX): - return {"total_eval": Decimal("10000"), "cash": Decimal("10000"), "stocks_eval": Decimal("0"), "profit_loss": Decimal("0")} - - monkeypatch.setattr(YFinanceBroker, "get_account_balance", fake_get_account_balance) - - bot = TradingBot() - await bot._initialize_broker() - assert bot.broker.account_no == "YFINANCE-PAPER" - await bot.broker.disconnect() -``` - -**Validation:** - -Run: - -```bash -uv run pytest tests/test_yfinance_integration.py -v -``` - -Expected: pass. - ---- - -### Task 11: Update documentation and examples - -**Objective:** Make yfinance paper mode discoverable and easy to run. - -**Files:** - -- Modify: `.env.example` -- Modify: `README.md` - -**README additions:** - -Add a section before KIS setup or inside install/run: - -```markdown -### KIS 없이 yfinance paper mode로 실행 - -KIS API 키 없이 UI와 RSI 전략을 paper trading으로 테스트하려면 `.env`에서: - -```env -BROKER=yfinance -TRADING_MODE=paper -KIS_PAPER_TRADING=true -DASHBOARD_USERNAME=admin -DASHBOARD_PASSWORD=change-me -``` - -이 모드는 yfinance에서 시세/일봉 데이터를 가져오고, 주문은 메모리 상에서 즉시 체결 처리합니다. 실제 주문은 발생하지 않습니다. - -실행: - -```bash -uv run python scripts/run_bot.py --web --web-port 9095 -``` - -또는 Docker: - -```bash -docker compose up -d --build -``` - -주의: - -- yfinance 데이터는 지연/누락될 수 있어 실거래 판단용으로 쓰면 안 됩니다. -- KRX 종목은 yfinance suffix가 필요할 수 있습니다. 예: `005930.KS`, `091990.KQ`. -- yfinance broker는 현재 live trading을 지원하지 않습니다. -``` - -**Validation:** - -Run: - -```bash -uv run pytest tests/ -v -``` - -Expected: all tests pass. - ---- - -### Task 12: Local runtime verification with real yfinance network - -**Objective:** Actually run the app in yfinance paper mode and verify dashboard responds. - -**Files:** - -- Modify local `.env` only after confirmation because it is environment/config write. - -**Manual `.env` change:** - -Set: - -```env -BROKER=yfinance -TRADING_MODE=paper -DASHBOARD_USERNAME=admin -DASHBOARD_PASSWORD=<local-password> -``` - -Leave KIS values unused. - -**Commands:** - -Stop old mock dashboard if still running: - -```bash -# Identify Hermes background proc if needed, or stop process using port 9095 after confirming scope. -ss -ltnp | grep ':9095' -``` - -Run direct dev server: - -```bash -uv run python scripts/run_bot.py --web --web-port 9095 -``` - -Expected logs: - -```text -YFinance paper broker connected -Trading Bot initialized successfully -Web dashboard started at http://localhost:9095 -``` - -Verify HTTP: - -```bash -curl -sS -L -o /tmp/berich_yfinance_home.html -w '%{http_code} %{content_type}\n' http://127.0.0.1:9095/ -grep -o '<title>[^<]*' /tmp/berich_yfinance_home.html | head -1 -``` - -Expected: - -```text -200 text/html; charset=utf-8 -<title>BeRich Dashboard -``` - -**Note:** If app redirects to login because auth is enabled, `200` login page is still acceptable; verify the process logs have yfinance broker connected. - ---- - -### Task 13: Docker verification - -**Objective:** Ensure container image still builds and runs with yfinance mode. - -**Commands:** - -```bash -docker compose build -docker compose up -d -sleep 10 -docker compose ps -docker logs --tail 100 quant-bot -``` - -Expected: - -- build succeeds. -- MySQL is healthy. -- `quant-bot` stays running. -- logs show `YFinance paper broker connected`, not KIS auth failure. - -If `logs/` or `data/` permissions recur: - -```bash -mkdir -p logs data -sudo chown -R 999:999 logs data -``` - -Then: - -```bash -docker compose restart trading-bot -``` - ---- - -## Files Likely to Change - -- `pyproject.toml` -- `uv.lock` -- `.env.example` -- `README.md` -- `src/utils/config.py` -- `src/broker/base.py` new -- `src/broker/yfinance.py` new -- `src/broker/factory.py` new -- `src/bot/core.py` -- tests: - - `tests/test_config.py` - - `tests/test_broker_base.py` - - `tests/test_yfinance_broker.py` - - `tests/test_broker_factory.py` - - `tests/test_yfinance_integration.py` - - possibly `tests/test_bot.py` - -## Test / Validation Matrix - -Run incrementally: - -```bash -uv run pytest tests/test_config.py -v -uv run pytest tests/test_broker_base.py -v -uv run pytest tests/test_yfinance_broker.py -v -uv run pytest tests/test_broker_factory.py -v -uv run pytest tests/test_yfinance_integration.py -v -uv run pytest tests/test_bot.py -v -``` - -Final full suite: - -```bash -uv run pytest tests/ -v -``` - -Runtime smoke: - -```bash -BROKER=yfinance TRADING_MODE=paper uv run python scripts/run_bot.py --web --web-port 9095 -curl -sS -L -o /tmp/berich_home.html -w '%{http_code} %{content_type}\n' http://127.0.0.1:9095/ -``` - -Docker: - -```bash -docker compose build -docker compose up -d -docker compose ps -docker logs --tail 100 quant-bot -``` - -## Risks / Tradeoffs - -1. **yfinance is not a broker.** It is data only. This mode must remain paper-only. -2. **Data quality/latency.** yfinance data can be delayed, adjusted, missing, or rate-limited. Good for prototyping, not production trading. -3. **KRX symbol mapping.** `.KS` vs `.KQ` cannot be inferred perfectly from a 6-digit code. Default `.KS` is best-effort only, so explicit `.KQ` symbols should be supported for KOSDAQ. -4. **Persistent paper state.** Persisting paper cash/positions/orders/fills in JSON is simpler than a DB migration, but it requires atomic writes and clear reset tooling to avoid corrupted or stale simulation state. -5. **Existing `PaperBroker` duplication.** `YFinanceBroker` will duplicate some paper execution logic. This is acceptable for minimal change, but later refactor could extract `PaperExecutionMixin` or make `PaperBroker` accept any data broker without requiring auth. -6. **Scheduler is currently `TradingScheduler(interval_seconds=60, us_only=True)`.** Supporting Korea as well as US may require revisiting market-hours logic so KRX strategies are not skipped unintentionally. - -## Confirmed Decisions - -1. Support both US and Korean symbols in yfinance paper mode. -2. Persist paper positions/state across ordinary restarts by default. -3. Update local `.env` to use `BROKER=yfinance` during implementation while leaving KIS values unused. - -## Recommended v1 Scope - -- Implement `BROKER=yfinance`, `TRADING_MODE=paper`. -- Support US symbols robustly. -- Support Korean symbols with explicit `.KS`/`.KQ` and numeric-code best-effort mapping. -- Persist paper cash/positions/orders/fills across restarts by default. -- Do not touch live trading. -- Verify direct dev run and Docker run. From 469e58c8401e65a4724ad366f27b7f2083905a0d Mon Sep 17 00:00:00 2001 From: Hojin Jang <23309485+jhojin7@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:10:22 +0900 Subject: [PATCH 5/5] Address PR review comments --- .env.example | 3 - README.md | 5 +- src/broker/yfinance.py | 262 +++++++++++++++++---------------- src/execution/order_manager.py | 4 + src/utils/config.py | 23 +-- tests/test_broker_factory.py | 2 +- tests/test_config.py | 40 ++++- tests/test_execution.py | 44 ++++++ tests/test_yfinance_broker.py | 73 ++++++++- 9 files changed, 301 insertions(+), 155 deletions(-) diff --git a/.env.example b/.env.example index 9868fd4..e591003 100644 --- a/.env.example +++ b/.env.example @@ -9,9 +9,6 @@ KIS_APP_KEY=your_app_key_here KIS_APP_SECRET=your_app_secret_here KIS_ACCOUNT_NO=12345678-01 -# Backward-compatible KIS mode flag -KIS_PAPER_TRADING=true - # KIS HTS ID — required for WebSocket execution-notice subscriptions. # Find it in KIS HTS/MTS login screen (보통 영문+숫자 5~12자). Without this, # the bot can place orders but cannot detect fills in real time and diff --git a/README.md b/README.md index d9afedc..35e8720 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,6 @@ KIS_APP_KEY=your_app_key KIS_APP_SECRET=your_app_secret KIS_ACCOUNT_NO=your_account_number -# Backward-compatible KIS mode flag -KIS_PAPER_TRADING=true - # MySQL MYSQL_ROOT_PASSWORD=your_root_password MYSQL_DATABASE=quant @@ -237,7 +234,7 @@ uv run python scripts/run_bot.py --web --web-port 9095 - 워밍업 없이 즉시 시작 - 대시보드에 `PAPER` 배지 표시 -KIS 기반 paper trading을 쓰려면 `BROKER=kis`, `KIS_PAPER_TRADING=true`와 유효한 KIS API 키가 필요합니다. +KIS 기반 paper trading을 쓰려면 `BROKER=kis`, `TRADING_MODE=paper`와 유효한 KIS API 키가 필요합니다. ## 테스트 diff --git a/src/broker/yfinance.py b/src/broker/yfinance.py index 714faf5..905f7ac 100644 --- a/src/broker/yfinance.py +++ b/src/broker/yfinance.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json import logging import os @@ -44,11 +45,7 @@ def row_to_bar(symbol: str, market: Market, timestamp, row) -> Bar: low=Decimal(str(row["Low"])), close=Decimal(str(row["Close"])), volume=int(row.get("Volume", 0) or 0), - timestamp=( - timestamp.to_pydatetime() - if hasattr(timestamp, "to_pydatetime") - else timestamp - ), + timestamp=(timestamp.to_pydatetime() if hasattr(timestamp, "to_pydatetime") else timestamp), timeframe="1d", ) @@ -74,6 +71,7 @@ def __init__( self._positions: dict[str, dict] = {} self._orders: dict[str, Order] = {} self._fills: list[Fill] = [] + self._state_lock = asyncio.Lock() self.state_path = Path(state_path) @property @@ -108,12 +106,16 @@ async def disconnect(self) -> None: def reset_state(self) -> None: """Reset paper account state and remove persisted state file.""" + self._reset_in_memory_state() + if self.state_path.exists(): + self.state_path.unlink() + + def _reset_in_memory_state(self) -> None: + """Restore the in-memory paper account to its initial empty state.""" self._cash = dict(self._initial_cash) self._positions = {} self._orders = {} self._fills = [] - if self.state_path.exists(): - self.state_path.unlink() def _history( self, @@ -155,107 +157,116 @@ async def get_historical_bars( if df.empty: raise ValueError(f"No yfinance historical data for {symbol} ({market.value})") df = df.dropna(subset=["Open", "High", "Low", "Close"]) - return [ - row_to_bar(symbol, market, idx, row) - for idx, row in df.tail(days).iterrows() - ] + return [row_to_bar(symbol, market, idx, row) for idx, row in df.tail(days).iterrows()] async def submit_order(self, order: Order) -> str: """Simulate immediate order execution at the current yfinance price.""" - order_id = f"YF-PAPER-{uuid4().hex[:8].upper()}" - order.order_id = order_id - order.status = OrderStatus.SUBMITTED - pnl = None + async with self._state_lock: + order_id = f"YF-PAPER-{uuid4().hex[:8].upper()}" + order.order_id = order_id + order.status = OrderStatus.SUBMITTED + pnl = None - try: - fill_price = await self.get_current_price(order.symbol, order.market) - except Exception: - fill_price = order.price or Decimal("0") - - cash_key = self._cash_key(order.market) - - if order.side == OrderSide.BUY: - cost = fill_price * order.quantity - if cost > self._cash[cash_key]: + try: + fill_price = await self.get_current_price(order.symbol, order.market) + if fill_price <= Decimal("0"): + raise ValueError(f"Non-positive yfinance price: {fill_price}") + except Exception as exc: logger.warning( - "[YF PAPER] Insufficient cash for %s: need %s, have %s", + "[YF PAPER] Rejecting %s %s: failed to get fill price: %s", + order.side.value, order.symbol, - f"{cost:,.2f}", - f"{self._cash[cash_key]:,.2f}", + exc, ) order.status = OrderStatus.REJECTED self._orders[order_id] = order self._save_state() return order_id - self._cash[cash_key] -= cost - if pos := self._positions.get(order.symbol): - old_qty = pos["quantity"] - new_qty = old_qty + order.quantity - pos["avg_price"] = ( - (pos["avg_price"] * old_qty) + (fill_price * order.quantity) - ) / new_qty - pos["quantity"] = new_qty - else: - self._positions[order.symbol] = { - "quantity": order.quantity, - "avg_price": fill_price, - "market": order.market, - } - - elif order.side == OrderSide.SELL: - pos = self._positions.get(order.symbol) - if not pos or pos["quantity"] < order.quantity: - logger.warning("[YF PAPER] Insufficient position for %s", order.symbol) - order.status = OrderStatus.REJECTED - self._orders[order_id] = order - self._save_state() - return order_id - - proceeds = fill_price * order.quantity - self._cash[cash_key] += proceeds - pnl = (fill_price - pos["avg_price"]) * order.quantity - pos["quantity"] -= order.quantity - if pos["quantity"] <= 0: - del self._positions[order.symbol] - - order.status = OrderStatus.FILLED - order.filled_quantity = order.quantity - order.filled_avg_price = fill_price - self._orders[order_id] = order + cash_key = self._cash_key(order.market) + + if order.side == OrderSide.BUY: + cost = fill_price * order.quantity + if cost > self._cash[cash_key]: + logger.warning( + "[YF PAPER] Insufficient cash for %s: need %s, have %s", + order.symbol, + f"{cost:,.2f}", + f"{self._cash[cash_key]:,.2f}", + ) + order.status = OrderStatus.REJECTED + self._orders[order_id] = order + self._save_state() + return order_id + + self._cash[cash_key] -= cost + if pos := self._positions.get(order.symbol): + old_qty = pos["quantity"] + new_qty = old_qty + order.quantity + pos["avg_price"] = ( + (pos["avg_price"] * old_qty) + (fill_price * order.quantity) + ) / new_qty + pos["quantity"] = new_qty + else: + self._positions[order.symbol] = { + "quantity": order.quantity, + "avg_price": fill_price, + "market": order.market, + } + + elif order.side == OrderSide.SELL: + pos = self._positions.get(order.symbol) + if not pos or pos["quantity"] < order.quantity: + logger.warning("[YF PAPER] Insufficient position for %s", order.symbol) + order.status = OrderStatus.REJECTED + self._orders[order_id] = order + self._save_state() + return order_id + + proceeds = fill_price * order.quantity + self._cash[cash_key] += proceeds + pnl = (fill_price - pos["avg_price"]) * order.quantity + pos["quantity"] -= order.quantity + if pos["quantity"] <= 0: + del self._positions[order.symbol] + + order.status = OrderStatus.FILLED + order.filled_quantity = order.quantity + order.filled_avg_price = fill_price + self._orders[order_id] = order + + current_rsi = None + try: + from src.web.app import get_dashboard_state - current_rsi = None - try: - from src.web.app import get_dashboard_state - - current_rsi = get_dashboard_state().rsi_values.get(order.symbol) - except Exception: - pass - - self._fills.append( - Fill( - order_id=order_id, - symbol=order.symbol, - market=order.market, - side=order.side, - quantity=order.quantity, - price=fill_price, - commission=Decimal("0"), - timestamp=datetime.now(), - pnl=pnl, - rsi=current_rsi, + current_rsi = get_dashboard_state().rsi_values.get(order.symbol) + except Exception: + pass + + self._fills.append( + Fill( + order_id=order_id, + symbol=order.symbol, + market=order.market, + side=order.side, + quantity=order.quantity, + price=fill_price, + commission=Decimal("0"), + timestamp=datetime.now(), + pnl=pnl, + rsi=current_rsi, + ) ) - ) - self._save_state() - await self.event_bus.publish( - Event( - event_type=EventType.ORDER_FILLED, - data=order, - timestamp=datetime.now(), - source="YFinanceBroker", + self._save_state() + await self.event_bus.publish( + Event( + event_type=EventType.ORDER_FILLED, + data=order, + timestamp=datetime.now(), + source="YFinanceBroker", + ) ) - ) - return order_id + return order_id async def get_positions( self, @@ -317,12 +328,13 @@ async def cancel_order( order_id: str, market: Market = Market.KRX, ) -> bool: - order = self._orders.get(order_id) - if not order or order.status == OrderStatus.FILLED: - return False - order.status = OrderStatus.CANCELLED - self._save_state() - return True + async with self._state_lock: + order = self._orders.get(order_id) + if not order or order.status == OrderStatus.FILLED: + return False + order.status = OrderStatus.CANCELLED + self._save_state() + return True def _cash_key(self, market: Market) -> str: return "krw" if market == Market.KRX else "usd" @@ -340,8 +352,7 @@ def _save_state(self) -> None: for symbol, pos in self._positions.items() }, "orders": { - order_id: self._order_to_dict(order) - for order_id, order in self._orders.items() + order_id: self._order_to_dict(order) for order_id, order in self._orders.items() }, "fills": [self._fill_to_dict(fill) for fill in self._fills], } @@ -352,27 +363,32 @@ def _save_state(self) -> None: def _load_state(self) -> None: if not self.state_path.exists(): return - data = json.loads(self.state_path.read_text()) - self._cash = { - "krw": Decimal(str(data.get("cash", {}).get("krw", self._initial_cash["krw"]))), - "usd": Decimal(str(data.get("cash", {}).get("usd", self._initial_cash["usd"]))), - } - self._positions = { - symbol: { - "quantity": int(pos["quantity"]), - "avg_price": Decimal(str(pos["avg_price"])), - "market": Market.from_string(pos["market"]), + try: + data = json.loads(self.state_path.read_text()) + self._cash = { + "krw": Decimal(str(data.get("cash", {}).get("krw", self._initial_cash["krw"]))), + "usd": Decimal(str(data.get("cash", {}).get("usd", self._initial_cash["usd"]))), } - for symbol, pos in data.get("positions", {}).items() - } - self._orders = { - order_id: self._order_from_dict(order_data) - for order_id, order_data in data.get("orders", {}).items() - } - self._fills = [ - self._fill_from_dict(fill_data) - for fill_data in data.get("fills", []) - ] + self._positions = { + symbol: { + "quantity": int(pos["quantity"]), + "avg_price": Decimal(str(pos["avg_price"])), + "market": Market.from_string(pos["market"]), + } + for symbol, pos in data.get("positions", {}).items() + } + self._orders = { + order_id: self._order_from_dict(order_data) + for order_id, order_data in data.get("orders", {}).items() + } + self._fills = [self._fill_from_dict(fill_data) for fill_data in data.get("fills", [])] + except (json.JSONDecodeError, OSError, KeyError, TypeError, ValueError) as exc: + logger.warning( + "Failed to load yfinance paper state from %s; starting fresh: %s", + self.state_path, + exc, + ) + self._reset_in_memory_state() def _order_to_dict(self, order: Order) -> dict: return { @@ -387,9 +403,7 @@ def _order_to_dict(self, order: Order) -> dict: "created_at": order.created_at.isoformat(), "filled_quantity": order.filled_quantity, "filled_avg_price": ( - str(order.filled_avg_price) - if order.filled_avg_price is not None - else None + str(order.filled_avg_price) if order.filled_avg_price is not None else None ), } diff --git a/src/execution/order_manager.py b/src/execution/order_manager.py index 1d800df..41d89e2 100644 --- a/src/execution/order_manager.py +++ b/src/execution/order_manager.py @@ -225,6 +225,10 @@ async def _calculate_buy_quantity( Example: $10,000 total × 20% = $2,000 max. If already holding $1,000, remaining = $1,000, stage 0.5 → buy $500. + + KRX and US markets are intentionally budgeted separately: KRX signals use + KRW balance/cash, while NYSE/NASDAQ/AMEX signals use USD balance/cash. + This avoids spending USD-buying power based on KRW cash, and vice versa. """ dashboard = get_dashboard_state() is_krx = market == Market.KRX diff --git a/src/utils/config.py b/src/utils/config.py index 8bd20b0..cfa8d2d 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -12,7 +12,6 @@ class Config: def __init__(self, config_dir: str = "config"): self.config_dir = Path(config_dir) self._settings: dict[str, Any] = {} - self._strategies: list = [] load_dotenv() def load(self) -> None: @@ -21,11 +20,6 @@ def load(self) -> None: if settings_path.exists(): self._settings = self._load_yaml(settings_path) - strategies_path = self.config_dir / "strategies.yaml" - if strategies_path.exists(): - strategies_config = self._load_yaml(strategies_path) - self._strategies = strategies_config.get("strategies", []) - def _load_yaml(self, path: Path) -> dict: """Load YAML file with environment variable substitution""" with open(path, "r", encoding="utf-8") as f: @@ -60,20 +54,19 @@ def get(self, key: str, default: Any = None) -> Any: def settings(self) -> dict: return self._settings - @property - def strategies(self) -> list: - return self._strategies - def get_broker_name(self) -> str: """Get selected broker implementation.""" - return os.getenv("BROKER", "kis").strip().lower() + broker = os.getenv("BROKER", "kis").strip().lower() + if broker not in {"kis", "yfinance"}: + raise ValueError("BROKER must be one of: kis, yfinance") + return broker def get_trading_mode(self) -> str: """Get trading mode: paper or live.""" - mode = os.getenv("TRADING_MODE") - if mode: - return mode.strip().lower() - return "paper" if os.getenv("KIS_PAPER_TRADING", "true").lower() == "true" else "live" + mode = os.getenv("TRADING_MODE", "paper").strip().lower() + if mode not in {"paper", "live"}: + raise ValueError("TRADING_MODE must be one of: paper, live") + return mode def get_kis_config(self) -> dict: """Get KIS broker configuration""" diff --git a/tests/test_broker_factory.py b/tests/test_broker_factory.py index f9c1645..db2bbfc 100644 --- a/tests/test_broker_factory.py +++ b/tests/test_broker_factory.py @@ -22,5 +22,5 @@ def test_factory_rejects_yfinance_live(monkeypatch): def test_factory_rejects_unknown_broker(monkeypatch): monkeypatch.setenv("BROKER", "unknown") - with pytest.raises(ValueError, match="Unsupported BROKER"): + with pytest.raises(ValueError, match="BROKER"): create_broker(Config(), EventBus()) diff --git a/tests/test_config.py b/tests/test_config.py index 26f30c3..7af8251 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,5 @@ +import pytest + from src.utils.config import Config @@ -12,20 +14,44 @@ def test_broker_reads_env_lowercase(monkeypatch): assert Config().get_broker_name() == "yfinance" -def test_trading_mode_defaults_from_kis_paper_flag(monkeypatch): +def test_broker_rejects_invalid_value(monkeypatch): + monkeypatch.setenv("BROKER", "unknown") + with pytest.raises(ValueError, match="BROKER"): + Config().get_broker_name() + + +def test_trading_mode_defaults_to_paper(monkeypatch): monkeypatch.setattr("src.utils.config.load_dotenv", lambda *args, **kwargs: None) monkeypatch.delenv("TRADING_MODE", raising=False) - monkeypatch.setenv("KIS_PAPER_TRADING", "false") + assert Config().get_trading_mode() == "paper" + + +def test_trading_mode_reads_env_lowercase(monkeypatch): + monkeypatch.setenv("TRADING_MODE", "LIVE") assert Config().get_trading_mode() == "live" -def test_trading_mode_env_overrides_kis_paper_flag(monkeypatch): - monkeypatch.setenv("TRADING_MODE", "PAPER") - monkeypatch.setenv("KIS_PAPER_TRADING", "false") - assert Config().get_trading_mode() == "paper" +def test_trading_mode_rejects_invalid_value(monkeypatch): + monkeypatch.setenv("TRADING_MODE", "sandbox") + with pytest.raises(ValueError, match="TRADING_MODE"): + Config().get_trading_mode() + + +def test_load_ignores_legacy_strategies_yaml(tmp_path): + (tmp_path / "settings.yaml").write_text("risk:\n max_positions: 5\n") + (tmp_path / "strategies.yaml").write_text("strategies:\n - legacy\n") + + config = Config(config_dir=str(tmp_path)) + config.load() + + assert config.get("risk.max_positions") == 5 + assert not hasattr(config, "strategies") def test_get_returns_default_for_empty_string(): config = Config() config._settings = {"database": {"url": ""}} - assert config.get("database.url", "sqlite+aiosqlite:///data/trading.db") == "sqlite+aiosqlite:///data/trading.db" + assert ( + config.get("database.url", "sqlite+aiosqlite:///data/trading.db") + == "sqlite+aiosqlite:///data/trading.db" + ) diff --git a/tests/test_execution.py b/tests/test_execution.py index b806108..f630b38 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -296,6 +296,50 @@ async def test_price_fetch_failure_returns_none( assert order is None +class TestCalculateBuyQuantity: + """Test market-currency-specific buy sizing.""" + + @pytest.mark.asyncio + async def test_us_signal_uses_usd_cash_not_krw_cash(self, order_manager): + dashboard_mock = MagicMock() + dashboard_mock.balance_krw = Decimal("100000000") + dashboard_mock.cash_krw = Decimal("100000000") + dashboard_mock.balance_usd = Decimal("10000") + dashboard_mock.cash_usd = Decimal("300") + dashboard_mock.positions = {} + dashboard_mock.storage = AsyncMock() + dashboard_mock.storage.get_all_strategy_configs = AsyncMock( + return_value=[{"symbols": [{"symbol": "AAPL", "max_weight": 20.0}]}] + ) + + with patch(PATCH_DASHBOARD, return_value=dashboard_mock): + quantity = await order_manager._calculate_buy_quantity( + "AAPL", Market.NASDAQ, Decimal("100"), 1.0 + ) + + assert quantity == 3 + + @pytest.mark.asyncio + async def test_krx_signal_uses_krw_cash_not_usd_cash(self, order_manager): + dashboard_mock = MagicMock() + dashboard_mock.balance_krw = Decimal("1000000") + dashboard_mock.cash_krw = Decimal("50000") + dashboard_mock.balance_usd = Decimal("1000000") + dashboard_mock.cash_usd = Decimal("1000000") + dashboard_mock.positions = {} + dashboard_mock.storage = AsyncMock() + dashboard_mock.storage.get_all_strategy_configs = AsyncMock( + return_value=[{"symbols": [{"symbol": "005930", "max_weight": 20.0}]}] + ) + + with patch(PATCH_DASHBOARD, return_value=dashboard_mock): + quantity = await order_manager._calculate_buy_quantity( + "005930", Market.KRX, Decimal("10000"), 1.0 + ) + + assert quantity == 5 + + class TestTradingPauseCheck: """Test _on_signal respects dashboard.trading_paused""" diff --git a/tests/test_yfinance_broker.py b/tests/test_yfinance_broker.py index 34b61a5..d1bba75 100644 --- a/tests/test_yfinance_broker.py +++ b/tests/test_yfinance_broker.py @@ -1,3 +1,4 @@ +import asyncio from decimal import Decimal from unittest.mock import AsyncMock @@ -116,7 +117,9 @@ async def test_yfinance_paper_state_persists_across_restart(tmp_path, monkeypatc broker1 = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000"), state_path=state_path) monkeypatch.setattr(broker1, "get_current_price", AsyncMock(return_value=Decimal("10"))) await broker1.connect() - await broker1.submit_order(Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5)) + await broker1.submit_order( + Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5) + ) broker2 = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000"), state_path=state_path) monkeypatch.setattr(broker2, "get_current_price", AsyncMock(return_value=Decimal("10"))) @@ -126,3 +129,71 @@ async def test_yfinance_paper_state_persists_across_restart(tmp_path, monkeypatc assert broker2._cash["usd"] == Decimal("950") assert positions[0].symbol == "AAPL" assert positions[0].quantity == 5 + + +@pytest.mark.asyncio +async def test_submit_order_rejects_when_price_lookup_fails(monkeypatch, tmp_path): + broker = YFinanceBroker( + EventBus(), initial_cash_usd=Decimal("1000"), state_path=tmp_path / "state.json" + ) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(side_effect=ValueError("no data"))) + + order = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5) + order_id = await broker.submit_order(order) + + assert order_id.startswith("YF-PAPER-") + assert order.status == OrderStatus.REJECTED + assert broker._cash["usd"] == Decimal("1000") + assert broker._positions == {} + assert broker._fills == [] + + +@pytest.mark.asyncio +async def test_submit_order_rejects_non_positive_price(monkeypatch, tmp_path): + broker = YFinanceBroker( + EventBus(), initial_cash_usd=Decimal("1000"), state_path=tmp_path / "state.json" + ) + monkeypatch.setattr(broker, "get_current_price", AsyncMock(return_value=Decimal("0"))) + + order = Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=5) + await broker.submit_order(order) + + assert order.status == OrderStatus.REJECTED + assert broker._cash["usd"] == Decimal("1000") + assert broker._positions == {} + + +@pytest.mark.asyncio +async def test_load_state_recovers_from_corrupt_json(tmp_path, caplog): + state_path = tmp_path / "paper_state.json" + state_path.write_text("{not valid json") + broker = YFinanceBroker(EventBus(), initial_cash_usd=Decimal("1000"), state_path=state_path) + + await broker.connect() + + assert broker.is_connected is True + assert broker._cash["usd"] == Decimal("1000") + assert broker._positions == {} + assert "Failed to load yfinance paper state" in caplog.text + + +@pytest.mark.asyncio +async def test_submit_orders_are_serialized_by_state_lock(monkeypatch, tmp_path): + broker = YFinanceBroker( + EventBus(), initial_cash_usd=Decimal("100"), state_path=tmp_path / "state.json" + ) + + async def slow_price(*args, **kwargs): + await asyncio.sleep(0) + return Decimal("10") + + monkeypatch.setattr(broker, "get_current_price", slow_price) + orders = [ + Order("AAPL", Market.NASDAQ, OrderSide.BUY, OrderType.MARKET, quantity=1) for _ in range(10) + ] + + await asyncio.gather(*(broker.submit_order(order) for order in orders)) + + assert all(order.status == OrderStatus.FILLED for order in orders) + assert broker._cash["usd"] == Decimal("0") + assert broker._positions["AAPL"]["quantity"] == 10