File size: 6,418 Bytes
d5b7ee9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
"""yFinance adapter β€” free market data with mock trading."""

from __future__ import annotations

import logging
import random
import time
from datetime import datetime, timedelta, timezone

import pandas as pd

from trading_cli.execution.adapters.base import (
    AccountInfo,
    MarketClock,
    OrderResult,
    Position,
    TradingAdapter,
)
from trading_cli.execution.adapters.registry import register_adapter

logger = logging.getLogger(__name__)


@register_adapter
class YFinanceAdapter(TradingAdapter):
    """yFinance adapter for free market data with simulated trading.

    Provides:
    - Real OHLCV data from Yahoo Finance
    - Real latest quotes from Yahoo Finance
    - Simulated account and positions (demo mode)
    """

    def __init__(self, config: dict) -> None:
        self._config = config
        self._cash = config.get("initial_cash", 100_000.0)
        self._positions: dict[str, dict] = {}
        self._order_counter = 1000
        self._base_prices = {
            "AAPL": 175.0, "TSLA": 245.0, "NVDA": 875.0,
            "MSFT": 415.0, "AMZN": 185.0, "GOOGL": 175.0,
            "META": 510.0, "SPY": 520.0,
        }
        logger.info("YFinanceAdapter initialized in demo mode")

    @property
    def adapter_id(self) -> str:
        return "yfinance"

    @property
    def supports_paper_trading(self) -> bool:
        return True  # Simulated trading

    @property
    def is_demo_mode(self) -> bool:
        return True

    # ── Account & Positions ───────────────────────────────────────────────────

    def get_account(self) -> AccountInfo:
        portfolio = sum(
            p["qty"] * self._get_mock_price(sym)
            for sym, p in self._positions.items()
        )
        equity = self._cash + portfolio
        return AccountInfo(
            equity=equity,
            cash=self._cash,
            buying_power=self._cash * 4,
            portfolio_value=equity,
        )

    def get_positions(self) -> list[Position]:
        positions = []
        for sym, p in self._positions.items():
            cp = self._get_mock_price(sym)
            ep = p["avg_price"]
            pl = (cp - ep) * p["qty"]
            plpc = (cp - ep) / ep if ep else 0.0
            positions.append(
                Position(sym, p["qty"], ep, cp, pl, plpc, cp * p["qty"])
            )
        return positions

    # ── Orders ───────────────────────────────────────────────────────────────

    def submit_market_order(self, symbol: str, qty: int, side: str) -> OrderResult:
        price = self._get_mock_price(symbol)
        self._order_counter += 1
        order_id = f"YF-{self._order_counter}"

        if side.upper() == "BUY":
            cost = price * qty
            if cost > self._cash:
                return OrderResult(order_id, symbol, side, qty, "rejected")
            self._cash -= cost
            if symbol in self._positions:
                p = self._positions[symbol]
                total_qty = p["qty"] + qty
                p["avg_price"] = (p["avg_price"] * p["qty"] + price * qty) / total_qty
                p["qty"] = total_qty
            else:
                self._positions[symbol] = {"qty": qty, "avg_price": price}
        else:  # SELL
            if symbol not in self._positions or self._positions[symbol]["qty"] < qty:
                return OrderResult(order_id, symbol, side, qty, "rejected")
            self._cash += price * qty
            self._positions[symbol]["qty"] -= qty
            if self._positions[symbol]["qty"] == 0:
                del self._positions[symbol]

        return OrderResult(order_id, symbol, side, qty, "filled", price)

    def close_position(self, symbol: str) -> OrderResult | None:
        if symbol not in self._positions:
            return None
        qty = self._positions[symbol]["qty"]
        return self.submit_market_order(symbol, qty, "SELL")

    def _get_mock_price(self, symbol: str) -> float:
        """Get a mock price with small random walk for realism."""
        base = self._base_prices.get(symbol, 100.0)
        noise = random.gauss(0, base * 0.002)
        return round(max(1.0, base + noise), 2)

    # ── Market Data ───────────────────────────────────────────────────────────

    def fetch_ohlcv(self, symbol: str, days: int = 90) -> pd.DataFrame:
        """Fetch OHLCV from yfinance."""
        try:
            import yfinance as yf
            period = f"{min(days, 730)}d"
            df = yf.download(symbol, period=period, interval="1d", progress=False, auto_adjust=True)
            if df.empty:
                return pd.DataFrame()
            return df.tail(days)
        except Exception as exc:
            logger.error("yfinance fetch failed for %s: %s", symbol, exc)
            return pd.DataFrame()

    def get_latest_quote(self, symbol: str) -> float | None:
        """Get latest price from yfinance."""
        try:
            import yfinance as yf
            ticker = yf.Ticker(symbol)
            info = ticker.fast_info
            price = getattr(info, "last_price", None) or getattr(info, "regularMarketPrice", None)
            if price:
                return float(price)
            hist = ticker.history(period="2d", interval="1d")
            if not hist.empty:
                return float(hist["Close"].iloc[-1])
            return None
        except Exception as exc:
            logger.warning("yfinance latest quote failed for %s: %s", symbol, exc)
            return None

    # ── Market Info ───────────────────────────────────────────────────────────

    def get_market_clock(self) -> MarketClock:
        now = datetime.now(tz=timezone.utc)
        # Mock: market open weekdays 9:30–16:00 ET (UTC-5)
        hour_et = (now.hour - 5) % 24
        is_open = now.weekday() < 5 and 9 <= hour_et < 16
        return MarketClock(
            is_open=is_open,
            next_open="09:30 ET",
            next_close="16:00 ET",
        )